TCP 深入 入门

客户端刚在 curl https://api.example.com/user/42 上按下回车。 上一篇看着包离开网卡;这一篇待在 L4 里,回答连接本身在干什么 —— 到 api.example.com:443 的 3-way 握手、滑动字节窗口、 最后的 FIN 对舞。5 个 section 把画面搭出来:3-way 握手(带可交互的包级走读);可靠性 —— 序列号、ACK、重传、SACK、RTO;流控和拥塞控制 —— rwnd vs cwnd、慢启动、CUBIC vs BBR、buffer-bloat;生命周期 —— TIME_WAIT、half-close、Nagle、keep-alive; 最后是速查表

01

3-way 握手 —— 为啥是 3 不是 2

curl 刚按回车。内核建 socket、选个随机初始序列号、给api.example.com:443 发 SYN、等一个 RTT。3 个包之后, 两端就接下来一百万字节连接的 seq 号、窗口大小、选项达成一致。

每条 TCP 连接都从 3 个包开始 —— SYNSYN-ACKACK。 为啥是 3 不是 2,因为边都要同步初始序列号(ISN), 且每边的 ISN 都要被对方确认才能信。2 个包只能同步一个方向。

客户端                                 服务器
  |---- SYN seq=X ----------------------->|     (1)  客户端选 ISN=X
  |<--- SYN-ACK seq=Y, ack=X+1 -----------|     (2)  服务器选 ISN=Y、
  |                                       |          确认看到 X
  |---- ACK ack=Y+1 --------------------->|     (3)  客户端确认 Y
  |                                       |
  |   ESTABLISHED   ←-- 两端          --→  ESTABLISHED

代价正好是数据流之前 1 个 RTT。在 LAN 上是 200 µs;跨洋是 100–200 ms。 这一个 RTT 就是为啥连接池在生产 HTTP 客户端里这么重要 —— 每次新 TCP 握手都让你在第一字节 payload 之前付一个 round trip, TLS 再加一个(TLS 1.2 是两个)。

TCP 连接一生 —— 握手 → 数据 → FIN 对舞客户端(curl)SYN_SENT服务器(api.example.com:443)LISTENSYNseq=X win=64240
帧 1 —— 客户端 SYN。开半连接。
1 / 8
8 帧展示一条完整 TCP 连接。注意节奏:客户端每个字节都被服务器 ACK 一个号、 接收方总在通告自己的 rwnd(空闲 buffer)、发送方在脑子里另维护一个cwnd(随观察到的丢包涨或塌)。拆连接时只有一方进TIME_WAIT —— 通常是先 close() 那个。

SYN flood 和 SYN cookies

第 1 步之后,服务器要记住「这个 4-tuple 有个半开连接、我选的 ISN=Y、 我在等客户端 ACK」。这状态住在 SYN queue(内核 sysctl net.ipv4.tcp_max_syn_backlog,默认 1024–4096)。 攻击者用伪造源 IP 狂发 SYN、从不完成握手,就能撑爆这队列, 把合法客户端拒之门外。经典 SYN-flood DoS。

防御是 SYN cookies(net.ipv4.tcp_syncookies = 1):SYN 队列满时,内核分配状态。 而是选 ISN=Y 为(4-tuple、MSS、时间戳、secret)的密码学哈希。 客户端 ACK 带着 ack = Y + 1 回来时,内核从 4-tuple 再推 Y、 校验哈希、当场重建连接状态。每半连接零内存。

SYN queue vs accept queue

第 3 个 ACK 到了之后连接完全建立;内核把它从 SYN 队列移到accept queue。用户代码里 accept() 从这队列出栈。 大小是 min(backlog, net.core.somaxconn),backloglisten() 的参数,somaxconn 在新内核默认 4096(很多年是 128 —— 无数生产卡顿的源)。 溢出会静默丢掉第 3 个 ACK,客户端 RTO 之后重试。症状:随机 1、3、7 秒的连接延迟。

