连接是 TCP 造出来的抽象:握手造它,挥手拆它,而线上绝大多数"连接建立失败/端口耗尽"故障,都落在这两个过程的状态与队列里。

学习目标

读完本章,你应当能够:

  1. 逐字段说明 TCP(Transmission Control Protocol,传输控制协议)报文头每个字段被哪个机制使用;
  2. 从零推导"为什么三次握手不能压缩成两次",说清历史 SYN 造成"幽灵连接"的完整场景;
  3. 区分 SYN 队列(半连接队列)与 accept 队列(全连接队列),用 ss -lnt 定位连接建立失败发生在哪一步;
  4. 说明 tcp_syncookies 的原理与代价;给出 TIME_WAIT 必须存在且必须为 2MSL 的两条理由,并评估各种"减少 TIME_WAIT"手段的边界;
  5. 画出 TCP 11 状态机全图,填出"某状态收到某标志位"的反应表。

前置知识检查

你需要知道在哪一节补齐
MTU/MSS 的层次归属与分片见第 15 章对应小节
端口与四元组、NAT(Network Address Translation)对四元组的影响见第 15 章对应小节
fd → 打开文件表 → inode 三层结构、dup 的共享语义见第 7 章对应小节
RTO(Retransmission Timeout)与重传见第 17 章对应小节

16.1 TCP 报文头全字段解读

教材讲过报头"有什么字段",面试真正问的是每个字段在哪个机制里被消费。

字段宽度被哪个机制使用不设/设错的后果
源端口 / 目的端口各 16 bit四元组定位与多路分解(demultiplexing)无法区分同主机并发连接;端口耗尽故障的直接来源
序号(Sequence Number)32 bit字节流编号、确认、重排、去重、SACK(Selective ACK)无法检测乱序与重复;重传判断不出对端收到多少
确认号(Acknowledgment Number)32 bit累积确认;仅 ACK 置位时有效发送方只能靠超时判丢包,吞吐塌陷
数据偏移(Data Offset)4 bit报文头长度,单位 4 字节,最小 5(20 字节)无法定位选项区与载荷边界,选项机制整体失效
保留位 + 标志位3 + 9 bitURG/ACK/PSH/RST/SYN/FIN 六大标志 + CWR/ECE/NS建连、拆除、复位全靠它们;ECE/CWR 是 ECN(Explicit Congestion Notification)的信道
窗口(Window)16 bit接收窗口 rwnd,流量控制;配合窗口扩大因子可左移只能表达 65535 字节窗口,长肥管道跑不满
校验和(Checksum)16 bit覆盖伪首部 + TCP 头 + 载荷无法发现链路层未检出的比特错误
紧急指针(Urgent Pointer)16 bit仅 URG=1 时指向紧急数据末尾URG 在实践中几乎无人实现,select 异常集合已废弃
选项(Options)变长,补齐到 4 字节MSS、窗口扩大(Window Scale)、SACK Permitted、时间戳只在握手期协商,协商失败即退化为默认行为
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------------------------------+
|          源端口 (16)          |          目的端口 (16)        |
+-------------------------------+-------------------------------+
|                        序号 (32)                              |
+---------------------------------------------------------------+
|                       确认号 (32)                             |
+--------+-------+--------------+-------------------------------+
|数据偏移|保留(3)|  标志位 (9)  |           窗口 (16)           |
|  (4)   |       |              |                               |
+-------------------------------+-------------------------------+
|          校验和 (16)          |        紧急指针 (16)          |
+-------------------------------+-------------------------------+
|                 选项 (变长, 补齐到 4 字节)                     |
+---------------------------------------------------------------+
|                          数据 ...                             |
+---------------------------------------------------------------+

窗口字段的语义要记牢:它通告的是本端还能接收多少字节(接收缓冲区空位),不是"我打算发多少"。应用读走多少,窗口就回涨多少;回涨通知靠不带数据的纯 ACK——这就是延迟确认与零窗口探测的舞台(见第 17 章)。

注意

SYN 与 FIN 各占 1 个序号。由此推出两个高频细节:第三次握手的 ACK 号是"服务端 ISN + 1";挥手时 FIN 的确认号也要 +1。报头固定 20 字节 + 选项最多 40 字节,正是"为什么不能把时间戳、SACK、窗口扩大全部塞进去"的根因,也是 MSS = MTU − 20 − 20 = 1460(以太网)的来源。


16.2 三次握手:每一步究竟在做什么

“SYN、SYN+ACK、ACK"是结论,面试要的是每步交换的信息与它解决的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
   客户端                                     服务端
     |                                          |  CLOSED -> LISTEN
     |  ① SYN  seq = x (客户端 ISN)             |  建半连接表项
     |----------------------------------------->|  LISTEN -> SYN_RCVD
     |     SYN_SENT                             |
     |                                          |
     |  ② SYN+ACK  seq = y (服务端 ISN)         |  表项移入 accept 队列
     |             ack = x + 1                  |  (服务端已单方面认定建连)
     |<-----------------------------------------|
     |                                          |
     |  ③ ACK  seq = x + 1, ack = y + 1         |  SYN_RCVD -> ESTABLISHED
     |----------------------------------------->|  (服务端确认客户端接受了 y)
     |     ESTABLISHED                          |

