本文整理自一次系统的 TCP 协议学习问答,覆盖报文格式、连接管理、可靠传输、流量控制、拥塞控制、缓冲区设计、粘包问题与异常处理。
一、TCP 报文段格式
TCP 头部固定 20 字节,加上可选选项,最大 60 字节。

各字段说明
| 字段 | 作用 |
|---|---|
| 源端口 / 目的端口 | 和 IP 一起构成四元组,标识连接 |
| 序号 | 本报文段数据第一个字节的编号,按字节递增 |
| 确认序号 | 期望收到对方的下一个字节序号,累计确认 |
| 数据偏移 | 头部长度,单位 4 字节,最小 5,最大 15 |
| 保留 | 保留将来使用 |
| 标志位 | URG、ACK、PSH、RST、SYN、FIN、NS、CWR、ECE |
| 窗口大小 | 接收方还能接收多少字节,用于流量控制 |
| 校验和 | 覆盖头部、数据和伪头部 |
| 紧急指针 | URG=1 时有效,现代少用 |
| 选项 | MSS、窗口缩放、SACK、Timestamps 等 |
| 数据 | 上层应用载荷 |
关键点
-
序号是字节编号,不是报文编号。
-
确认序号是累计确认,表示 N 之前全部收到。
-
SYN 和 FIN 各消耗一个序号,纯 ACK 不消耗。
-
窗口大小是接收方通告的,不是发送方自己的。
为什么序号和确认序号分开
因为它们是两个方向、两个独立数据流的状态变量:
-
序号:我发送的数据在我→你方向上的位置。
-
确认序号:我期望你下一次发送的数据在你→我方向上的位置。
一个管发送方向,一个管接收方向,方向相反,必须分开。
二、三次握手与四次挥手
三次握手