TCP Fast Open

TFO(RFC 7413)跳过后续到同一服务器连接的一个 RTT 代价。 服务器在第一条连接里给客户端发个不透明的 TFO cookie; 下次连接,客户端发 SYN 同一包里带 data 和 cookie。 服务器校验 cookie,应用在握手完成之前就收到请求 —— TCP 连接上的 0-RTT 数据。 Linux 里支持得不错(net.ipv4.tcp_fastopen),但公网上用 TFO 罕见; 中间盒会丢非标准的 SYN+data,HTTP/2 连接复用 + HTTP/3 0-RTT 覆盖了大多数用例。

要点。「TCP 要 3 个包,因为两端都得同步并确认对方的初始序列号 —— 两个只覆盖一个方向。代价是数据前 1 个 RTT。SYN 队列装半开连接(SYN cookies 防御); accept 队列装等 accept() 的完全 ESTABLISHED 连接。后者溢出 导致神秘的客户端 1/3/7 秒延迟,因为第 3 个 ACK 被静默丢了。」

02

可靠性 —— 序列号、ACK、重传、SACK

IP 给我们 best-effort datagram 服务:包会丢、乱序、重复、错。 TCP 通过给每字节编号、对接收方没确认的东西重传,把它变成可靠、按序的字节流。

TCP 连接里每个字节都有序列号。 TCP header 里 32 位字段数字节(不是包)、4 GB 后绕回 —— 这就是为啥高带宽长连接用 PAWS(Protection Against Wrapped Sequence) 时间戳选项消歧。

接收方回 ACK 号 =「我期待的下一字节」。ACK 是累积的:ack=1001 意思是「我收到到字节 1000 为止的所有东西,之后没有」。 流中间丢一段,后续每个 ACK 还是说「我还在等 1001」, 发送方看到就是重复 ACK

两种丢失检测

TCP 通过两种机制之一触发重传:

  1. 超时(RTO)。一段的 ACK 在重传超时内没到,就重传。 RTO 是动态的:RTO = SRTT + max(G, 4 · RTTVAR),SRTT 是平滑 RTT,RTTVAR 是平滑 RTT 方差(RFC 6298)。 Linux 下限 200 ms、上限 120 s;多数发行版默认天花板 60 s。 每次超时后 RTO 翻倍(指数退避)。连续 6 次超时连接被中止 (net.ipv4.tcp_retries2 = 15 是数据阶段,大概 15 分钟)。
  2. 快重传。连续 3 个重复 ACK 告诉发送方「接收方在收后面段、 但还缺这个」,所以立即重传不等 RTO。原本是 Reno;NewReno 扩展, 现在是基于 SACK 的丢失恢复。

SACK —— 选择性确认

纯累积 ACK 在突发中单点丢失时浪费带宽 —— 发送方不知道是只重传缺失段 还是之后全部。SACK(RFC 2018,在 SYN 选项里协商) 让接收方报「我有 1001–2000 和 3001–8000;缺 2001–3000」。 发送方正好重传缺失范围、停在那。没 SACK,有丢包连接级联到多 RTT 的恢复窗口。

DSACK(RFC 2883,duplicate SACK)反过来: 让接收方报「我刚收到一次 1001–2000」。发送方知道自己的重传是误判 —— 通常因为原包只是慢,不是丢 —— 之后可以对假阳性切 cwnd 不那么激进。

TLP —— 尾部丢失探测

现代 Linux(~3.10+)出 TLP 应对尾部丢失: 应用发个小突发、最后一段丢了,没后段触发重复 ACK —— 只能等 RTO 超时救你,代价几百 ms。TLP 在约 2 · SRTT(比 RTO 快得多) 后发一个探测包,要么拿到快 ACK 要么通过重复 ACK 触发。 请求/响应工作负载(大多数段都在尾部)上大幅改善。

一次丢失恢复 trace