第一步(SYN, seq = x):客户端宣告自己的初始序号(Initial Sequence Number, ISN),请求序号同步。它不确认任何东西——服务端还没开口。

第二步(SYN+ACK, seq = y, ack = x + 1):两件事压进一个报文——宣告服务端 ISN y,并确认客户端 ISN x。此刻服务端已单方面认为连接建立,进入 SYN_RCVD 并分配接收缓冲区等资源。

第三步(ACK, seq = x + 1, ack = y + 1):客户端确认服务端 ISN,把连接推到 ESTABLISHED,并让服务端知道客户端接受了 y。

为什么双方各自独立编号、必须交换 ISN。 全双工连接有两条独立的字节流,各自需要起点,才能计算确认号、判断乱序与重排。

为什么 ISN 必须随机化、不能从 0 开始。 两条独立理由,面试要都答:

  1. 防止旧连接的报文被新连接误收。 四元组会被复用(客户端端口释放后再用、NAT 复用映射、服务端重建同名连接)。若 ISN 从 0 每轮递增,上一轮某个迟到报文的序号恰好落进新连接的接收窗口,就会被当作合法数据交付应用层。随机化把这一概率压到约 1/2³²。
  2. 防止序列号猜测攻击。 ISN 若可预测,攻击者无需看到握手报文即可盲猜有效序号,从而伪造数据注入或伪造 RST 打断连接——在没有认证机制的 TCP 上这是致命的。

第三次握手能否携带数据? 协议允许,服务端也会正常缓存。但默认不发:服务端 cwnd 初值只有 1 个 MSS 量级(见第 17 章),第二次报文一旦因 SYN 队列溢出被丢,客户端的数据会随重传的 ACK 一起重发,白费带宽;更根本的是客户端此刻还不确定服务端是否接受连接。

为什么不是四次。 把第二步拆成"单独回 ACK"+“单独发 SYN“语义完全等价,但白白多一个 RTT。第二步能合并的依据:SYN 与 ACK 是同一报头里的两个独立标志位,同时置位不违反任何约束,接收方也无歧义(ack 号永远指向上次收到的对端序号,seq 号是本端新序列的起点)。凡两件事能无损放进一个报文,协议设计都会合并——这是读 TCP 的通用规律。挥手侧同理会出现"少于四次"的情况,见 16.6.1。

重要

正确表述:“两次握手让服务端单方面完成状态迁移,而连接是双方的共同状态;第三次握手是客户端把自己的迁移结果通报给服务端,使双方对’连接已存在’达成一致。"——这个句式可以直接迁移到挥手题上。


16.3 为什么不能是两次握手

题目假设:客户端发 SYN,服务端回 SYN+ACK 后即进入 ESTABLISHED,不等第三次确认。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
   客户端 C                          服务端 S
      |                                 |
      | ① SYN(seq=x1) → 网络拥塞/路由抖动,被滞留
      |    (C 超时后放弃, 该报文仍漂在网络中)
      |                                 |
      | ② C 重发 SYN(seq=x2) ---------->|  S 认为连接建立
      |<---------------- SYN+ACK        |  S 分配缓冲区, 进入 ESTABLISHED
      | ③ ACK                           |
      |    C 用完连接, 正常四次挥手关闭  |
      |    S 释放连接, 回到 LISTEN       |
      |                                 |
      | ④ 迟到的 ① 终于送达 ----------->|  S 收到一个"格式完全合法"的 SYN
      |    (对一个早已关闭的旧连接的请求) |  两次模型下: 立即 ESTABLISHED,
      |                                 |  并分配缓冲区/表项 ← "幽灵连接"
      |    C 对此一无所知                |  S 发 SYN+ACK
      |<---------------- SYN+ACK        |
      |    C 无对应连接 → 回 RST 或不响应 |
      |                                 |  S 侧资源被占, 直到重传耗尽

关键点:两次握手模型下,服务端无法区分"新连接请求"与"早已作废的历史连接请求”——它对两者做出同样的承诺、分配同样的资源。而客户端本地没有任何相关状态,会忽略或 RST 掉那个 SYN+ACK,结果是服务端持有一批客户端根本不承认的连接。第三次 ACK 才是客户端对"我确实想建这条连接"的承诺:历史 SYN 送到时,客户端内核因找不到对应 socket 而回 RST,服务端收到即清理。只有"客户端确认了服务端 ISN"这一事实能筛掉幽灵连接。

小心

两个会立刻被追问的错答:说成"两次握手不能保证可靠传输”——可靠性归第 17 章的重传机制,与轮次无关,会被反问"那三次握手就不丢包了吗”;说成"为了防 SYN Flood”——因果反了,防历史连接误建是设计动机,SYN Flood 是后来针对该设计的攻击。


16.4 SYN 队列与 accept 队列

内核为 listen socket 维护两个独立队列,这是排查"连接建立失败"的核心。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
   第一次 SYN 到达 ──►┌────────────────────────┐
                      │ SYN 队列 (半连接)       │ 上限:
                      │ 表项状态 SYN_RCVD       │ min(tcp_max_syn_backlog,
                      └───────────┬────────────┘     somaxconn, backlog)
                                  │ 第三次 ACK 到达
                                  ▼
                      ┌────────────────────────┐
   应用程序 accept() ◄─│ accept 队列 (全连接)    │ 上限:
                      │ 表项状态 ESTABLISHED    │ min(backlog, somaxconn)
                      └────────────────────────┘