为什么是三次:
逻辑上包含四个动作:A 的 SYN、B 的 ACK、B 的 SYN、A 的 ACK。但 B 的 ACK 和 SYN 没有时间差、方向一致,可以合并在同一个报文里,所以实际只传三个报文。
三次是保证双方都确认对方收发能力正常、且序号同步的最小次数。
四次挥手
A B
| 1. FIN, seq = u | A: FIN_WAIT_1
| ---------------------------------------> | B: ESTABLISHED -> CLOSE_WAIT
| 2. ACK, ack = u+1 |
| <--------------------------------------- | A: FIN_WAIT_2
| (B 可能还有数据要发) |
| 3. FIN, seq = w, ack = u+1 | B: LAST_ACK
| <--------------------------------------- |
| 4. ACK, ack = w+1 | A: TIME_WAIT
| ---------------------------------------> | B: CLOSED
| (A 等待 2MSL 后关闭) | A: CLOSED
为什么是四次:
TCP 全双工,两个方向必须分别关闭。B 收到 A 的 FIN 后,ACK 可以立刻发,但 FIN 要等 B 自己的数据发完。ACK 和 FIN 之间存在时间差,不能合并。
四次挥手的本质:
ACK 是即时的,FIN 是延迟的,两者之间隔着"被动方可能还有数据要发"。如果 B 收到 FIN 时正好也没数据了,ACK 和 FIN 可以合并,变成三次挥手。
TIME_WAIT
主动关闭方发完最后一个 ACK 后进入 TIME_WAIT,等待 2MSL。
两个原因:
-
保证最后一个 ACK 能到达 B,如果丢了可以重发。
-
让旧连接的报文在网络中消亡,避免干扰新连接。
影响:
-
连接逻辑上已关闭,但内核保留四元组。
-
相同四元组的新连接不能被复用。
-
如果是客户端主动关闭,临时端口在这段时间被占用。
-
SO_REUSEADDR能让服务端在 TIME_WAIT 期间重新 bind 监听端口,但不能绕过四元组复用限制。
三、可靠传输:超时重传与快速重传
超时重传
发送方发出数据后,如果 RTO 内没收到 ACK,就重传。
RTO 计算:
SRTT = (1 - α) * SRTT + α * RTT_sample (α = 1/8)
RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT_sample| (β = 1/4)
RTO = SRTT + 4 * RTTVAR
RTO >= 1 秒(早期)或 200ms(现代)
指数退避:
每次重传失败,RTO 翻倍:
第 1 次超时:RTO
第 2 次超时:2 * RTO
第 3 次超时:4 * RTO
...
Karn 算法:
重传报文的 ACK 不用于计算 RTT,因为无法判断确认的是第一次还是重传。
快速重传
接收方收到失序报文时,重复发送当前期望的 ACK。发送方连续收到 3 个重复 ACK,就认为该段丢了,立刻重传,不等 RTO。
触发条件:
- 收到 3 个重复 ACK。
失效情况:
-
丢的是最后一个报文。
-
发送窗口太小,凑不够 3 个重复 ACK。
-
重复 ACK 本身丢失。
-
对端完全无响应。
两者关系
| 机制 | 角色 | 触发条件 | 速度 |
|---|---|---|---|
| 快速重传 | 优先机制 | 3 个重复 ACK | 快 |
| 超时重传 | 兜底机制 | RTO 超时 | 慢 |
快速重传负责"能提前发现就快传",超时重传负责"发现不了也得传"。
四、流量控制
目标
让发送方别发太快,别把接收方缓冲区撑爆。
核心机制:滑动窗口
接收方在每个 ACK 里带窗口大小(rwnd),告诉发送方自己还能接收多少。
发送方约束:
已发送未确认的字节数 <= rwnd
零窗口与窗口探测
接收方缓冲区满时通告 Window = 0,发送方停止发送,启动持续计时器,定期发窗口探测报文,防止死锁。
糊涂窗口综合征(SWS)
接收方每次只腾出一点空间就通告小窗口,发送方发小报文,效率极低。
解决:
-
接收方:Clark 算法,窗口小于阈值时通告 0。
-
发送方:Nagle 算法,攒够 MSS 或收到 ACK 再发。
Nagle 算法
-
有已发送未确认数据时,不发送新的小报文。
-
攒到 MSS 或收到 ACK 再发。
-
可用
TCP_NODELAY关闭。
五、滑动窗口
是什么
一个动态变化的区间,表示发送方当前可以发送多少字节,接收方当前还能接收多少字节。
窗口大小的决定因素
发送窗口 = min(rwnd, cwnd)
| 窗口 | 含义 | 由谁维护 |
|---|---|---|
| rwnd | 接收方还能收多少 | 接收方通告 |
| cwnd | 网络还能承受多少 | 发送方维护 |
异常情况下的窗口变化
| 情况 | 窗口左边界 | 窗口右边界 | 关键机制 |
|---|---|---|---|
| 正常传输 | 随 ACK 右移 | 左边界 + min(rwnd,cwnd) | 累计确认 |
| 数据丢失 | 冻结在空洞处 | 可能伸缩但受限 | 快速重传/超时重传 |
| ACK 丢失 | 超时后随重传 ACK 右移 | 同上 | 重传 |
| 零窗口 | 不动 | 收缩到左边界 | 窗口探测 |
| 窗口恢复 | 收到新 ACK 后右移 | 扩大 | ACK 或探测 |
为什么要设计滑动窗口
停等协议"一个 RTT 只能发一个报文",吞吐量受 RTT 限制。滑动窗口让发送方连续发送多个报文,同时用窗口边界区分已确认、未确认、可发送区域,支撑可靠传输、流量控制和拥塞控制。
六、三个窗口的关系
| 窗口 | 维护方 | 作用 |
|---|---|---|
| rwnd | 接收方 | 流量控制,防止压垮接收方 |
| cwnd | 发送方 | 拥塞控制,防止压垮网络 |
| swnd | 发送方 | 实际发送窗口 = min(rwnd, cwnd) |
swnd = min(rwnd, cwnd)
谁小听谁的。
七、拥塞控制

目标
别压垮网络,避免拥塞崩溃。
核心变量:cwnd
发送方自己维护,表示当前网络状况下还能发多少未确认数据。
四大核心算法

