2846 字
约 9 分钟
20
S02-X08 · TCP 可靠传输:重传、拥塞与流控

"网络抖动"是最容易被拿来当结论的词。但 TCP 从不抖动——它只是在丢包、乱序、窗口耗尽这几种确定状态之间迁移。看懂 ss -ti 里那几行数字,比反复重启服务有用。下面按内核真正维护的变量拆:重传怎么触发、窗口由谁决定、TIME_WAIT 为何存在、应用层三个开关怎么取。


1. 可靠性的四个机制

机制 触发条件 内核动作 代价
序号 + 累积确认 每个字节都有序号,ACK 携带期望的下一个序号 发送方按 snd_una 滑动窗口 累积 ACK 无法表达"中间缺一段"
超时重传(RTO) 定时器到期仍无 ACK 重传最早未确认段,RTO 指数退避 恢复最慢,通常是最后手段
快速重传 收到 3 个重复 ACK(RFC 5681) 不等定时器,立即重传缺失段 窗口太小时凑不出 3 个 dupACK
SACK 对端在选项里通告已收到的非连续区间 只重传真正丢失的段 需两端都支持(net.ipv4.tcp_sack)

四者是兜底与加速的关系:RTO 是最后保险,快速重传是常态路径,SACK 决定重传精度——开了它,一次丢多段不会被误判成大面积丢失。实际发生了什么,看这里:

$ ss -tin dst 10.0.0.8
	 cubic wscale:7,7 rto:204 rtt:1.5/0.6 ato:40 mss:1448
	 cwnd:10 ssthresh:7 bytes_retrans:4344 retrans:0/3
	 reordering:3 lost:1 sacked:12

retrans:0/3 是"当前/累计"重传次数,lost 与 sacked 直接反映丢失与选择性确认的段数。


2. RTT 估算与 RTO 怎么算出来

变量 含义 更新方式
SRTT 平滑往返时间 每个 RTT 样本按 1/8 权重做指数加权
RTTVAR RTT 偏差(近似平均绝对偏差) 按 1/4 权重加权
RTO 重传超时 SRTT + max(G, 4 × RTTVAR),并夹在 RTO.min/RTO.max 之间

为什么要乘系数:RTT 分布右偏,均值附近很密、长尾很长,只用 SRTT 当阈值会频繁误判重传。乘 4 倍偏差相当于给一个随抖动自适应的安全边界——抖得越厉害,容忍越宽。这也解释了 RTO 下限为什么不设得很低:避免低延迟链路上过度重传。

$ sysctl net.ipv4.tcp_rto_min net.ipv4.tcp_rto_max
$ nstat -az TcpRetransSegs TcpExtTCPTimeouts

nstat 是累计计数器,只看增量:两次采样相减,再除以总段数。


3. 拥塞控制的四个阶段

cwnd 由发送方自己维护,ssthresh 是"该慢下来"的分界线。

阶段 进入条件 窗口变化 退出条件
慢启动 连接开始或超时后重置 每个 ACK 加 1 个 MSS,按 RTT 指数增长 cwnd ≥ ssthresh
拥塞避免 慢启动结束 每个 RTT 加约 1 个 MSS,线性增长 检测到丢包
快速恢复 收到 3 个 dupACK ssthresh 减半,cwnd 落到新 ssthresh 附近继续发 收到新数据的 ACK
超时重置 RTO 到期 ssthresh 减半,cwnd 回到初始窗口重新慢启动 重新增长

直觉:快速重传路径上的丢包被当作轻度拥塞,只砍一半;超时被视为重度拥塞,直接回起点。所以吞吐对 RTO 极其敏感。cubic 与 bbr 改的正是后半段——bbr 不再把丢包当拥塞信号,改按带宽与 RTT 建模。


4. cwnd 与 rwnd 是两个不同的限制

名称 维护方 表达的含义 谁能让它变大
cwnd 拥塞窗口 发送方内核 网络能承受多少 收到 ACK,或算法估出更高带宽
rwnd 接收窗口 接收方内核通告 我还能收多少(应用读得慢就变小) 接收方应用把数据读走
实际可发量 两者取 min 同时受拥塞算法约束 两个都大才发得多
# 人为压小接收缓冲,观察 rwnd 变成瓶颈
$ sysctl -w net.ipv4.tcp_rmem='4096 87380 6291456'
$ ss -tinm | grep -E "skmem|rwnd"

ss -tim 的 skmem:(r...,rb...) 是 socket 实际占用与上限;rwnd_limited、cwnd_limited 这类标记(视内核版本)直接指出瓶颈在哪一侧。判错瓶颈是最常见的调优失误——rwnd 受限却去调拥塞算法,等于旋错了钮。


5. 流量控制与零窗口探测

接收方缓冲区满时通告 rwnd=0,发送方停发。怎么知道对方腾出了空间?靠零窗口探测:发送方周期性发 1 字节探测段,接收方回 ACK 时带上新窗口值。

$ tcpdump -ni eth0 -vv 'host 10.0.0.8 and tcp' | grep -i "win 0"

大量 win 0 说明接收方应用读得太慢,不是网络问题。调大 net.core.rmem_max 只是把问题推后,真正解法是消费端提速或加背压。


6. 握手、挥手与 TIME_WAIT

状态 出现在哪一侧 含义
SYN_SENT 客户端 已发 SYN,等 SYN+ACK
ESTABLISHED 双方 数据传输中
FIN_WAIT_1 / FIN_WAIT_2 主动关闭方 已发 FIN,等对方 FIN
CLOSE_WAIT 被动关闭方 收到 FIN,等本地应用调 close()
LAST_ACK 被动关闭方 已发 FIN,等最后 ACK
TIME_WAIT 主动关闭方 等 2MSL 后释放

