Linux 基础体系 · 第 62/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux TCP 深入:握手、状态机、窗口、重传、拥塞控制和队列

TCP(Transmission Control Protocol)是一种面向连接、可靠、有序、字节流的传输协议。这里的“面向连接”不是指底层建立了一条专用线路,而是通信双方维护了一组连接状态;“可靠”也不是保证数据一定到达,而是通过序号、确认、重传和拥塞控制尽力提供可靠交付。

在 Linux 中,一条 TCP 连接至少会同时经过这些层次:

应用程序
  │ read()/write()/send()/recv()
  ▼
Socket 接收队列 / 发送队列
  ▼
TCP:序号、确认、窗口、重传、拥塞控制
  ▼
IP:路由、分片或 PMTU 处理
  ▼
qdisc、网卡发送队列、驱动、物理网络

因此,write() 成功并不表示对端应用程序已经收到数据,甚至不表示数据已经离开本机。它通常只表示数据已经被内核接受并放入后续处理路径中的某个缓冲区。


一、TCP 连接究竟标识了什么

TCP 连接由四元组标识:

源 IP + 源端口 + 目的 IP + 目的端口

例如:

192.0.2.10:53124 -> 198.51.100.20:443

服务端监听 0.0.0.0:443 时,监听 Socket 本身并不等于某一条已建立连接。客户端连接到服务端后,内核会为这个四元组维护独立的连接控制块,通常称为 TCP control block。Linux 内部实现细节会随内核版本变化,但逻辑上至少需要保存:

  • 当前 TCP 状态;
  • 发送序号和接收序号;
  • 对端通告的接收窗口;
  • 本端接收窗口;
  • 拥塞窗口;
  • 未确认数据和重传计时器;
  • 时间戳、SACK 等协商能力;
  • 与 Socket、路由和网络命名空间的关联。

因此,同一个服务端端口可以同时承载很多连接:

10.0.0.1:50001 -> 10.0.0.2:443
10.0.0.1:50002 -> 10.0.0.2:443
10.0.0.3:50001 -> 10.0.0.2:443

它们的目的端口相同,但四元组不同。


二、三次握手:连接状态如何建立

2.1 SYN、ACK 和初始序号

TCP 报文段带有:

  • SEQ:本报文段中数据的起始序号;
  • ACK:期望接收的下一个序号;
  • SYN:同步初始序号;
  • FIN:发送方向结束;
  • RST:立即复位连接;
  • ACK:确认字段有效。

SYN 和 FIN 虽然通常不携带应用数据,但各自消耗一个序号。这是理解握手和关闭的关键。

假设客户端选择初始序号 1000,服务端选择初始序号 7000

客户端                                      服务端
  |                                           |
  | SYN, SEQ=1000                             |
  |------------------------------------------>|
  |                                           |
  |       SYN+ACK, SEQ=7000, ACK=1001        |
  |<------------------------------------------|
  |                                           |
  | ACK, SEQ=1001, ACK=7001                  |
  |------------------------------------------>|
  |                                           |

逐步解释:

  1. 客户端发送 SYN=1, SEQ=1000,表示客户端的发送序号空间从 1000 开始。
  2. 服务端收到后发送 SYN=1, ACK=1, SEQ=7000, ACK=1001
    • SEQ=7000 是服务端自己的初始序号;
    • ACK=1001 表示客户端的 SYN 已被接收,客户端下一个序号应为 1001。
  3. 客户端发送 ACK=1, SEQ=1001, ACK=7001,确认服务端 SYN。

三次握手不是简单的“确认服务端在线”。它同时完成了:

  • 双方交换初始序号;
  • 双方确认对方能够接收自己的报文;
  • 协商 MSS、窗口扩大、SACK、时间戳等 TCP 选项;
  • 为避免旧连接残留报文混入新连接建立序号基础。

如果只有两次握手,服务端无法确认客户端是否收到了自己的 SYN,也难以区分某些延迟到达的旧报文。

2.2 为什么服务端收到第三个 ACK 后才交给应用

服务端收到第一个 SYN 后,通常还不能把连接放入应用的已连接队列。此时客户端尚未确认服务端的 SYN,服务端需要保留半连接状态,并等待第三个 ACK。

典型路径是:

sequenceDiagram
    participant C as 客户端
    participant L as Linux 服务端 TCP
    participant A as accept() 应用

    C->>L: SYN
    Note over L: 创建半连接状态<br/>进入 SYN-RECV
    L->>C: SYN+ACK
    C->>L: ACK
    Note over L: 连接转入已完成队列<br/>进入 ESTABLISHED
    A->>L: accept()
    L-->>A: 返回已连接 Socket

