vault backup: 2026-05-27 00:12:00

This commit is contained in:
hhs
2026-05-27 00:12:00 +08:00
parent 838e5e7824
commit 91b4fe3f36
9 changed files with 1116 additions and 180 deletions
@@ -74,16 +74,16 @@ graph BT
```mermaid
flowchart LR
A["OSI 七层模型<br/>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<br/>HTTP DNS SMTP SSH FTP SMTP POP3 IMAP WebSocket"] --> T["传输层 — Transport<br/>TCP UDP SCTP"]
T --> N["网络层 — Internet<br/>IP IPv6 ICMP ARP NAT"]
N --> D["链路层 — Link<br/>Ethernet Wi-Fi 802.11 VLAN HDLC PPP"]
D --> P["物理层 — Physical<br/>双绞线 光纤 无线电 IEEE 802.15"]
A["应用层 — Application<br/>HTTP, DNS, SMTP, SSH, FTP, POP3, IMAP, WebSocket"] --> T["传输层 — Transport<br/>TCP, UDP, SCTP"]
T --> N["网络层 — Internet<br/>IP, IPv6, ICMP, ARP, NAT"]
N --> D["链路层 — Link<br/>Ethernet, Wi-Fi, 802.11, VLAN, HDLC, PPP"]
D --> P["物理层 — Physical<br/>双绞线, 光纤, 无线电, 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 分片(分片会严重影响性能)。
## 分层在代码中的体现
@@ -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<br/>SrcPort DstPort Seq Ack Flags..."] --> B["Payload"]
end
subgraph "UDP Datagram"
C["UDP Header 8B<br/>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 加密<br/>+ 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 的详细原理与配置
@@ -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 连接
@@ -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["传播延迟<br/>Propagation"] --> B["传输延迟<br/>Transmission"]
B --> C["排队延迟<br/>Queuing"]
C --> D["处理延迟<br/>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
+120 -25
View File
@@ -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 的实际部署
@@ -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 已经发完了...<br/>没检测到冲突!🔥
A->>H: "发送一个超短帧 (长度 < 2τ)"
H->>A: "冲突信号返回"
Note over A: "此时 A 已经发完了...<br/>没检测到冲突!"
```
所以规定:
所以必须满足:
$$\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地址与广播域]] — 广播域与碰撞域的区别
+195 -27
View File
@@ -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<br/>回答: 你是哪个 Master?"]
Offset["Replication Offset<br/>回答: 我同步到哪了?"]
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 <runId> <offset>, 然后发送 RDB 文件
Note over R,M: "阶段 1: 握手与身份确认"
R->>M: PSYNC ? -1
Note right of R: "? = 我不知道你的 Run ID<br/>-1 = 我没有 offset 记录"
M->>M: 执行 BGSAVE 生成 RDB 快照
M-->>R: "+FULLRESYNC <runId> <offset>"
Note left of M: "意思是: 好, 全量同步,<br/>我的 Run ID 是 xxx,<br/>当前 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 <masterRunId> <myOffset>
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 <runId> <myOffset>
Note right of R: "我认识你, Run ID 是 xxx,<br/>我的 offset 是 10000"
M->>M: 检查 runId 匹配<br/>检查 offset 10000 是否在 backlog 范围内
alt "offset 在 backlog 范围内 → 增量同步"
M-->>R: "+CONTINUE"
M-->>R: 发送 offset 10000 之后的增量命令
Note left of M: "只发你缺的 10001 ~ 10500,<br/>不用重传整个数据集"
R->>R: 重放增量命令, offset 追到 10500
else "offset 已被覆盖 → 退化为全量同步"
M-->>R: "+FULLRESYNC"
Note left of M: "你缺的太多了, 增量同步<br/>不可能了, 转为全量重传"
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 发送<br/>PSYNC <runId> <offset>"] --> CheckRunID{"runId 匹配?"}
CheckRunID -->|"不匹配<br/>(Master 重启或新 Master)"| FullSync["返回 +FULLRESYNC<br/>全量同步"]
CheckRunID -->|"匹配"| CheckOffset{"offset 对应的数据<br/>还在 backlog 里?"}
CheckOffset -->|"是"| PartialSync["返回 +CONTINUE<br/>增量同步"]
CheckOffset -->|"否<br/>(已被覆盖)"| FullSync
CheckRunID -->|"runId = '?'<br/>(首次连接)"| 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 <offset>` 命令,告诉 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 都不健康怎么办?
+4
View File
@@ -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]] — 知识索引总览
+389
View File
@@ -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<<uint(i)*100) * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
time.Sleep(backoff + jitter)
}
return false, errors.New("max retries exceeded")
}
```
> [!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 知识索引总览