TIME_WAIT 的两个理由缺一不可:

  1. 保证最后的 ACK 可达——ACK 丢了对方会重发 FIN,处于 TIME_WAIT 还能再回一次;直接关闭则回 RST,对端拿到错误语义。
  2. 让旧连接的重复报文消亡——同一四元组上的新连接不能收到上一连接的陈旧段。

2MSL 就是为这两件事留的窗,MSL 通常按 30 或 60 秒量级实现(按具体内核核对)。

$ ss -s
$ ss -tan state time-wait | wc -l
$ ss -tan state close-wait | head -20

7. CLOSE_WAIT 堆积是应用没关连接

CLOSE_WAIT 只在被动关闭方出现,是"内核已处理完、剩下的活在用户态"的状态。数量持续上涨只有一个解释:应用收到对端 FIN(read() 返回 0 或 EOF 事件),却没调 close() 释放 fd。

成因 表现 修法
连接池借出未归还 随时间线性增长 finally 里归还,或用 try-with-resources
异常路径漏掉关闭 与某类错误同时出现 统一在 finally/defer 中关闭
事件循环忽略 EOF 单进程 fd 逼近 ulimit -n 处理 EPOLLRDHUP 与 read()==0 分支
阻塞读卡在别处 连接挂着不释放 加读超时(SO_TIMEOUT/read_timeout)

所以看到 CLOSE_WAIT 先看应用代码:内核在这件事上已经做完了它的部分。


8. Nagle 与 TCP_NODELAY

Nagle 的规则:有未确认的小数据在途时,把后续小数据攒起来一起发。它减少包数,代价是延迟。

场景 建议 原因
请求-响应型 RPC(小包、等回复) 关 Nagle 攒包会与延迟 ACK 叠加出固定停顿
批量上传、日志推送 保持 Nagle 减少包数,提高有效载荷比
交互式实时信令 关 Nagle,缩短 flush 间隔 延迟优先
需要自己控制消息边界 关 Nagle + 应用层批量写 两者叠加会放大延迟
Socket s = new Socket();
s.setTcpNoDelay(true);   // 等价于 TCP_NODELAY
s.setKeepAlive(true);    // 等价于 SO_KEEPALIVE

"Nagle + 延迟 ACK"是经典的几十毫秒级卡顿来源:发送方等 ACK 攒包,接收方等数据才回 ACK,互相等。这不是故障,是两个合法规避策略叠在一起。


9. SO_KEEPALIVE 与业务心跳不是一回事

维度 SO_KEEPALIVE 业务心跳
探测内容 TCP 层空段,对端内核自动回 应用层报文,对端应用必须处理
能发现什么 主机不可达、连接被中间设备清掉 进程假死、线程池耗尽、依赖不可用
默认时间 通常以小时计(按 tcp_keepalive_* 核对) 秒到分钟级,由业务定
中间设备友好度 长空闲连接常被 NAT/LB 先清掉 心跳本身维持了活跃性
能判断业务可用吗 不能 能
$ sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes

结论:长连接必须有应用层心跳,SO_KEEPALIVE 只管兜底。心跳间隔要小于链路最短空闲超时(NAT、SLB、云防火墙各不相同),否则连接会被中间设备单方面回收。


10. 常见坑

坑 现象 正解
只调大 somaxconn 不管 accept 队列 仍有连接被拒 同时看 ListenOverflows/ListenDrops 与应用 accept() 速度
只看 netstat 不抓包 结论全靠猜 ss -ti 看窗口与重传,tcpdump 看实际报文
把高重传率当网络故障 链路其实很好 先看应用是否写得太碎(每个字段一次 write)
TIME_WAIT 多就开 tcp_tw_reuse 表面数字下降 先确认主动关闭方为何过多(短连接、复用差)
盲目调 tcp_rmem 上限 内存涨、吞吐不变 先分清是 rwnd 还是 cwnd 受限
关掉 SACK 图省事 丢多段时恢复变慢 保持开启
心跳间隔大于 LB 空闲超时 连接被静默回收 压到链路空闲超时的一半以下
$ nstat -az | grep -E "ListenOverflows|ListenDrops|TcpRetransSegs|TCPTimeouts"
$ ss -lnt | awk 'NR>1{print $2, $4}'

11. 边界

需求 更合适的做法 理由
可控的重传与多流隔离 QUIC / HTTP/3 内核 TCP 的字节流语义无法按流隔离
极低延迟的可靠传输 应用层 FEC 或专用协议 TCP 重传至少一个 RTT
只需本地不丢的传输 Unix socket 绕开协议栈,无重传开销
跨机房大吞吐 调大 BDP 相关缓冲 + 选算法 单连接受窗口上限约束
要看端到端质量分布 eBPF/tcpdump + RTT 直方图 计数器只给总量

一句话:TCP 的"毛病"都是为保证顺序交付付的代价;要同时拿到顺序与隔离,只能换传输层。


12. 自测

  1. 快速重传与超时重传分别在什么条件下触发,谁恢复更快?
  2. RTO 公式为什么要乘 4 倍 RTT 偏差,而不是直接用平均 RTT?
  3. cwnd 与 rwnd 分别由谁维护?实际发送量由什么决定?
  4. TIME_WAIT 的两个存在理由是什么,各少了会出什么问题?
  5. CLOSE_WAIT 持续上涨,该看内核还是应用?为什么?
  6. 关掉 Nagle 而链路存在延迟 ACK 时,为什么会叠加出固定量级的延迟?
S02-X08 · TCP 可靠传输:重传、拥塞与流控
https://www.clxhxhhr.top/posts/5900/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。