项目SYN 队列(半连接)accept 队列(全连接)
存放对象收到 SYN、未完成第三次握手的连接握手已完成、等应用 accept() 的连接
长度上限min(tcp_max_syn_backlog, somaxconn, backlog)min(backlog, net.core.somaxconn)
溢出行为默认丢弃新 SYN,不回 RST,客户端自行重传看 net.ipv4.tcp_abort_on_overflow:0 = 忽略第三次 ACK(服务端悄悄丢弃);1 = 回 RST 让客户端立刻失败重试
观测量netstat -s 的 SYNs to LISTEN sockets droppednetstat -s 的 listen overflows
ss -lnt 列不直接显示Recv-Q = 当前队列长度,Send-Q = 上限

ss -lnt 在 LISTEN 下的 Recv-Q/Send-Q 语义与其他状态完全不同,是最容易读错的地方:

1
2
3
4
5
6
7
8
9
# LISTEN 下这两列不是收发字节数,而是 accept 队列的"当前长度/上限"
# 之所以先看这一列:Recv-Q 持续贴近 Send-Q,就说明应用 accept 不够快,是溢出根因
ss -lnt 'sport = :8080'
# State  Recv-Q Send-Q Local Address:Port
# LISTEN 511    511    0.0.0.0:8080      <- 队列已满,新连接正在被丢弃

# 对比 ESTABLISHED:Recv-Q 是"已收到但应用还没 read 的字节数"
# 长期非 0 说明应用读取慢,与网络无关 —— 两列含义不同,切忌混读
ss -nt state established '( sport = :8080 )'

警告

tcp_abort_on_overflow = 0(默认)的表现极具迷惑性:客户端 connect() 成功返回,ss 里也能看到 ESTABLISHED,但服务端内核在 accept 队列满时把这个已握完手的连接悄悄丢掉,随后它因服务端不认账而被 RST。现象是"偶发连接被重置,重试即成功"。排查第一步是 netstat -s | grep -i listen 加 ss -lnt 的 Recv-Q。


16.5 SYN Flood 与防御

攻击机理直接利用 16.4 的队列:用伪造源 IP 的海量 SYN 填满 SYN 队列,服务端为每个表项分配资源并按 tcp_synack_retries 重传 SYN+ACK,而真实客户端的 SYN 因队列满被丢弃。tcp_syncookies 的原理是:不分配状态,把状态编码进 ISN。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 收到 SYN 且半连接队列已满、tcp_syncookies=1 时:
   不创建 SYN_RCVD 表项, 而是构造 cookie 当作服务端 ISN 回给对方

   cookie = MAC( 客户端IP, 客户端端口, 服务端IP, 服务端端口,
                 时间戳计数器, 本机秘密值 )
            |<-------- 24 bit ------>|<--- 8 bit --->|

 第三次 ACK 回来时:
   用 ack 号减 1 反推时间戳与校验值, 重算 MAC 比对
   一致则直接建连 —— 全程不依赖任何已存表项
  • 无状态是它的全部价值:SYN 队列被"绕过"而非"扩容",因此能抗住任意大的洪流。
  • 代价一:选项协商退化。 服务端 ISN 被占用来编码 cookie,MSS、窗口扩大因子、SACK、时间戳都无法随 cookie 编码,只能放弃。这就是"syncookies 不能与某些 TCP 选项共存"的确切含义——它用长肥管道与 SACK 的性能换抗洪能力。
  • 代价二:cookie 编码空间有限,时间戳计数器粒度决定了 cookie 有效期(约 64 秒量级),超时的第三次 ACK 会被拒。
参数作用取值建议与理由
tcp_syncookies队列溢出时改用无状态 cookie 应答1(按需启用),避免常态下损失选项协商;2 为无条件启用
tcp_syn_retries客户端 SYN 重传次数,决定 connect() 多久失败默认 6,总退避约 127 秒,业务上通常过久,需配合应用层连接超时
tcp_synack_retries服务端 SYN+ACK 重传次数默认 5,退避约 1 + 2 + 4 + 8 + 16 + 32 秒,决定半连接表项存活时长,调小可加快回收
tcp_max_syn_backlogSYN 队列上限默认 256~1024,高并发服务端须上调,否则先于 accept 队列溢出
net.core.somaxconn / tcp_abort_on_overflowaccept 队列的全局上限 / 队列满时是否回 RSTsomaxconn 5.4+ 默认 4096;tcp_abort_on_overflow=0 会造成"客户端成功、服务端丢弃"的幽灵连接

注意

更彻底的方向是把握手负担外移:负载均衡设备做 SYN 代理(客户端先与 LB 握手,LB 再与后端建连),或改用 QUIC(见第 18 章)——QUIC 把传输握手与 TLS 握手合并,并用地址验证令牌/无状态重试包达到类似 syncookies 的效果,且不必牺牲选项协商。


16.6 四次挥手与 TIME_WAIT