send seq=1、1001、2001、3001、4001、5001  (5 段,每段 1 KB)
recv ack=1001                              (拿到第一)
recv ack=2001                              (拿到第二)
recv ack=2001  [SACK 3001-5001]            (DUP-ACK 1;第三丢了)
recv ack=2001  [SACK 3001-6001]            (DUP-ACK 2)
recv ack=2001  [SACK 3001-6001]            (DUP-ACK 3 → 快重传!)
fast-retransmit seq=2001                   (只补缺失那个)
recv ack=6001                              (全跟上了)
cwnd 减半(Reno) —— 之后是 congestion-avoidance 阶段

要点。「TCP 给每字节编号(32 位、PAWS 防绕回), ACK 是累积的(下一期望字节)。丢失通过 RTO 超时检测(慢,几十到几百 ms) 或 3 个重复 ACK(快重传)。SACK 报洞,只重传缺失范围;DSACK 标记误判重传; TLP 探测尾部丢失,这样最后一段丢了不付一个完整 RTO 的代价。」

03

流控 + 拥塞控制 —— rwnd、cwnd、Reno → CUBIC → BBR

两个不同的问题共享同一个机制:滑动窗口。流控保护接收方不被淹。拥塞控制保护网络不被淹。 发送方两个都得听,实际被两者中更紧的那个 pace。

rwnd —— 接收方流控

每个 ACK header 都通告一个接收窗口(rwnd):「我现在有这么多空闲 buffer」。 发送方承诺线上未确认字节绝不超过接收方通告的。 裸 16 位字段上限 64 KB —— 对现代带宽是个笑话 —— 所以窗口缩放选项(RFC 7323,在 SYN 里协商) 让接收方把通告值乘以最多 214,把有效窗口扩到 1 GB。

如果应用一段时间不 read(),内核 buffer 填满, 通告的 rwnd 缩到零,发送方停。这是好的(无损反压), 直到应用临时卡了一下、连接闲着 —— 发送方会发零窗口探测直到 rwnd 重开。年度病:慢消费者就这么不报任何「错误」地拖死整条管线。

cwnd —— 拥塞控制

发送方还维护一个拥塞窗口(cwnd), 是网络(不是接收方)能吸纳多少的内部估计。 在途字节卡在 min(rwnd, cwnd)。不同的拥塞控制算法只在 cwnd 怎么演化上不同。

经典 Reno / NewReno / CUBIC 家族走 3 个阶段:

  1. 慢启动。cwnd 起在 tcp_init_cwnd(Linux 默认 10 段 × 1460 B MSS ≈ 14 KB —— RFC 6928),每 RTT 翻倍(每 ACK +1 MSS)。 指数增长,直到 cwnd 撞 ssthresh 或观察到丢失。
  2. 拥塞避免。cwnd 线性增长、每 RTT +1 MSS。AIMD:加性增、积性减。
  3. 丢失反应。Reno 把 cwnd 减半。CUBIC 记下丢失时的 cwnd (W_max)、沿三次方曲线长回去 —— 在峰附近平、远离峰加速。 结果:高 BDP 链路上比 Reno 纯 AIMD 恢复快。

Linux 自 2.6.19(2006)起默认 CUBIC。 看:sysctl net.ipv4.tcp_congestion_control;同一个改。

BBR —— 基于模型、对丢失无感

Google 在 2016 引入 BBR(Bottleneck Bandwidth and Round-trip propagation time)作为根本不同的方法。CUBIC 和 Reno 是基于丢失的: 它们填瓶颈队列直到包丢、把丢当信号。在 10 ms LAN 上没问题; 在 200 ms 跨洋深中间盒 buffer 链路上,等队列填满包丢的时候, 延迟已经爆了(「buffer bloat」),CUBIC 不得不大幅退让。

