文章目录
-
- 前言
- [一、TCP 首部:先认识这几个关键字段](#一、TCP 首部:先认识这几个关键字段)
- 二、三次握手:建立连接的流程
- 三、为什么必须是三次握手
- 四、握手中的丢包:三种情况分别怎么办
-
- [第一次握手 SYN 丢了](#第一次握手 SYN 丢了)
- [第二次握手 SYN+ACK 丢了](#第二次握手 SYN+ACK 丢了)
- [第三次握手 ACK 丢了](#第三次握手 ACK 丢了)
- [五、半连接队列、全连接队列、SYN 攻击](#五、半连接队列、全连接队列、SYN 攻击)
-
- [大量 SYN 进来会发生什么](#大量 SYN 进来会发生什么)
- 六、四次挥手:断开连接的流程
- 七、第三次挥手一直没发、2MSL、TIME_WAIT
-
- 第三次挥手一直没发会发生什么
- [为什么四次挥手后要等 2MSL](#为什么四次挥手后要等 2MSL)
- [服务端出现大量 TIME_WAIT 的原因](#服务端出现大量 TIME_WAIT 的原因)
- [八、TCP vs UDP](#八、TCP vs UDP)
- [九、TCP 为什么可靠](#九、TCP 为什么可靠)
- 十、粘包问题怎么解
- 十一、拥塞控制:四件套
-
- [1. 慢启动](#1. 慢启动)
- [2. 拥塞避免](#2. 拥塞避免)
- [3. 拥塞发生](#3. 拥塞发生)
- [4. 快速恢复](#4. 快速恢复)
- 收尾
前言
这篇文章把 TCP 传输层讲透:三次握手、四次挥手、可靠传输、粘包、拥塞控制,全是面试和线上排查的高频问题。读完你能把 TCP 的完整生命周期串起来,也能说清 TIME_WAIT、CLOSE_WAIT、SYN 攻击这些坑到底是怎么回事。
一、TCP 首部:先认识这几个关键字段
序列号
建立连接时由计算机生成一个随机数作为初始值,通过 SYN 包传给对端。每发送一次数据,序列号就累加一次"数据字节数"。作用:解决网络包乱序。
确认应答号
指下一次期望收到的序列号。发送端收到这个确认,就认为这个序号以前的数据都被正常接收。作用:解决丢包。
控制位
- ACK:为 1 时确认应答字段生效。TCP 规定,除了最初建立连接的 SYN 包,其余包必须置 1。
- RST:为 1 表示连接出现异常,必须强制断开。
- SYN:为 1 表示希望建立连接,同时序列号字段写入初始值。
- FIN:为 1 表示今后不再发送数据,希望断开连接。
二、三次握手:建立连接的流程
TCP 是面向连接的协议,传输前必须先建立连接,靠三次握手完成。三次握手的过程如下图:
客户端 服务端
CLOSED LISTEN
│ ── SYN(seq=x) ────────► │ 客户端进入 SYN_SENT
│ ◄─ SYN+ACK(seq=y,ack=x+1) │ 服务端进入 SYN_RCVD
│ ── ACK(ack=y+1) ────────► │ 客户端进入 ESTABLISHED
│ │ 服务端进入 ESTABLISHED
- 初始状态:客户端 CLOSED,服务端主动监听端口,处于 LISTEN。
- 第一次握手:客户端随机初始化序列号,置 SYN=1,发 SYN 包(不带数据),进入 SYN_SENT。
- 第二次握手:服务端随机初始化自己的序列号,确认应答号填
client_isn + 1,置 SYN+ACK,回包(不带数据),进入 SYN_RCVD。 - 第三次握手:客户端回 ACK,确认应答号填
server_isn + 1,进入 ESTABLISHED。服务端收到后也进入 ESTABLISHED。
第三次握手可以携带数据,前两次不可以。 这是面试常考题。
三、为什么必须是三次握手
三次握手的原因,按重要性排:
- 阻止重复的历史连接初始化(主要原因)
- 同步双方的初始序列号
- 避免资源浪费
原因一:阻止历史连接
RFC 793 原话:三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
场景:客户端先发了 SYN(seq=90),报文被网络阻塞,服务端没收到。客户端重启后重新建连,发 SYN(seq=100)。
- 旧 SYN 先到,服务端回
SYN+ACK(ack=91)。 - 客户端发现期望的确认号是
100+1,不是90+1,判断这是历史连接,立刻回 RST。 - 服务端收到 RST 释放连接,最新 SYN 到了再正常握手。
两次握手为什么不行? 服务端收到 SYN 就进入 ESTABLISHED,没有中间状态阻止历史连接。它可能在不知道的情况下建立一个历史连接,还白白发了数据,浪费资源。
原因二:同步双方初始序列号
序列号是可靠传输的关键:接收方靠它去重、按序接收、确认收到哪些数据。所以客户端发的初始序列号要服务端确认,服务端发的也要客户端确认。一来一回正好两次。第二步和第三步能优化成一步,于是三次握手就能可靠同步双方的初始序列号。
原因三:避免资源浪费
只有两次握手时,客户端 SYN 在网络中阻塞,没收到 ACK 就重发。服务端每收到一个 SYN 只能建一个连接,最终建立一堆冗余无效连接,白白占用资源。
四、握手中的丢包:三种情况分别怎么办
第一次握手 SYN 丢了
客户端迟迟收不到 SYN+ACK,触发超时重传,重传 SYN(序列号相同)。次数由 tcp_syn_retries 控制,默认 5。超时时间 1、2、4、8、16 秒(每次翻倍),最后一次后再等 32 秒,仍没回应就断开连接。总耗时约 63 秒。
第二次握手 SYN+ACK 丢了
有意思的来了。客户端觉得自己的 SYN 丢了,重传 SYN;服务端收不到第三次 ACK,也重传 SYN+ACK。两边同时重传:
- 客户端重传 SYN,次数由
tcp_syn_retries控制。 - 服务端重传 SYN+ACK,次数由
tcp_synack_retries控制(默认 5)。
第三次握手 ACK 丢了
客户端收到 SYN+ACK 后已进入 ESTABLISHED。服务端迟迟收不到确认,触发超时重传 SYN+ACK,直到收到或达到最大重传次数后断开连接。
注意:ACK 报文本身不会重传。ACK 丢了,由对方重传对应的报文(SYN+ACK 或数据)。
五、半连接队列、全连接队列、SYN 攻击
服务端收到 SYN 后,内核把连接放进半连接队列 ,回 SYN+ACK。收到第三次握手的 ACK 后,把连接从半连接队列移到全连接队列 (accept 队列)。应用调 accept(),就是从全连接队列取连接。
SYN ──► 半连接队列 ── 收到 ACK ──► 全连接队列 ── accept() ──► 应用程序
两个队列都有长度上限,满了就丢弃,或回 RST。
大量 SYN 进来会发生什么
半连接队列被打满,后续 SYN 被丢弃,正常客户端也建不了连接------这就是 SYN 攻击。
防 SYN 攻击的四种方式:
- 调大
netdev_max_backlog:网卡收包速度大于内核处理速度时,数据包先排队,控制这个队列的上限(默认 1000,可调到 10000)。 - 增大半连接队列 :同时调大
tcp_max_syn_backlog、listen()的 backlog、net.core.somaxconn三个参数。 - 开启
tcp_syncookies:半连接队列满时不再丢包,根据算法算出一个 cookie,放进 SYN+ACK 的序列号。收到客户端 ACK 时校验合法性,合法就直接把连接放进 accept 队列。设置tcp_syncookies=1(仅当队列放不下时启用)即可绕过 SYN 半连接建立连接。 - 调小
tcp_synack_retries:让处于 SYN_RCVD 状态的连接快速断开,减少半连接占用。
六、四次挥手:断开连接的流程
四次挥手的过程:
主动方 被动方
│ ── FIN ────────────────► │ 主动方进入 FIN_WAIT_1
│ ◄── ACK ──────────────── │ 被动方进入 CLOSE_WAIT
│ │ 被动方应用读完数据后 close
│ ◄── FIN ──────────────── │ 被动方进入 LAST_ACK
│ ── ACK ────────────────► │ 主动方进入 TIME_WAIT
│ │ 被动方收到 ACK 进入 CLOSE
│ 2MSL 后进入 CLOSE
- 主动方调 close,发 FIN(代表不再发数据),进入 FIN_WAIT_1。
- 被动方收到 FIN,立刻回 ACK,进入 CLOSE_WAIT。协议栈会给 FIN 包插一个 EOF 到接收缓冲区末尾,应用通过 read 感知,读到 EOF 时
read()返回 0。 - 被动方应用读完后,有数据就先发完,再调 close 发 FIN,进入 LAST_ACK。
- 主动方收到 FIN,回 ACK,进入 TIME_WAIT。
- 被动方收到 ACK,进入 CLOSE。主动方等 2MSL 后也进入 CLOSE。
为什么中间两次不能合并
被动方收到 FIN 时,内核马上回 ACK,但应用可能还有数据要发,FIN 的控制权在应用层。由应用决定什么时候调 close,所以 ACK 和 FIN 一般分开发。
特殊情况:三次挥手
如果被动方没有数据要发 ,且开启了延迟确认机制,第二次和第三次挥手会合并成一个报文,挥手就从四次变三次。
七、第三次挥手一直没发、2MSL、TIME_WAIT
第三次挥手一直没发会发生什么
主动方收到 ACK 后停在 FIN_WAIT_2,等对方发 FIN。
- 用
shutdown关闭的连接:可以一直停在 FIN_WAIT_2,因为它可能还要收发数据。 - 用
close关闭的孤儿连接:不能收发数据,不能持续太久。tcp_fin_timeout控制这个状态的时长,默认 60 秒,超时没收到 FIN 就强制关闭。
被动方则一直停在 CLOSE_WAIT------这个状态没有超时,应用不 close,连接就永远挂着。大量 CLOSE_WAIT 堆积 = 代码漏 close,是 bug 不是参数问题。
为什么四次挥手后要等 2MSL
MSL 是报文最大生存时间。IP 头里有个 TTL 字段,是数据报能经过的最大路由数,每过一个路由器减 1,减到 0 就丢弃。Linux 认为报文经过 64 个路由器不会超过 30 秒,所以 Linux 的 MSL = 30 秒。
等 2 倍 MSL,是因为网络中可能同时存在双向的报文,一来一回正好 2 个 MSL。更重要的是:2MSL 相当于允许最后一个 ACK 丢一次。如果主动方最后的 ACK 丢了,被动方会重发 FIN,这个 FIN 在第二个 MSL 内能到达,TIME_WAIT 状态的连接还能应对。
服务端出现大量 TIME_WAIT 的原因
核心一句:谁主动 close,谁进 TIME_WAIT。服务端大量 TIME_WAIT = 服务端在频繁主动关连接。三种常见场景:
场景一:HTTP 没用长连接。 HTTP/1.0 默认关 keep-alive,HTTP/1.1 默认开。只要请求或响应 header 里有 Connection: close,长连接就失效,变成短连接。每次请求都走"建连→请求→响应→断连",而多数 Web 服务是服务端主动关闭,于是大量 TIME_WAIT。
场景二:HTTP 长连接超时。 Web 服务一般有 keep-alive 超时参数,比如 nginx 的 keepalive_timeout。客户端完成请求后 60 秒没发新请求,定时器到点,服务端主动关。现象往往是:大量连接建立后长时间没数据。
场景三:HTTP 长连接请求数达上限。 nginx 的 keepalive_requests 默认 100,即一条长连接最多跑 100 个请求。QPS 到 10000 甚至更高时,很快就关一批连接,产生大量 TIME_WAIT。解法:调大 keepalive_requests。
八、TCP vs UDP
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接,传输前先建连 | 无连接,即刻传输 |
| 服务对象 | 一对一 | 一对一、一对多、多对多 |
| 可靠性 | 可靠交付,不丢不重不乱序 | 不可靠,丢了就丢了 |
| 拥塞/流量控制 | 有 | 没有 |
| 首部开销 | 20 字节起(有选项字段会更长) | 固定 8 字节 |
| 传输方式 | 流式,无边界,保序 | 报文式,有边界,可能丢包乱序 |
九、TCP 为什么可靠
靠六招:连接管理、序列号、确认应答、超时重传、流量控制、拥塞控制。
- 连接管理:三次握手 + 四次挥手,可靠传输的前提。
- 序列号:给每个字节编号,防止丢失、避免重复、保证有序,还能实现"多次发送、一次确认"。
- 确认应答:接收方回 ACK 告知收到情况。
- 超时重传:数据包丢了重发;确认包丢了,接收方靠序列号识别重复数据并丢弃,重发 ACK。
- 流量控制:接收方处理能力有限,通过滑动窗口限制发送速度,防止缓冲区溢出。
- 拥塞控制:网络拥堵时减少发送,通过拥塞窗口实现。
发送方的发送速度 = min(滑动窗口, 拥塞窗口)。
十、粘包问题怎么解
粘包的本质:不知道一条用户消息的边界在哪。知道了边界,接收方就能划出有效消息。一般三种分界方式:
- 固定长度消息:每条消息固定 N 字节,接满 N 字节就算一条。最简单,但灵活性差,实际很少用。
- 特殊字符作边界:两个消息之间插特殊字符串,读到它就认为读完一条。HTTP 就是例子,用回车换行作边界。注意:消息内容里出现这个字符要转义。
- 自定义消息结构:包头 + 数据,包头固定大小且带数据长度字段。收到包头就解析出数据长度,读满即组装成一条完整消息。
c
struct message {
u_int32_t message_length; // 4 字节,表示后面数据的长度
char message_data[]; // 真正的数据
};
十一、拥塞控制:四件套
TCP 被设计成"无私"的协议,网络拥堵时自我牺牲、降低发送量,避免把网络填满。核心变量是拥塞窗口 cwnd:网络没拥塞就增大,出现拥塞就减小。判断依据:发送方没在限定时间内收到 ACK,即超时重传,就认为拥塞了。
四种算法按顺序:
1. 慢启动
刚建连时一点点提高发送量。规则:每收到一个 ACK,cwnd + 1。初始 cwnd=1,发 1 个 MSS;收到 1 个 ACK 变 2,收到 2 个 ACK 变 4......发包数指数增长。
涨到哪停?看慢启动门限 ssthresh:
- cwnd < ssthresh:慢启动(指数增长)
- cwnd >= ssthresh:拥塞避免(线性增长)
2. 拥塞避免
规则:每收到一个 ACK,cwnd 加 1/cwnd。从指数增长变成线性增长,增长放缓。
3. 拥塞发生
触发重传就进入拥塞发生。两种重传处理不同:
- 超时重传 :说明网络很糟。
ssthresh = cwnd/2,cwnd重置为 1,回到慢启动重新开始。一夜回到解放前,反应很激烈。 - 快速重传 :接收方发现丢中间包时,连发三次前一个包的 ACK,发送端快速重传,不必等超时。TCP 认为情况不严重:
cwnd = cwnd/2,ssthresh = cwnd,进入快速恢复。
4. 快速恢复
进入前 cwnd 和 ssthresh 已更新。规则:
cwnd = ssthresh + 3(3 表示确认收到了 3 个重复 ACK)。- 重传丢失的数据包。
- 再收到重复 ACK,cwnd + 1。
- 收到新数据的 ACK,把 cwnd 设为 ssthresh,进入拥塞避免。
比超时重传温和得多,不会从高水位直接打回原形,后续再线性增长。
收尾
TCP 的知识点其实是一张网:三次握手 → 可靠传输 → 流量/拥塞控制 → 四次挥手 → TIME_WAIT,一环扣一环。把连接的生命周期串起来,面试和排障都能快人一步。