写在前面
前两篇讲完了 TCP 的"宏观调控"——连接管理、可靠性、流量控制、拥塞控制。但 TCP 还有一批很常用、却常被忽略的"细节机制" :它们大多是默认开启或默认关闭的开关,在文档里不起眼,却直接决定了一个连接在高延迟、小包、长连接、服务器重启这些场景下的真实表现。一个 SACK 没开、一个 Nagle 没关、一个端口没复用,可能就是线上延迟毛刺或"Address already in use"的根因。
本篇逐个讲清这些进阶机制的原理和"什么时候开 / 关",最后把目光投向正在替代 TCP 的 QUIC 和多路径 MPTCP。
一、SACK:选择确认
累积确认的痛点
TCP 的 ACK 是累积的:ACK 号 N 表示"序号 N 之前的所有数据都收到了",对 N 之后的乱序数据则只字不提。一个窗口里如果丢了多个段,发送端只看 ACK 根本不知道后面哪些到了、哪些没到,只能保守地从 N 开始一个个重传 ——哪怕其中很多段接收端早就收到了。
举例,发送端发了 5 个段(S1~S5),S2 丢了:
| |
没有额外信息时,发送端只知道"S2 起还没齐",S3/S4/S5 到底到没到无从判断 ,最坏情况会把 S2~S5 全重传一遍。
SACK 怎么解决
SACK(Selective ACKnowledgment,RFC 2018)在 TCP 头部选项里增加一个 SACK 选项,让接收端额外报告"我这边已经收到的、但不连续的数据块" :
| |
发送端拿到 SACK 就知道只有 S2 真的丢了 ,只重传 S2,S3/S4/S5 不必重发。一个窗口里丢多个段时,SACK 能在一个 RTT 内把它们一次性补齐,而不是像 NewReno 那样每个丢包耗一个 RTT。
每个 SACK 选项最多能携带 3~4 个不连续块(受 TCP 选项字段 40 字节的限制,开了时间戳后通常是 3 块)。还有个扩展叫 D-SACK(RFC 2883):接收端可以用它告诉发送端"你重传的那段我之前已经收过了",帮助发送端区分真丢包和乱序。
该不该开:几乎总是该开。SACK 在现代操作系默认就是开启的 (Linux 的 net.ipv4.tcp_sack = 1),它在有丢包时显著减少不必要的重传。关掉它基本只在和一些极古老设备的兼容场景里才考虑。
二、Nagle 算法、延迟 ACK 与 TCP_NODELAY / CORK
Nagle 算法:小包合并
很多交互式应用(Telnet、SSH、游戏)会频繁发 1 字节、几字节的小包,每个小包加上 TCP/IP 头有 40+ 字节,开销极大。Nagle 算法(RFC 896)规定:
如果连接上有尚未被确认 的小段数据在途,就先别发新的小段 ,攒着;等要么"前面的数据被 ACK 了",要么"攒够一个全尺寸段(MSS)",再一起发。
效果是把零散小包合并成大包,减少头部开销。多数系统默认开启 Nagle 。
和延迟 ACK 的冲突
延迟 ACK(Delayed ACK,RFC 1122)是接收端的优化:收到数据后不立刻回 ACK,最多攒两个全尺寸段、或延迟约 200ms 再回 ,好让 ACK 能捎带反向数据、或合并多个 ACK。
这两个优化单独看都合理,叠在一起却会打架 ,产生著名的"40ms(或更长)毛刺":
| |
本质是:Nagle 在等待本端先前已发送数据的 ACK,而接收端正按延迟 ACK 策略暂缓这个 ACK;其间本端后续的小段就可能被多压一个延迟 ACK 周期。Nagle 不会等待“本端对对方数据的 ACK”。
怎么关:TCP_NODELAY 与 TCP_CORK
| 选项 | 作用 | 何时用 |
|---|---|---|
TCP_NODELAY | 关掉 Nagle ,小包立即发 | 低延迟、请求-响应、交互式:SSH / RDP / 游戏 / HTTP/2 多路复用 / 数据库短查询,基本都该开 |
TCP_CORK(Linux) | 反向操作 :把连接"塞住",攒到 MSS 或显式"拔塞"才发 | 批量场景:先写头部、再写 body,希望它们合到一个包发出去,write(headers); write(body); 后再拔塞 |
经验法则:
- 低延迟 / 交互场景,开 TCP_NODELAY (关掉 Nagle)。代价是多几个小包,但换来可预测的低延迟。绝大多数 RPC 框架、数据库驱动、HTTP 客户端默认就设了 TCP_NODELAY。
- 大批量传输、希望合并发送的,用 TCP_CORK ,并记得发完拔塞。
- 不要"既不开 NODELAY 也不开 CORK"地裸跑一个请求-响应协议——Nagle + 延迟 ACK 的毛刺会让你排查到怀疑人生。
三、TCP keepalive:探活死连接
它解决什么问题
连接建立后,如果对端进程崩溃、被 kill -9、或者网络中间的链路静默断开(没发 FIN/RST),发送端会一直以为连接还在——直到下次写数据时才发现对方已不在,这中间可能空等几个小时甚至无限期。keepalive 就是 TCP 层的"定期探活":连接空闲超过阈值后,发一个空探测包,看对端还回不回。
Linux 默认参数(保守得离谱)
| 参数 | 默认值 | 含义 |
|---|---|---|
tcp_keepalive_time | 7200 秒(2 小时) | 连接空闲多久后开始发第一个探测 |
tcp_keepalive_intvl | 75 秒 | 相邻探测之间的间隔 |
tcp_keepalive_probes | 9 次 | 连续多少次探测失败就判定连接死了 |
也就是说,默认情况下一条死连接要等 2 小时 + 9×75 秒 ≈ 2 小时 11 分 才会被 TCP 层发现。对绝大多数应用这个延迟毫无意义。可在 /proc/sys/net/ipv4/ 改全局默认,或对单个 socket 用 setsockopt + TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT 单独设。
该用 TCP keepalive 还是应用层心跳
多数长连接服务(数据库连接池、消息队列、RPC 长连接)倾向于自己做应用层心跳 ,而不是裸依赖 TCP keepalive:
- TCP keepalive 只能告诉你"TCP 层通不通" ,不能告诉你"对端进程是否真的在干活"(比如对方线程死锁了,TCP 连接依然在)。
- 应用层心跳(定时 ping / 业务层探活)能穿越 HTTP 代理、负载均衡、NAT 这些TCP keepalive 看不到的中间层 ——很多中间设备会独立维护连接状态,TCP 探活可能根本走不到对端。
- 心跳间隔可以根据业务设(通常 30s~60s),比 2 小时合理得多。
建议:长连接、对断连敏感的场景,开 keepalive 并把参数调小(如 idle=60s、intvl=10s、probes=3),同时配应用层心跳 兜底;纯短连接或无所谓的场景可以不管。
四、SO_REUSEADDR / SO_REUSEPORT:端口复用
排障时人人都见过 Address already in use——上次服务没退干净、端口还占着。这两个 socket 选项就是解决"端口能不能重新绑"的,但它们不是一回事 。
SO_REUSEADDR
最经典用途:允许新 socket 绑定一个仍处于 TIME_WAIT 状态的端口 。服务重启时,上一条连接可能还在 2MSL 的 TIME_WAIT 里占着端口,没这个选项就得干等几十秒到两分钟。设了 SO_REUSEADDR 就能立刻 bind 成功,实现快速重启。
- 主要解决 TIME_WAIT 占端口的问题。
- 在 Linux 上不允许 两个 socket 同时绑定完全相同的 IP:port。
注意:TIME_WAIT 出现在主动关闭 连接的一方。对服务器而言,通常是客户端主动断;但服务器若主动关闭(如短连接服务端关连接),它的端口也会进 TIME_WAIT。SO_REUSEADDR 让这些端口的复用不被卡住。客户端侧用固定源端口重连时同理受益。
SO_REUSEPORT(Linux 3.9+,2013)
比 SO_REUSEADDR 强得多:允许多个 socket 同时绑定完全相同的 IP:port ,内核把进来的连接在这些 socket 之间做负载均衡 。
典型用途:
- 零停机重启 :新进程绑定同一端口、起来接流量后,老进程再优雅退出。
- 多进程 / 多线程监听同一端口 :nginx、Envoy 等用多个 worker 各持一个监听 socket,内核把新连接均匀分发,避免"惊群"。
| |
| 特性 | SO_REUSEADDR | SO_REUSEPORT |
|---|---|---|
| 绑定 TIME_WAIT 端口 | 可以 | 可以 |
| 同 IP:port 多 socket 共存 | 不行(Linux) | 可以 |
| 内核负载均衡 | 无 | 有(Linux) |
| 可移植性 | 较通用 | 差(Linux / BSD 语义不同,Windows 的 SO_REUSEADDR 行为另类) |
一句话:SO_REUSEADDR 解决"端口占着没法 bind",SO_REUSEPORT 解决"多个进程同时监听同一端口" 。
五、展望:QUIC 与 MPTCP
TCP 已经四十多岁了,内核里固化、中间盒识别、握手慢、队头阻塞这些问题根上很难改。两条"另起炉灶"的演进路线值得关注。
QUIC:UDP 之上的现代传输
QUIC(RFC 9000,2021)跑在 UDP 之上,把传输层 + TLS 1.3 加密揉在一起 重新设计,是 HTTP/3 的传输基础。它解决了 TCP 的几个老毛病:
- 流级队头阻塞:HTTP/2 在单条 TCP 上多路复用,一个包丢了会卡住所有流(TCP 不知道流的概念)。QUIC 在每条流上独立做丢包恢复 ,一条流丢包不影响其他流。
- 握手慢:TCP 三次握手 + TLS 握手要 2~3 个 RTT;QUIC 把传输握手和 TLS 握手合并,首次连接 1-RTT、重连甚至 0-RTT 。
- 连接迁移:QUIC 用连接 ID 而非"四元组(IP+端口)“标识连接,手机从 Wi-Fi 切到 4G / 5G,IP 变了连接不断。
- 拥塞控制、丢包恢复在应用层实现,可以快速迭代(BBR 等算法在 QUIC 栈里更容易铺开)。
代价:跑在 UDP 上,某些防火墙 / NAT 对 UDP 不友好;CPU 开销比内核态 TCP 高(但在持续优化)。
MPTCP:多路径 TCP
MPTCP(RFC 8684,2020)让一条 TCP 连接同时跨越多条物理路径 ——比如手机同时用 Wi-Fi 和蜂窝网络收发数据。好处是带宽聚合、路径冗余和无缝切换。Linux 内核 5.6(2020)起原生支持。它对上层应用基本透明,但端到端要求两边都支持,部署普及度还有限。
小结
- SACK(RFC 2018)让接收端报告已收到的乱序数据块,发送端只补丢的、不瞎重传;默认该开。
- Nagle 攒小包、延迟 ACK 压 ACK,两者叠加会产生延迟毛刺;低延迟场景用 TCP_NODELAY 关掉 Nagle,批量合并发送用 TCP_CORK 。
- TCP keepalive 探活死连接,默认 2 小时才开始探测、约 2h11m 才判死,太保守;长连接建议调小参数并配应用层心跳 。
- SO_REUSEADDR 解决 TIME_WAIT 占端口、SO_REUSEPORT(Linux 3.9+)让多进程同时监听同一端口并内核负载均衡。
- QUIC(UDP 之上、流级无队头阻塞、1/0-RTT、连接迁移)是 HTTP/3 的传输基础;MPTCP 让一条连接跨多条路径。
下一篇:TCP/IP(十):DNS —— 域名是怎么一步步解析成 IP 的,递归 / 迭代查询、层级与缓存、常见记录类型,以及 DoH / DoT / DNSSEC。
参考:
- RFC 2018 — TCP Selective Acknowledgment Options
- RFC 896 — Congestion Control in IP/TCP Internetworks(Nagle 算法)
- RFC 1122 — Requirements for Internet Hosts(Delayed ACK)
- RFC 9000 — QUIC: A UDP-Based Multiplexed Transport
- RFC 8684 — TCP Extensions for Multipath Operation
- SO_REUSEADDR vs SO_REUSEPORT — Stack Overflow