TCP协议的全面复习
TCP 协议复习
一、TCP 在协议栈中的定位
- TCP 是传输层协议,负责端到端的可靠传输(不是安全,加密是 TLS 的事)
- TCP 底层跑在 IP(网络层)之上,IP 是不可靠的(尽力而为),TCP 在不可靠的 IP 之上实现可靠
- HTTP / WebSocket 是应用层协议,跑在 TCP 之上
- HTTP:请求→响应→断开,一次性的
- WebSocket:长连接,双向实时通信
二、TCP 连接管理
三次握手
Client Server
| ── SYN (seq=x) ───────→ | "我来了,你能收到吗?"
| ←── SYN+ACK (seq=y, ack=x+1) | "收到了,你也能收到我吗?"
| ── ACK (ack=y+1) ────→ | "收到了,咱俩都能收发"
| 连接建立(ESTABLISHED) |
目的: 确保双方收发能力都正常(client 发→server 收、server 发→client 收)。
三次握手避免了什么?(经典面试题)
核心答案:防止已失效的旧 SYN 报文段突然到达服务端,导致服务端建立无效连接。
具体场景:
1. Client 发出 SYN1,但网络拥堵,SYN1 在路上卡住了
2. Client 等不到回复,以为丢了,重发 SYN2
3. SYN2 先到达,Server 响应 SYN+ACK,正常建立连接
4. 双方通信完毕,连接关闭
5. 此时 SYN1 才姗姗来迟,到达 Server
如果是两次握手:
Server 收到旧 SYN1 → 回复 SYN+ACK → 直接进入 ESTABLISHED
→ Server 傻等数据,Client 压根不知道这个连接
→ 服务端资源白白被占用
三次握手:
Server 收到旧 SYN1 → 回复 SYN+ACK → 等待 ACK
→ Client 收到 SYN+ACK,发现"我没请求连接啊"
→ Client 回复 RST 重置,Server 不会建立无意义的连接
为什么不是两次握手?
综上,两次握手无法处理网络延迟导致的历史 SYN。服务端会为不存在需求的连接分配资源。
为什么不是四次握手?
效率问题。三次已经足够验证双方收发能力——Client 第二次就能验证 Server 的收发,Server 第三次能验证 Client 的收发。多加一次纯属浪费。
ISN(初始序列号)为什么要随机?
- 防止历史连接的残留数据干扰新连接 — 如果 ISN 固定从 0 开始,旧连接因网络延迟迟到的数据包被新连接误接收(新连接的 seq 范围刚好覆盖到)
- 防止序列号预测攻击(如 TCP 劫持) — 攻击者如果知道下一次连接的 ISN,可以伪造 TCP 报文注入恶意数据。随机 ISN 让攻击者无法预测
- 现代系统用半随机算法: ISN = 初始偏移 + 基于时间的增量,Linux 内核通过 MD5 哈希时钟等随机因子生成
四次挥手
Client Server
| ── FIN ───────────────→ | "我说完了"
| ←── ACK ─────────────── | "知道了"(但可能还有数据要发)
| ←── FIN ─────────────── | "我也说完了"
| ── ACK ───────────────→ | "知道了"
| 等2MSL(TIME_WAIT) | 直接 CLOSED
为什么四次不是三次: TCP 全双工,每个方向独立关闭。服务端收到 FIN 后可能还有数据在缓冲区,必须先发完数据才能发自己的 FIN,所以 ACK 和 FIN 不能合并。
TCP 状态迁移(完整链路)
Client 视角: Server 视角:
CLOSED CLOSED
| SYN-SENT | LISTEN
| | SYN-RECEIVED
| |
└──────→ ESTABLISHED ←────────┘
|
FIN-WAIT-1 ──FIN──→ CLOSE-WAIT (对方数据可能还在发)
FIN-WAIT-2 ←──ACK── CLOSE-WAIT
TIME-WAIT ←──FIN── LAST-ACK
(2MSL后) ──ACK──→ CLOSED
CLOSED
两个易出问题的状态
CLOSE_WAIT(服务端卡住):
- 服务端收到 FIN 后进入 CLOSE_WAIT,必须由应用层主动调用 close() 才能发 FIN 进入 LAST-ACK
- 如果程序忘了 close()(比如代码 bug 没释放 socket),连接永远卡在 CLOSE_WAIT
- 线上服务大量 CLOSE_WAIT → 文件描述符耗尽 → 无法接受新连接
TIME_WAIT(客户端堆积):
- 高并发短连接场景(如 HTTP/1.0 每次请求都关连接),主动关闭方堆积大量 TIME_WAIT
- 每个 TIME_WAIT 占用一个本地端口对,65535 个端口很快耗尽
- 解决方法:SO_REUSEADDR、长连接(HTTP Keep-Alive)、调小 2MSL(一般不推荐)
TIME_WAIT(2MSL)
- 谁发最后一个 ACK,谁承担 TIME_WAIT(通常是主动关闭方,即客户端)
- MSL 是什么:Maximum Segment Lifetime,一个 IP 报文在网络中存活的最长时间
- 为什么等 2MSL:
- 防止最后一个 ACK 丢失 — 丢了的话服务端会重发 FIN,客户端在 TIME_WAIT 期间能重发 ACK
- 让旧连接的数据包"死透" — 2MSL = 一个方向上 MSL,再加上反向一个 MSL,确保所有方向上的残留包都过期,避免干扰复用同端口的新连接
- 2MSL 是固定值(Linux 内核写死 60s),不受网络状况影响
- 注意区分:2MSL 是 TIME_WAIT 的时长,RTO 是超时重传的时长,两个不同的东西
SYN Flood 攻击 + SYN Cookie
攻击原理
攻击者 Server(受害者)
| ── SYN1(伪造IP)──────→ | 分配TCB+半连接队列 → SYN+ACK
| ── SYN2(伪造IP)──────→ | 分配TCB+半连接队列 → SYN+ACK
| ── SYN3(伪造IP)──────→ | 分配TCB+半连接队列 → SYN+ACK
| ...大量伪造SYN... | 半连接队列满!
| 正常用户的SYN被丢弃 → 拒绝服务
攻击者只发 SYN 不回 ACK,服务端的半连接队列被撑爆。
SYN Cookie 防御
正常流程:
Server收到SYN → 分配TCB结构体 → 存入半连接队列 → 回复SYN+ACK
SYN Cookie流程:
Server收到SYN → 不分配TCB!
→ 把 (源IP, 源端口, 时间戳) 哈希成一个"Cookie"
→ 把Cookie当作seq填入SYN+ACK发回去
→ 收到ACK时验证Cookie,通过才分配TCB
核心思想: 资源不在握手第二步分配,延迟到第三步验证通过后再分配。这就把你之前学的三次握手第二步(存半连接队列)的存储压力变成了纯计算压力。攻击者伪造再多 SYN 也无法耗尽服务器内存。
三、TCP 六大可靠性机制
① 确认应答(ACK)
- TCP 面向字节流,ACK 确认的是字节编号而非"第几个包"
seq(序列号)= 本端发送数据的起始字节编号ack(确认号)= 期望对方下次发送的起始字节编号,隐含表示之前的全部收到- 如果重复收到已确认的包,接收方丢弃但要重发 ACK
延迟 ACK(Delayed ACK)
接收方不会每个包都立刻回 ACK,而是等一会儿:
- 如果后续收到连续的包,只回复最后一个(累计确认),减少 ACK 包数量
- 如果在这期间有反向数据要发,就把 ACK 捎带(piggyback) 在数据包上
- 通常延迟 200ms,如果在这期间又有新包到达,合并确认
延迟 ACK × Nagle 算法:互相拖慢的经典问题
- Nagle 算法: 发送方想攒小数据,等前面的 ACK 回来再发下一个包
- 延迟 ACK: 接收方想等会儿再发 ACK
- 两个碰到一起 → 发送方等 ACK,接收方等更多数据 → 短暂死锁(200ms 级延迟)
- 解决:对延迟敏感的应用开启 TCP_NODELAY 关闭 Nagle
② 超时重传(RTO)
- RTO 是动态计算的,不是固定值
- 计算公式:
SRTT = 0.875 × 旧SRTT + 0.125 × 新RTT(平滑加权)RTTVAR = 0.75 × 旧RTTVAR + 0.25 × |SRTT - 新RTT|(波动量)RTO = SRTT + 4 × RTTVAR
- 重传二义性: 重传包返回的 ACK 分不清是确认原包还是重发包 → 重传期间不采样 RTT,RTO 翻倍(Karn 算法)
- RTO vs 2MSL: RTO 用于数据发送超时重传(动态),2MSL 用于连接关闭后等待(固定)
③ 滑动窗口
- 解决"发一个等一个"的低效问题,实现批量发送批量确认
- 窗口大小 = 接收方告知的剩余缓冲区大小(ACK 里的
window字段) - 收到 ACK → 窗口右滑,新的数据纳入窗口
- 发送方数据分三区:已发已确认 | 已发待确认 | 可发未发
发送方视角:
[1-3 已确认] | [4-7 已发待确认] | [8-10 可发送] | [11+ 不允许发]
←──── 滑动窗口(即"飞行中的数据")────→
- window=0 死锁问题: ACK(window>0) 丢失 → 发送方永远等 → 死锁
- 解决:持续计时器(persist timer),发送方定期发 1 字节探测包询问窗口是否恢复
- 糊涂窗口综合征(Silly Window Syndrome): 接收方只释放一点点窗口空间,发送方立刻塞入很小的包 → 浪费大量头部开销
- David D. Clark 方案:接收方窗口 < 1 MSS 或 < 缓冲区一半时,谎报 window=0,等攒够了再告诉对方
④ 拥塞控制
- 滑动窗口管接收方容量,拥塞窗口管网络容量
- 实际发送量 = min(滑动窗口, 拥塞窗口)
- ssthresh(慢启动阈值): 分界线,决定用哪种增长策略
流量控制 vs 拥塞控制 — 本质区别
| 流量控制(滑动窗口) | 拥塞控制 | |
|---|---|---|
| 关心的对象 | 接收方能不能处理 | 网络中间设备能不能处理 |
| 范围 | 端到端 | 全局(所有连接共享网络) |
| 反馈来源 | 接收方主动告知 window | 自行探测(丢包 = 信号) |
| 打个比方 | 桶能不能装得下 | 水管能不能流得过去 |
两个阶段
| 阶段 | 条件 | 增长方式 |
|---|---|---|
| 慢启动 | cwnd < ssthresh | 指数增长(每轮翻倍) |
| 拥塞避免 | cwnd >= ssthresh | 线性增长(每轮 +1) |
"慢启动"名字容易误导——实际是指数级快速增长,"慢"只是指起点小(从 1 开始)。
两种丢包检测 + 两种应对
| 超时重传(RTO超时) | 3 个重复 ACK | |
|---|---|---|
| 严重程度 | 重刑(网络堵死了) | 轻刑(偶尔丢几个包) |
| cwnd | 归 1,重新慢启动 | 砍半,继续跑 |
| ssthresh | 设为丢包时 cwnd 的一半 | 设为丢包时 cwnd 的一半 |
| 后续 | 慢启动 → 拥塞避免 | 快速恢复 → 拥塞避免 |
为什么 3 个重复 ACK 不用归 1: 说明后续包还是到了,网络还活着,只是个别丢包。能收到后续包就意味着数据仍在传输,否则后面那些包到不了。
快重传 + 快恢复
- 快重传: 收到 3 个重复 ACK → 不等超时,立刻重传丢失的包
- 快恢复: cwnd 减半(不是归 1),ssthresh = cwnd,接着用拥塞避免线性增长,不从头慢启动
⑤ 序号 + 按序到达
- TCP 给每个字节编号(seq),接收方按序号排序、去重、拼接
- 保证上层拿到的数据是完整有序的字节流
- 前提:ISN 随机生成,避免不同连接间的数据混淆
⑥ 校验和
- TCP 头部 + 数据按 16 位一组求和取反,填入 checksum 字段
- 接收方同样算法验证,对不上 → 数据损坏 → 丢弃,不回复 ACK → 发送方超时重传
四、知识点对比速查表
| 概念 | 作用 | 固定/动态 | 所在阶段 |
|---|---|---|---|
| 2MSL | TIME_WAIT 等待时长 | 固定(60s) | 四次挥手最后 |
| RTO | 超时重传判定时间 | 动态(基于 RTT) | 数据传输 |
| 滑动窗口 | 接收方容量控制(流量控制) | 动态(接收方告知) | 数据传输 |
| 拥塞窗口 | 网络容量控制(拥塞控制) | 动态(自行探测) | 数据传输 |
| ssthresh | 慢启动/拥塞避免分界线 | 动态(丢包后重设) | 拥塞控制 |
| 状态 | 常见问题 | 原因 |
|---|---|---|
| TIME_WAIT | 大量堆积,端口耗尽 | 高并发短连接,主动关闭方 |
| CLOSE_WAIT | 大量堆积,fd 耗尽 | 服务端程序忘了 close() |
五、常见面试追问(自测用)
- 三次握手可以携带数据吗?为什么?
- 四次挥手可以变为三次吗?
- TIME_WAIT 过多怎么解决?
- TCP Keep-Alive 和 HTTP Keep-Alive 有什么区别?
- 如果网络中有大量重复的 ACK 包,TCP 怎么处理?
- 客户端和服务端同时发送 SYN(同时打开)会发生什么?
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)