16.6.1 每一步的语义与状态迁移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
   主动关闭方 A                            被动关闭方 B
      | ① FIN  seq = u  (A 无数据要发了)        |  ESTABLISHED -> CLOSE_WAIT
      |--------------------------------------->|  (B 仍可发数据!)
      |     A: FIN_WAIT_1                       |
      |<---------------------------------------| ② ACK  ack = u + 1
      |     A: FIN_WAIT_2                       |
      |                                         |  B 的应用调用 close()
      |<---------------------------------------| ③ FIN  seq = w
      |     A: TIME_WAIT                        |  B: LAST_ACK
      |--------------------------------------->| ④ ACK  ack = w + 1
      |     等 2MSL -> CLOSED                   |  B: CLOSED

FIN 的语义仅是"我没有数据要发了",它占一个序号,但不关闭接收方向。B 进 CLOSE_WAIT 后仍可继续发数据,A 必须继续 ACK——这就是半关闭(half-close),也是 CLOSE_WAIT 堆积的物理基础:B 的应用不调 close(),该状态就永远停留。

为什么不一定四次。 ① ACK 与 FIN 若同时就绪,B 会合并成 FIN+ACK,四次退化为三次(延迟确认加剧这种合并);② 同时关闭时双方都发 FIN,各自经 CLOSING 进 TIME_WAIT;③ 一方设了 SO_LINGER(见 16.7)或进程崩溃时直接回 RST,四次挥手被完全跳过——这是抓包里"突然 RST“的常见来源。

16.6.2 四个"堆积状态"的辨析与排查

状态出现在哪一侧含义排查方法
CLOSE_WAIT被动关闭方(已收 FIN 并回 ACK,等本端应用 close())应用层没关闭 fdss -tan state close-wait 找 fd → lsof -p <pid> 定位 → 查连接池 release 是否被 finally/try-with-resources 覆盖。必定是代码 bug,改内核参数无效
LAST_ACK被动关闭方(已发 FIN,等对端最后 ACK)等对端确认本端 FIN通常瞬时。大量停留说明对端不回 ACK(对端崩溃或被防火墙丢弃),属网络/对端问题
TIME_WAIT主动关闭方正常关闭,等 2MSL 后释放堆积说明本端是主动关闭方,见 16.6.3、16.6.4
FIN_WAIT_2主动关闭方(已收 ACK,等对端 FIN)对端迟迟不发 FIN对端 CLOSE_WAIT 侧应用没关连接;FIN_WAIT_2 有超时,由 tcp_fin_timeout(默认 60 s)控制

一句话记住分辨线:CLOSE_WAIT 是应用没关(bug),TIME_WAIT 是内核在等(正常或端口压力)。

16.6.3 TIME_WAIT 为什么存在,且必须是 2MSL

先澄清 MSL(Maximum Segment Lifetime):报文能在网络中存活的最长时间。RFC 793 建议 2 分钟;Linux 实现中 TCP_TIMEWAIT_LEN 固定为 60 秒,即 2MSL = 60 s,对应实现假设的 MSL = 30 s。tcp_fin_timeout 不控制 TIME_WAIT 时长(它控制 FIN_WAIT_2),这是最常见的读错参数。TIME_WAIT 有两条互相独立的理由:

理由一:保证最后一个 ACK 能到达对端。

1
2
3
4
5
6
7
8
   A(主动关闭)                        B(被动关闭)
      | ④ ACK 丢失 ✗                  |  B 收不到 ACK, 重传 FIN
      |<------------------------------| ③' FIN 重传
      |                               |
      | 若 A 已 CLOSED:               |  A 无此连接状态 -> 回 RST
      |                               |  B 收 RST: 连接异常终止
      | 若 A 在 TIME_WAIT:            |  A 仍有状态 -> 重发 ACK
      |------------------------------>|  B 正常进入 CLOSED

若 A 发完最后一个 ACK 就 CLOSED 并释放四元组,B 重传的 FIN 到达时 A 只能回 RST,B 会把已成功传输的数据判为异常终止;TIME_WAIT 让 A 保留状态以重发 ACK,并在收到重复 FIN 时重置计时器。

理由二:让本次连接的旧报文在网络中全部消失。 考虑四元组复用:关闭后立刻用同样的 (源IP, 源端口, 目的IP, 目的端口) 建连,若上一轮某个被延迟的报文仍在网络中,其序号落进新连接的接收窗口,就会被当作新数据交付应用层。2MSL 的物理含义正是:一个报文从发出到被网络丢弃最多花 MSL,对端对它做出反应(如重传)再花 MSL,故 2MSL 后本轮连接的所有报文及其所有可能的响应都必然消失。

重要

记忆锚:理由一防"对端收不到 ACK 而误判失败”,理由二防"旧报文污染同四元组的新连接"。前者关于本次连接能否优雅结束,后者关于下一次连接能否正确开始。只答一条必被追问"还有吗"。

16.6.4 TIME_WAIT 过多的解法与代价

先判断量级是否真异常。本地端口由 net.ipv4.ip_local_port_range 决定(默认约 32768~60999,约 2.8 万个)。若服务是客户端角色(调外部接口、做代理转发),TIME_WAIT 消耗本地端口,到几万量级即报 Cannot assign requested address;若服务是服务端角色(固定监听端口),TIME_WAIT 不消耗端口,只占内存表项。