accept() 等待的是已完成的连接,而不是任意到达的 SYN。应用调用 accept() 太慢时,可能出现:

  • SYN 仍能到达,但半连接队列满;
  • 三次握手已完成,但已连接队列满;
  • 内核丢弃新连接、延迟确认,或在特定条件下启用 SYN cookies;
  • 客户端看到连接超时或连接被拒绝,具体表现取决于队列状态和内核行为。

2.3 同时打开和异常握手

双方几乎同时发送 SYN 时,会出现 simultaneous open:

A -> B: SYN
B -> A: SYN
A -> B: SYN+ACK
B -> A: SYN+ACK
...

这不是常见客户端—服务端模式,但 TCP 状态机允许它发生。

常见失败情况包括:

  • SYN 到达监听端口之外:可能返回 RST;
  • 防火墙丢弃 SYN:客户端重传 SYN,最终超时;
  • 对端发送了不可接受的 ACK:可能触发 RST;
  • 中间设备只允许单向流量:握手可能卡在 SYN-SENTSYN-RECV

三、TCP 状态机:不是只有 ESTABLISHED

TCP 状态描述的是连接控制状态,不等同于应用是否正在读写数据。

3.1 建立阶段的主要状态

状态 含义
CLOSED 没有连接状态
LISTEN 服务端等待 SYN
SYN-SENT 已发送 SYN,等待对方响应
SYN-RECV 收到 SYN,已发送 SYN+ACK,等待最终 ACK
ESTABLISHED 双向连接已建立

典型服务端转换:

CLOSED
  └─ bind() + listen() → LISTEN
       └─ 收到 SYN → SYN-RECV
            └─ 收到最终 ACK → ESTABLISHED

典型客户端转换:

CLOSED
  └─ connect() → SYN-SENT
       └─ 收到 SYN+ACK → ESTABLISHED

阻塞式 connect() 通常在握手完成后返回;非阻塞 Socket 的 connect() 可能先返回 EINPROGRESS,应用必须通过 poll()epoll()select() 等待可写事件,再用 getsockopt(SO_ERROR) 检查连接是否真的成功。仅仅收到“可写”事件不能直接当作连接成功。

3.2 关闭阶段和半关闭

TCP 是全双工的,因此两个方向可以分别关闭。shutdown(fd, SHUT_WR) 只关闭本端发送方向,仍可继续读取对端数据。

主动关闭的一方通常经历:

ESTABLISHED
  └─ close()/shutdown(SHUT_WR)
       → FIN-WAIT-1
       → FIN-WAIT-2
       → TIME-WAIT
       → CLOSED

被动关闭的一方通常经历:

ESTABLISHED
  └─ 收到 FIN
       → CLOSE-WAIT
       └─ 应用 close()
            → LAST-ACK
            → CLOSED

更完整的主要转换如下:

stateDiagram-v2
    [*] --> LISTEN: bind + listen
    LISTEN --> SYN_RECV: 收到 SYN
    SYN_RECV --> ESTABLISHED: 收到最终 ACK

    [*] --> SYN_SENT: connect
    SYN_SENT --> ESTABLISHED: 收到 SYN+ACK

    ESTABLISHED --> FIN_WAIT_1: 主动 close
    FIN_WAIT_1 --> FIN_WAIT_2: 收到 ACK
    FIN_WAIT_1 --> CLOSING: 同时收到 FIN
    FIN_WAIT_2 --> TIME_WAIT: 收到 FIN
    CLOSING --> TIME_WAIT: 收到 ACK

    ESTABLISHED --> CLOSE_WAIT: 收到 FIN
    CLOSE_WAIT --> LAST_ACK: 应用 close
    LAST_ACK --> CLOSED: 收到 ACK

    TIME_WAIT --> CLOSED: 定时器到期

3.3 CLOSE-WAITTIME-WAIT 的含义不同

CLOSE-WAIT 表示:

对端已经发送 FIN,本端内核已经确认,但本地应用还没有关闭发送方向。

大量 CLOSE-WAIT 通常说明应用没有正确关闭 Socket,常见于连接泄漏、异常错误路径或连接生命周期管理错误。调低内核参数不能修复这个问题。

TIME-WAIT 表示主动关闭方在一段时间内保留连接状态。它有两个重要作用:

  1. 确保对端有足够时间收到最终 ACK,必要时重传 FIN;
  2. 避免旧连接的延迟报文混入使用同一四元组的新连接。