BBR 持续地从入 ACK 速率估计瓶颈带宽(BtlBw)、 从观测到的最小 RTT 估计往返传播延迟(RTprop)。 发送速率被 pace —— 包在时间上平滑摊开,不按窗口爆发 —— 维持在BDP = BtlBw × RTprop 附近。在有损长肥链路(跨洋、移动)上, BBR 常达 CUBIC 的 2–10× 吞吐。干净 LAN 上打平。

BDP —— 信封背面的算术

带宽延迟积告诉你饱和一条链路需要多少数据「在途」:

BDP = 带宽 × RTT
1 Gbps × 100 ms  = 12.5 MB    (跨美大陆)
10 Gbps × 200 ms = 250 MB     (跨洋 10G)
100 Mbps × 30 ms = 375 KB     (家用宽带)

如果发送方在途字节比 BDP 少,你就在桌上留带宽 —— 这是「RTT-bound」。 原因:rwnd 太小(忘了窗口缩放、默认 net.ipv4.tcp_rmem 太低)、 cwnd 太小(慢启动里、或从丢失恢复)、或应用在写间卡。 调 net.ipv4.tcp_rmemtcp_wmem是长肥连接欠交付时第一站。

Buffer bloat 和 qdisc

交换机/路由器和家用 Wi-Fi AP 里长 FIFO 队列吸收包不丢, 对 TCP 隐藏拥塞、把 RTT 吹到秒级。修:BBR(它 pace 并看 RTT 不看丢失)和 Linux 上的 fq_codel /cake qdisc(从长队列主动丢、给 TCP 诚实的丢失信号)。sysctl net.core.default_qdisc 选 qdisc; 现代发行版默认 fq_codel

要点。「两个窗口控制发送方:rwnd(接收方告诉我他的空 buffer;流控) 和 cwnd(我估计网络能力;拥塞控制)。在途卡在 min(rwnd, cwnd)。 CUBIC 基于丢失、主流;BBR 基于模型,在 BtlBw × RTprop pace、 靠不填中间盒 buffer 赢有损长肥链路。用 BDP 看你是带宽限还是 RTT 限。」

04

生命周期 —— TIME_WAIT、FIN、Nagle、keep-alive

拆连接结果比建有意思。FIN 对舞、著名的 TIME_WAIT、Nagle 算法对掐延迟 ACK、keep-alive —— 这些都是生产代码最常出问题的地方。

FIN 对舞和 TIME_WAIT

TCP 双向,所以关也双向。每端写完了发个 FIN;每端 ACK 对方的 FIN。 最一般情况总共 4 个包(FIN、ACK、FIN、ACK), 服务器把自己的 FIN 搭在客户端 FIN 的 ACK 上时,常压成 3 个。

客户端                                 服务器
  |---- FIN ---------------------------->|
  |<--- ACK -----------------------------|     客户端 → FIN_WAIT_2
  |<--- FIN -----------------------------|
  |---- ACK ---------------------------->|     服务器 → CLOSED
  |                                       |
  |  TIME_WAIT  (持 4-tuple 2·MSL)        |
  |       ↓ 60s 后                        |
  |     CLOSED                            |

close() 那端进 TIME_WAIT、把 4-tuple(local_ip, local_port, remote_ip, remote_port)2 · MSL —— Maximum Segment Lifetime 的两倍, Linux 默认 60 s(net.ipv4.tcp_fin_timeout)。两个原因:

  1. 吸收最后 ACK 丢了时的重复 FIN。没 TIME_WAIT,同 4-tuple 下一条连接 会看到死连接的游离 FIN、自己拆了自己。
  2. 确保老连接的延迟段不和复用同 4-tuple 的新连接撞(「旧重复段」问题)。