手段真实作用代价与边界
SO_REUSEADDR对 TIME_WAIT 数量毫无影响。只做两件事:允许 bind 处于 TIME_WAIT 的地址(重启不必等 2MSL),允许同一端口绑定不同本地 IP它不让 TIME_WAIT 变少,只让"绑定"不被拒。当成减少 TIME_WAIT 的手段是根因性误解
tcp_tw_reuse允许主动发起连接的一方,在时间戳(tcp_timestamps=1)开启时复用 TIME_WAIT 中的四元组建新连接只对主动发起方有效;必须开时间戳(复用后旧报文的序号保护由 PAWS 承担);不改架构,只做缓解
缩短 tcp_fin_timeout只缩短 FIN_WAIT_2 的等待上限完全不影响 TIME_WAIT 时长(Linux 由内核常量 TCP_TIMEWAIT_LEN 固定)。当解法属彻底误读
连接池化 / 长连接从根上减少连接创建次数,因而减少 TIME_WAIT 总量需处理空闲超时、对端半关闭、失效连接重建(见第 18 章)。唯一从源头解决的方向
让对端主动关闭把 TIME_WAIT 推给对端需协议层协商,且对端运维未必接受
tcp_tw_recycle已废弃并移除见下方警示

小心

net.ipv4.tcp_tw_recycle 在 4.12 及之后的内核中已被彻底移除,在旧内核上也绝不应启用。它依赖"同一源 IP 的时间戳必须单调递增",而 NAT 设备后的多个客户端共用一个源 IP、各自时间戳并不同步,服务端会把时间戳更小的正常 SYN 当作过期报文直接丢弃——NAT 后的客户端出现随机、大面积连不上。


16.7 半关闭、SO_LINGER 与端口复用选项

操作作用能否继续读对连接的影响
shutdown(fd, SHUT_WR)只发 FIN,关闭发送方向能,接收方向仍可读连接进入半关闭(FIN_WAIT_1/FIN_WAIT_2),fd 仍被占用
shutdown(fd, SHUT_RD)关闭接收方向,对端再发数据则回 RST不能fd 仍被占用
shutdown(fd, SHUT_RDWR)双向关闭并发 FIN不能触发完整挥手
close(fd)fd 引用计数减 1;减到 0 才发 FIN取决于是否还有其他副本释放 fd;引用计数未归零则连接不会关闭

最后一行是高频陷阱:fork 或 dup 出来的 fd 共享同一打开文件表项,一方 close 只减引用计数,连接依旧活着(见第 7 章三层结构)。SO_LINGER 四种组合——定义 struct linger { int l_onoff; int l_linger; }:

l_onoffl_lingerclose() 行为用途与风险
0忽略默认:后台异步发完剩余数据并正常四次挥手绝大多数场景
10丢弃发送缓冲区未发数据,直接发 RST强制跳过四次挥手、不产生 TIME_WAIT;代价是对端可能丢数据并被报"连接被重置"
1>0close() 阻塞至多 l_linger 秒,等数据发完并收到对端 ACK;超时退化为发 RST适合关闭前必须确保送达的场景;风险是阻塞调用线程,在事件循环里会拖垮整个 loop
1负值未定义/视实现而定,不应使用—

警告

“把 SO_LINGER 的 l_linger 设 0 来消灭 TIME_WAIT"是流传最广的偏方之一。它确实让主动关闭方发 RST 而不进 TIME_WAIT,但代价是:① 发送缓冲区里未确认的数据被静默丢弃;② 对端按异常终止处理,可能触发重试或告警;③ 绕过了理由二,旧报文污染新连接的风险重新出现。

SO_REUSEADDR 与 SO_REUSEPORT 的差异:前者管能不能 bind(允许绑定 TIME_WAIT 地址、允许同一端口绑定不同本地 IP);后者管谁来 accept——允许多个 socket(甚至不同进程)绑定完全相同的地址与端口,内核按四元组哈希在它们之间做负载均衡,每个进程有独立队列,从而消除 accept 惊群(thundering herd)与锁竞争。注意 SO_REUSEPORT 要求同一连接始终分到同一 socket,故哈希必须稳定;它只解决分发,不解决"谁来关闭连接”。


16.8 TCP 状态机全图与状态×标志位反应表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  [CLOSED] --passive open--> LISTEN --recv SYN/send SYN+ACK--> [SYN_RCVD]
      |                                                             |
      | active open / send SYN                     recv ACK / recv RST
      v                                       (移入 accept 队列 / 关闭)
  [SYN_SENT] --recv SYN+ACK / send ACK --+                      |
                                         v                      v
                          +----------------------------------+
                          |          ESTABLISHED             |  <-- 数据收发
                          +--+----------------------------+--+
           recv FIN / send ACK|                          | close / send FIN
                              v                          v
                      +--------------+          +----------------+
                      |  CLOSE_WAIT  |          |  FIN_WAIT_1    |
                      +------+-------+          +---+--------+---+
            close/send FIN   |          recv ACK   |        | recv FIN
            (不 close 即堆积) v      (对端未 close)  v        | send ACK
                      +--------------+     +------------+    | 同时关闭: 经
                      |  LAST_ACK    |     | FIN_WAIT_2 |    | CLOSING 汇入
                      +------+-------+     +-----+------+    |
                   recv ACK  |           recv FIN |         |
                             v           send ACK v         v
                          CLOSED <--2MSL-- +------------------+
                                          |    TIME_WAIT     |
                                          +------------------+