因此,TIME-WAIT 不是“内存泄漏”,也不应默认视为故障。大量短连接会产生大量 TIME-WAIT,解决方向通常是复用持久连接、减少无必要的主动关闭、正确设置连接池,而不是盲目缩短超时时间。

SO_REUSEADDR 主要影响本地地址绑定语义,不能把所有 TIME-WAIT 问题变成“立即复用”。tcp_tw_reuse 等内核参数受版本、时间戳和网络条件影响,修改前必须验证内核文档和实际业务风险;不应使用过时的 tcp_tw_recycle 建议。


四、字节流、序号和确认:TCP 如何知道丢了什么

TCP 不保留应用的消息边界。应用执行两次:

send(fd, "abc", 3, 0);
send(fd, "def", 3, 0);

对端可能一次 recv() 得到 "abcdef",也可能先得到 "ab",再得到 "cdef"。TCP 只保证字节顺序,不保证 send()recv() 的一一对应关系。

4.1 累积确认

假设客户端发送了三个数据段:

段 1:SEQ=1001,长度=1000
段 2:SEQ=2001,长度=1000
段 3:SEQ=3001,长度=1000

如果段 2 丢失,段 3 到达,接收端仍可能回复:

ACK=2001

因为 ACK=2001 表示“序号小于 2001 的字节都收到了,我现在期待 2001”。它没有确认段 3。

如果发送端连续看到多个相同的重复 ACK,可能触发快速重传,而不必等 RTO 定时器超时。

4.2 SACK 如何补充累积确认

启用 SACK 后,接收端可以在重复 ACK 中告诉发送端:

ACK=2001
SACK block: [3001, 4001)

这表示:

  • 1001 到 2000 已经收到;
  • 2001 到 3000 缺失;
  • 3001 到 4000 已经收到。

发送端因此只需重传缺失范围,而不是盲目重传所有未确认数据。SACK 仍然没有改变 TCP 的按序交付语义:应用通常不能读到中间缺失字节之后的数据。


五、窗口:流量控制和拥塞控制是两套限制

发送端实际允许在网络中保持的未确认数据,通常受两个窗口共同限制:

可发送窗口 = min(rwnd, cwnd)

其中:

  • rwnd(receiver window)是接收窗口,表示接收端缓冲区还能接收多少数据;
  • cwnd(congestion window)是拥塞窗口,表示发送端根据网络拥塞判断允许发送多少数据。

二者解决不同问题:

  • rwnd 防止“发送太快,接收端应用来不及取走”;
  • cwnd 防止“发送太快,网络路径中的队列和链路被压垮”。

5.1 完整算例

假设:

  • MSS = 1460 字节;
  • 接收端通告 rwnd = 10 × MSS = 14600 字节;
  • 发送端当前 cwnd = 4 × MSS = 5840 字节;
  • 当前已有 1 个 MSS 未确认。

那么新的数据最多还能发送:

min(14600, 5840) - 1460
= 4380 字节
= 3 个 MSS

如果接收端应用处理很慢,使 rwnd 降到 2 × MSS = 2920,那么即使网络完全空闲,发送端也只能保持:

min(2920, 5840) = 2920 字节

此时瓶颈是接收端,而不是拥塞控制。

反过来,如果接收端窗口为 1 MiB,但发送端 cwnd 只有 10 MSS,那么发送端仍不能一次发送 1 MiB。此时瓶颈是拥塞窗口。

5.2 零窗口和窗口探测

当接收端 Socket 接收缓冲区被应用数据占满时,接收端可能通告:

rwnd = 0

发送端不能继续发送普通数据,但会周期性发送窗口探测报文,确认对端窗口是否重新打开。应用持续不读取数据时,发送端可能出现:

  • 发送队列增长;
  • send() 阻塞;
  • 非阻塞 send() 返回 EAGAIN
  • TCP 连接最终因超时或应用主动关闭而失败。

这与网络丢包不同:抓包可能看到报文很少,ss 却显示接收窗口为零或发送端等待窗口更新。

5.3 窗口扩大和 BDP

TCP 首部的基础窗口字段只有 16 位,最大值为 65535。窗口扩大选项把窗口解释为:

实际窗口 = 首部窗口值 × 2^shift

该选项必须在三次握手期间协商,不能在连接建立后临时启用。

带宽时延积(BDP)用于估计“让链路保持忙碌所需的在途数据量”:

BDP = 带宽 × RTT

例如:

  • 带宽 = 100 Mbit/s;
  • RTT = 50 ms。

则:

BDP = 100,000,000 bit/s × 0.05 s
    = 5,000,000 bit
    = 625,000 字节