生产里同客户端发很多短 HTTP/1.0 风格连接时这事变重要: TIME_WAIT 条目堆积,4-tuple 空间耗尽,connect() 开始以EADDRNOTAVAIL 失败。看:ss -tan state time-wait | wc -l; 合法缓解:

  • 连接池。治标治本。复用一条连接发多个请求; HTTP/1.1 keep-alive、HTTP/2 多路复用、gRPC 池化 channel。
  • SO_REUSEADDR。允许把监听 socket 绑到有 TIME_WAIT 条目的端口。
  • SO_REUSEPORT。允许同端口多个 socket —— 内核在监听进程间 负载均衡入连接。分片服务器常见模式(Nginx、Envoy worker 进程)。
  • net.ipv4.tcp_tw_reuse = 1。新出连接在安全时复用 TIME_WAIT 态本地 4-tuple(PAWS 时间戳证明新 SYN 是真新)。 只出方向,且只配时间戳;生产安全。

不要net.ipv4.tcp_tw_recycle。 Linux 4.12 删掉了,因为它弄坏了 NAT 后任何客户端 —— 多客户端共享公网 IP、 他们的时间戳跨 NAT 不单调、服务器会静默丢 SYN。 在服务器上看到开了,当 bug 处理。

半关和 shutdown(SHUT_WR)

close(fd) 关双向。shutdown(fd, SHUT_WR)发 FIN 但读端保持开,这样在你示意「我发完了」之后还能收响应。 HTTP/1.1 历史上用这个在读响应前标请求体结束;gRPC 在客户端流式 RPC 用。

Nagle vs 延迟 ACK

两个延迟-vs-吞吐优化,都开时互相磕得很惨:

  • Nagle 算法(默认开):发送方留住小写,直到 MSS 字节排好队或前一段被 ACK。砍掉「tinygram」问题(每 1 字节载荷 40 字节 header)。 用 setsockopt(TCP_NODELAY, 1) 关。
  • 延迟 ACK(默认开):接收方延迟纯 ACK ~40 ms, 指望搭响应数据走。典型请求/响应流上把 ack 流量砍一半。

合起来,一个教科书病:客户端 write(small_header) 然后write(small_body)。第一个出去;Nagle 留住第二个等 ACK。 接收方延迟 ACK 40 ms 等更多数据搭。结果:每个请求多 40 ms 延迟。 修要么 TCP_NODELAY 要么 —— 更好 —— 用户态聚合写 (一次 writev(),或 send() 前在 buffer 里建完整请求)。

TCP keep-alive

SO_KEEPALIVE 告诉内核在闲连接上发 keep-alive 探测、能查到死对端。 默认没用:net.ipv4.tcp_keepalive_time = 7200(第一次探测前闲 2 小时!)、tcp_keepalive_intvl = 75tcp_keepalive_probes = 9 —— 总 ~2h 11m 才查到死对端。任何对网的东西,设 per-socketTCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT 到合理值 (闲 60 s、间隔 10 s、3 次探测 → 90 s 失败)。 gRPC 同样原因自带应用层 keep-alive。

连接排空

负载均衡器把后端摘掉时,应该排空:停发新连接、让现有的跑完 (或截止后用干净 FIN 关)。错的方法是硬拍下后端 —— 打开的连接吃 RST、 在途请求失败。对的方法:健康检查里报不健康、等 keep-alive 过期、发 FIN。 Kubernetes preStop hook 正为这事而存在。

要点。「先关的进 TIME_WAIT 2·MSL(默认 60 s)吸收掉队者。 TIME_WAIT 耗尽的治法是连接池,不是 tcp_tw_recycle(已删,坏 NAT)。 Nagle + 延迟 ACK 是教科书 40 ms 延迟病;靠聚合写修、别关 Nagle。 默认 TCP keep-alive 闲 2 小时 —— 对重要的设 per-socket TCP_KEEPIDLE。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。

为什么 TCP 要 3-way 握手(不是 2)?

两端都要同步的初始序列号(ISN),且每端的 ISN 都得被对方确认才能信。 2 个包只同步一个方向(客户端 ISN 到了服务器,但服务器 ISN 永远没被确认)。 第 3 个包闭环。代价:数据前 1 个 RTT,TLS 再加 1–2 个。