11 种状态是 CLOSED、LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、CLOSE_WAIT、CLOSING、LAST_ACK、TIME_WAIT。TCP 是事件驱动的有限状态机:事件 = 收到的标志位组合 + 本端应用调用(open/send/close)+ 超时。

当前状态收到 ACK收到 FIN收到 RST
CLOSED回 RST回 RST忽略
LISTEN忽略忽略忽略
SYN_SENT忽略(未置 SYN 的 ACK 无效)建连后再处理connect() 失败,→ CLOSED
SYN_RCVD移入 accept 队列,→ ESTABLISHED先建连再走 CLOSE_WAIT→ CLOSED
ESTABLISHED更新发送窗口回 ACK,→ CLOSE_WAIT异常终止,→ CLOSED
FIN_WAIT_1确认我方 FIN:已收对端 FIN 则 → TIME_WAIT,否则 → FIN_WAIT_2回 ACK,→ CLOSING 或 TIME_WAIT→ CLOSED
FIN_WAIT_2忽略回 ACK,→ TIME_WAIT→ CLOSED
CLOSE_WAIT忽略忽略(已在关闭中)→ CLOSED
CLOSING确认我方 FIN,→ TIME_WAIT重发 ACK→ CLOSED
LAST_ACK→ CLOSED重发 ACK→ CLOSED
TIME_WAIT忽略重发 ACK 并重置 2MSL 计时器提前 → CLOSED

所有非 LISTEN、非 SYN_SENT 的非 CLOSED 状态收到裸 SYN 都属异常,统一回 RST;LISTEN 收到 SYN 回 SYN+ACK 并进入 SYN_RCVD,SYN_SENT 收到对端 SYN 即"同时打开",回 SYN+ACK 后进入 SYN_RCVD。

TIME_WAIT 行加粗项正是理由一在状态机层面的落地:收到对端重传的 FIN 时不回 RST,而是重发 ACK。


本章小结

关键结论一句话表述常见错误
不能两次两次握手只让服务端单方面迁移状态,历史 SYN 会造成客户端不认的幽灵连接只背"防止旧连接请求",说不出旧 SYN 如何产生
不能四次服务端 SYN 与对客户端 SYN 的 ACK 可放进同一报文,拆开无收益说"四次也行,只是三次更快",漏掉"不违反任何约束"
两个队列SYN 队列溢出丢 SYN;accept 队列溢出看 tcp_abort_on_overflow,默认 0 时连接"成功后消失"把两者混成一个 backlog,忽略 somaxconn 取小值
CLOSE_WAIT / TIME_WAIT前者是被动关闭方应用没调 close()(代码 bug);后者是主动关闭方的正常等待把两者一起归为内核参数问题
TIME_WAIT 的 2MSL① 能重发最后一个 ACK;② 让旧报文消失,避免污染新四元组只说一条;把 tcp_fin_timeout 当成控制 TIME_WAIT 的参数
端口复用选项SO_REUSEADDR 只管"能否 bind",不减 TIME_WAIT;SO_REUSEPORT 管内核级分发把 SO_REUSEADDR 当成 TIME_WAIT 优化手段
tcp_tw_recycle因 NAT 时间戳假设错误已被移除按旧博客去开启

自测题

Q1(推导) 请在不使用"三次握手"这个词的前提下,解释为什么服务端在第二个报文之后不能认为连接已建立,并给出一个能让错误模型暴露的资源耗尽场景。

答案与解析

核心论点是连接是双方的共同状态,不是单方的承诺。第二个报文只完成了"服务端告知自己的 ISN 并确认了客户端 ISN",服务端据此进入 ESTABLISHED;但客户端尚未确认服务端的 ISN,也无法分辨该报文是回应本次请求还是上一轮的迟到回应。若模型在此结束,服务端为每条收到的 SYN 都分配了接收缓冲区与表项,而客户端可能根本没有对应状态。

资源耗尽场景:伪造源 IP 发送大量 SYN。两次模型下服务端对每个 SYN 立即完成建连并分配资源,无法用"客户端是否回应第三次确认"筛除伪造请求——因为根本没有第三次。常见错误:答"两次不可靠,数据可能丢失",可靠性归第 17 章重传机制,与轮次无关。追问:① “客户端收到 SYN+ACK 后崩了,三次握手不也一样消耗服务端资源吗”→ 用 SYN_RCVD 超时回收与 syncookies 回答;② “RST 在这里起什么作用”→ 客户端无对应 socket 时内核回 RST,服务端收到即清理。

Q2(判断) 某 Java 服务出现大量 CLOSE_WAIT,运维建议调大 tcp_max_syn_backlog 与 somaxconn。判断该建议是否有效,并说明你会怎么定位。

答案与解析

无效,方向性错误。 CLOSE_WAIT 表示"本端已收到对端 FIN 并回了 ACK,正等本端应用调用 close()",其唯一出口是应用层 close();两个队列参数只影响连接建立阶段的表项长度。