如果发送端的 rwndcwnd 长期明显小于约 625 KB,TCP 可能无法填满这条链路。这个计算只是上限估计,实际还受 MSS、协议开销、接收应用处理速度、拥塞算法和中间设备影响。

Linux 通常通过自动调优 TCP 缓冲区来适配连接,但实际可用值仍受 net.ipv4.tcp_rmemnet.ipv4.tcp_wmem、Socket 上限和内存压力等因素影响。调整缓冲区不能绕过丢包、拥塞或接收应用停顿。


六、重传:超时重传与快速重传

TCP 必须在“确认没有丢包”和“不要因为正常延迟而过早重传”之间取平衡。

6.1 RTO 如何从 RTT 估算

设:

  • SRTT:平滑往返时间;
  • RTTVAR:RTT 变化程度;
  • RTO:重传超时时间;
  • R:本次测得的 RTT;
  • G:时钟粒度。

经典估算过程可表示为:

RTTVAR = (1 - β) × RTTVAR + β × |SRTT - R|
SRTT   = (1 - α) × SRTT + α × R
RTO    = SRTT + max(G, K × RTTVAR)

常见 RFC 6298 参数是:

α = 1/8
β = 1/4
K = 4

第一次测量时通常初始化:

SRTT = R
RTTVAR = R / 2
RTO = SRTT + 4 × RTTVAR

完整算例:

  1. 第一次 RTT 测量 R=100 ms
SRTT = 100 ms
RTTVAR = 50 ms
RTO = 100 + 4×50 = 300 ms
  1. 第二次测量 R=120 ms
RTTVAR = 0.75×50 + 0.25×|100-120|
       = 37.5 + 5
       = 42.5 ms

SRTT = 0.875×100 + 0.125×120
     = 102.5 ms

RTO = 102.5 + 4×42.5
    = 272.5 ms

真实 Linux 行为还包含最小、最大 RTO 限制、计时器实现和重传退避等细节。发生超时重传后,RTO 通常会指数退避,例如下一次使用更大的超时时间,以避免网络已经拥塞时继续快速发送。

6.2 为什么重传报文不应直接拿来测 RTT

如果一个报文原本发送后没有及时收到 ACK,后来发生重传,ACK 到达时无法确定它确认的是原始报文还是重传报文。若错误地把这个时间当作 RTT 样本,会污染 RTO 估计。

这就是 Karn 算法的核心:重传报文的确认通常不用于 RTT 采样;时间戳选项可以在更复杂的实现中帮助区分发送实例。

6.3 快速重传

当接收端发现数据缺口但后续数据到达时,会重复确认缺口起点。发送端收到足够多的重复 ACK 后,可以推断某个段丢失,立即重传。

快速重传依赖“后续数据已经到达”这一条件,因此:

  • 单个报文丢失且后面没有更多数据时,仍可能只能等待 RTO;
  • ACK 丢失不一定导致重传,因为后续 ACK 可能覆盖它;
  • 乱序不等于丢包,路径负载均衡、链路重排也可能产生重复 ACK;
  • 启用 SACK 后,发送端能更准确地重建缺口。

6.4 重传不一定由网络链路丢包引起

观察到 TCP Retransmission 可能有多种原因:

  • 真实丢包;
  • 无线链路或虚拟网络短暂丢包;
  • 接收端处理慢造成 ACK 延迟;
  • 接收端窗口不足;
  • 中间防火墙或 NAT 丢弃状态;
  • 路径 MTU 问题;
  • 抓包点位于虚拟设备或网卡卸载边界;
  • 网卡校验和、TSO/GSO/GRO 卸载让抓包呈现出与线上报文不同的形态。

因此,不能仅凭 Wireshark 中一条 “TCP Retransmission” 就认定物理链路丢包。


七、拥塞控制:TCP 如何估计网络容量

流量控制看接收端,拥塞控制看网络路径。拥塞控制的基本变量是 cwnd,但不同算法对它的含义和更新方式不同。

7.1 慢启动

连接初期,发送端不知道路径能承受多少数据。慢启动阶段通常以较小的拥塞窗口开始,每收到一个确认就增加窗口,因此每个 RTT 的窗口大致呈指数增长:

RTT 0: 1 MSS
RTT 1: 2 MSS
RTT 2: 4 MSS
RTT 3: 8 MSS

这里的“慢”是相对于直接发送巨大数据量而言,窗口实际增长很快。

当发生丢包或窗口达到 ssthresh 后,连接通常离开慢启动,进入拥塞避免。传统拥塞避免大致每个 RTT 增加一个 MSS,呈线性增长:

cwnd = 10 MSS
下一个 RTT ≈ 11 MSS
再下一个 RTT ≈ 12 MSS

具体 Linux 算法可能使用更复杂的更新规则。

7.2 丢包如何影响窗口

传统基于丢包的算法把丢包视为拥塞信号:

  • 重传超时通常表示更严重的拥塞,cwnd 大幅降低并重新慢启动;
  • 重复 ACK 触发的快速重传表示可能只是少量丢包,通常进入快速恢复;
  • ACK 继续到达表示网络仍能传递数据,窗口不会像超时那样归零。

“丢包率低所以性能一定好”是错误的。吞吐量还受 RTT、窗口、MSS、重传、接收能力和拥塞算法影响。

7.3 CUBIC、BBR 与算法选择

Linux 中常见的拥塞控制算法包括 CUBIC,部分内核和发行版也提供 BBR。它们不是同一种思路:

  • CUBIC 主要根据拥塞窗口历史和时间函数增长,适合高带宽长距离网络,并使用丢包等事件调整窗口;
  • BBR 试图估计瓶颈带宽和最小 RTT,控制发送速率和在途数据量,不把每一次丢包都当作唯一拥塞信号。

可以查看当前算法:

sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control

示例输出可能是:

net.ipv4.tcp_congestion_control = cubic
net.ipv4.tcp_available_congestion_control = reno cubic

可临时切换为系统已提供的算法:

sudo sysctl -w net.ipv4.tcp_congestion_control=cubic

这只影响之后创建的连接或符合内核策略的连接,不能把一个不可用的算法凭空启用。是否有 BBR 取决于内核版本、编译配置、模块和发行版。生产环境修改全局算法前,应先在独立连接、网络命名空间或灰度实例中验证吞吐、延迟、丢包、公平性和 CPU 消耗。

可以对单条连接使用 TCP_CONGESTION Socket 选项,但程序必须处理 setsockopt() 失败,并确认算法名称在目标系统中可用。不能把某个算法在测试环境中的吞吐数字直接外推到不同 RTT、队列管理和对端实现的生产网络。

7.4 ECN:不丢包也能报告拥塞

显式拥塞通知(ECN)允许网络设备在 IP 层标记拥塞,而不是先丢包。端点协商并正确处理 ECN 后,发送端可以降低发送速率。

ECN 是否有效取决于:

  • 客户端、服务端和中间设备是否都支持;
  • 防火墙或负载均衡器是否保留 ECN 标记;
  • 队列管理器是否配置为标记;
  • 对端和内核是否正确响应。

因此,开启某个 sysctl 并不保证整条路径都在使用 ECN。


八、Linux 中的队列:至少要区分四种队列

“队列满了”不是一个足够精确的诊断结论。TCP 问题至少要区分以下队列。

8.1 SYN 半连接队列

服务端收到 SYN 后,连接进入 SYN-RECV,等待客户端最终 ACK。这个阶段的状态通常位于 SYN backlog 相关结构中。

可查看监听情况:

ss -lnt

示例:

State  Recv-Q Send-Q Local Address:Port
LISTEN 128    4096   0.0.0.0:443

LISTEN Socket,Recv-QSend-Q 的具体含义与内核、ss 版本有关,不能机械地把它们当作普通已建立连接的收发字节。常见 Linux 输出中,监听 Socket 的队列列通常用于观察已完成连接队列和监听上限;SYN 半连接队列还受 net.ipv4.tcp_max_syn_backlog 等因素影响。

相关参数:

sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog

应用传入 listen(fd, backlog) 的值不是最终唯一上限。Linux 会结合 somaxconn 等限制处理;发行版和内核版本可能有差异。

8.2 已完成连接队列

第三次握手完成后,连接等待应用调用 accept()。如果应用线程不足、事件循环阻塞、GC 停顿或进程调度延迟,这个队列可能积压。

典型故障路径是:

SYN 到达
  → 三次握手完成
  → 已完成连接队列增长
  → accept() 不及时
  → 队列达到上限
  → 新连接延迟、丢弃或失败

这与 SYN flood 不同:前者可能主要表现为 SYN-RECV 增多,后者可能握手已完成但应用拿不到连接。

8.3 Socket 发送和接收队列

对于已建立连接:

ss -tin

示例可能类似:

ESTAB 0 32768 192.0.2.10:443 198.51.100.20:53124
     cubic wscale:7,7 rto:204 rtt:12.3/1.4 ato:40
     mss:1460 cwnd:20 bytes_sent:...

