From 91b4fe3f366e5f3828c660e6f5a5b80eaa309cd0 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Wed, 27 May 2026 00:12:00 +0800 Subject: [PATCH] vault backup: 2026-05-27 00:12:00 --- .../01-基础概念/01-OSI与TCP-IP模型对比.md | 30 +- .../01-基础概念/02-数据封装与解封装.md | 140 +++++-- .../01-基础概念/03-寻址体系MAC-IPTPort.md | 62 ++- .../01-基础概念/04-带宽延迟RTT与吞吐量.md | 119 ++++-- hhs/NETWORK/02-链路层/01-Ethernet帧结构.md | 145 +++++-- .../02-链路层/02-CSMA-CD与以太网退避.md | 185 ++++++--- hhs/Redis/06-主从与哨兵.md | 222 ++++++++-- hhs/Redis/09-高级特性.md | 4 + hhs/Redis/09-高级特性/分布式锁.md | 389 ++++++++++++++++++ 9 files changed, 1116 insertions(+), 180 deletions(-) create mode 100644 hhs/Redis/09-高级特性/分布式锁.md diff --git a/hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比.md b/hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比.md index 0f7cc3f..d54e640 100644 --- a/hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比.md +++ b/hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比.md @@ -74,16 +74,16 @@ graph BT ```mermaid flowchart LR A["OSI 七层模型
ISO 标准"] --> B{谁先落地?} - B -->|"1970s 末"| C["TCP/IP 已部署在 ARPANET"] - B -->|"1980s 后"| D["OSI 标准太多太复杂"] + B -->|"1983 Flag Day"| C["TCP/IP 正式接管 ARPANET"] + B -->|"标准滞后"| D["OSI 标准太多太复杂"] C --> E["事实标准 ✅"] D --> E style E fill:#98FB98,color:#000 ``` -- **先发优势**:ARPANET / Internet 先用上了 TCP/IP,基础设施已经建成 +- **先发优势**:1983 年 1 月 1 日(Flag Day),ARPANET 强制切换到 TCP/IP,基础设施已建成,生态不可逆转 - **简单粗暴**:TCP/IP 只关心能不能跑通,不搞复杂的标准化流程 -- **开源免费**:Berkeley BSD Unix 内嵌 TCP/IP 协议栈,免费分发 +- **开源免费**:Berkeley BSD Unix 内嵌 TCP/IP 协议栈(1983 年 4.2BSD),免费分发 - **OSI 的反面教材**:层层审批导致协议膨胀(X.400 邮件、X.25 分组交换),开发者根本懒得用 ## 五层教学模型 @@ -92,10 +92,10 @@ flowchart LR ```mermaid flowchart TD - A["应用层 — Application
HTTP DNS SMTP SSH FTP SMTP POP3 IMAP WebSocket"] --> T["传输层 — Transport
TCP UDP SCTP"] - T --> N["网络层 — Internet
IP IPv6 ICMP ARP NAT"] - N --> D["链路层 — Link
Ethernet Wi-Fi 802.11 VLAN HDLC PPP"] - D --> P["物理层 — Physical
双绞线 光纤 无线电 IEEE 802.15"] + A["应用层 — Application
HTTP, DNS, SMTP, SSH, FTP, POP3, IMAP, WebSocket"] --> T["传输层 — Transport
TCP, UDP, SCTP"] + T --> N["网络层 — Internet
IP, IPv6, ICMP, ARP, NAT"] + N --> D["链路层 — Link
Ethernet, Wi-Fi, 802.11, VLAN, HDLC, PPP"] + D --> P["物理层 — Physical
双绞线, 光纤, 无线电, IEEE 802.15"] style A fill:#DDA0DD style T fill:#FFD700 style N fill:#87CEEB @@ -153,7 +153,19 @@ sequenceDiagram > 每一层只知道自己那一层的 header 是什么格式,对其他层的内容一无所知。这就是**层间透明**原则。 > [!QUESTION] 如果每一层都要加头部,开销岂不是很大? -> 没错。一个 1 字节的应用层数据,经过 TCP/IP/以太网三层封装后,可能膨胀到 50+ 字节(TCP 头 20B + IP 头 20B + 以太网帧头帧尾 18B)。这就是为什么对小数据量、低延迟场景,业界会选择 **UDP**(头部仅 8 字节)甚至 **QUIC**(合并传输层与加密层)来减少开销。 +> 没错。一个 1 字节的应用层数据,经过 TCP/IP/以太网三层封装后,可能膨胀到 58 字节(TCP 头 20B + IP 头 20B + 以太网帧头帧尾 18B)。这就是为什么对小数据量、低延迟场景,业界会选择 **UDP**(头部仅 8 字节)甚至 **QUIC**(合并传输层与加密层)来减少开销。 + +### MTU 与 MSS:分层边界如何约束数据大小 + +分层不只是理论模型,它直接决定了每层能传多大的数据: + +| 概念 | 所在层 | 含义 | 典型值 | +|------|--------|------|--------| +| **MTU**(Maximum Transmission Unit) | 链路层 | 一个帧能承载的最大数据量 | 以太网默认 **1500B** | +| **MSS**(Maximum Segment Size) | 传输层 | 一个 TCP 段能承载的最大应用数据 | 1500 - 20(IP) - 20(TCP) = **1460B** | + +> [!QUESTION] 为什么下载大文件时网络层会有大量小包? +> 当你要发一个 100KB 的文件时,TCP 会按 MSS(1460B)将其切割成约 70 个段,每个段再交给网络层封装成 Packet,最后由链路层加上帧头帧尾变成 Frame。**分层的每一级都有自己的大小限制**,超出就要分片或分段——这就是 Path MTU Discovery(路径 MTU 发现)存在的原因:找到整条路径上最小的 MTU,避免中间路由器做 IP 分片(分片会严重影响性能)。 ## 分层在代码中的体现 diff --git a/hhs/NETWORK/01-基础概念/02-数据封装与解封装.md b/hhs/NETWORK/01-基础概念/02-数据封装与解封装.md index 8d01523..c1b2b3a 100644 --- a/hhs/NETWORK/01-基础概念/02-数据封装与解封装.md +++ b/hhs/NETWORK/01-基础概念/02-数据封装与解封装.md @@ -40,19 +40,19 @@ flowchart LR ```mermaid sequenceDiagram - participant App as HTTP 应用 - participant TCP as TCP Socket - participant IP as 网络层 IP - participant ETH as 以太网驱动 + participant App as "HTTP 应用" + participant TCP as "TCP Socket" + participant IP as "网络层 IP" + participant ETH as "以太网驱动" - App->>TCP: write("GET / HTTP/1.1...") - Note over TCP: 分配 seq/ack, state=ESTABLISHED - TCP->>IP: 传递 Segment (daddr=x.x.x.x, sport=1234, dport=80) - Note over IP: 查路由表 → 下一跳网关 - IP->>ETH: 传递 Packet (dst_mac=?, src_ip=192.168.1.100, dst_ip=93.184.216.34) - Note over ETH: ARP 解析 → dst_mac=aabb.ccdd.eeff - ETH->>ETH: 拼接 源MAC+目的MAC+EtherType+Payload+FCS - ETH-->>App: sendto() 返回 → 帧已发出 ✅ + App->>TCP: "write GET / HTTP/1.1..." + Note over TCP: "分配 seq,ack, 连接已建立" + TCP->>IP: "Segment, daddr=x.x.x.x, sport=1234, dport=80" + Note over IP: "查路由表, 下一跳网关" + IP->>ETH: "Packet, src_ip=192.168.1.100, dst_ip=93.184.216.34" + Note over ETH: "ARP 解析, dst_mac=aabb.ccdd.eeff" + ETH->>ETH: "拼接 srcMAC,dstMAC,EtherType,Payload,FCS" + ETH-->>App: "sendto 返回, 帧已发出" ``` ### Ethernet II 帧完整结构 @@ -69,22 +69,26 @@ Offset Size Field 说明 总长度范围:**64 ~ 1518 bytes**(不含 VLAN Tag;有 VLAN Tag 则上限 1522)。 +> [!tip] 为什么有最小 64 字节的限制? +> 以太网使用 CSMA/CD 检测冲突,发送方需要在发完帧之前检测到碰撞信号。64 字节是为了保证在最大网络直径内,信号能往返一次。如果 IP 包太小导致帧不够 64 字节,链路层会自动填充 **Padding** 补齐到最小长度。接收端根据 IP 头部的 "Total Length" 字段识别真实载荷边界,剥离多余的填充。 + > [!note] MTU = 1500 是什么意思? -> MTU(Maximum Transmission Unit)是链路层允许的最大 Payload 大小,即 IP 包的最大体积。超过会被分片(Fragmentation),但分片会降低性能且增加丢包风险——这也是现代应用层(如 TLS、gRPC)倾向于小包传输的原因。 +> MTU(Maximum Transmission Unit)是链路层允许的最大 Payload 大小,即 IP 包(头部 + 载荷)的最大体积。超过 1500 字节的 IP 包会被路由器**分片(Fragmentation)**——拆成多个小片段分别传输,接收端再重组。 +> 分片的代价:任何一个分片丢失都会导致整个包重传;每片都需要独立的 IP 头,浪费带宽。因此 TCP 在三次握手时会协商 **MSS**(Maximum Segment Size)来主动避分片,而 UDP 应用(如 DNS、QUIC)则通过程序层面控制载荷大小。更底层的 **PMTU Discovery** 机制还能动态探测路径上最小的 MTU。 ## 接收端:逐层解封装 ```mermaid sequenceDiagram - participant ETH as 网卡收到 Frame - participant IP as 剥离以太网头 - participant TCP as 剥离 TCP 头 - participant App as 提取 HTTP 数据 + participant ETH as "网卡收到 Frame" + participant IP as "剥离以太网头" + participant TCP as "剥离 TCP 头" + participant App as "提取 HTTP 数据" - ETH->>IP: 校验 FCS OK → 取 EtherType=0x0800 → 剥离以太网头 - IP->>TCP: 校验 IP Checksum → 取 Protocol=6(TCP) → 剥离 IP 头 - TCP->>App: 按 Sequence Number 重组 → 剥离 TCP 头 - App->>App: 解析 "GET / HTTP/1.1..." 🎉 + ETH->>IP: "FCS 校验通过, EtherType=0x0800, 剥离以太网头" + IP->>TCP: "IP Checksum 正确, Protocol=6 TCP, 剥离 IP 头" + TCP->>App: "按 Sequence Number 重组, 剥离 TCP 头" + App->>App: "解析 GET / HTTP/1.1..." ``` 每层的动作可以概括为三步: @@ -95,32 +99,88 @@ sequenceDiagram ## 各层 PDU 对照表 -| OSI 名称 | PDU | TCP/IP 对应 | 关键标识字段 | -|----------|-----|-------------|-------------| -| 数据 (Data) | 报文段 | 应用层消息 | — | -| 段 (Segment) | TCP Segment | 传输层 PDU | 源端口 + 目的端口 | -| 包 (Packet) | IP Datagram | 网络层 PDU | 源 IP + 目的 IP + Protocol | -| 帧 (Frame) | Ethernet Frame | 链路层 PDU | 源 MAC + 目的 MAC + EtherType | -| 比特流 (Bits) | Bitstream | 物理层信号 | 电信号 / 光脉冲 | +| OSI 层 | PDU 名称 | TCP/IP 对应 | 关键标识字段 | +|--------|---------|-------------|-------------| +| 应用层 | 消息 (Message) | 应用层数据 | HTTP 请求行、DNS 报文等 | +| 传输层 | 段/数据报 (Segment / Datagram) | 传输层 PDU | 源端口 + 目的端口 | +| 网络层 | 包 (Packet / Datagram) | 网络层 PDU | 源 IP + 目的 IP + Protocol | +| 链路层 | 帧 (Frame) | 链路层 PDU | 源 MAC + 目的 MAC + EtherType | +| 物理层 | 比特流 (Bits) | 物理层信号 | 电信号 / 光脉冲 / 无线电波 | + +> [!QUESTION] TCP 叫 Segment,UDP 叫什么? +> UDP 的 PDU 通常叫 **Datagram**(数据报),因为它不建立连接、不做分段重组,每个数据报独立发送。所以同一层可以有不同的 PDU 名称,取决于使用的传输协议。 ## NAT 场景下的封装变化 NAT 路由器修改了中间层的地址: -``` -发送端内部主机 NAT 路由器 外部 Web 服务器 -┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ -│ Src IP: 10.0.0.5:45678 │ │ SNAT: 改写 Src IP │ │ Dst IP: 203.0.113.1:80 │ -│ Dst IP: 203.0.113.1:80 │ → │ │ → │ │ -│ │ │ → Src IP: 8.8.8.8:45678 │ │ ← Src IP: 203.0.113.1:80 │ -│ │ │ → Dst IP: 203.0.113.1:80 │ │ → Src IP: 10.0.0.5:45678 │ -└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +```mermaid +sequenceDiagram + participant Host as "内部主机" + participant NAT as "NAT 路由器" + participant Server as "外部服务器" + + Host->>NAT: "Src=10.0.0.5:45678 Dst=203.0.113.1:80" + Note over NAT: "SNAT 改写源地址" + NAT->>Server: "Src=8.8.8.8:45678 Dst=203.0.113.1:80" + Server->>NAT: "Src=203.0.113.1:80 Dst=8.8.8.8:45678" + Note over NAT: "查 NAT 表, 反向翻译" + NAT->>Host: "Src=203.0.113.1:80 Dst=10.0.0.5:45678" ``` -注意:NAT 不触碰应用层载荷,只是在中途修改了 IP 和 TCP 端口。这也意味着 **传输层的 Checksum 需要重新计算**。 +NAT **不触碰应用层载荷**,只修改 IP 头和 TCP/UDP 端口。但这也意味着**传输层的 Checksum 需要重新计算**,因为 TCP/UDP 的校验和覆盖了伪首部(Pseudo Header)中的源/目的 IP 地址。 + +## TCP vs UDP:封装有什么不同? + +两者都在传输层,但封装风格截然不同: + +| 对比项 | TCP | UDP | +|--------|-----|-----| +| 首部大小 | 最小 20 字节,最大 60 字节(含 Options) | 固定 8 字节 | +| 连接状态 | 三次握手建连接,携带 seq/ack 等状态 | 无连接,每个数据报独立 | +| 分段重组 | 传输层自动分段和重组 | 不分段,超过 MTU 则由 IP 层分片 | +| 校验和 | 强制(IPv4/IPv6) | IPv4 可选,IPv6 强制 | + +```mermaid +flowchart LR + subgraph "TCP Segment" + A["TCP Header 20-60B
SrcPort DstPort Seq Ack Flags..."] --> B["Payload"] + end + subgraph "UDP Datagram" + C["UDP Header 8B
SrcPort DstPort Len Chksum"] --> D["Payload"] + end + + style A fill:#FFD700 + style C fill:#FFA07A +``` + +> [!QUESTION] UDP 头只有 8 字节,是不是"更好"? +> 更小的头部确实开销更低,但你失去的是可靠传输、流量控制、拥塞控制等保障。选择哪个取决于业务场景:文件传输用 TCP,游戏/视频/QUIC 用 UDP + 应用层自建可靠性。 + +## TLS 封装:在哪一层"套娃"? + +TLS(Transport Layer Security)常被认为是"应用层协议",但它实际上在 **OSI 第 5-6 层(会话/表示层)** 工作,夹在应用层和传输层之间: + +```mermaid +flowchart LR + subgraph "HTTPS = HTTP + TLS + TCP" + H["HTTP 报文"] --> T["TLS 加密
+ TLS Record Header 5B"] + T --> C["TCP Segment"] + C --> I["IP Packet"] + end + + style H fill:#DDA0DD + style T fill:#FF6347 + style C fill:#FFD700 + style I fill:#87CEEB +``` + +关键点:TLS Record 协议会在 HTTP 载荷前添加 **5 字节的 Record Header**(ContentType + Version + Length),然后整个 TLS Record 作为 TCP 的 Payload 继续向下封装。所以 HTTPS 比 HTTP 多了 TLS 的封装开销(握手阶段更大,数据阶段约 ~40 字节 overhead)。 ## 关联笔记 -- [[hhs/NETWORK/OSI与TCP-IP模型对比]] — 分层模型的起源与对比 -- [[hhs/NETWORK/IPv4协议详解]] — IP 包的详细格式 -- [[hhs/NETWORK/TCP状态机详解]] — TCP 段的结构与状态管理 +- [[hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比]] — 分层模型的起源与对比 +- [[hhs/NETWORK/03-网络层/01-IPv4首部与分段重组]] — IP 包的详细格式与分片机制 +- [[hhs/NETWORK/04-传输层/01-TCP段结构与状态机]] — TCP 段的结构与状态管理 +- [[hhs/NETWORK/04-传输层/06-UDP协议与SCTP]] — UDP 的无连接封装特点 +- [[hhs/NETWORK/03-网络层/08-NAT原理与应用]] — NAT 的详细原理与配置 diff --git a/hhs/NETWORK/01-基础概念/03-寻址体系MAC-IPTPort.md b/hhs/NETWORK/01-基础概念/03-寻址体系MAC-IPTPort.md index b7b50a3..cce7876 100644 --- a/hhs/NETWORK/01-基础概念/03-寻址体系MAC-IPTPort.md +++ b/hhs/NETWORK/01-基础概念/03-寻址体系MAC-IPTPort.md @@ -48,7 +48,7 @@ sequenceDiagram Src->>Bcast: ARP Request "192.168.1.20 是谁?→ ff:ff:ff:ff:ff:ff" Dst-->>Src: ARP Reply "我是!MAC = aa:bb:cc:11:22:33" - Src->>Cache: 写入缓存条目 (TTL ≈ 15~30 分钟) + Src->>Cache: 写入缓存条目 (Linux 默认 ≈ 30s, Windows ≈ 15~45s, 可配置) Note over Src,Dst: 之后的通信直接用此 MAC,不再发 ARP ``` @@ -67,7 +67,7 @@ $ arp -a ## 二、IP 地址(IPv4 32 位 / IPv6 128 位) -### IPv4 地址结构 +### IPv4 首部结构 ``` 版本(4 bits) IHL(4 bits) DSCP(8 bits) Total Length(16 bits) @@ -91,6 +91,31 @@ Options (可选)... | NAT | 广泛使用(缓解地址耗尽) | 不需要(地址充足) | | SLAAC | — | 支持无状态自动配置 | | 组播 | 有限支持 | 原生支持 | +| 地址解析 | ARP(广播) | NDP / Neighbor Discovery(组播,替代 ARP) | + +### IPv6 地址表示与压缩 + +IPv6 地址共 128 位,用冒号分隔为 8 组,每组 4 个十六进制数: + +``` +完整写法: 2001:0db8:0000:0000:0000:0000:0000:0001 +压缩规则 1 — 每组前导零可省略: 2001:db8:0:0:0:0:0:1 +压缩规则 2 — 连续全零组用 :: 替代(仅一次): 2001:db8::1 +``` + +> [!QUESTION] `::` 能用几次? +> 只能用**一次**。如果出现两个 `::`,解析器无法确定每组省略了多少个零,地址就会歧义。 + +### IPv6 特殊地址 + +| 地址 | 用途 | +|------|------| +| `::1` | 环回地址,等价于 IPv4 的 `127.0.0.1` | +| `::` | 未指定地址,等价于 IPv4 的 `0.0.0.0` | +| `fe80::/10` | 链路本地地址(Link-Local),同一链路自动通信 | +| `ff00::/8` | 组播地址 | + +> 更多 IPv6 细节见 [[hhs/NETWORK/03-网络层/03-IPv6地址与扩展头部]]。 ### IP 地址分类(历史) @@ -104,6 +129,29 @@ Options (可选)... > [!warning] CIDR 已淘汰分类编址 > 现代网络全部使用 **CIDR(Classless Inter-Domain Routing)** 无类编址,子网掩码可以是任意位数(如 /23、/27)。 +> CIDR 用"前缀长度"取代了固定的 A/B/C 类划分,例如 `192.168.1.0/24` 表示前 24 位是网络号,剩余 8 位是主机号。 +> 更多细节见 [[hhs/NETWORK/03-网络层/02-CIDR与子网划分]]。 + +### 特殊 IP 地址 + +| 地址 | 用途 | +|------|------| +| `127.0.0.1` / `::1` | 环回地址(Loopback),本机内部通信 | +| `0.0.0.0` | 表示"本机所有网卡"(服务端 bind 时常用) | +| `169.254.x.x` | 链路本地地址(DHCP 失败时自动分配) | + +### 私有 IP 地址段 + +互联网上的路由器**不会转发**来自以下范围的数据包,它们仅用于内网,通过 NAT 转换为公网 IP 后才能访问互联网: + +| 类别 | 地址范围 | CIDR | 可用地址数 | +|------|---------|------|-----------| +| A 类 | `10.0.0.0` ~ `10.255.255.255` | `10.0.0.0/8` | ~1677 万 | +| B 类 | `172.16.0.0` ~ `172.31.255.255` | `172.16.0.0/12` | ~104 万 | +| C 类 | `192.168.0.0` ~ `192.168.255.255` | `192.168.0.0/16` | ~6.5 万 | + +> [!tip] 私有地址 + NAT = 解决 IPv4 耗尽 +> 一个公网 IP 可以通过 NAT 映射背后成百上千个私有 IP,这就是为什么 IPv4 至今仍在大规模使用。详见 [[hhs/NETWORK/03-网络层/08-NAT原理与应用]]。 ## 三、端口号(16 位) @@ -134,7 +182,8 @@ flowchart LR | 端口 | 协议 | 服务 | |------|------|------| -| 21 | FTP | 文件传输控制 | +| 20 | FTP-Data | 文件传输数据通道 | +| 21 | FTP | 文件传输控制通道 | | 22 | SSH | 安全远程登录 | | 25 | SMTP | 邮件发送 | | 53 | DNS | 域名解析 | @@ -164,6 +213,7 @@ flowchart TD ## 关联笔记 -- [[hhs/NETWORK/OSI与TCP-IP模型对比]] — 三层地址对应 OSI 的不同层级 -- [[hhs/NETWORK/IPv4协议详解]] — IP 地址的详细编码与 CIDR 计算 -- [[hhs/NETWORK/TCP状态机详解]] — 四元组如何唯一标识 TCP 连接 +- [[hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比]] — 三层地址对应 OSI 的不同层级 +- [[hhs/NETWORK/03-网络层/01-IPv4首部与分段重组]] — IP 首部的详细字段与分段机制 +- [[hhs/NETWORK/03-网络层/02-CIDR与子网划分]] — CIDR 计算与子网划分实战 +- [[hhs/NETWORK/04-传输层/01-TCP段结构与状态机]] — 四元组如何唯一标识 TCP 连接 diff --git a/hhs/NETWORK/01-基础概念/04-带宽延迟RTT与吞吐量.md b/hhs/NETWORK/01-基础概念/04-带宽延迟RTT与吞吐量.md index 817f3e7..6f28c4e 100644 --- a/hhs/NETWORK/01-基础概念/04-带宽延迟RTT与吞吐量.md +++ b/hhs/NETWORK/01-基础概念/04-带宽延迟RTT与吞吐量.md @@ -1,5 +1,5 @@ --- -tags: [计算机网络, 带宽, 延迟, RTT, 吞吐量, BDP] +tags: [计算机网络, 带宽, 延迟, RTT, 吞吐量, BDP, Jitter, Goodput] create time: 2026-05-17 22:40 --- @@ -29,26 +29,52 @@ create time: 2026-05-17 22:40 > [!tip] bit vs Byte:记住除 8 > 运营商说的 "100M 宽带" 是 **100 Mbps**。换算成下载速度约 `100 ÷ 8 ≈ 12.5 MB/s`。 -### 延迟(Latency / Propagation Delay) +### 延迟(Latency) -信号从发送端传播到接收端所需的时间,仅取决于**物理距离和介质光速**: +一个数据包从发送到被接收所经历的**总时间**,由四部分组成: -$$\text{Prop. Delay} = \frac{\text{Distance}}{v}$$ +$$\text{Latency} = \text{传播延迟} + \text{传输延迟} + \text{排队延迟} + \text{处理延迟}$$ -其中 $v$ 是传播速度。光纤中光速约为 $2 \times 10^8$ m/s(真空中光速的 2/3)。 +```mermaid +flowchart LR + A["传播延迟
Propagation"] --> B["传输延迟
Transmission"] + B --> C["排队延迟
Queuing"] + C --> D["处理延迟
Processing"] -| 场景 | 典型延迟 | + style A fill:#DDA0DD,color:#000 + style B fill:#87CEEB,color:#000 + style C fill:#FFD700,color:#000 + style D fill:#98FB98,color:#000 +``` + +| 延迟类型 | 公式 | 取决于 | 典型值 | +|---------|------|--------|--------| +| **传播延迟** | $\frac{\text{Distance}}{v}$ | 物理距离、介质 | 光纤中 $v \approx 2 \times 10^8$ m/s(真空中光速的 2/3) | +| **传输延迟** | $\frac{\text{Packet Size}}{\text{Bandwidth}}$ | 数据包大小、链路带宽 | 1500B 以太网帧在 1Gbps 链路上 ≈ 12 μs | +| **排队延迟** | 不确定 | 路由器拥塞程度 | 空闲时 ≈ 0,拥塞时可达数百 ms | +| **处理延迟** | 不确定 | 路由器/交换机性能 | 通常 < 1 ms | + +> [!question] 这四类延迟,哪个最"不可控"? +> 排队延迟。传播延迟由物理距离决定(选不了机房位置就改不了),传输延迟由包大小和带宽决定,处理延迟通常很小。但排队延迟随网络拥塞程度剧烈波动——这也是为什么它直接关联**抖动(Jitter)** 和 **丢包**。 + +| 场景 | 典型单向延迟(主要贡献:传播延迟) | |------|---------| | 同机房内 | 0.1–1 ms | | 同城(同一城市数据中心) | 1–5 ms | | 跨省(国内) | 20–40 ms | | 跨洋(中美) | 80–150 ms | | 卫星轨道(LEO) | 20–40 ms | -| TCP 握手 + TLS 1.3 | 1~2 RTT(额外 RTT × 加密计算时间) | + +> [!tip] TCP + TLS 的额外延迟 +> 建立一个 HTTPS 连接还需要 **TCP 三次握手(1 RTT)+ TLS 1.3 握手(1 RTT)**,在数据发出前就已经"花掉"了 2 个 RTT。这也是 HTTP/2、连接复用和 TLS Session Resumption 存在的重要原因。 ### RTT(Round-Trip Time) -从发送方发出数据包到收到确认应答的总时间。**RTT = 2 × 单向延迟 + 排队处理时间 + 传输时间**。 +从发送方发出数据包到收到确认应答的总时间。严格来说: + +$$\text{RTT} = 2 \times (\text{传播延迟} + \text{传输延迟} + \text{排队延迟} + \text{处理延迟})$$ + +实际测量中,`ping` 工具测得的 RTT 主要反映传播延迟(往返),而 TCP 的 RTT 估算(用于计算 RTO)还需包含协议栈处理时间。 ```mermaid flowchart LR @@ -60,6 +86,22 @@ flowchart LR style D fill:#98FB98,color:#000 ``` +### 抖动(Jitter) + +连续数据包之间延迟的**变化量**。即使平均延迟很低,抖动大也会严重影响体验: + +$$\text{Jitter} = |\text{RTT}_n - \text{RTT}_{n-1}|$$ + +| 应用类型 | 对抖动的敏感度 | 说明 | +|---------|--------------|------| +| 语音通话 / 视频会议 | 🔴 极高 | 抖动 > 30ms 就会出现卡顿、断续 | +| 在线游戏 | 🔴 高 | 操作响应不稳定,玩家体验崩塌 | +| 文件下载 | 🟢 无所谓 | 只关心总吞吐量,不在乎每个包的到达节奏 | +| HTTP API 调用 | 🟡 中等 | 长尾延迟影响 P99 响应时间 | + +> [!question] 如何对抗抖动? +> 答案是**接收端缓冲(Jitter Buffer)**。音视频应用会在接收端先攒一小段数据再播放,用少量额外延迟换取平滑输出。WebRTC 的 jitter buffer 通常 20–200ms。 + ### 吞吐量(Throughput) 单位时间内**实际成功传输的数据量**,受限于最窄环节: @@ -86,6 +128,11 @@ flowchart LR > [!question] 为什么我的千兆网卡只跑到 100MB/s? > 因为千兆以太网理论上限是 `1Gbps ÷ 8 = 125MB/s`,去掉以太网帧头、IP 头、TCP 头等协议开销后,纯 Payload 大约 **94 MB/s**。如果还用了 HTTPS/TLS,CPU 加解密还会进一步限制吞吐量。 +> [!info] Goodput vs Throughput:别搞混 +> **Throughput**(吞吐量)是单位时间内传输的**总数据量**(含协议头)。**Goodput**(有效吞吐量)是应用层实际收到的**有效数据量**,要扣除所有协议头、重传包、确认包等开销。 +> $$\text{Goodput} < \text{Throughput} \leq \text{Bandwidth}$$ +> 用 `iperf3` 测出来的通常是 throughput;你的文件下载速度反映的更接近 goodput。 + ## 带宽时延积(BDP) **BDP(Bandwidth-Delay Product)** 定义了链路上"在途"数据的最大量——这是维持满带宽所需的发送缓冲大小: @@ -138,28 +185,46 @@ flowchart TD ## Go 中的实践示例 -```go -// 设置 TCP 连接保持活跃,定期检测死链接 -tcp := net.ListenConfig{ - Control: func(network, address string, c syscall.RawConn) error { - return c.Control(func() { - // 开启 keepalive,每 30s 探测一次 - syscall.SetsockoptInt(int(c.Fd()), syscall.SOL_SOCKET, - syscall.SO_KEEPALIVE, 1) - }) - }, -} +### 测量 RTT(TCP 连接延迟) -// HTTP Transport 的连接池配置 -transport := &http.Transport{ - MaxIdleConns: 100, // 最多空闲连接数 - MaxIdleConnsPerHost: 10, // 每个 host 最多 10 个空闲 - IdleConnTimeout: 90 * time.Second, // 空闲超时释放 +```go +// 测量一次 TCP 连接的 RTT —— 最直接的延迟指标 +func measureRTT(addr string) (time.Duration, error) { + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return 0, err + } + conn.Close() + return time.Since(start), nil // TCP 握手完成 ≈ 1 RTT } ``` +### 按 BDP 调大 TCP 缓冲区 + +```go +// 高带宽长延迟链路(如跨洋 100Mbps, RTT 150ms)的 BDP = 1.875MB +// 系统默认的 TCP 窗口(128KB~256KB)远远不够 +transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + d := net.Dialer{} + conn, err := d.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + tcpConn := conn.(*net.TCPConn) + // 根据 BDP 设置读写缓冲区(2MB,略大于 BDP) + tcpConn.SetReadBuffer(2 << 20) + tcpConn.SetWriteBuffer(2 << 20) + return tcpConn, nil + }, +} +``` + +> [!tip] 为什么不直接用系统默认窗口? +> Linux 默认 `tcp_rmem` 的最大值通常是 6MB,但单连接的初始窗口只有 ~128KB。对于高 BDP 链路,窗口增长(慢启动)到填满管道需要好几个 RTT,前几秒吞吐量会远低于带宽上限。应用层手动设大缓冲区 + 启用窗口缩放(`tcp_window_scaling`)可以加速这个过程。 + ## 关联笔记 -- [[hhs/NETWORK/OSI与TCP-IP模型对比]] — 网络分层中各层的角色 -- [[hhs/NETWORK/TCP状态机详解]] — TCP 的拥塞控制直接影响吞吐量 -- [[hhs/NETWORK/TCP内核参数调优]] — BDP 对应内核参数 rmem/wmem +- [[hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比]] — 网络分层中各层的角色 +- [[hhs/NETWORK/09-性能优化/01-TCP内核调优与连接复用]] — BDP 对应内核参数 rmem/wmem diff --git a/hhs/NETWORK/02-链路层/01-Ethernet帧结构.md b/hhs/NETWORK/02-链路层/01-Ethernet帧结构.md index 0636994..b6ec0af 100644 --- a/hhs/NETWORK/02-链路层/01-Ethernet帧结构.md +++ b/hhs/NETWORK/02-链路层/01-Ethernet帧结构.md @@ -7,7 +7,29 @@ create time: 2026-05-17 22:50 ## 概述 -Ethernet(以太网)是目前最主流的局域网技术,由 Xerox、DEC 和 Intel 在 1980 年联合制定(IEEE 802.3)。理解 Ethernet 帧结构是掌握网络底层通信的基础——每一帧都包含了足够的信息来让网卡判断"这是给我的吗?内容完整吗?上层协议是什么?" +Ethernet(以太网)是目前最主流的局域网技术。最初由 Robert Metcalfe 在 Xerox PARC 于 1973 年发明,1980 年由 Xerox、DEC、Intel 联合发布 DIX 标准,1983 年 IEEE 正式发布 802.3 标准。 + +理解 Ethernet 帧结构是掌握网络底层通信的基础——每一帧都包含了足够的信息来让网卡判断"这是给我的吗?内容完整吗?上层协议是什么?" + +## 物理层封装:前导码与帧间隔 + +在以太网帧到达网线之前,物理层(PHY)会自动加上 **前导码 (Preamble)** 和 **帧首定界符 (SFD)**,这部分对软件不可见,但理解它有助于理解"帧"与"包"的边界。 + +``` +|<---------- Preamble + SFD (8B) -------->|<------------- Ethernet Frame ------------>| +┌───────────────────────┬────────┬─────────┬──────────┬──────────┬──────────┬─────────┐ +│ Preamble (7 bytes) │ SFD(1B)│Dest MAC │ Src MAC │ EtherType│ Payload │ FCS │ +│ 10101010 × 7 │10101011│ 6 bytes │ 6 bytes │ 2 bytes │ 46-1500B │ 4 bytes │ +└───────────────────────┴────────┴─────────┴──────────┴──────────┴──────────┴─────────┘ + ↑ 14B Header +``` + +- **Preamble**: 7 字节的 `10101010` 交替模式,用于接收方的**时钟同步**(位同步) +- **SFD** (Start Frame Delimiter): `10101011`,最后两个连续 1 表示"帧数据从下一个字节开始" +- **IFG** (Inter-Frame Gap): 帧与帧之间至少间隔 **12 字节**(96 bit time),让网卡有时间处理上一帧 + +> [!question] 为什么前导码是 7 字节而不是 8 字节? +> 前 7 字节用于时钟同步,SFD 单独标记帧起始。接收方需要足够的跳变沿来锁定频率,7 字节 × 8 bit = 56 次跳变,在早期的 10Mbps 以太网中大约需要 5.6μs 完成同步。 ## Ethernet II 帧格式(最常见) @@ -39,34 +61,60 @@ Ethernet(以太网)是目前最主流的局域网技术,由 Xerox、DEC 现代 Gigabit+ 网卡已不用 CSMA/CD,但为了兼容仍保留此限制。 -## IEEE 802.3 / 802.3Q(VLAN Tagged)帧 +## IEEE 802.1Q(VLAN Tagged)帧 -当网络中使用 VLAN 时,帧中会插入一个 4 字节的 802.1Q Tag: +当网络中使用 VLAN 时,帧中会**插入**一个 4 字节的 802.1Q Tag。注意:TPID 占据了原本 EtherType 的位置,真正的 EtherType 跟在 TCI 之后: ``` -┌──────────┬──────────┬──────┬──────┬──────────┬─────────────┬──────────┐ -│ Dest MAC │ Src MAC │ Type │ TPID │ TCI/VID │ Payload │ FCS │ -│ 6 bytes │ 6 bytes │ 2B │2B │ 2B │ 46–1500 B │ 4 bytes │ -└──────────┴──────────┴──────┴──────┴──────────┴─────────────┴──────────┘ +┌──────────┬──────────┬─────────────┬──────────┬──────────┬─────────────┬──────────┐ +│ Dest MAC │ Src MAC │ TPID (0x8100)│ TCI │ EtherType│ Payload │ FCS │ +│ 6 bytes │ 6 bytes │ 2 bytes │ 2 bytes │ 2 bytes │ 42–1500 B │ 4 bytes │ +│ │ │ │ PCP DEI │ 0x0800 │ │ │ +│ │ │ │ 3b 1b │ │ │ │ +│ │ │ │ VID(12b) │ │ │ │ +└──────────┴──────────┴─────────────┴──────────┴──────────┴─────────────┴──────────┘ + ←──────── 4B 802.1Q Tag ────────→ ``` 新增字段: -- **TPID** (Tag Protocol Identifier): `0x8100` — 标识这是带 VLAN Tag 的帧 -- **TCI** (Tag Control Information): 包含 PCP(优先级,3 bit)、CFI(规范格式指示符,1 bit)、**VID**(VLAN ID,12 bit) +- **TPID** (Tag Protocol Identifier): `0x8100` — 占据 EtherType 的位置,标识"这不是普通协议类型,而是 VLAN Tag" +- **TCI** (Tag Control Information): 包含 PCP(优先级,3 bit)、DEI(丢弃适格指示符,1 bit)、**VID**(VLAN ID,12 bit) -VID 取值范围 1–4094,共 4094 个可用 VLAN。 +> [!note] CFI → DEI +> 早期标准中该位叫 CFI (Canonical Format Indicator),用于标记 MAC 地址是否为规范格式。802.1Q-2011 修订后改为 DEI (Drop Eligible Indicator),用于标识在网络拥塞时可优先丢弃的帧。 + +VID 取值范围 0–4095,其中 **VID 0** 用于表示优先级帧(不含 VLAN 信息),**VID 4095** 为保留值,实际可用 VLAN 为 1–4094(共 4094 个)。 + +> [!question] 插入 4 字节 Tag 后帧会不会超过最大长度? +> 会。带 Tag 的帧最大长度从 1518 字节扩展到 **1522 字节**。这也是为什么交换机端口通常区分"最大帧长度"配置。 ## 常见 EtherType 对照表 -| EtherType | 十六进制 | 协议 | -|-----------|---------|------| +| 协议 | EtherType | 说明 | +|------|-----------|------| | IPv4 | `0x0800` | IP 数据包 | | IPv6 | `0x86DD` | IPv6 数据包 | | ARP | `0x0806` | 地址解析协议 | | RARP | `0x8035` | 反向 ARP(历史遗留) | -| PPPoE | `0x8864` | PPP over Ethernet | +| PPPoE Discovery | `0x8863` | PPPoE 发现阶段 | +| PPPoE Session | `0x8864` | PPPoE 会话阶段 | | 802.1Q | `0x8100` | VLAN 标记 | | LACP | `0x8809` | 链路聚合控制协议 | +| LLDP | `0x88CC` | 链路层发现协议 | + +### EtherType vs Length:如何区分? + +这是以太网中一个经典的历史遗留问题。Ethernet II 和 IEEE 802.3 原始标准在同一个 2 字节位置上使用了**不同的语义**: + +| 值范围 | 含义 | 对应标准 | +|--------|------|----------| +| ≥ `0x0600` (1536) | **EtherType** — 上层协议标识 | Ethernet II | +| ≤ `0x05DC` (1500) | **Length** — Payload 字节数 | IEEE 802.3 LLC/SNAP | + +由于合法的 Payload 长度最大为 1500 字节,而合法的 EtherType 最小为 1536,两者不会重叠。**现代网络中绝大多数帧都是 Ethernet II 格式**,802.3 LLC/SNAP 仅在一些特殊场景(如 STP、某些工业协议)中出现。 + +> [!question] 如果你用 Wireshark 抓到一个该字段值为 0x05EE 的帧,它是什么? +> `0x05EE` = 1518,落在 1501–1535 之间——这是一个非法值,正常的以太网实现不会产生这样的帧。 ## MAC 地址空间与 OUI @@ -83,22 +131,69 @@ link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff # Linux 也可以用 ethtool -P eth0 查看出厂 MAC ``` -### 单播/多播位(LSB of First Byte) +### 第一个字节的两个重要比特位 -第一个字节的最低比特位决定寻址类型: +MAC 地址的第一个字节中,**最低两位**各自承载独立含义(注意:在网络传输中 MAC 地址按 **LSB first** 顺序发送,但日常书写采用 MSB first): -| LSB | 类型 | 示例 | -|-----|------|------| -| 0 | 单播 (Unicast) | `aa:bb:cc:dd:ee:ff` | -| 1 | 多播 (Multicast) | `01:00:5e:xx:xx:xx`(IPv4 组播) | -| 1 | 广播 (Broadcast) | `ff:ff:ff:ff:ff:ff` | +``` +第一字节: b7 b6 b5 b4 b3 b2 b1 b0 + ↑ I/G 位 (Individual/Group) + ↑ U/L 位 (Universal/Local) +``` + +#### I/G bit(bit 0)— 单播/组播 + +| 值 | 类型 | 说明 | 示例 | +|----|------|------|------| +| 0 | 单播 (Unicast) | 发给单一设备 | `aa:bb:cc:dd:ee:ff` | +| 1 | 组播 (Multicast) | 发给一组设备 | `01:00:5e:xx:xx:xx`(IPv4 组播) | + +广播地址 `ff:ff:ff:ff:ff:ff` 是组播的特例(所有 bit 为 1)。 + +#### U/L bit(bit 1)— 全局/本地管理 + +| 值 | 类型 | 说明 | +|----|------|------| +| 0 | 全局管理 (GUA) | 由 IEEE 分配 OUI,厂商保证唯一(网卡出厂 MAC) | +| 1 | 本地管理 (LAA) | 管理员手动设置或软件生成(如 Docker 容器 MAC、虚拟网卡) | > [!tip] 如何快速判断?看 MAC 第一个字节的十六进制最后一位 -> - `a` → 1010 → LSB=0 → 单播 -> - `3` → 0011 → LSB=1 → 多播/广播 +> - `a` → 101**0** → I/G=0, U/L=0 → 单播 + 全局管理 +> - `3` → 001**1** → I/G=1, U/L=1 → 组播 + 本地管理 +> - `2` → 001**0** → I/G=0, U/L=1 → 单播 + 本地管理(如 Docker 生成的 MAC) + +### MAC 地址的比特序 + +MAC 地址在网线上传输时采用 **LSB first**(小端序),这与我们日常书写 `aa:bb:cc:dd:ee:ff` 的 MSB first 习惯相反。这也解释了为什么 IPv4 组播 MAC 的前缀是 `01:00:5e` 而不是 `01:00:5f`——因为 `01` 的实际传输顺序是 `10000000`,I/G 位确实是第 1 个被发出的比特。 + +## MTU、MSS 与 Jumbo Frame + +### 关键概念区分 + +| 概念 | 层级 | 标准值 | 说明 | +|------|------|--------|------| +| **MTU** (Maximum Transmission Unit) | 链路层 | 1500 bytes | 帧中 Payload 的最大长度(不含 Header 和 FCS) | +| **MSS** (Maximum Segment Size) | 传输层 (TCP) | 1460 bytes | TCP 数据段的最大载荷,= MTU - 20(IP) - 20(TCP) | +| **Max Frame** | 链路层 | 1518 bytes | 整个以太网帧(含 Header 14B + FCS 4B) | + +### Jumbo Frame(巨型帧) + +部分交换机和网卡支持 **Jumbo Frame**,将 MTU 提升到 **9000 字节**(甚至更大): + +| 帧类型 | MTU | 最大帧长 | 适用场景 | +|--------|-----|----------|----------| +| 标准以太网 | 1500 B | 1518 B | 通用网络 | +| Jumbo Frame | 9000 B | 9018 B | 数据中心、iSCSI、NFS 存储网络 | + +> [!warning] Jumbo Frame 的使用陷阱 +> 1. **全链路必须一致**:路径上任何一台设备不支持 Jumbo Frame,就会导致分片或丢包 +> 2. **非标准协议**:IEEE 从未正式标准化 Jumbo Frame(>1500 的 MTU),各家厂商实现略有差异 +> 3. **实际收益**:减少帧头开销比例,CPU 中断次数降低,在大数据传输场景下吞吐量提升 10%–30% ## 关联笔记 -- [[hhs/NETWORK/OSI与TCP-IP模型对比]] — 以太网属于链路层 -- [[hhs/NETWORK/MAC地址与广播域]] — MAC 地址的工作范围 -- [[hhs/NETWORK/Switch与路由器]] — Switch 如何使用 MAC 表转发帧 +- [[hhs/NETWORK/01-基础概念/01-OSI与TCP-IP模型对比]] — 以太网属于链路层 +- [[hhs/NETWORK/02-链路层/03-MAC地址与广播域]] — MAC 地址的工作范围 +- [[hhs/NETWORK/02-链路层/04-Switch与路由器]] — Switch 如何使用 MAC 表转发帧 +- [[hhs/NETWORK/02-链路层/02-CSMA-CD与以太网退避]] — CSMA/CD 碰撞检测机制 +- [[hhs/NETWORK/02-链路层/05-VLAN与Trunk]] — 802.1Q VLAN 的实际部署 diff --git a/hhs/NETWORK/02-链路层/02-CSMA-CD与以太网退避.md b/hhs/NETWORK/02-链路层/02-CSMA-CD与以太网退避.md index 03bd84d..d14196a 100644 --- a/hhs/NETWORK/02-链路层/02-CSMA-CD与以太网退避.md +++ b/hhs/NETWORK/02-链路层/02-CSMA-CD与以太网退避.md @@ -1,5 +1,5 @@ --- -tags: [计算机网络, CSMA-CD, 以太网退避, 冲突检测] +tags: [计算机网络, CSMA-CD, 以太网退避, 冲突检测, 碰撞域, 全双工, 半双工] create time: 2026-05-17 23:00 --- @@ -40,19 +40,39 @@ flowchart TD | 3 | **Collision Detection** | 发送过程中持续监测是否有冲突信号 | | 4 | **Collision Handling** | 检测到冲突 → 发 Jam Signal → 执行退避 → 重试 | +## Slot Time(时隙) + +在讲解退避算法之前,先认识一个核心概念——**Slot Time(时隙/争用期)**: + +$$\text{Slot Time} = 512 \text{ bit-time} = 51.2 \mu s \quad (\text{在 10Mbps 以太网下})$$ + +时隙是 CSMA/CD 中所有时间计算的基本单位,它的值约等于**最大往返传播延迟(~46.4μs)+ Jam Signal 时间(~3.2μs)+ 安全余量**。退避算法中的等待时间、最小帧长、冲突检测窗口,全部以它为基准。 + +> [!QUESTION] 为什么 Slot Time 要比理论往返延迟长一点? +> 因为检测到冲突后还需要发送 Jam Signal 确保所有节点都感知到碰撞。如果时隙刚好等于往返延迟,发送方在时隙结束时还没完成 Jam Signal 的发送,就可能误以为"没冲突"。 + +## Jam Signal(强化冲突信号) + +检测到冲突后,发送方会立刻发出一个 **32 bit 的 Jam Signal**,目的有二: + +1. **强化冲突**:确保网络上所有节点(包括距冲突点较远的节点)都能检测到碰撞 +2. **延长冲突持续时间**:使冲突信号维持足够长的时间,避免有节点"刚好错过"碰撞窗口 + +Jam Signal 的传输时间约 3.2μs(10Mbps),已包含在 Slot Time 的计算中。 + ## 二进制指数退避算法 -冲突后的等待时间通过随机退避来避免再次冲突: +冲突后的等待时间通过**随机退避**来避免再次碰撞——选到相同随机数的概率随窗口增大而降低: -$$\text{等待时间} = \min(r, 2^{10}-1) \times 512 \text{ bit-time}$$ +$$\text{backoff} = r \times \text{Slot Time},\quad r \in \{0, 1, \ldots, \min(2^k - 1, 1023)\}$$ -其中 $r$ 为重传次数,`512 bit-time` 是最小帧长对应的传输时间(即争用期)。 +其中 $k$ 为该帧的重传次数。每次冲突后窗口翻倍(指数增长),上限固定在 1023。 ```mermaid flowchart TD - k["重传次数 k"] --> r["r = random(0 ~ 2^k - 1)"] - r --> capped["capped at 2^10 - 1 = 1023 (k > 10)"] - capped --> backoff["backoff_time = r × 512 bit-time"] + k["重传次数 k"] --> window["窗口大小 min(2^k - 1, 1023)"] + window --> r["r = random(0 ~ 窗口大小)"] + r --> backoff["backoff = r × 51.2μs"] style k fill:#DDA0DD,color:#000 style backoff fill:#98FB98,color:#000 @@ -60,69 +80,140 @@ flowchart TD ### 退避表 -| 重传次数 k | 窗口大小 2^k - 1 | 可能等待的 slot 数范围 | -|-----------|------------------|---------------------| -| 1 | 1 | {0, 1} | -| 2 | 3 | {0, 1, 2, 3} | -| 3 | 7 | {0..7} | -| 4 | 15 | {0..15} | -| 5–10 | 最大值 1023 | {0..1023} | -| >10 | 仍为 1023 | 上限固定 | +| 重传次数 k | 窗口大小 min(2^k - 1, 1023) | r 的取值范围 | 最大等待时间 | +|-----------|---------------------------|-------------|------------| +| 1 | 1 | {0, 1} | 51.2μs | +| 2 | 3 | {0..3} | 153.6μs | +| 3 | 7 | {0..7} | 358.4μs | +| 4 | 15 | {0..15} | 768μs | +| 5–10 | 2^k - 1 → 1023 | {0..1023} | 52.4ms | +| >10 | 1023(上限固定) | {0..1023} | 52.4ms | -如果重传 16 次仍然失败,帧被丢弃并向上层报告错误。 +> [!tip] 为什么窗口要"封顶"在 1023? +> 如果不封顶,k=16 时窗口可达 65535,等待时间超过 3 秒——太久了。封顶保证了最坏情况下的重传延迟有上界,同时也意味着高冲突率下不同节点的退避选择可能重复,这就是网络严重拥塞时的"公平性代价"。 + +如果重传 **16 次**仍然失败,帧被丢弃并向上层报告错误。 ### 示例 假设节点 A 和 B 同时发送,发生冲突: ``` -时刻 0ms: A 和 B 同时发送 → 冲突! - 两者都发送 32bit Jam Signal -时刻 0.1ms: A 选 r=3 → 等 3×512bit-time = 3×51.2μs ≈ 154μs - B 选 r=1 → 等 1×51.2μs ≈ 51μs -时刻 0.05ms: B 先等完,先尝试发送 → A 还在等 -时刻 0.15ms: A 等完,发现信道已被 B 占用 → 退避重选 r -... +时刻 0μs: A 和 B 同时开始发送 +时刻 23.2μs: A 检测到冲突(假设距冲突点约 23.2μs) + A 和 B 均发送 Jam Signal (32bit, 约 3.2μs) + 此时 Slot Time 计时仍在继续... +时刻 51.2μs: Slot Time 结束,进入退避期 + A 选 r=3 → 等 3 × 51.2μs = 153.6μs + B 选 r=1 → 等 1 × 51.2μs = 51.2μs +时刻 102.4μs: B 先等完 → 检测信道空闲 → 发送 ✅ +时刻 204.8μs: A 等完 → 检测信道忙碌 → 等待... + (A 的本次重传计数 k=2,下次窗口扩大为 {0..3}) ``` +> [!QUESTION] 第二次冲突时,A 和 B 还是同时选到相同 r 怎么办? +> 第二次冲突时窗口翻倍到 {0..3},选到相同值的概率从 1/2 降到 1/4。指数退避的核心思想就是:**冲突越频繁,等待范围越大,再次碰撞的概率越低**。当然,极端情况下仍可能连续冲突——这就是为什么 16 次后要放弃。 + ## 最小帧长与争用期 ### 为什么最小帧是 64 字节? -关键公式:**帧传输时间 ≥ 2 × 端到端传播延迟** +核心原则:**帧传输时间 ≥ 往返传播延迟** -如果帧太短,可能在冲突信号到达之前就已经发完了——发送方根本不知道发生过冲突。 +如果帧太短,发送方可能在冲突信号返回之前就已经发完了——根本不知道发生过冲突,也就无法重传。 ```mermaid sequenceDiagram - participant A as 节点 A - participant H as 最远节点 H - Note over A,H: 传播延迟 = τ + participant A as "节点 A" + participant H as "最远节点 H" + Note over A,H: "传播延迟 τ" - A->>H: 开始发送一个超短帧 (长度 < 2τ) - H->>A: 冲突信号沿原路返回 - Note over A: 此时 A 已经发完了...
没检测到冲突!🔥 + A->>H: "发送一个超短帧 (长度 < 2τ)" + H->>A: "冲突信号返回" + Note over A: "此时 A 已经发完了...
没检测到冲突!" ``` -所以规定: +所以必须满足: -$$\text{最小帧长} \geq 2 \times \tau \times \text{带宽}$$ +$$\text{最小帧长} \geq 2 \times \tau_{\max} \times \text{带宽}$$ -对于经典 10Mbps 以太网,最长电缆段 2500m,$\tau \approx 12.5\mu s$: +其中 $\tau_{\max}$ 是网络中最远两个节点之间的**单程传播延迟**。 -$$\text{最小帧长} \geq 2 \times 12.5\mu s \times 10^7 \text{ bps} = 250,000 \text{ bits?}$$ +### 从理论推导到工程标准 -等等,实际工程中使用了中继器和更短的网段组合,最终标准定为 **512 bit-time = 64 bytes**。 +对于经典 10Mbps 以太网(10BASE5 粗缆 + 4 个中继器,总跨度约 2500m): -## 现代以太网中的命运 +$$\tau_{\max} \approx 46.4\mu s \quad \text{(含中继器延迟)}$$ -| 年代 | 拓扑 | 介质 | CSMA/CD? | -|------|------|------|---------| -| 1980s | 总线型 | 同轴电缆 | ✅ 必需 | -| 1990s | 星型 + Hub | 双绞线 | ✅ 存在但极少冲突 | -| 2000s+ | 全双工 Switch | 双绞线/光纤 | ❌ 已禁用 | +代入公式: -全双工模式下,收发通道分离(两根线对),不存在碰撞的可能,CSMA/CD 自动关闭。从 Gigabit Ethernet (802.3ab) 起,交换机端口默认全双工运行。 +$$\text{最小帧长} \geq 46.4\mu s \times 10^7 \text{ bps} \approx 464 \text{ bits}$$ + +IEEE 在此基础上统一向上取整,取了一个**干净的 2 的幂次方**——512 bits(64 bytes),这也是 Slot Time 的值。多出的 48 bits 作为安全余量,同时覆盖了 Jam Signal 的传输时间。 + +> [!tip] 帧长与 Slot Time 的关系 +> 最小帧长 = Slot Time × 带宽 = 512 bit-time × 1 bps = **512 bits = 64 bytes** +> +> 这不是巧合——最小帧长的设计保证了:只要帧还在传输中(长度 ≥ Slot Time),冲突就一定能被检测到。退避算法以 Slot Time 为单位,最小帧长以 Slot Time 为下限,二者在设计上是统一的。 + +### 碰撞域(Collision Domain) + +理解 CSMA/CD 就不得不理解**碰撞域**——所有共享同一冲突检测范围的节点组成一个碰撞域: + +```mermaid +graph LR + subgraph CD1["碰撞域 1 (Hub 连接)"] + A["节点 A"] --- Hub["Hub 集线器"] + B["节点 B"] --- Hub + C["节点 C"] --- Hub + end + Switch["交换机"] --- Hub + Switch --- D["节点 D"] + Switch --- E["节点 E"] + subgraph CD2["碰撞域 2"] + D + E + end + + style CD1 fill:#FFE4E1,stroke:#FF6B6B,stroke-width:2px + style CD2 fill:#E1F5FE,stroke:#4FC3F7,stroke-width:2px +``` + +- **Hub**:不隔离碰撞域——所有连接到同一 Hub 的设备属于**同一个**碰撞域 +- **Switch**:每个端口是一个独立碰撞域,从根本上消除了 CSMA/CD 的需求 +- **广播域**则不同,由 VLAN 划分,和 CSMA/CD 无关(详见 [[05-VLAN与Trunk]]) + +## 半双工 vs 全双工 + +CSMA/CD 的"监听-发送-检测-退避"机制本质上是**半双工**的——同一时间只能收或发,不能同时进行。全双工模式通过物理上分离收发通道彻底绕开了这个问题: + +| 模式 | 通道 | 冲突 | CSMA/CD | 典型场景 | +|------|------|------|---------|---------| +| 半双工 (Hub) | 共享 | 存在 | 必需 | 早期总线/Hub 网络 | +| 全双工 (Switch) | 收发分离 | 不存在 | 自动关闭 | 现代交换网络 | + +从 **Gigabit Ethernet (802.3ab)** 起,交换机端口默认全双工运行。虽然标准中仍保留了半双工 CSMA/CD 定义(用于兼容),但实际网络中已几乎不再使用。 + +```mermaid +graph LR + subgraph HalfDuplex["半双工 (Hub)"] + direction LR + H1["节点"] -- "发送" --> H2["Hub"] + H2 -- "接收" --> H1 + end + subgraph FullDuplex["全双工 (Switch)"] + direction LR + S1["节点"] -- "发送" --> S2["Switch"] + S2 -- "接收" --> S1 + end + + style HalfDuplex fill:#FFF3E0,stroke:#FF9800 + style FullDuplex fill:#E8F5E9,stroke:#4CAF50 +``` + +> [!note] 半双工 vs 全双工的本质区别 +> - **半双工**:收发共用一条通道,同一时刻只能单向通信,需要 CSMA/CD 协调 +> - **全双工**:收发各有独立通道(物理上两对线),可同时双向通信,CSMA/CD 自动关闭 > [!note] 如何检查当前网卡是否启用了 CSMA/CD? > ```bash @@ -130,9 +221,11 @@ $$\text{最小帧长} \geq 2 \times 12.5\mu s \times 10^7 \text{ bps} = 250,000 > Supports Collision Detection: yes > Current Collision Detection: disabled (full-duplex) > ``` +> 现代网卡通常显示 "disabled (full-duplex)",说明 CSMA/CD 已关闭。 ## 关联笔记 -- [[hhs/NETWORK/Ethernet帧结构]] — 以太网 II 帧的格式定义 -- [[hhs/NETWORK/Switch与路由器]] — Switch 如何通过全双工消除冲突 -- [[hhs/NETWORK/VLAN与Trunk]] — VLAN 隔离冲突域 +- [[01-Ethernet帧结构]] — 以太网 II 帧的格式定义,64 字节最小帧长的来源 +- [[04-Switch与路由器]] — Switch 如何通过全双工消除冲突 +- [[05-VLAN与Trunk]] — VLAN 如何隔离广播域 +- [[03-MAC地址与广播域]] — 广播域与碰撞域的区别 diff --git a/hhs/Redis/06-主从与哨兵.md b/hhs/Redis/06-主从与哨兵.md index 51d3d7b..96d089d 100644 --- a/hhs/Redis/06-主从与哨兵.md +++ b/hhs/Redis/06-主从与哨兵.md @@ -107,56 +107,156 @@ if replicaCount >= 1 { ### 全量同步 vs 增量同步 -主从同步分为两种模式,理解它们的前提是知道一个关键概念:**Replication Offset(复制偏移量)**——你可以把它想象成一条数据"流水线"上的**刻度尺**。Master 每写入一条命令,刻度就往后移一点;Replica 同步到哪里了,也有自己的刻度。只要两个刻度对得上,就能"增量同步"。 +回到"抄作业"的类比:你缺了一整学期的笔记,那就得**全量抄一本**(全量同步);你只是昨天请了一天假,那就**只抄昨天那几页**(增量同步)。Redis 的主从同步也一样,分这两种模式: -> [!NOTE] PSync:一次连接,多次复用 -> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。旧版 SYNC 每次断线都得全量传输(相当于每次请假都得抄一整本笔记),PSYNC 改为"只抄你缺的那几页"。 +- **全量同步(Full Resync)**:Master 把**整个数据集**打包成 RDB 文件发给 Replica。适用于首次连接、断线太久等场景。代价大,应尽量避免 +- **增量同步(Partial Resync)**:Master 只发送 **Replica 缺失的那部分命令**。适用于正常运行期间的短暂断线。代价小,是常态 + +> [!QUESTION] 那 Redis 怎么判断应该走"全量"还是"增量"呢? +> 这就要说到两个关键的"身份标识"——Run ID 和 Replication Offset。 + +#### 前置概念:两个关键标识 + +要理解全量/增量的判断逻辑,需要先搞清楚两个东西: + +**1. Run ID —— "你是哪个 Master?"** + +每个 Redis 实例启动时会随机生成一个 40 字符的唯一标识符(如 `8b1f8a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a`)。Replica 第一次连接 Master 时会记住它的 Run ID。之后每次重连,Replica 先报出"我记得的 Run ID",Master 对比: + +- **Run ID 匹配** → "你还认识我" → 有可能增量同步 +- **Run ID 不匹配** → "我不认识你"(Master 重启了 / 换了台机器)→ 只能全量同步 + +**2. Replication Offset —— "你同步到哪了?"** + +你可以把它想象成一条**数据流水线上的刻度尺**。Master 和 Replica 各自维护一个 offset: + +- Master 每写入一条命令,offset 就往后移(比如从 `1000` 到 `1050`) +- Replica 每同步一条命令,自己的 offset 也往后移 +- 只要 Master 的 **backlog 缓冲区** 里还保留着 Replica 需要的那段数据(即 Replica 的 offset 在 backlog 覆盖范围内),就能增量同步 + +```mermaid +flowchart TD + subgraph Identity["两个身份标识"] + RunID["Run ID
回答: 你是哪个 Master?"] + Offset["Replication Offset
回答: 我同步到哪了?"] + end + + RunID -->|"匹配"| CheckOffset["检查 Offset"] + RunID -->|"不匹配"| FullSync["只能全量同步"] + CheckOffset -->|"Offset 在 backlog 范围内"| PartialSync["增量同步"] + CheckOffset -->|"Offset 已被覆盖"| FullSync +``` + +> [!NOTE] PSYNC 协议:让增量同步成为可能 +> Redis 2.8 之前用的是 `SYNC` 命令,**每次断线都全量传输**——相当于你每次请假都得抄一整本笔记,不管你只缺一天还是缺一个月。Redis 2.8+ 引入了 `PSYNC` 协议,Replica 重连时带上 Run ID 和自己的 Offset,Master 就能判断"你只缺了一点"还是"你缺太多了",从而决定增量还是全量。 + +#### 全量同步:逐步拆解 + +全量同步发生在 **首次连接** 或 **断线太久导致增量同步不可能** 的场景。整个过程分为三个阶段: ```mermaid sequenceDiagram participant R as Replica participant M as Master - Note over R,M: "阶段 1: 全量同步(首次连接或断线过久)" - R->>M: PSYNC ? -1 (首次连接, 没有 offset 记录) - M->>M: 触发 BGSAVE, 生成 RDB 快照文件 - M-->>R: +FULLRESYNC , 然后发送 RDB 文件 + Note over R,M: "阶段 1: 握手与身份确认" + R->>M: PSYNC ? -1 + Note right of R: "? = 我不知道你的 Run ID
-1 = 我没有 offset 记录" + M->>M: 执行 BGSAVE 生成 RDB 快照 + M-->>R: "+FULLRESYNC " + Note left of M: "意思是: 好, 全量同步,
我的 Run ID 是 xxx,
当前 offset 是 yyy" + M-->>R: 发送 RDB 文件 R->>R: 清空旧数据, 用 RDB 全量覆盖 - Note over M: "RDB 生成期间, 新的写入命令不能丢" + Note over R,M: "阶段 2: RDB 传输期间的新写入不能丢" + Note over M: "BGSAVE 期间, 客户端仍在写入" loop "RDB 传输期间的新写入" - M->>M: 追加到 repl-backlog-buffer + M->>M: 新命令追加到 repl-backlog-buffer end - M-->>R: 发送 backlog 中缓存的命令流 - R->>R: 重放缓冲命令, 追上 Master 的进度 + Note over R,M: "阶段 3: 发送缓冲命令, 追赶进度" + M-->>R: 发送 backlog 中缓存的增量命令 + R->>R: 逐条重放, 追上 Master 最新进度 +``` - Note over R,M: "阶段 2: 增量同步(后续心跳, 每秒一次)" - loop "正常运行期间" - R->>M: PSYNC - M->>R: 只发送 offset 之后的增量命令 +> [!NOTE] 阶段 2 为什么不会丢数据? +> 你可能会担心:BGSAVE 是某一时刻的快照,那快照之后到 RDB 传输完成这段时间的写入怎么办?答案是 **backlog 缓冲区**。Master 在生成 RDB 的同时,会把所有新写入的命令追加到 backlog 里,等 RDB 传完后再把这些缓冲命令发给 Replica。所以 Replica 先拿到"截止到某个时间点的完整快照",再拿到"快照之后的增量命令",两者拼起来就是完整的最新数据。 + +#### 增量同步:逐步拆解 + +增量同步是 **正常运行时的常态**。Replica 短暂断线后重连,只需要补齐断线期间缺失的命令: + +```mermaid +sequenceDiagram + participant R as Replica + participant M as Master + + Note over R,M: "Replica 短暂断线后重连" + R->>M: PSYNC + Note right of R: "我认识你, Run ID 是 xxx,
我的 offset 是 10000" + M->>M: 检查 runId 匹配
检查 offset 10000 是否在 backlog 范围内 + + alt "offset 在 backlog 范围内 → 增量同步" + M-->>R: "+CONTINUE" + M-->>R: 发送 offset 10000 之后的增量命令 + Note left of M: "只发你缺的 10001 ~ 10500,
不用重传整个数据集" + R->>R: 重放增量命令, offset 追到 10500 + else "offset 已被覆盖 → 退化为全量同步" + M-->>R: "+FULLRESYNC" + Note left of M: "你缺的太多了, 增量同步
不可能了, 转为全量重传" end ``` -> [!QUESTION] 全量同步的代价是什么? -> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。 +> [!QUESTION] 怎么理解"offset 在 backlog 范围内"? +> 回到"圆形笔记本"的比喻——backlog 是一个固定大小的环形缓冲区(默认仅 1MB)。Master 不断往里写新命令,写满了就从头覆盖最旧的内容。当 Replica 断线重连时,它报出自己的 offset,Master 检查: +> +> - 这个 offset 对应的数据**还在 backlog 里**(没被覆盖)→ 只需发送 backlog 中这段数据 → 增量同步 +> - 这个 offset 对应的数据**已经被覆盖了**(断线太久,backlog 装不下)→ 只能全量同步 + +#### PSYNC 完整决策流程 + +把上面的逻辑串起来,PSYNC 的完整判断过程如下: + +```mermaid +flowchart TD + Start["Replica 发送
PSYNC "] --> CheckRunID{"runId 匹配?"} + + CheckRunID -->|"不匹配
(Master 重启或新 Master)"| FullSync["返回 +FULLRESYNC
全量同步"] + + CheckRunID -->|"匹配"| CheckOffset{"offset 对应的数据
还在 backlog 里?"} + + CheckOffset -->|"是"| PartialSync["返回 +CONTINUE
增量同步"] + CheckOffset -->|"否
(已被覆盖)"| FullSync + + CheckRunID -->|"runId = '?'
(首次连接)"| FullSync +``` + +> [!NOTE] 一句话总结 PSYNC 的决策逻辑 +> 先看你"认不认识我"(Run ID),再看你"缺的那部分我还记不记得"(Offset 是否在 backlog 内)。两个条件都满足才走增量,否则全量。 + +#### 全量同步的代价 + +> [!QUESTION] 全量同步开销有多大? +> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。更糟糕的是,如果多个 Replica 同时触发全量同步,Master 会被拖垮。 +> +> 所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。 #### 什么情况下触发全量同步? | 触发条件 | 为什么? | 能避免吗? | |---------|------|------| -| **首次连接** | Replica 是空的,没有数据也没有 offset,只能全量下载 | ❌ 必然发生 | -| **Master 重启** | Master 重启后 Run ID 变了,Replica 认不出"老朋友" | ⚠️ 持久化可缓解(重启后 Run ID 不变) | -| **Replica 手动执行 SLAVEOF** | 相当于主动"认新主人",必须重新来过 | ❌ 手动触发,通常可控 | -| **Backlog 溢出** | 断线太久,Master 的"缓冲笔记本"已经翻页覆盖了断线前的内容 | ✅ **增大 `repl-backlog-size`** | -| **config-resetstat 执行** | 元数据被重置,offset 信息丢失 | ⚠️ 少用此命令 | +| **首次连接** | Replica 是空的,没有 Run ID 也没有 offset,PSYNC 只能发 `? -1` | ❌ 必然发生 | +| **Master 重启** | 重启后 Run ID 变了,Replica 发的旧 Run ID 匹配不上 | ⚠️ 持久化可缓解(配置 `save` 后重启 Run ID 不变) | +| **Replica 执行 SLAVEOF 指向新 Master** | 新 Master 的 Run ID 和旧的不同,必须全量重来 | ❌ 手动触发,通常可控 | +| **Backlog 溢出** | 断线太久,Replica 需要的 offset 已被环形缓冲区覆盖 | ✅ **增大 `repl-backlog-size`** | +| **config-resetstat 执行** | 重置 INFO 统计计数器,可能干扰监控判断,但不会直接影响复制 offset | ⚠️ 少用此命令 | > [!WARNING] 生产环境最常见的"不必要全量同步" -> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**。 +> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**,具体配置和估算方法见下一节。 ### 复制背压(Replication Backlog) -每个 Master 内部维护一个固定大小的 **环形缓冲区**(默认仅 1MB),这是实现增量同步的关键: +每个 Master 内部维护一个固定大小的 **环形缓冲区**(64-bit 系统上默认 1MB,Redis 7.0+ 默认 64MB),这是实现增量同步的关键: ```mermaid flowchart LR @@ -180,6 +280,69 @@ repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒) - backlog = 1MB → 约支持 **2 秒** 断线恢复 - backlog = 256MB → 约支持 **8 分钟** 断线恢复 +### REPLCONF ACK:Master 如何感知 Replica 状态 + +> [!QUESTION] Master 一直在往 Replica 发数据,但它是单向发送的——Master 怎么知道 Replica 是否还活着、同步到哪了? +> 前面讲的都是 "Master → Replica" 的数据流。但复制是双向"互动"的:Master 需要知道每个 Replica 的实时状态,才能做运维决策(比如判断延迟、拒绝写入保护数据一致性)。这个感知能力靠的是 **Replica 主动向 Master 汇报**。 + +**工作原理**:Replica 每秒向 Master 发送一次 `REPLCONF ACK ` 命令,告诉 Master 两件事: + +1. **"我还活着"** — 心跳保活 +2. **"我的同步进度是 offset=xxx"** — 用于计算延迟 + +Master 收到后更新该 Replica 的状态。通过 `INFO replication` 可以看到: + +```text +connected_slaves:3 +slave0:ip=192.168.1.21,port=6379,state=online,offset=12345600,lag=0 +slave1:ip=192.168.1.22,port=6379,state=online,offset=12345600,lag=0 +slave2:ip=192.168.1.23,port=6379,state=online,offset=12344800,lag=2 +``` + +- **offset**:该 Replica 当前同步到哪里了(对比 Master 的 `master_repl_offset` 可知差距) +- **lag**:Master 有多久(秒)没收到这个 Replica 的 ACK,lag 越大说明 Replica 越"失联" + +如果 Master 在 `repl-timeout`(默认 60 秒)内没有收到某 Replica 的 ACK,就将其标记为 `state=offline`。 + +```mermaid +sequenceDiagram + participant R as Replica + participant M as Master + + Note over M: "Master 持续写入, offset 推进" + R->>M: REPLCONF ACK 10000 + Note left of M: "记录: slave0 offset=10000, lag=0" + M->>M: 继续写入, offset 到 10100 + R->>M: REPLCONF ACK 10050 + Note left of M: "记录: slave0 offset=10050, lag=1" + + Note over R: "Replica 断线..." + Note over M: "repl-timeout(60s) 内没收到 ACK" + Note left of M: "标记: slave0 state=offline" +``` + +> [!NOTE] REPLCONF ACK 的实际意义:写入保护 +> 这个机制不只是"看看状态"——它直接支撑了**脑裂场景下的写入保护**。配合以下配置: +> ```conf +> min-replicas-to-write 1 # 至少 1 个 Replica 在线 +> min-replicas-max-lag 10 # 且 lag 不超过 10 秒 +> ``` +> Master 根据 REPLCONF ACK 汇报的 lag 值判断:**在线且低延迟的 Replica 数量不满足条件时,Master 直接拒绝写入**。这样即使 Master 被网络隔离(脑裂),它也不会默默接受写入——因为没有 Replica 能同步这些数据,写入最终会丢失。 +> +> 简单说:**没有 REPLCONF ACK → Master 对 Replica 状态一无所知 → 无法做写入保护 → 脑裂时必然丢数据**。 + +> [!QUESTION] REPLCONF ACK 和 Sentinel 的心跳有什么区别? +> 两者都是"心跳检测",但**方向和目的不同**: +> +> | 对比 | REPLCONF ACK | Sentinel PING | +> |------|-------------|---------------| +> | 方向 | Replica → Master | Sentinel → Master/Replica | +> | 频率 | 每秒 1 次 | 每秒 1 次 | +> | 目的 | Master 感知 Replica 存活和同步进度 | Sentinel 感知节点存活,触发故障转移 | +> | 判定失联后 | Master 标记 Replica 为 offline,触发 min-replicas 保护 | Sentinel 标记 SDOWN/ODOWN,触发选主和 Failover | +> +> 两者互补,缺一不可。 + ### 大 Key 问题 > [!QUESTION] 一个 key 有 5MB 大小,会有什么问题? @@ -282,10 +445,15 @@ replica-lazy-flush no # 确保同步前快速清理旧数据 ```conf # 适用于金融、账务等场景 -WAIT 1 100 # 写入时确认至少 1 个副本成功 repl-backlog-size 512mb # 更大缓冲区容纳更多增量命令 ``` +```go +// WAIT 是运行时命令,不能写在 redis.conf 里,需在业务代码中调用 +// 写入后等待至少 1 个副本确认同步,最多阻塞 100ms +client.Do(ctx, "WAIT", 1, 100) +``` + ### 常用调试命令 ```bash @@ -413,8 +581,8 @@ flowchart TD | 优先级 | 评估维度 | 含义 | 举例 | |--------|---------|------|------| -| 1(最高) | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A | -| 2 | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" | +| 1(最高) | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" | +| 2 | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A | | 3(最低) | **Run ID 字典序最小** | 纯粹的 tie-breaker | 两个 Replica 前两项完全相同,选 Run ID 字母序靠前的 | > [!WARNING] 如果所有 Replica 都不健康怎么办? diff --git a/hhs/Redis/09-高级特性.md b/hhs/Redis/09-高级特性.md index e6c9db0..110cc67 100644 --- a/hhs/Redis/09-高级特性.md +++ b/hhs/Redis/09-高级特性.md @@ -141,6 +141,9 @@ if ok == 1 { } ``` +> [!tip] 分布式锁深入阅读 +> 本节仅展示 Lua 实现分布式锁的核心代码。关于安全解锁、锁续期(Watchdog)、Redlock 算法、可重入锁等进阶内容,详见 [[hhs/Redis/09-高级特性/分布式锁]]。 + ### 经典场景 2:限流器(令牌桶简化版) ```lua @@ -465,6 +468,7 @@ flowchart TD ## 关联笔记 +- [[hhs/Redis/09-高级特性/分布式锁]] — 分布式锁完整解析(Redlock、Watchdog、可重入锁) - [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳) - [[hhs/Redis/07-集群方案]] — Cluster 下 Pipeline/Lua 的限制 - [[hhs/Redis/README]] — 知识索引总览 diff --git a/hhs/Redis/09-高级特性/分布式锁.md b/hhs/Redis/09-高级特性/分布式锁.md new file mode 100644 index 0000000..4febdbc --- /dev/null +++ b/hhs/Redis/09-高级特性/分布式锁.md @@ -0,0 +1,389 @@ +--- +tags: [Redis, 分布式锁, 分布式系统, 一致性] +create time: 2026-05-26 23:41 +--- + +# 分布式锁 + +## 概述 + +分布式锁是分布式系统中协调多节点访问共享资源的核心机制。Redis 凭借单线程模型和高性能成为实现分布式锁的首选方案,但「用 SET NX 就够了」的想法往往埋下隐患。本文从最基础的实现出发,逐步剖析安全解锁、锁续期、Redlock 算法等关键问题,帮助你构建生产级的分布式锁。 + +## 一、为什么需要分布式锁? + +在单机环境下,一个 `sync.Mutex` 或 `synchronized` 就能保护临界区。但在分布式场景下,多个服务实例部署在不同机器上,进程内的锁无法跨节点生效。 + +> [!QUESTION] 想象一个场景 +> 电商系统有 3 个订单服务实例,用户下单时都需要扣减库存。如果两个实例同时读到库存为 1,各扣减 1,最终库存变成 -1——超卖了。这就是典型的**并发竞态问题**。 + +分布式锁的核心语义很简单:**在同一时刻,只有一个客户端能持有锁**。但实现起来需要满足以下条件: + +| 条件 | 说明 | +|------|------| +| **互斥性** | 任意时刻只有一个客户端持有锁 | +| **无死锁** | 持有锁的客户端崩溃后,锁能自动释放 | +| **容错性** | 只要大部分节点存活,加锁/解锁就能正常工作 | +| **解铃还须系铃人** | 谁加的锁谁解,不能误删别人的锁 | + +## 二、基本实现:SET NX EX + +最简单也最常用的方案——利用 Redis 的 `SET` 命令附带 `NX` 和 `EX` 选项: + +```bash +SET lock:order:1001 $client_token NX EX 30 +# ↑ key ↑ 唯一标识 ↑ 不存在才设置 ↑ 30秒过期 +``` + +```go +// 加锁 +ok, err := rdb.SetNX(ctx, "lock:order:1001", clientToken, 30*time.Second).Result() +if !ok { + // 未获取到锁,处理重试或失败逻辑 + return errors.New("lock is held by another client") +} + +// 执行业务逻辑 +doBusiness() + +// 解锁(必须用 Lua 保证原子性!) +rdb.Eval(ctx, unlockScript, []string{"lock:order:1001"}, clientToken) +``` + +> [!WARNING] 为什么必须带 EX(过期时间)? +> 如果不设过期时间,持有锁的客户端崩溃后,这把锁将**永远不会释放**——其他客户端全部死等,形成死锁。`EX` 是分布式锁的安全网。 + +> [!QUESTION] 为什么 client_token 必须是全局唯一的? +> 假设你用固定字符串 `"my-lock"` 作为 value,那么任何客户端解锁时都无法判断这把锁是不是自己加的。UUID 或 Snowflake ID 能保证每个客户端的标识独一无二。 + +## 三、安全解锁:Lua 原子性释放 + +解锁看似简单——`DEL key` 就行?**不行**。考虑这个时序: + +```mermaid +sequenceDiagram + participant A as "Client A" + participant R as "Redis" + participant B as "Client B" + A->>R: GET lock → 自己的 token + Note over A: 业务执行太久... + Note over R: lock 过期自动释放 + B->>R: SET lock NX → 成功! + A->>R: DEL lock 💥 + Note over B: 我的锁被 A 删了! +``` + +**解决方案**:解锁时必须先检查 value 是否是自己的 token,且「检查 + 删除」必须原子化——这正是 Lua 脚本的强项: + +```lua +-- unlock.lua +-- KEYS[1] = lock key +-- ARGV[1] = client token +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +else + return 0 -- 不是我的锁,拒绝删除 +end +``` + +```go +var unlockScript = redis.NewScript(` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 +`) + +// 解锁时传入当初加锁用的同一个 token +result, _ := unlockScript.Run(ctx, rdb, []string{"lock:order:1001"}, clientToken).Int() +if result == 0 { + log.Warn("锁已过期或不属于当前客户端") +} +``` + +> [!TIP] 完整的加锁/解锁流程 +> 1. 生成唯一 token(UUID) +> 2. `SET key token NX EX 30` 加锁 +> 3. 执行业务逻辑 +> 4. Lua 脚本原子性检查 + 释放锁 +> 5. 无论成功失败,都要走到第 4 步(建议 `defer`) + +## 四、锁续期:Watchdog 机制 + +> [!QUESTION] 业务执行时间超过了锁的 TTL 怎么办? +> 如果锁设了 30 秒过期,但业务跑了 40 秒,第 30 秒时锁自动释放,其他客户端就能拿到锁——互斥性被打破。 + +Redisson(Java 生态中 Redis 客户端)的解决方案是 **Watchdog**:后台线程定期检查锁是否还被持有,如果是就续期。用 Go 模拟这个思路: + +```go +// Watchdog 自动续期(简化版) +func startWatchdog(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration) context.CancelFunc { + ctx, cancel := context.WithCancel(ctx) + go func() { + ticker := time.NewTicker(ttl / 3) // 每 TTL/3 检查一次 + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return // 锁已释放,停止续期 + case <-ticker.C: + // 原子续期:只有还持有锁才续 + renewed := renewScript.Run(ctx, rdb, []string{key}, token, int(ttl.Seconds())) + if renewed.Int() == 0 { + return // 锁已不属于我,停止续期 + } + } + } + }() + return cancel // 调用 cancel() 即停止 watchdog +} +``` + +```lua +-- renew.lua(原子续期) +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) +end +return 0 +``` + +```mermaid +flowchart LR + A["加锁 TTL=30s"] --> B["业务执行中"] + B -->|"T/3 = 10s"| C{"还持有锁?"} + C -->|"是"| D["续期 30s"] + D --> B + C -->|"否"| E["停止续期"] + B --> F["业务完成"] + F --> G["释放锁 + 停止 watchdog"] +``` + +> [!WARNING] Watchdog 不是银弹 +> - 如果续期时 Redis 节点刚好故障,续期请求会失败——锁仍会过期 +> - 真正需要超长锁持有时间时,考虑拆分业务逻辑,缩短临界区 +> - Watchdog 的 interval 建议设为 TTL 的 1/3,留出容错空间 + +## 五、Redlock 算法 + +### 单节点的隐患 + +上述方案都基于单个 Redis 实例。如果这个实例主从切换时,锁数据还没同步到从节点,锁就「凭空消失」了。 + +> [!QUESTION] 主从切换时锁丢失的例子 +> 1. Client A 在 master 上加锁成功 +> 2. Master 还没来得及把锁同步到 slave 就宕机了 +> 3. Slave 升级为新 master +> 4. Client B 在新 master 上加同一把锁——成功了! +> 结果:A 和 B 同时持有锁,互斥性被打破。 + +### Redlock 的思路 + +Antirez(Redis 作者)提出的 Redlock 算法:**向 N 个独立的 Redis 节点(非集群,互不通信)同时加锁**,当且仅当大多数节点(N/2 + 1)加锁成功且总耗时未超过锁的 TTL,才算获取成功。 + +```mermaid +flowchart TD + C["Client"] -->|"SET NX EX"| N1["Redis Node 1"] + C -->|"SET NX EX"| N2["Redis Node 2"] + C -->|"SET NX EX"| N3["Redis Node 3"] + C -->|"SET NX EX"| N4["Redis Node 4"] + C -->|"SET NX EX"| N5["Redis Node 5"] + N1 -->|"OK ✅"| C + N2 -->|"OK ✅"| C + N3 -->|"FAIL ❌"| C + N4 -->|"OK ✅"| C + N5 -->|"OK ✅"| C + Note over C: "4/5 成功 > 5/2+1=3,加锁成功!" +``` + +```go +// Redlock 伪代码(生产环境建议使用 redsync 等成熟库) +func redlock(ctx context.Context, clients []*redis.Client, key, token string, ttl time.Duration) bool { + successCount := 0 + startTime := time.Now() + + // 并发向所有节点发起加锁 + var wg sync.WaitGroup + var mu sync.Mutex + for _, c := range clients { + wg.Add(1) + go func(c *redis.Client) { + defer wg.Done() + ok, _ := c.SetNX(ctx, key, token, ttl).Result() + if ok { + mu.Lock() + successCount++ + mu.Unlock() + } + }(c) + } + wg.Wait() + + elapsed := time.Since(startTime) + quorum := len(clients)/2 + 1 + + // 加锁有效时间 = TTL - 加锁耗时 - 时钟漂移 + // 注意:elapsed 包含了网络往返时间,clock drift 需要根据实际环境估算(通常几毫秒) + validity := ttl - elapsed + return successCount >= quorum && validity > 0 +} +``` + +### Redlock 的争议 + +Martin Kleppmann 在 [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) 中指出了 Redlock 的几个问题: + +| 问题 | 说明 | +|------|------| +| **时钟跳跃** | NTP 同步导致节点时钟突变,锁的实际过期时间不可控 | +| **GC 停顿** | 客户端获取锁后进入 GC,锁在客户端视角未过期但实际已过期 | +| **复杂度高** | 需要部署 5 个独立 Redis 实例,运维成本远高于单节点 | + +### Fencing Token:Kleppmann 的解法 + +Kleppmann 在批评 Redlock 的同时,提出了一个更安全的思路——**Fencing Token(防护令牌)**。核心思想:锁服务每次授予锁时,递增一个单调递增的 token,客户端携带这个 token 去访问资源,资源端拒绝持有过期 token 的写入。 + +```mermaid +sequenceDiagram + participant L as "Lock Service" + participant A as "Client A" + participant B as "Client B" + participant S as "Storage" + A->>L: 获取锁 → token=33 + Note over A: GC 停顿... + Note over L: A 的锁过期 + B->>L: 获取锁 → token=34 + B->>S: 写入(token=34) ✅ 成功 + Note over A: GC 恢复,以为还持有锁 + A->>S: 写入(token=33) ❌ 被拒绝 + Note over S: "33 < 34,拒绝过期请求!" +``` + +> [!NOTE] 为什么 Fencing Token 比 Redlock 更安全? +> Redlock 依赖**时间假设**(所有节点时钟一致),Fencing Token 把安全性转移到了**资源端校验**——不依赖时钟,只要资源端(如数据库)能比较 token 大小即可。实际场景中,可以在数据库的 `WHERE` 条件中加入 token 版本号,天然实现幂等校验。 + +> [!TIP] 实际生产中的选择 +> - **对互斥性要求一般**(如防重复提交):单节点 `SET NX EX` + Lua 解锁足矣 +> - **对互斥性要求较高**:Redlock 或 etcd/ZooKeeper 的分布式锁(基于 Raft/ZAB 协议,不依赖时钟) +> - **金融级强一致**:直接用数据库行锁或分布式共识系统 + +## 六、可重入锁 + +如果同一个线程/协程需要多次获取同一把锁(比如递归调用),普通锁会自己把自己锁死——这就是需要**可重入锁**的场景。 + +核心思路:value 不再是简单的 token,而是一个包含 **持有者标识 + 重入次数** 的结构。 + +```lua +-- reentrant_lock.lua +local key = KEYS[1] +local token = ARGV[1] +local ttl = ARGV[2] + +local holder = redis.call("hget", key, "holder") +if holder == token then + -- 已持有,增加重入计数 + redis.call("hincrby", key, "count", 1) + redis.call("expire", key, ttl) + return 1 +end + +if holder == false then + -- 无人持有,获取锁 + redis.call("hset", key, "holder", token, "count", 1) + redis.call("expire", key, ttl) + return 1 +end + +return 0 -- 被其他人持有 +``` + +```go +var reentrantLockScript = redis.NewScript(` + local holder = redis.call("hget", KEYS[1], "holder") + if holder == ARGV[1] then + redis.call("hincrby", KEYS[1], "count", 1) + redis.call("expire", KEYS[1], ARGV[2]) + return 1 + end + if holder == false then + redis.call("hset", KEYS[1], "holder", ARGV[1], "count", 1) + redis.call("expire", KEYS[1], ARGV[2]) + return 1 + end + return 0 +`) +``` + +```go +var reentrantUnlockScript = redis.NewScript(` + local holder = redis.call("hget", KEYS[1], "holder") + if holder ~= ARGV[1] then + return 0 -- 不是自己的锁 + end + local count = redis.call("hincrby", KEYS[1], "count", -1) + if count <= 0 then + redis.call("del", KEYS[1]) -- 重入归零,释放锁 + end + return 1 +`) +``` + +> [!NOTE] Hash 结构的选择 +> 使用 Hash(`HSET`)而非 String,是因为 Hash 可以在一个 key 中同时存储 `holder` 和 `count`,解锁时原子性递减计数,归零才删除 key。 + +## 七、常见陷阱与最佳实践 + +### 陷阱清单 + +| 陷阱 | 后果 | 解决方案 | +|------|------|---------| +| 不设 TTL | 崩溃后死锁 | `SET NX EX`,TTL 设为业务预估时间的 2-3 倍 | +| 直接 DEL 解锁 | 可能误删别人的锁 | Lua 脚本原子性检查 + 删除 | +| 锁粒度太粗 | 并发度低,吞吐下降 | 按资源维度加锁(如 `lock:order:{id}`) | +| 锁粒度太细 | 管理复杂,容易遗漏 | 抽象锁工具层,统一管理 | +| 未考虑网络分区 | 锁失效但客户端认为持有 | Watchdog + 业务层幂等 | +| 未做重试 | 瞬时竞争直接失败 | 指数退避 + jitter 重试 | + +### Redis Cluster 下的注意事项 + +Redis Cluster 采用哈希槽分片,Lua 脚本要求操作的 key 在同一个 slot 上。单把分布式锁只涉及一个 key,通常没有问题,但有两个场景需要注意: + +> [!WARNING] 多 key 加锁的坑 +> 如果业务需要同时锁定多个资源(如 `lock:order:1` 和 `lock:order:2`),这两个 key 可能分布在不同 slot,Lua 脚本会报 `CROSSSLOT` 错误。解决方案: +> 1. 使用 Hash Tag 强制同 slot:`lock:{order}:1`、`lock:{order}:2`(花括号内的部分决定 slot) +> 2. 逐个加锁,配合死锁预防(如按 key 字典序依次获取) + +> [!NOTE] Cluster 故障转移与锁丢失 +> Cluster 模式下的主从切换和哨兵模式类似——异步复制导致锁可能在故障转移后丢失。Cluster 不会自动重试 Lua 脚本,客户端需要自己处理 `MOVED`/`ASK` 重定向。 + +### 重试策略:指数退避 + +```go +func acquireLockWithRetry(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration, maxRetries int) (bool, error) { + for i := 0; i < maxRetries; i++ { + ok, err := rdb.SetNX(ctx, key, token, ttl).Result() + if ok { + return true, nil + } + if err != nil { + return false, err + } + // 指数退避 + 随机抖动 + backoff := time.Duration(1< [!TIP] 分布式锁的黄金法则 +> 1. **锁要短命**——临界区尽量小,TTL 尽量短 +> 2. **解锁要安全**——Lua 原子性检查 + 删除 +> 3. **业务要幂等**——即使锁失效,幂等性也能兜底 +> 4. **不要过度设计**——单节点方案能解决的,不要上 Redlock + +## 关联笔记 + +- [[hhs/Redis/09-高级特性]] — Lua 脚本基础、事务机制 +- [[hhs/Redis/06-主从与哨兵]] — 主从同步延迟导致的锁丢失问题 +- [[hhs/Redis/07-集群方案]] — Cluster 模式下 Lua 脚本的 slot 限制 +- [[hhs/Redis/README]] — Redis 知识索引总览