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 知识索引总览