其中:

  • Recv-Q:应用尚未读取的接收数据,通常可理解为接收队列积压;
  • Send-Q:尚未被确认或仍待发送的数据,具体显示受内核版本和 Socket 状态影响;
  • rtt:内核测得的往返时间及变化;
  • cwnd:当前拥塞窗口;
  • rto:重传超时估计;
  • wscale:窗口扩大协商结果;
  • mss:最大报文段大小。

ss -tin 需要读取内核 TCP 诊断信息,部分字段可能因权限、内核配置或 iproute2 版本缺失。

Recv-Q 长期增长通常说明接收应用读取慢;Send-Q 长期增长可能说明:

  • 对端没有及时确认;
  • 接收窗口太小;
  • 拥塞窗口较小;
  • 网络路径丢包或拥塞;
  • 本机发送路径或 qdisc 堵塞。

必须结合 cwndrwnd、RTT、重传和应用行为判断,不能只看一个数字。

8.4 网卡、qdisc 和设备队列

TCP 把数据交给 IP 后,还要经过路由、排队规则(qdisc)、网卡驱动和设备发送队列。查看设备统计:

ip -s link show dev eth0
tc -s qdisc show dev eth0

ip -s link 可观察 RX/TX 丢包、错误和字节包计数;tc -s qdisc 可观察 qdisc 的 backlog、drops、overlimits 等统计。命令输出依赖当前 qdisc 和 iproute2 版本,字段不能脱离设备类型解释。

例如:

qdisc fq_codel 8001: root refcnt 2 limit 10240p
 Sent 1234567 bytes 8901 pkt (dropped 12, overlimits 0 requeues 3)
 backlog 0b 0p requeues 3

dropped 可能是 qdisc 层丢弃,不等于 TCP 一定已经完成重传;TCP 还要经过 ACK、RTO 或快速重传路径后才会表现为 TCP 层重传。

现代 Linux 常见 fq_codelfq 等 qdisc,但实际配置可能是 noqueue、厂商 qdisc、容器虚拟设备或云平台定制路径。不要仅凭某一台机器的默认配置推断所有发行版。


九、SYN cookies:缓解队列耗尽,但不是无限容量

SYN flood 的基本思想是发送大量 SYN,却不完成第三次握手,使服务端保持大量 SYN-RECV 状态。

Linux 可以在特定条件下使用 SYN cookies:不为每个半连接立即保存完整状态,而是把必要信息编码到服务端返回的初始序号中,等最终 ACK 到达后再恢复连接。

查看相关设置:

sysctl net.ipv4.tcp_syncookies

SYN cookies 的边界是:

  • 它主要是队列耗尽时的防御机制,不是提高正常业务连接容量的通用方案;
  • 某些 TCP 选项可能无法完整保留;
  • 依赖客户端最终 ACK,不能解决所有带宽耗尽或应用层攻击;
  • 过度依赖 cookies 可能掩盖 listen() 太小、accept() 太慢或前端容量不足的问题。

生产上看到大量 SYN-RECV 时,应同时查看:

ss -ant state syn-recv | wc -l
nstat -az | egrep 'Syncookies|Listen'

nstat 的计数器名称和可见字段会因内核版本变化。诊断时要记录时间窗口,因为累计计数不能直接当作当前速率。


十、抓包如何对应到状态机和窗口

10.1 最小可运行实验

在一台 Linux 机器上启动一个监听服务:

python3 -m http.server 8080 --bind 127.0.0.1

另一个终端抓包:

sudo tcpdump -i lo -nn -tttt 'tcp port 8080'

再执行:

curl -v http://127.0.0.1:8080/

预期能看到类似过程:

127.0.0.1:随机端口 > 127.0.0.1:8080: Flags [S]
127.0.0.1:8080 > 127.0.0.1:随机端口: Flags [S.]
127.0.0.1:随机端口 > 127.0.0.1:8080: Flags [.]
...
127.0.0.1:随机端口 > 127.0.0.1:8080: Flags [F.]
127.0.0.1:8080 > 127.0.0.1:随机端口: Flags [F.]

说明:

  • [S] 是 SYN;
  • [S.] 是 SYN+ACK;
  • [.] 是普通 ACK;
  • [P.] 常表示带数据的 PUSH+ACK,但 PUSH 不是应用消息边界;
  • [F.] 是 FIN+ACK。

在回环接口上进行实验时,不要把结果直接等同于真实网卡:回环没有真实链路延迟,且本机卸载、协议栈路径与物理设备不同。

10.2 查看单条连接

启动服务后运行:

ss -ntp 'sport = :8080'

若权限不足,进程名和 PID 可能不显示;sudo 可以获得更多信息,但应注意生产环境中暴露进程信息的权限边界。