定位路径:ss -tan state close-wait 找连接与本地端口 → ss -tanp 或 lsof -p <pid> 映射到进程与 fd → 审查连接获取/释放代码。典型根因有三:① 连接池 close/release 不在 finally 或 try-with-resources 中,异常路径漏释放;② 连接池 maxIdle/maxLifetime 与对端空闲超时不匹配,对端先关、本端不感知;③ finally 里只提交事务而没归还连接到池。常见错误:把它与 TIME_WAIT 一起归为内核参数问题。追问:① “CLOSE_WAIT 占用 fd 吗”→ 占用,这正是它最终引发 Too many open files 的原因;② “调 ulimit -n 能否缓解”→ 只是把爆炸时间推后,属掩盖问题。

Q3(计算) 某服务端 tcp_timestamps=0,ip_local_port_range 为默认 32768~60999。它作为主动发起方每秒向同一后端的同一端口建立 5000 条短连接,且由本端主动关闭。判断是否会出现本地端口耗尽,并说明 tcp_tw_reuse 能否救场。

答案与解析

可用本地端口约 60999 − 32768 + 1 ≈ 28232 个。本端主动关闭 → 每条连接进 TIME_WAIT 并占住其四元组 60 秒(TCP_TIMEWAIT_LEN = 60 s)。稳态下处于 TIME_WAIT 的四元组数约 5000 × 60 = 300000,远超 28232,约 5.6 秒就会耗尽端口空间。要注意四元组是 (源IP, 源端口, 目的IP, 目的端口),若后端有多个地址/端口,占用会按目的端组合分摊,瓶颈变成 可用源端口数 × 目的端组合数;本题限定同一后端同一端口,故必然耗尽。

tcp_tw_reuse 不能救场:题目已设 tcp_timestamps=0,而它要求时间戳开启(依赖 PAWS 保证复用后旧报文不被误收);即便开启时间戳,它也只在发起新连接时允许复用,属缓解而非根治。真正的解法是连接池/长连接(把 5000 次/秒的建连降到接近零),辅以扩大 ip_local_port_range、增加后端地址多样性。常见错误:答"把 tcp_fin_timeout 调小",它控制 FIN_WAIT_2,不影响 TIME_WAIT 的 60 秒。追问:① “为什么 tcp_tw_reuse 需要时间戳”→ 复用同一四元组后,旧连接的迟到报文可能落入新连接窗口,PAWS 用时间戳单调性拒收;② “SO_REUSEADDR 有帮助吗”→ 只对"服务端重启时 bind 失败"有帮助。

Q4(设计) 要上线一个"每台机器每秒对下游发起上万次 HTTP 短连接"的网关。请给出减少连接管理开销的方案,按收益排序并说明每个方案的 TCP 层依据。

答案与解析

按收益排序:① 连接池 + 长连接(keep-alive),把建连与拆连的次数从每秒上万降到接近零,直接消除 TIME_WAIT 总量与握手 RTT(依据 16.6.4);② 降低主动关闭方压力,与下游协商空闲超时,连接池 maxLifetime 必须小于对端空闲超时,避免使用被对端静默关闭的连接(依据 16.6.2);③ 扩大可用四元组空间,调整 ip_local_port_range 并在下游增加 (IP, 端口) 组合(依据 Q3 计算);④ SO_REUSEPORT 做接入侧多进程负载均衡,若同时对外提供入口,多进程各自 listen 同端口、由内核按四元组哈希分发,可消除惊群(16.7);⑤ 谨慎使用 tcp_tw_reuse,仅"本端主动发起 + 时间戳开启"有效且依赖 PAWS;⑥ 明确不要用 tcp_tw_recycle(已移除,NAT 场景必炸)与 SO_LINGER 置 0(丢数据且破坏 TIME_WAIT 的保护语义)。

常见错误:把 SO_REUSEADDR 排在前面——它只解决"重启时 bind 失败"与"多 IP 同端口"。追问:① “长连接会不会有时间戳回绕风险”→ 超过 24.8 天的连接需靠时间戳/序号回绕保护,故应设 maxLifetime 定期重建;② “改 HTTP/2 多路复用有没有帮助”→ 有,但那是第 18 章主题。

Q5(辨析) 面试官问:"TIME_WAIT 是为了让最后一个 ACK 能重发,那我调小 tcp_fin_timeout 不就缩短了吗?“请指出其中的两处错误。

答案与解析

错误一:tcp_fin_timeout 不控制 TIME_WAIT。 它控制 FIN_WAIT_2 的存活上限(对端迟迟不发 FIN 时多久放弃),默认 60 秒;TIME_WAIT 时长由内核常量 TCP_TIMEWAIT_LEN(60 秒,即 2MSL,对应实现假设的 MSL = 30 s)决定,用户态无法通过 sysctl 修改。