| 算法 | 作用 |
|---|---|
| 慢启动 | cwnd 从 1 开始,指数增长 |
| 拥塞避免 | cwnd 线性增长,缓慢逼近 |
| 快速重传 | 3 个重复 ACK,立刻重传 |
| 快速恢复 | cwnd 减半,而不是降到 1 |
状态转换
超时
↓
cwnd = 1
ssthresh = cwnd/2
↓
┌─────────┐
│ 慢启动 │ cwnd 指数增长
└────┬────┘
│ cwnd >= ssthresh
↓
┌─────────────┐
│ 拥塞避免 │ cwnd 线性增长
└──────┬──────┘
│
┌──────────┴──────────┐
│ │
3 个重复 ACK 超时
│ │
↓ ↓
┌───────────┐ cwnd = 1
│ 快速恢复 │ ssthresh = cwnd/2
│ cwnd 减半 │ 重新慢启动
└───────────┘
演进
| 版本 | 特点 |
|---|---|
| Tahoe | 丢包一律 cwnd=1 |
| Reno | 加入快速恢复 |
| NewReno | 处理一次丢多个包 |
| SACK | 选择性确认,精准重传 |
| CUBIC | Linux 默认,三次函数增长 |
| BBR | 基于带宽和 RTT 建模,不靠丢包 |
八、缓冲区设计
为什么用环形缓冲区
TCP 是连续字节流,数据尾部追加、头部消费,是典型 FIFO。
环形缓冲区的优势:
-
零搬移:只移动 head/tail 指针,O(1)。
-
固定内存:预先分配,不扩容、不释放。
-
空间即时复用:指针回绕,读过的空间立刻可写。
-
缓存友好:连续内存,CPU 缓存命中率高。
-
实现简单:只需 head、tail、size。
发送缓冲区
┌─────────┬─────────────┬──────────┬────────┐
│已确认 │已发送未确认 │ 待发送 │ 空闲 │
└─────────┴─────────────┴──────────┴────────┘
↑ ↑
head tail
已发送未确认的数据必须保留,直到 ACK 到达。
接收缓冲区
┌───────────┬──────────┬────────┐
│已收到未读 │ 可接收 │ 空闲 │
└───────────┴──────────┴────────┘
↑head ↑写入位置
rwnd 直接由剩余空间决定。
环形缓冲区与滑动窗口
-
滑动窗口是逻辑视图,环形缓冲区是物理存储。
-
窗口在环形缓冲区上滑动,就是滑动窗口的物理实现。
九、延迟应答
是什么
接收方收到数据后不立刻回 ACK,等一小段时间(通常 40ms~200ms),争取把 ACK 捎带在反向数据里。
为什么
-
减少纯 ACK 报文数量。
-
配合捎带确认,提高网络利用率。
-
减少发送方处理负担。
代价
-
增加 RTT 测量误差,导致 RTO 偏大。
-
和 Nagle 算法叠加可能引入延迟。
-
窗口小时影响吞吐。
不该延迟的情况
-
收到失序报文。
-
收到重复报文。
-
接收缓冲区快满。
-
收到 FIN。
-
检测到丢包。
十、粘包问题
本质
TCP 是面向字节流的协议,不保留应用层的消息边界。所谓"粘包",是应用层把字节流当报文流用,没有自己划分边界。
现象
发送方连续 send("hello")、send("world"),接收方可能读到:
-
"hello" / "world"
-
"helloworld"
-
"hel" / "loworld"
-
等等
原因
-
TCP 是字节流,不保存写入边界。
-
Nagle 算法可能合并小报文。
-
发送/接收缓冲区可能合并多次写入。
-
MSS 可能拆分消息。
解决方案
| 方案 | 说明 |
|---|---|
| 固定长度 | 每个消息固定 N 字节 |
| 长度字段 | 消息头带长度,最常用 |
| 特殊分隔符 | 如 \n,需转义 |
| 自描述格式 | JSON、Protobuf 等 |
核心:应用层自己定义消息边界,不要假设一次 recv 等于一个消息。
和 UDP 对比
| 维度 | TCP | UDP |
|---|---|---|
| 数据形式 | 字节流 | 数据报 |
| 边界 | 不保留 | 保留 |
| 粘包 | 有 | 无 |
十一、TCP 异常情况
进程终止
-
进程正常终止、崩溃、被 kill -9,内核都会关闭 fd,发送 FIN。
-
和正常关闭没有区别。
-
例外:如果进程是被动关闭方且没来得及 close,可能停在 CLOSE_WAIT。
机器重启
-
正常重启:先停止进程,关闭 fd,发送 FIN,和进程终止一样。
-
强制重启/掉电:来不及发 FIN,连接静默失效。
机器掉电 / 网线断开
-
本机瞬间消失,对端完全不知道,仍认为连接是 ESTABLISHED。
-
对端有写入操作时:数据无 ACK,超时重传多次失败,最终发 RST。
-
对端没有写入操作时:靠 TCP Keepalive 检测。
TCP Keepalive
| 参数 | 默认值 | 含义 |
|---|---|---|
tcp_keepalive_time |
7200 秒 | 空闲多久开始探测 |
tcp_keepalive_intvl |
75 秒 | 探测间隔 |
tcp_keepalive_probes |
9 | 探测次数 |
总共约 2 小时 11 分钟才判定死亡。默认关闭,需要 SO_KEEPALIVE 开启。
局限: 时间太长,实际应用通常自己做心跳。
应用层检测
-
HTTP 长连接:Keep-Alive、定期请求。
-
WebSocket:Ping/Pong。
-
数据库连接池:
SELECT 1。 -
IM(如 QQ):心跳 + 断线重连。
-
RPC 框架:心跳 + 超时 + 重连。
其他异常
-
对端发送 RST:端口未监听、连接已关闭、半连接队列满等。
-
半打开连接:一方已关闭,另一方不知道。
-
中间设备超时:NAT、防火墙清除表项。
-
网络分区:两端互相不可达。
异常情况汇总
| 异常 | 是否发 FIN | 对端如何感知 | 检测机制 |
|---|---|---|---|
| 进程正常终止 | 是 | 收到 FIN | 正常关闭 |
| 进程崩溃 | 是(内核代发) | 收到 FIN | 正常关闭 |
| 进程 kill -9 | 是(内核代发) | 收到 FIN | 正常关闭 |
| 进程死但 fd 未关 | 否 | 无 | CLOSE_WAIT 超时 |
| 机器正常重启 | 是 | 收到 FIN | 正常关闭 |
| 机器强制重启 | 否 | 无 | Keepalive/写入触发 RST |
| 机器掉电 | 否 | 无 | Keepalive/写入触发 RST |
| 网线断开 | 否 | 无 | Keepalive/写入触发 RST |
| 对端 RST | 否 | 收到 RST | 立即终止 |
| 中间设备超时 | 否 | 无 | 心跳/重连 |
| 应用层空闲 | 否 | 无 | 应用心跳 |
十二、核心总结
为什么TCP这么复杂? 因为要保证可靠性,同时由尽可能的提高性能.
可靠性:
• 校验和
• 序列号(按序到达)
• 确认应答
• 超时重发
• 连接管理
• 流量控制
• 拥塞控制
提高性能:
• 滑动窗口
• 快速重传
• 延迟应答
• 捎带应答
其他:
• 定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
-
TCP 是面向字节流的可靠传输协议,不保留消息边界。
-
三次握手 同步双方序号,四次挥手分别关闭两个方向。
-
超时重传 是兜底,快速重传是优先,两者配合保证可靠。
-
滑动窗口是中枢,同时支撑可靠传输、流量控制和拥塞控制。
-
三个窗口:rwnd 管接收方,cwnd 管网络,swnd = min(rwnd, cwnd)。
-
环形缓冲区是滑动窗口的物理实现,零搬移、O(1)、固定内存。
-
延迟应答减少 ACK 数量,但可能增加 RTT 误差。
-
粘包不是 TCP 的 bug,是应用层没有定义消息边界。
-
异常情况分进程层、主机层、网络层、应用层,Keepalive 太慢,实际靠应用层心跳。
十三、一句话收尾
TCP 用序号和确认序号保证可靠,用滑动窗口支撑流量和拥塞控制,用三次握手和四次挥手管理连接,用超时和快速重传兜底丢包,用环形缓冲区高效存储,用应用层协议处理边界和异常------它不完美,但足够可靠、足够灵活,是互联网的基石。