测试非阻塞连接时,应这样判断错误:

int err = 0;
socklen_t len = sizeof(err);

if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) {
    perror("getsockopt");
} else if (err != 0) {
    fprintf(stderr, "connect failed: %s\n", strerror(err));
}

SO_ERROR 返回的是 Socket 保存的异步错误。没有检查它就把 poll() 的可写事件当成成功,会把连接拒绝、超时等错误误判为成功。

10.3 使用 TCP_INFO 时要承认版本差异

Linux 提供 TCP_INFO,应用可以通过 getsockopt(fd, IPPROTO_TCP, TCP_INFO, ...) 获取 RTT、拥塞窗口、重传计数、状态等诊断信息。结构体字段来自 Linux UAPI,字段会随内核发展增加,不同发行版头文件也可能不同。

因此:

  • 编译时应使用目标系统的 <netinet/tcp.h>
  • 只读取结构体中确实存在且版本兼容的字段;
  • 代码应检查 getsockopt() 返回长度;
  • 不应把某个字段的单位、更新时机当成跨所有内核版本完全不变的接口契约。

命令行的 ss -iss -tin 通常已经利用了这些内核诊断信息,是排查单条连接的首选工具。


十一、从现象反推故障路径

11.1 connect() 超时

可能路径:

客户端 SYN
  → 路由错误 / 防火墙丢弃 / 服务端不可达
  → 客户端重传 SYN
  → 超时

诊断顺序:

ip route get <服务端IP>
sudo tcpdump -i any -nn 'host <服务端IP> and tcp port <端口>'
ss -nt state syn-sent

如果抓不到 SYN,先查本机路由、策略路由、网络命名空间和防火墙。如果能看到 SYN 发出但没有任何响应,需要查看服务端和路径中间设备。如果收到 RST,通常意味着目标主机主动表示该端口没有监听,或某个设备主动复位。

11.2 大量 SYN-RECV

可能是:

  • 客户端或路径丢失最终 ACK;
  • 半连接队列太小;
  • SYN flood;
  • 服务端 SYN+ACK 出口路径受阻;
  • 负载均衡器前后端状态不一致。

命令:

ss -ant state syn-recv
nstat -az
sudo tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0'

不能仅通过 SYN-RECV 数量判断攻击。应观察增长速率、源地址分布、完整握手比例和入口带宽。

11.3 大量 CLOSE-WAIT

故障链通常是:

对端 FIN
  → Linux ACK 并进入 CLOSE-WAIT
  → 应用没有 close()
  → Socket 长期占用

查看进程:

ss -tanp state close-wait
lsof -nP -iTCP:<端口> -sTCP:CLOSE-WAIT

修复点在应用的 EOF、异常、超时和取消路径中,而不是简单重启或调整 TCP 状态参数。

11.4 大量 TIME-WAIT

这通常表示本机大量主动关闭短连接。先确认是谁在主动关闭:

ss -tan state time-wait
ss -s

如果是 HTTP 等请求—响应型短连接,优先检查:

  • 是否启用了连接复用;
  • 客户端连接池是否过小;
  • 服务端是否错误地主动关闭;
  • 代理和负载均衡器是否改变了连接生命周期。

只有在明确理解端口复用、延迟报文和 NAT 行为后,才考虑内核参数调整。

11.5 ESTABLISHED 但请求延迟高

需要把等待时间拆开:

DNS / 建连时间
+ 服务端排队时间
+ 服务端处理时间
+ TCP 发送等待
+ 客户端读取时间

如果 ss -tin 显示 RTT 正常、重传很少,但 Recv-Q 长期增长,可能是客户端应用读取慢。如果 Send-Q 增长、cwnd 小且重传增加,可能是路径拥塞或丢包。如果 Send-Q 不大但应用响应迟,问题可能在服务端线程池、锁、数据库或应用队列,而不是 TCP。


十二、容易混淆的边界

12.1 TCP 可靠不等于应用语义可靠

TCP ACK 只说明接收端 TCP 栈已经接收并确认字节,不能证明:

  • 对端应用已经处理;
  • 数据已经写入数据库;
  • 业务操作已经提交;
  • 对端进程没有在随后崩溃。

如果业务需要确认“操作完成”,必须在应用协议中定义响应或事务语义。

12.2 send() 成功不等于对端收到

阻塞 send() 可能只是把数据复制到本地发送缓冲区。之后数据可能在:

Socket 发送队列
→ TCP 分段
→ IP 路由
→ qdisc
→ 网卡
→ 网络
→ 对端 TCP
→ 对端 Socket 接收队列
→ 对端应用 recv()