错误二:把 TIME_WAIT 的理由只归结为"重发 ACK"。 还有第二条独立理由:让本轮连接的所有报文在网络中消失,避免污染使用相同四元组的新连接;即使能保证最后一个 ACK 一定送达,第二条理由依然要求等待约 2MSL。加分回答:可补"把主动关闭方角色转移到可控一侧是常见工程做法;彻底消除 TIME_WAIT 的方向是减少建连次数(连接池)或换用多路复用方案(QUIC,见第 18 章)"。常见错误:答"调小 tcp_fin_timeout 会让 TIME_WAIT 更快释放,但有风险”——把参数名与风险绑定,恰好暴露没读 man 7 tcp。追问:① “MSL 与 2MSL 是什么关系”→ MSL 是单程报文最大存活时间,2MSL 覆盖一去一回;② “为什么 Linux 用 60 秒”→ 内核把 TCP_TIMEWAIT_LEN 定为固定值,是网络半径与端口压力之间的折中。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
为什么 TCP 三次握手不能改成两次?是否理解"连接是双方共同状态"错误回答:只答"防止旧连接请求"就停住,说不出旧 SYN 怎么产生、为什么客户端会回 RST。加分:完整给出"延迟 SYN + 连接已关闭 + 客户端无状态"场景链16.3
为什么三次握手不是四次?是否理解标志位可合并错误回答:“四次也行,只是三次更快”——漏了"不违反任何约束"这一层,也没提同时打开与延迟确认捎带16.2、16.6.1
TCP 的初始序号为什么要随机化,不能从 0 开始?序号空间与安全性错误回答:只说"为了安全"。加分:两条理由分开讲(旧报文误收 + 序列号猜测攻击),并给出概率量级约 1/2³²16.2
第三次握手可以携带数据吗?协议细节与工程取舍错误回答:“不可以,SYN 不能带数据”——混淆了第一次与第三次。加分:协议允许但默认不发,因服务端 cwnd 初值小、丢包会连带重传数据16.2
ss -lnt 里 LISTEN 状态的 Send-Q 和 Recv-Q 是什么意思?两个队列与观测错误回答:“收发缓冲区大小”——那是 ESTABLISHED 下的含义。加分:Recv-Q 是 accept 队列当前长度、Send-Q 是上限,贴上限即溢出16.4
CLOSE_WAIT 很多是什么原因,怎么处理?状态语义与排查错误回答:“调内核参数"或"提高 ulimit"。加分:断言这是应用没调 close() 的代码 bug,并给出连接池 finally/try-with-resources 的定位路径16.6.2
TIME_WAIT 为什么必须有,为什么是 2MSL 而不是 1MSL?本章第一号难点错误回答:只说"重发 ACK”。加分:第二条理由(旧报文消失,防污染同四元组新连接)+ 说明 MSL 是单程、2MSL 覆盖一去一回16.6.3
SO_REUSEADDR 能不能减少 TIME_WAIT?参数真实语义错误回答:“能,开启后 TIME_WAIT 就少了”。正确:它只解决 bind 被 TIME_WAIT 拒绝的问题,数量不变16.6.4、16.7
tcp_tw_recycle 为什么被移除?是否读过真实内核变更错误回答:“因为它不安全"这类空话。加分:NAT 后多客户端共源 IP、时间戳不同步,服务端按时间戳丢弃正常 SYN 导致随机连不上,4.12 起已移除16.6.4
syncookies 是怎么防 SYN Flood 的,有什么代价?无状态化思想错误回答:“它扩大了半连接队列”。加分:状态编码进 ISN 的 MAC;代价是选项协商退化、cookie 有效期有限16.5
shutdown(SHUT_WR) 和 close 有什么区别?半关闭与 fd 引用计数错误回答:“没区别”。加分:shutdown 只关发送方向、接收仍可读;close 是减引用计数,dup/fork 共享时可能根本不发 FIN16.7
SO_REUSEADDR 和 SO_REUSEPORT 的区别是什么?是否分清两个易混选项错误回答:“都是为了端口复用,差不多”。加分:前者管能否 bind,后者是内核按四元组哈希做负载均衡、消除惊群16.7
服务端大量连接处于 SYN_RCVD 说明什么?半连接状态与攻击识别错误回答:“正常现象,不用管”。加分:区分三种成因——客户端不回第三次 ACK(可能有防火墙丢包)、真实 SYN Flood、tcp_max_syn_backlog 偏小,并给出 netstat -s 对应计数器16.4、16.5
同时关闭(simultaneous close)时状态怎么走?状态机是否真的画过错误回答:“不可能同时关闭”。正确路径:FIN_WAIT_1 → CLOSING → TIME_WAIT,前提是双方都成为主动关闭方16.6.1、16.8

延伸阅读

  • RFC 793《Transmission Control Protocol》——状态机定义(含状态转移图)、ISN 选择、TIME_WAIT 与 MSL 的原始表述。
  • RFC 1122《Requirements for Internet Hosts — Communication Layers》4.2.2 节——TCP 实现的最低要求,含半关闭与 TIME_WAIT 的强制项。
  • RFC 6298《Computing TCP’s Retransmission Timer》——握手期 SYN 重传与 RTO 初值(1 s)的规范来源。
  • RFC 7323《TCP Extensions for High Performance》——窗口扩大因子、时间戳与 PAWS、SACK 选项,是理解"syncookies 牺牲了什么"的必备背景。
  • Linux 手册页 man 7 tcp、man 7 socket——tcp_tw_reuse/tcp_fin_timeout/tcp_syncookies/SO_LINGER/SO_REUSEPORT 的权威语义,注意区分官方文档与网上偏方。
  • Linux 内核文档 Documentation/networking/ip-sysctl.rst——tcp_max_syn_backlog、tcp_abort_on_overflow 与 accept 队列溢出的精确行为。
  • 《TCP/IP 详解 卷 1:协议》第 17~18 章——连接建立与终止的报文级时序,可作抓包对照(第 19 章讲怎么读这些报文)。