"网络抖动"是最容易被拿来当结论的词。但 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 的两个理由缺一不可:
- 保证最后的 ACK 可达——ACK 丢了对方会重发 FIN,处于
TIME_WAIT还能再回一次;直接关闭则回 RST,对端拿到错误语义。 - 让旧连接的重复报文消亡——同一四元组上的新连接不能收到上一连接的陈旧段。
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. 自测
- 快速重传与超时重传分别在什么条件下触发,谁恢复更快?
- RTO 公式为什么要乘 4 倍 RTT 偏差,而不是直接用平均 RTT?
cwnd与rwnd分别由谁维护?实际发送量由什么决定?TIME_WAIT的两个存在理由是什么,各少了会出什么问题?CLOSE_WAIT持续上涨,该看内核还是应用?为什么?- 关掉 Nagle 而链路存在延迟 ACK 时,为什么会叠加出固定量级的延迟?