任意后续阶段都可能失败或延迟。

12.3 延迟 ACK 不等于 ACK 丢失

接收端通常不会对每个小数据段都立即发送独立 ACK,可能等待短时间以合并确认。发送端也可能通过大段数据、累计 ACK 和 SACK 继续工作。只有当 ACK 延迟超出合理范围、窗口关闭或触发重传时,才应将其视为异常线索。

12.4 抓包中的“大包”可能不是线上大包

TSO/GSO 可能让 TCP 层或发送路径看到大于 MTU 的逻辑段,网卡再切分;GRO/LRO 可能在接收方向合并多个线上报文。抓包位置不同,看到的分段形态也不同。

检查网卡卸载:

ethtool -k eth0

临时关闭某些卸载功能可以帮助验证抓包现象:

sudo ethtool -K eth0 gro off gso off tso off

这是高风险诊断操作,可能改变 CPU 消耗、吞吐和生产流量表现。应在维护窗口或隔离环境使用,并在实验结束后恢复原配置;不同驱动也可能拒绝某些设置。


十三、一个实用的诊断闭环

排查 TCP 性能或连接问题时,建议把观测分为四个层次,而不是先修改 sysctl:

1. 应用层

记录:

  • connect() 开始和结束时间;
  • 每次 send()recv() 的返回值和错误;
  • 请求开始、服务端响应和业务完成时间;
  • 连接关闭原因;
  • 线程池、事件循环和连接池状态。

2. Socket 和 TCP 层

ss -s
ss -tan
ss -tin
nstat -az

关注:

  • 状态分布;
  • Recv-QSend-Q
  • RTT、RTO、cwnd;
  • 重传、重复 ACK、超时;
  • 监听队列和 SYN-RECV 数量。

3. IP、设备和 qdisc 层

ip -s link
ip route
tc -s qdisc show

关注:

  • RX/TX drops;
  • 设备错误;
  • qdisc backlog 和 drops;
  • 路由是否走错接口;
  • 容器或网络命名空间中是否使用了不同设备和路由表。

4. 报文层

sudo tcpdump -i any -nn -s 0 -w tcp.pcap \
  'host <对端IP> and tcp port <端口>'

再根据状态机检查:

  • SYN 是否发出;
  • SYN+ACK 是否返回;
  • 第三个 ACK 是否到达;
  • 数据序号是否连续;
  • ACK 是否重复;
  • 是否出现窗口为零;
  • FIN、RST 和重传发生在何处。

只有把这四层时间线对齐,才能区分“应用慢”“接收窗口小”“拥塞窗口小”“队列满”“路径丢包”和“防火墙丢包”。


十四、参数调整的因果边界

下面这些参数经常被调整,但它们只影响特定环节:

sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.ipv4.tcp_rmem
sysctl net.ipv4.tcp_wmem
sysctl net.ipv4.tcp_congestion_control
  • 增大 somaxconn 不能让应用更快调用 accept()
  • 增大 tcp_max_syn_backlog 不能解决入口带宽被 SYN flood 打满;
  • 增大 TCP 缓冲区不能消除接收应用不读取导致的零窗口;
  • 切换拥塞算法不能修复错误路由、MTU 黑洞或严重物理丢包;
  • 增大 backlog 不能替代连接限流、负载均衡和应用容量规划。

临时修改可用:

sudo sysctl -w net.core.somaxconn=4096

永久配置通常写入 /etc/sysctl.d/99-tcp-tuning.conf,再执行:

sudo sysctl --system

但配置文件路径、加载顺序和云厂商镜像行为可能存在差异。修改前应保存原值:

sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog

修改后要通过实际流量和指标验证,至少观察连接成功率、握手延迟、SYN-RECVRecv-QSend-Q、重传率、RTT、CPU 和内存压力。内核 TCP 参数通常是系统级影响,容器内执行也未必只影响当前容器,具体取决于参数是否属于网络命名空间。


TCP 的核心不是某一个参数,而是多个反馈回路共同作用:

握手建立序号和能力
  → 状态机维护连接生命周期
  → 接收窗口限制接收端承受能力
  → 拥塞窗口限制网络在途数据
  → ACK 提供交付反馈
  → 快速重传和 RTO 修复丢失数据
  → SYN、accept、Socket、qdisc 队列吸收并暴露瞬时压力

理解这些因果关系后,SYN-RECVCLOSE-WAITTIME-WAITRecv-QSend-Qcwnd 和 TCP 重传就不再是孤立的监控数字,而是 TCP 状态机在某个具体阶段的可观测结果。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。