rwnd 和 cwnd 区别?

rwnd接收方对空闲 buffer 的通告, 在每个 ACK header 里 —— 流控。cwnd发送方对网络容量的内部估计,线上永远不出现 —— 拥塞控制。 在途未确认字节卡在 min(rwnd, cwnd)。任一个太小都吞吐瓶颈。

CUBIC vs BBR —— 各赢在哪?

CUBIC 基于丢失:填瓶颈队列到包丢、然后沿三次方曲线退。赢在干净低延迟 LAN/数据中心链路。 BBR 基于模型:估 BtlBw 和 RTprop、在 BtlBw × RTprop pace、忽略丢失作拥塞信号。 赢在有损长肥链路(跨洋、移动、带 buffer bloat 的家用宽带)上, CUBIC 在这要么填巨大中间盒 buffer、要么因随机丢包崩。 Google 报在 YouTube 上 deploy BBR 拿到 2–14× 吞吐;干净 LAN 上打平。

TIME_WAIT 是什么、生产怎么缓解?

close() 那端持 4-tuple 2·MSL(Linux 默认 60 s,net.ipv4.tcp_fin_timeout),吸收延迟重传、防老段和复用 4-tuple 撞。 耗尽症状:ss -tan state time-wait | wc -l 巨大、connect() 以 EADDRNOTAVAIL 失败。修:连接池 (HTTP/1.1 keep-alive、HTTP/2、gRPC),出方向 net.ipv4.tcp_tw_reuse = 1。 永远不用 tcp_tw_recycle —— 内核 4.12 删了,因为坏 NAT。

Nagle + 延迟 ACK 病理?

Nagle 留住小写,等 MSS 字节或前一段的 ACK。延迟 ACK 把纯 ACK 延 ~40 ms 指望搭数据。 应用做背靠背小写(header 然后 body)时,Nagle 留住第二个,接收方延 ACK, 每个请求多 ~40 ms 延迟。修:用户态聚合写(writev() 或 buffer 后send()),而不是只设 TCP_NODELAY —— 那修了症状但留下 tinygram 问题。

怎么测连接是带宽限还是 RTT 限?

BDP = 带宽 × RTT。发送方在途字节比 BDP 少就是 RTT 限 (rwnd 小、cwnd 小、或应用在写间卡)。在途保 BDP 字节还看到丢失 / cwnd 退, 就是带宽限。看:ss -ti(显示 cwnd、rwnd、rtt、loss); profile 用 tcpdump + tcptraceiperf3

Code review 红旗

  • 条件反射关 Nagle。setsockopt(TCP_NODELAY, 1)后面每个请求 10 个小写。你把一个 40 ms 卡换成了线上 10 个 40 字节 header 的 tinygram。 应该聚合写(writev()、或先建 buffer)。
  • 生产 sysctl 里 tcp_tw_recycle = 1。内核 4.12 删掉了;坏了 NAT 后任何客户端。当 bug 处理。 对的旋钮是 tcp_tw_reuse,给出方向连接。
  • SO_LINGER timeout=0 误用。关时强发 RST 而不是干净 FIN。 对端看到连接重置、在途数据丢。只在测试关闭路径里用,永不对真流量用。
  • 应用协议来回单字节消息。每个请求一个 RTT;吞吐 ≤ 载荷 / RTT,跟带宽无关。 50 ms RTT 意味着每条连接 20 req/s,管子多粗都没用。pipeline / 多路复用或接受延迟预算。
  • 没连接池 —— 每请求新 TCP+TLS。TCP 1 RTT + TLS 1–2 RTT = 第一字节载荷前 2–3 RTT。 100 ms 跨大陆链路上每个请求 300 ms 开销。HTTP/1.1 keep-alive、HTTP/2、HTTP/3 修; 自己滚客户端没这个把性能丢在地上。