第 21 章 网络重传与 TCP 调优——“建不上连”和”找了一周的重传”
第 10 章我们讲过 TCP 的微观旅程和
net.ipv4.tcp_*参数家族。这一章我们把其中两类最磨人、且最常被”抄错参数”放大的网络故障拎出来,走完整迭代环: (a) 高并发短连接下TIME_WAIT堆积,把本地端口耗尽,新连接建不上——这是”以为调一个参数就能解决、结果踩进已删除参数的坑”的经典; (b) TCP 重传率 5%,RT 莫名涨,调了一周tcp_*没找到根——根因根本不在 TCP 拥塞控制,而在 MTU 黑洞。 两起事故都会把”为什么是三次握手""TIME_WAIT 为什么不能随便删”这些设计意图讲透。
一、先认两张脸:连接建不上 vs 数据传得慢
墨:老哥,网络故障我也先让你分两类。下面两种,“看起来都像网络坏了”,治法天差地别,你先猜怎么分?
你:TIME_WAIT 那种是”建连阶段”的问题;重传那种是”连上了但传得慢”。
墨:对,而且这两类分别对应 TCP 的两个不同阶段,参数完全不重叠:
- 建连阶段(三次握手 /
TIME_WAIT):涉及tcp_tw_reuse/tcp_tw_recycle(已删)、ip_local_port_range、tcp_max_tw_buckets、SO_REUSEADDR。治的是”连不上”。 - 传输阶段(重传 / 拥塞 / MTU):涉及
tcp_retries2、tcp_mtu_probing、tcp_sack、tcp_window_scaling、tcp_rmem/wmem。治的是”连上了但慢/丢”。
你:先说 TIME_WAIT 吧,我老听说”把 tcp_tw_recycle 开起来就好了”,真的吗?
墨:这是全网最危险的 TCP 误导之一,先给你个暴论:tcp_tw_recycle 在 Linux 4.12(2017)已经彻底删掉了,你现在的机器上 sysctl 写它只会报”不存在”。而且它当年开了也不是”就好”,是”在 NAT 后面大规模丢包”——因为它用时间戳做 PAWS 判断,而 NAT 后面多个客户端的时钟不齐,直接把合法包当过期丢了。所以:别碰它,它已经死了,且死得对。
二、事故 A:高并发短连接,新连接建不上
📦 案例:某 API 网关,Go 写,对后端用短连接(每请求新建连接)。大促压测时,网关大量报 "dial tcp: cannot assign requested address"。
现象(第 0 层)
压测到 3 万 QPS,网关错误率飙升,日志满屏:
dial tcp 10.0.1.20:8080: connect: cannot assign requested address
而后端服务完全健康,CPU/内存都闲。错误码是 EADDRNOTAVAIL——本地没有可用端口分配给新连接。
墨:关键信号:EADDRNOTAVAIL + 后端健康 = 问题在网关”主动建连”这一侧,本地源端口不够用,不是后端拒绝、不是网络断。这就把方向直接锁到”本地端口 / TIME_WAIT”。
推断 1:是不是连接泄漏,没关?(可能推错)
你:是不是我代码里连接没 Close(),泄漏了?
墨:先查 ss -tan | wc -l 和按状态分类:
ss -tan | awk '{print $1}' | sort | uniq -c结果:ESTABLISHED 才几千,但 TIME_WAIT 好几万。说明连接有关闭(没泄漏成 ESTAB 堆积),是关闭后陷在 TIME_WAIT 里释放不掉,把端口占住了。
结论:推错一半——不是泄漏,是 TIME_WAIT 堆积占用端口。
再观察:TIME_WAIT 为什么占端口
墨:复习第 10 章的设计意图:TIME_WAIT 是主动关闭方在发完最后一个 ACK 后,必须停留 2×MSL(默认 60s)的状态,目的是:
- 确保最后一个 ACK 万一丢了,能重发(对端还在等);
- 让网络中残留的旧连接报文过期,不被新连接(same 四元组)误收。
所以一个 TIME_WAIT 占用 (本机IP:本机端口 → 对端IP:对端端口) 这个四元组的源端口整整 60 秒。短连接高并发下,源端口被海量 TIME_WAIT 占满,新连接申请源端口时无处可分配 → EADDRNOTAVAIL。
推断 2:TIME_WAIT 堆积,但”删掉它”是错的
你:那把 TIME_WAIT 时间调短,或者直接不允许它存在?
墨:这正是踩坑区。 几种”根治”想法和它的真实后果:
tcp_tw_recycle=1:已删除(4.12+),且当年 NAT 下丢包。绝路。tcp_tw_reuse=1:这个才是正解之一——它允许作为客户端(主动建连方)时,复用处于TIME_WAIT的 socket 用于新 outbound 连接,且只在时间戳递增时安全复用(PAWS 保护)。注意:它只对”主动建连的客户端”有效,对被动监听的服务端 accept 侧无效。tcp_max_tw_buckets:TIME_WAIT总数硬上限,超了就直接扔掉最老的。是”兜底安全阀”,调大没解决根因,设太小会丢状态。
调整 1:客户端侧复用 + 扩端口范围
sysctl -w net.ipv4.tcp_tw_reuse=1 # 客户端安全复用 TIME_WAIT
sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 可用源端口从默认~28k扩到64k
sysctl -w net.ipv4.tcp_max_tw_buckets=200000 # 兜底上限,别让单桶挤爆调整 2:从”短连接”改”长连接/连接池”(治本)
墨:调参是止血,治本是别产生那么多 TIME_WAIT。 网关对后端改用连接池 + 长连接(HTTP keep-alive / gRPC 复用),把”每请求一建一毁”变成”池里复用”。运维改网关客户端为连接池后,TIME_WAIT 数量从几万降到几百。
改进:建连恢复正常,收口
墨:tcp_tw_reuse=1 + 扩端口范围 + 连接池三连,EADDRNOTAVAIL 归零。复盘那句最容易记错的:
TIME_WAIT不是 bug,是 TCP 的安全设计(防旧报文串门 + 保证 ACK 必达)。你能”复用”它(tcp_tw_reuse),但别想”删掉”它(tcp_tw_recycle已死且当年有害)。根治靠连接池,不靠砍状态。
三、事故 B:重传率 5%,找了一周
📦 案例:某跨机房调用,RT 比同机房高 3 倍,ss -tin看retrans(重传率)约 5%,但 ping 延迟正常、带宽没满。
现象(第 0 层)
监控:tcp_retrans_segs 增长快,retrans 率 5%,RT P99 是同城集群的 3 倍。ping(ICMP)延迟正常(2ms),iperf 带宽也没满。一群人调了一周 tcp_*(窗口、tcp_sack、tcp_window_scaling),毫无改善。
墨:注意矛盾:ping 正常、带宽没满、但 TCP 重传 5%。这说明不是拥塞、不是带宽不够,是有特定大小的包在链路某处被静默丢弃——而且丢的是”大包”,小包(ICMP echo 才 64 字节)不受影响。这强烈指向 MTU / PMTU 黑洞。
推断 1:是不是网络拥塞?(可能推错)
你:重传率高,不就是网络堵了吗?加 tcp_sack、调窗口。
墨:查 tc/iftop 带宽占用——链路利用率才 20%;且 ping 正常。拥塞的特征是”带宽打满 + 队列丢包 + 所有尺寸包都受影响”,本案只对大包丢、小包通畅、带宽空闲,完全不像拥塞。调了一个星期 tcp_sack/窗口,自然无效——因为根因不在拥塞控制,在链路 MTU。
结论:推错。方向从”拥塞”转到”特定尺寸包被丢”。
推断 2:MTU 黑洞(ICMP 被禁导致 PMTU 失效)
墨:经典 MTU 黑洞现场。链路中间有一段 GRE 隧道 / VPN,MTU 只有 1400(比标准 1500 小)。按 PMTU(路径 MTU 发现)协议,发送方发 DF(不分片)位的大包(1500),中间设备发现超 MTU,应回一个 ICMP “Fragmentation Needed” 让发送方把包缩小到 1400。但——中间防火墙把 ICMP 这类消息禁了(安全策略常见操作)。于是:发送方永远收不到”该缩小”的通知,持续发 1500 的大包,中间设备默默丢弃(因为超 MTU 又不能分片)。表现就是:大包(>1400)全丢 → TCP 触发重传 → 重传偶发用小包或探测包反而通过 → 重传率卡在 5% 上下,RT 飙升但 ping 正常。
调整:让 TCP 自己探测 MTU,或显式降 MSS
墨:两条路:
# 路 1:开启 MTU 探测(正解)
sysctl -w net.ipv4.tcp_mtu_probing=1 # 1=默认关,但检测到黑洞时启动探测
sysctl -w net.ipv4.tcp_base_mss=1024 # 探测起点 MSS
# 路 2:两端显式把 MTU/MSS 设小(隧道场景直白有效)
ip link set dev eth0 mtu 1400 # 或应用层设 MSStcp_mtu_probing 的机制:默认不发探测包(=0),但一旦检测到”该重传的小包能成、大包老丢”这种不对称,就启动 PLPMTUD(基于 TCP 的 PMTU 发现),逐步把 MSS 往小试,找到能过的最大值。它不依赖 ICMP,所以能绕过”ICMP 被禁”的黑洞。
改进:重传率归零,收口
墨:开 tcp_mtu_probing=1 后,内核自动把 MSS 探到 1360 左右(1400 减去头),重传率从 5% 降到 <0.1%,RT 回到同城水平。复盘:
重传率高 ≠ 拥塞。当”小包通、大包丢、带宽闲、ping 正常”同时出现,先怀疑 MTU 黑洞,而不是去调拥塞控制。 而且 ICMP 被禁是元凶之一——很多”安全加固”把 ICMP 全禁了,顺手把 PMTU 也废了。
你:所以那一周白调了 tcp_sack?
墨:没白调,是”先排除”的过程。 排障就是不断把”可能”变成”已排除”。但这一周本可以缩短——只要一开始就看 retrans 率分布 + 测不同包大小(ping -s 1472 测大包通不通),就能早一点定位 MTU,而不是在 tcp_* 拥塞参数里打转。
四、配套参数:这两类故障涉及的 tcp_*
| 参数 / 文件 | 默认 | 类别 | 拿什么换什么 |
|---|---|---|---|
net.ipv4.tcp_tw_reuse | 0 | 建连 | 1=客户端安全复用 TIME_WAIT ↔ 仅在时间戳递增时(PAWS)安全 |
net.ipv4.tcp_tw_recycle | (已删除) | 建连 | 不存在了,别配;当年 NAT 下丢包 |
net.ipv4.ip_local_port_range | 32768 60999 | 建连 | 扩范围 ↔ — |
net.ipv4.tcp_max_tw_buckets | 见内核 | 建连 | 兜底上限 ↔ 太小丢状态 |
net.ipv4.tcp_mtu_probing | 0 | 传输 | 1=检测到黑洞启动探测 ↔ 略增探测开销 |
net.ipv4.tcp_base_mss | 512 | 传输 | 探测起点 ↔ — |
net.ipv4.tcp_retries2 | 15 | 传输 | 重传多少次放弃(决定连接”假死”时长) ↔ 调小更快报错 |
net.ipv4.tcp_sack / tcp_window_scaling | 1/1 | 传输 | 丢包恢复/大窗口 ↔ 极老设备才关 |
墨:tcp_retries2(默认 15 次,指数退避后约 1330 分钟才放弃一个”死连接”)也是个隐形坑——某端悄悄挂了、FIN 没发出来,对端连接要十几分钟才感知到”死了”,期间一直重传占着资源。某些对”快速失败”敏感的服务会调小它(如设 58)。
五、参数总表(这一章动过的”拿什么换什么”)
| 参数 / 文件 | 默认 | 本次取值 | 拿什么换什么 |
|---|---|---|---|
tcp_tw_reuse | 0 | 1 | 客户端复用 TIME_WAIT ↔ 仅 PAWS 安全 |
ip_local_port_range | 32768-60999 | 1024-65535 | 更多源端口 ↔ — |
tcp_max_tw_buckets | 默认 | 200000 | 兜底上限 ↔ 太小丢状态 |
| 连接池(治本) | — | 启用 | 根因消除 ↔ 改架构 |
tcp_mtu_probing | 0 | 1 | 绕过 MTU 黑洞 ↔ 略增探测开销 |
tcp_base_mss | 512 | 1024 | 探测起点 ↔ — |
六、🔧 思考题(都配参考答案)
思考题 1(基础): 为什么 tcp_tw_recycle 是”危险且已删除”的参数?为什么 tcp_tw_reuse 相对安全?两者适用场景有何不同?
【参考答案】
tcp_tw_recycle:在 4.12 内核已彻底删除。当年它用时间戳做 PAWS 快速回收 TIME_WAIT,但 NAT/负载均衡后多个客户端共享一个公网 IP、时钟不齐,内核会把”时间戳较小”的合法包当过期丢弃 → NAT 后大规模丢包。所以既已不存在,且当年就有害。
tcp_tw_reuse:只作用于主动建连的客户端(outbound),且只在”新连接的时间戳比 TIME_WAIT 里记录的大(PAWS 递增)“时才安全复用该四元组端口。它不缩短服务端 accept 侧的任何状态,也不影响被动关闭。
区别:tw_reuse 是”谨慎复用客户端端口”,tw_recycle 是”激进全局回收且破坏 NAT”。所以高并发客户端场景用 tw_reuse+连接池,绝不碰 tw_recycle。
思考题 2(深入): 案例 A 调了一堆 tcp_* 没用,最后靠 tcp_tw_reuse+扩端口+连接池解决。请解释”为什么是短连接高并发才会踩这个坑”,以及”为什么连接池是治本”。
【参考答案】
为什么短连接高并发才踩:TIME_WAIT 占用 (本机IP:源端口→对端IP:对端端口) 四元组的源端口约 60s(2×MSL)。短连接 = 每请求新建+关闭,源源不断产生 TIME_WAIT,且因为对端 IP:端口往往集中(都连同一后端),源端口被海量 TIME_WAIT 占满 → 新连接无端口可用(EADDRNOTAVAIL)。长连接/连接池下,连接复用、关闭极少,TIME_WAIT 数量级降百倍,自然不踩。
为什么连接池治本:调 tw_reuse/扩端口是”在必须产生大量 TIME_WAIT 的前提下缓解”,而连接池直接”不产生那么多 TIME_WAIT”——把每请求一建一毁变成池内复用。根因消除,而非 symptoms 缓解。所以连接池是治本,调参是止血。
思考题 3(权衡): 案例 B 的 MTU 黑洞,为什么”禁 ICMP”会废掉 PMTU?tcp_mtu_probing 凭什么能绕过它?
【参考答案】
PMTU(路径 MTU 发现)原理:发送方发 DF(不分片)大包,中间设备若发现超其 MTU,应回 ICMP “Fragmentation Needed” 告知发送方把包缩到该 MTU。若中间防火墙把 ICMP 禁了,发送方永远收不到缩小通知,持续发超 MTU 大包 → 被中间设备静默丢弃(不能分片又超 MTU)→ 大包全丢、小包(ICMP echo 64B)通 → 重传率高但 ping 正常。这就是”禁 ICMP 顺手废了 PMTU”。
tcp_mtu_probing(PLPMTUD):不依赖 ICMP,而是 TCP 自己发探测包,逐步增大/减小 MSS 试探”多大能通过”,根据 ACK/超时判断。它绕开了”ICMP 被禁”这个前提,所以能在黑洞里找到可用 MSS(本案探到 ~1360)。代价是探测期间略有开销,但相比 5% 重传划算。
思考题 4(进阶总账): 你接手”跨机房 RT 高、重传率 5%“的故障。请给一套分层排查顺序,从”最可能且最便宜”到”最不可能”,每层看什么、调什么。
【参考答案】
- 包大小 × 通断(最快,排除 MTU 黑洞):
ping -s 1472 <对端>测大包通不通;不通但ping(小包)通 → MTU 黑洞。调tcp_mtu_probing=1或两端降 MTU/MSS。 - 带宽/拥塞:
iftop/tc看链路利用率。满 → 真拥塞,调窗口/sack/队列;空闲 → 不是拥塞(本案),别在tcp_*拥塞参数打转。 - 重传分布:
ss -tin看retrans率、nstat -az TcpRetransSegs。区分”全尺寸丢”vs”随机丢”。 - 两端
TIME_WAIT/连接状态:ss -tan分类。若建连阶段的问题(另案)在此暴露。 - 拥塞控制算法:
sysctl net.ipv4.tcp_congestion_control(cubic/bbr)。仅在前几层都排除、且真有长肥管道(bufferbloat/高 BDP)时换 BBR。 顺序核心:先查”链路层丢特定包(MTU)“和”带宽是否满”,再碰 TCP 算法——绝大多数”调一周没用”都是因为跳过了第 1、2 层。
七、第二个现场:连接池耗尽引发的”建连超时雪崩”
墨:第 21 章第一个现场(A)是”系统层端口被 TIME_WAIT 占满”,第二个现场讲它的应用层表亲——“连接池被占满”,两者是同一类病的两个层次,排障时都得查。
📦 案例:微服务 A 高并发调下游 B,高峰期大量 "connection timeout",但 B 自身完全健康。
现象(第 0 层)
A 的报错:dial tcp B: context deadline exceeded(建连/取连接超时)。B 侧监控:CPU、内存、RT 全正常,没有被压。报错集中在”调用高峰”那几分钟。
推断 1:B 挂了?(推错)
你:下游超时被调服务,第一反应 B 扛不住。 墨:查 B——健康。且超时不是”请求慢”,是”拿不到连接就超时”(连接池等待超时,而非 B 处理慢)。方向立刻从”B 处理”转向”A 的连接资源管理”。
再观察:连接池耗尽(命中)
墨:看 A 的连接池指标——池子满了,新请求在等空闲连接,等到超时。根因是:A 的连接池上限设太小,而下游 B 偶发慢(哪怕几秒)让连接被长期占用不放,慢的请求越多,被占的连接越多,池越快耗尽,新请求拿不到连接 → 超时雪崩。这是”连接资源被占满,新请求拿不到”——和第 21 章案 A 的 TIME_WAIT 端口耗尽是同一类机理,只是层次不同:
- 系统层:四元组源端口被
TIME_WAIT占着(内核管)→EADDRNOTAVAIL。 - 应用层:连接池对象被占着(应用管)→ 取连接超时。
调整
墨:三剂药:
- 调大连接池 + 加”取连接超时”与”连接最大空闲/存活时间”,别让慢连接永久占坑。
- 给下游 B 加限流 + 给 A 加熔断:B 一慢,A 就熔断一部分流量,别让慢请求把池子拖死(呼应第 19 章”断雪崩链”)。
- 系统层配合:A 对 B 用长连接 +
tcp_tw_reuse=1,减少系统层端口压力(案 A 的解法),双管齐下。
改进:超时归零,收口
墨:连接池扩容 + 熔断后,超时消失。复盘:“建连/取连接超时”要区分系统层(TIME_WAIT/端口)和应用层(连接池),两者解法不同但机理同源——都是”连接资源被占满”。排障时两个池都要看:ss -tan 看系统层 TIME_WAIT,应用 metrics 看连接池水位。
八、网络排障总账(把第 10、21 章收成武器)
墨:把网络相关排障收成一张决策表,任何网络故障照表走:
| 症状 | 第一怀疑层 | 验证 | 治本 |
|---|---|---|---|
EADDRNOTAVAIL/建连失败 | 系统层 TIME_WAIT 占端口 | ss -tan | grep TIME_WAIT | wc -l | tcp_tw_reuse=1+扩端口+连接池 |
| 取连接超时(应用报错) | 应用层连接池耗尽 | 应用连接池 metrics | 扩池+熔断+限流 |
| 重传率高/RT 高(小包通大包丢) | MTU 黑洞 | ping -s 1472 | tcp_mtu_probing=1/降 MTU |
| 带宽起不来(长肥管道) | 窗口/BDP | ss -tin 看 cwnd/rwnd | rmem_max+tcp_rmem+window_scaling+BBR |
| 邻居表溢出 | ARP 容量 | dmesg neighbour overflow | gc_thresh*↑ |
| 合法流量被误丢 | rp_filter 非对称路由 | 回包路径检查 | rp_filter=2 |
| 高 PPS 丢包 | 软中断/队列 | /proc/net/softnet_stat | 多队列+RPS+netdev_budget |
墨:网络排障的总心法(和第 20 章 IO 同构):先分层(建连阶段 vs 传输阶段),再看”平均值掩盖分布”(带宽闲≠没丢大包),最后才碰拥塞控制算法。多数人”调一周没用”都是跳过了前两层,直接在 tcp_* 拥塞参数里打转(第 21 章案 B 的教训)。
九、第三个现场:客户端 ephemeral 端口耗尽,EADDRNOTAVAIL 建连失败
墨:案 A 讲的是”服务端”被 TIME_WAIT 占满源端口,别人连不进来;案 B 讲 MTU 黑洞。这第三个现场,讲它的客户端翻版——你的服务作为客户端疯狂去连下游,结果自己的 ephemeral(临时)端口被 TIME_WAIT 占光,连下游也连不上。机理同源,但角色反了,排障时极易和”下游挂了”搞混。
📦 案例:一个网关服务,高并发回调下游几十个 HTTP 接口,高峰期大量 "dial tcp: connect: EADDRNOTAVAIL",但下游每个接口自身都健康。
现象(第 0 层)
网关日志:dial tcp 10.x.x.x:8080: connect: EADDRNOTAVAIL(地址不可用)。下游监控全绿。报错集中在”并发回调高峰”。
推断 1:下游挂了 / 限流了?(推错)
你:连不上,第一反应下游挂了或限流。
墨:查下游——健康、没限流、没丢包。且报错不是”连接被拒/超时”,是”地址不可用”(EADDRNOTAVAIL),这是本地在说”我找不到一个空闲的本地源端口来发起这次连接”,和下游无关。方向立刻从”下游”转向”本机端口资源”。
再观察:客户端 TIME_WAIT 占满 ephemeral 区间(命中)
墨:在网关机上 ss -tan | grep TIME_WAIT | wc -l——几十万条 TIME_WAIT,且 sysctl net.ipv4.ip_local_port_range 还是默认 32768 60999(约 2.8 万个端口)。传动链:网关作为客户端,每连一次下游就消耗一个本地 ephemeral 端口;连接关闭后端口进入 TIME_WAIT(默认 60 秒,第 10 章讲过它的存在意义)。高并发下,2.8 万端口被 TIME_WAIT 占满,新连接申请不到空闲端口 → EADDRNOTAVAIL。这和服务端案 A 是同一机理、相反角色:案 A 是”别人连我,我的端口被占”;本案是”我连别人,我的端口被占”。
调整
墨:三剂药(和案 A 同构,但作用在客户端):
sysctl -w net.ipv4.tcp_tw_reuse=1:允许对出站连接复用TIME_WAIT状态的端口(仅适用于客户端主动连出,安全——第 21 章案 A 已讲清tw_reusevs 已删除的tw_recycle区别)。- 扩大
ip_local_port_range:sysctl -w net.ipv4.ip_local_port_range="1024 65535",把可用 ephemeral 端口从 2.8 万扩到约 6.4 万(甚至更大)。 - 连接复用 / 长连接:网关对下游改用连接池 + HTTP keep-alive,不每请求新建一条 TCP(这是治本——根因是”每回调都短连接”,连接池能把这个量级砍掉一两个数量级,呼应案 B 的”应用层连接池”思路)。
改进:报错归零,收口
墨:三剂药下去,EADDRNOTAVAIL 归零。复盘:“建连失败”要分清是服务端端口耗尽(别人连我)还是客户端端口耗尽(我连别人),两者 tw_reuse+扩 port_range+连接池都有效,但定位端点不同——服务端看 ss -tan 里被动监听侧的 TIME_WAIT,客户端看主动连出侧的 TIME_WAIT。更治本的是”别每请求新建连接”:长连接/连接池,从根上减少端口消耗,比任何时候扩端口都干净。
实战补遗一:tcp_retries2 默认 15 次,把”故障后端”挂成 15 分钟的僵尸
墨:你上游服务连的一个后端实例假死了——进程还在、TCP 能握手,但就是不处理请求。你的上游线程池慢慢被这些”石沉大海”的连接占满,雪崩。你第一反应查上游代码、查线程池大小,对吧?
你:连接能建、请求发出去没回,肯定是上游没设超时 / 线程池太小,加超时不就完了?
墨:加应用层超时当然对,但你有没有想过:在应用层超时之前,内核已经在替你”耐心”等了——tcp_retries2 默认 15 次指数退避重传,总时长能到 15~30 分钟。这 15 分钟里,那条连接对应用来说”还在飞”,线程一直占着,故障后端的”僵尸连接”越积越多,雪崩在你设的超时生效前就发生了。
现象:一个假死后端,拖垮整条调用链 15 分钟
微服务架构,上游 A 调下游 B。B 某实例因死锁假死(进程活、accept 队列还能握手、但 worker 不处理请求)。A 的新请求能成功建连(TCP 握手走 accept 队列,不进业务),发出去后 B 不回。A 这边这些连接挂着,线程池逐渐被占。A 的应用层 read timeout 设了 30s,但建连成功后的请求走的是内核重传——tcp_retries2 在 B 彻底不 ACK 时决定何时放弃。默认 15 次,指数退避累计 ~15 分钟才 ETIMEDOUT。这 15 分钟里 A 的线程池被僵尸连接占满,连锁拒绝其他正常请求 → 雪崩。
推断(可能推错):先查上游线程池和代码
第一推断:「A 的线程池太小 / 没限并发。」——调大线程池,雪崩暂缓但僵尸连接还是占着,问题没根解。推错(治标)。
第二推断:「B 的 worker 死了,是 B 的 bug。」——B 确实有死锁(根因之一),但”A 被拖 15 分钟”是 A 这侧 tcp_retries2 太宽容 + 无应用超时,是两个独立问题。
真相:内核重传超时是”最后一道、且太慢”
TCP 在收不到 ACK 时按 tcp_retries2(默认 15)做指数退避重传,基于 RTO 算总上限(Linux 实现默认上限约 15 分钟量级)。这本来是”网络瞬时丢包要重试”的好意,但对”对端进程假死、永远不回”的场景,它变成”替故障后端兜底 15 分钟”。应用层 read timeout 如果 > 这个内核上限或没设,就傻等。
调整:内核快弃 + 应用必设超时(双层)
net.ipv4.tcp_retries2=5(或 6):把重传上限从15 分钟压到几十秒几分钟,故障连接更快ETIMEDOUT,线程早释放(拿”丢包恢复能力略降”换”故障发现快”);- 应用层必须有
read/request timeout(如 3~5s),且不依赖内核兜底——这是根本,tcp_retries2只是内核最后保险; - 加健康检查 / 熔断:连不上的后端实例尽早踢出负载均衡,别等连接超时;
- 验证:复现假死后,
ss -i看连接retrans涨、timer倒计时,调小tcp_retries2后ETIMEDOUT提前到预期。
再观察
tcp_retries2=5 + 应用 read timeout=3s + 健康检查 5s 踢实例后,单实例假死的爆炸半径从 15 分钟压到 <10s,A 不再雪崩。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
tcp_retries2=5 | 长丢包恢复能力略降 | 故障连接早弃、线程早释放 |
应用层 read timeout | 改代码 | 不依赖内核兜底 |
| 健康检查/熔断 | 额外机制 | 故障实例早踢出 |
🔧 思考题:既然应用层设了 read timeout,为什么还要调 tcp_retries2?
【参考答案】
两层职责不同:应用层 read timeout 是”我等多久没数据就放弃”,但它依赖内核把连接状态如实报上来——若内核还在埋头重传(默认 15 次),在 read timeout 内连接可能一直”看起来在飞”,且更关键的是:建连成功但首字节不来的连接,应用层超时生效前,内核重传期里这条 socket 占着 fd 和线程;另外连接池里”借出”一条其实已死的连接,应用层超时未必覆盖全路径。所以 tcp_retries2 小一点 = 让内核更快承认”这条 TCP 死了”,应用层超时才能在一个合理短的上限内兜底。二者是”快弃 + 不傻等”的配合,不是二选一。这也呼应第 15 章:net 参数大多是”兜底行为”,根因还得在应用层/架构层解决。
实战补遗二:tcp_mtu_probing 解开”MTU 黑洞”:大包静默丢,连接能建但传不动
墨:你跨公网传大文件,连接建得上、小请求正常,但大文件传输到一半就卡死/极慢。你查带宽、查重传,都正常。我说:去看 tcp_mtu_probing。
你:MTU 不是 1500 吗?难道中间有更小 MTU?那不该直接不通吗?
墨:默认 MTU 1500 是”理想路径”。但跨公网/跨运营商,中间某段可能 MTU 只有 1400 甚至更小。正常有 PMTU 发现(发 DF=不分片包,被小 MTU 设备丢时回 ICMP “需要分片”),发送方据此调小 MSS。但如果中间防火墙把 ICMP 丢了,PMTU 发现失效 → 大包(>对端 MTU)被静默丢弃、不回 ICMP → 你只看到”大传输卡死”,小包(不超 MTU)正常。这就是 MTU 黑洞。
现象:小请求飞快,大文件传输卡死
某服务跨公网同步大文件(几十 MB),连接建得上、握手快、小元数据请求正常;但大文件 POST 到一半(超过某尺寸)就停滞,客户端超时。抓包看:大段数据发出去,但没有 ACK 回来、也没有 ICMP 差错——典型的”大包被静默丢”。
推断(可能推错):先查带宽和重传
第一推断:「带宽不够 / tcp_retries2 重传。」——nstat TCPRetrans 不高(因为包根本没到对端,不是丢了重传,是”发出去就没了”),带宽也没满。推错,因为没想”包没到对端”可能是 MTU。
真相:PMTU 发现被 ICMP 黑洞废掉
发送方 MSS 基于本地 MTU=1500。但对端路径某段 MTU=1400,且中间设备丢了 PMTU 的 ICMP “need frag”。于是 >1400 的包(DF 置位)被静默丢弃,发送方收不到 ICMP、不知道该调小 MSS,持续发大包 → 持续被丢 → 大传输卡死。小包(<1400)不受影响,所以”小请求正常”。
调整:让内核主动探测 MSS
net.ipv4.tcp_mtu_probing=1(或 2):开启 MSS 探测——即使 PMTU 的 ICMP 被黑洞,内核也会主动尝试更小的 MSS,找到能通的大小;- 配合
net.ipv4.tcp_base_mss(探测起始 MSS,如 1024)加速收敛; - 验证:
ss -i看连接的mss是否被探针调小、nstat看TCPMTUP相关计数;大文件传输不再卡。
再观察
开 tcp_mtu_probing=1 + tcp_base_mss=1024 后,大文件传输 MSS 自动降到对端路径能通的 1400(或更低),卡死消失,吞吐恢复正常。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
tcp_mtu_probing=1 | 探测期稍慢收敛 | 避开 MTU 黑洞 |
tcp_base_mss 调小 | 首包略小 | 探测更快找到通径 |
🔧 思考题:为什么不能”所有连接都直接用小 MSS(如 1400)“一劳永逸,还要 PMTU/探测?
【参考答案】
小 MSS 解决”能通”,但牺牲吞吐:每个 TCP 段 payload 小 → 段数多 → 头部开销大、ACK 多、每条流效率降。PMTU/探测的意义是”在能通的前提下尽量用大 MSS”(路径 MTU 真是 1500 就用 1500,只有遇到黑洞才降)。全用小 MSS 是”为少数坏路径惩罚所有好路径”。所以默认靠 PMTU(ICMP 正常时最优),tcp_mtu_probing 是”ICMP 被黑洞时的兜底”——只在该探测的连接上降 MSS,不影响正常路径。这也呼应第 15/21 章:net 参数大多是”正常路径走最优、异常路径有兜底”,tcp_mtu_probing 就是 MTU 这层的兜底开关,平时不触发、遇到黑洞才显身手;和 tcp_retries2(第 21 章实战补遗)、somaxconn(第 15 章)一样,都是”正常不挡你、异常时救你”的设计哲学。
实战补遗三:tcp_retries2 与”假死连接”——应用层以为连着,内核早放弃了
墨:tcp_retries2 默认 15(重传统计),这意味着一个没人回应的连接,内核会重传约 15 次才宣布死亡,按指数退避算下来要 13~30 分钟。你知道这会带来什么坑吗?
你:30 分钟?!那一个对端宕机的连接,应用要等半小时才知道断了?
墨:对,这就是经典的”假死连接”问题。应用层 send() 返回成功(数据进了内核发送缓冲区),应用以为”连着呢”,但其实对端早就崩了,内核在背后默默重传、等 ACK。这 30 分钟里:连接占着、fd 占着、内存里的发送缓冲区堆积、连接池以为这连接可用一直发——直到 30 分钟后内核才 ETIMEDOUT 通知应用。
真实事故:一个用连接池的支付网关,对端某节点静默宕机(网络分区,不是 RST)。网关的连接池里那几条连到该节点的连接全变假死:send() 不报错、应用继续派单,但数据根本没送达。监控看”连接数正常、无报错”,业务看”部分订单石沉大海”。半小时后内核才超时,连接池才剔除这些连接——但这半小时丢的订单已造成资损。
推断:为什么监控看不到?因为 TCP 层”连接还 establish”,应用层没异常,所有常规健康检查(telnet 通、netstat 显示 ESTABLISHED)都显示”正常”。真正的死穴是 tcp_retries2 太长,内核替应用”硬撑”了 30 分钟。
调整(应用层 + 内核层双管):① 内核:sysctl -w net.ipv4.tcp_retries2=5(把重传次数压到 5,超时缩短到约 20~30 秒,具体看 RTO),让假死连接更快被内核判死;② 应用层:连接池加应用级心跳 + 读超时(SetReadDeadline),别只依赖 TCP 的”establish 状态”。再观察:对端宕机后 30 秒内连接池剔除假死连接、订单改走健康节点,资损窗口从 30 分钟降到 30 秒。
你:tcp_retries2 压太低会不会”误杀”正常但慢的链路?
墨:会,这是权衡。tcp_retries2 小 → 假死连接死得快、但长 RTT 或临时拥塞的慢链路可能被过早判死;tcp_retries2 大 → 慢链路耐受、但假死连接撑很久。所以:
- 内网/同机房(RTT <1ms、极少丢):压到 3~5,假死秒级暴露;
- 跨机房/公网(RTT 几十 ms、偶发丢):留 8~10,别太狠,靠应用层心跳兜底;
- 无论哪种:
tcp_retries2只管”内核什么时候放弃”,真正可靠的”连接还活着吗”必须由应用层心跳/KeepAlive确认——别把 TCP 的 establish 状态当”业务连通”的判据。
🔧 思考题:net.ipv4.tcp_keepalive_time(默认 7200 秒=2 小时)和 tcp_retries2 都能”发现死连接”,它俩有什么区别?该靠哪个?
【参考答案】
完全不同,且默认都不靠谱:
tcp_keepalive_time(默认 2 小时):连接空闲超过 2 小时,内核才发第一个保活探测包,之后按tcp_keepalive_intvl/tcp_keepalive_probes探测。问题是”空闲 2 小时才探”——一个一直在send的假死连接(数据进发送缓冲区、在对端没 ACK 的路上),keepalive 根本不触发(因为连接不”空闲”)。所以 keepalive 只管”空闲死连接”,不管”忙但假死”。tcp_retries2:管的是”发了数据没收到 ACK、在重传”的连接多久被判死,正好覆盖”忙但假死”场景——所以本事故靠压tcp_retries2解决。
结论:两者互补,但默认都不够快(2 小时 / 30 分钟)。生产必须双改:内核压 tcp_retries2(暴露忙时假死)+ 应用层心跳(最终裁判,因为应用知道”业务该多久回”)。千万别只靠 tcp_keepalive——它救不了正在发数据却对端已死的连接,这是无数”订单石沉大海”事故的共同盲区。Go 的 http.Transport 有 IdleConnTimeout/MaxIdleConns,但更要自己在 net.Conn 上 SetReadDeadline 做业务心跳,别迷信底层 TCP。
实战补遗四:TCP 窗口收缩——rwnd 满导致的”吞吐塌方”
墨:前面讲了 tcp_retries2(假死连接)和 tcp_mtu_probing(MTU 黑洞)。还有一个让”连接建得起来、但吞吐惨不忍睹”的幽灵:接收窗口(rwnd)被填满。
你:rwnd 不是接收方告诉发送方”我还能收多少”吗?满了不发就是流控,怎么会”塌方”?
墨:流控本意是保护接收方。但配置错了,rwnd 会变成”自我限速”——连接活着、没丢包、没重传,但吞吐只剩正常的零头。讲个真实事故。
一个数据同步服务(传大文件),源端网卡 10G,目的端也是 10G,但实测同步速度只有 200Mbps(本该近 10G 的 1/50)。ss -ti 看连接:没 retrans(不是重传)、RTT 正常、但 rwnd 经常在极小值(几 KB)徘徊,cwnd(拥塞窗口)也被压低。
推断:rwnd 小 = 接收方在说”我收不下,你慢点”。为什么收不下?看接收端:它的接收缓冲区(rmem)太小,应用消费数据的速度跟不上网络到达速度,内核收下的数据堆在 socket 缓冲区里,缓冲区满 → 回 rwnd=0 叫停 → 发送方停 → 应用消费一点 → rwnd 开一点 → 又满。这种”一停一开”的锯齿,把吞吐限死在”应用消费速度”而非”网络带宽”。
根因:接收端 Go 程序读 socket 的 goroutine 太慢(单 goroutine 串行读、中间还做 JSON 解析阻塞),消费速度 << 网络到达速度,缓冲区永远满,rwnd 焊死在 0 附近。这是应用消费慢导致的窗口塌方,不是网络问题。
调整:① 应用层:接收端用多 goroutine 并行消费 + 解码与读分离(读 socket 的 goroutine 只 io.Copy 到 channel,别在热路径做重解析),提高消费速度;② 内核层:调大 net.core.rmem_max 和 socket 的 SO_RCVBUF(net.ipv4.tcp_rmem 的第三个值设大,如 4096 87380 16777216),让窗口有空间涨;③ 确认没开 net.ipv4.tcp_window_scaling=0(窗口缩放,关了 rwnd 最大只能 64KB,长肥管道直接废)。再观察:消费速度上来 + 窗口放大,rwnd 不再焊死 0,同步速度冲到 8Gbps。
你:cwnd(拥塞窗口)和 rwnd(接收窗口)到底谁说了算?
墨:TCP 发送方的实际发送窗口 = min(cwnd, rwnd)——取两者较小值:
cwnd:拥塞控制算的,防”网络管道塞爆”(基于丢包/延迟反馈,第 10、21 章的 BBR/CUBIC);rwnd:接收方声明的,防”接收方处理不过来”(流控)。
所以”吞吐塌方”可能来自两边任一边缩小:
cwnd小 → 多半是丢包/重传让拥塞控制收窗(查retrans、ssthresh);rwnd小 → 多半是接收端消费慢/缓冲区小(查ss -ti的rwnd+ 接收端 CPU/消费逻辑)。
本事故是 rwnd 小(应用消费慢),不是 cwnd 小(网络没丢)。常见混淆:很多人一看”吞吐上不去”就调 tcp_rmem/tcp_wmem(缓冲区),但如果根因是应用消费慢,调大缓冲区只是”让 rwnd 锯齿幅度变大、塌方稍缓”,不治本——必须提高消费速度。口诀:窗口塌方先分清 cwnd(网络侧)还是 rwnd(接收侧),调参才有方向;ss -ti 一行同时看 cwnd/rwnd/retrans 就能定位。
🔧 思考题:tcp_window_scaling(窗口缩放)默认开,它把 rwnd 上限从 64KB 提到多少?为什么”长肥管道”(高带宽高延迟,如跨洋)必须开?
【参考答案】
tcp_window_scaling=1(默认开)允许 rwnd 用 14 位缩放因子,把最大窗口从 64KB(2^16)提到约 1GB(64KB × 2^14)。为什么长肥管道(LFN,Long Fat Network,如跨洋 100ms RTT、10Gbps)必须开?因为带宽时延积(BDP)= 带宽 × RTT。100ms RTT、10Gbps 的 BDP = 10Gbps × 0.1s = 1.25Gb ≈ 156MB——也就是说,要让这条管道跑满,接收窗口至少得 156MB。若 window_scaling=0,rwnd 最大 64KB,实际吞吐被限在 64KB / 0.1s = 5Mbps,10G 的管道只用到 0.05%!
所以 LFN 场景:① window_scaling 必须开(否则窗口天花板太低);② tcp_rmem 的 max 要 ≥ BDP(否则窗口涨不到需要的大小);③ 接收端消费速度 ≥ BDP/RTT(否则 rwnd 还是被应用拖垮)。三者缺一不可。口诀:窗口上限 = min(64KB×缩放, tcp_rmem_max, 应用消费速度×RTT),长肥管道下缩放因子是”能不能跑满带宽”的命门,关掉它等于给 10G 水管套了个 64KB 的节流阀。这也是为什么”跨洋同步慢”第一反应查 window_scaling 和 rmem。
实战补遗五:tcp_window_scaling 与 BBR——长肥管道(LFN)里默认 Reno 为何跑不满带宽
墨:老哥,你跨机房/跨国传大文件,带宽 1G 但实测只跑 100M,你查过 TCP 吗?
你:查过,防火墙、MTU 都正常,就是跑不满。
墨:经典”长肥管道”问题。带宽时延积(BDP)太大,老拥塞控制 + 小窗口直接把带宽锁死了。这节讲 tcp_window_scaling 和 BBR,都是”为什么跑不满、怎么跑满”。
现象:北京到法兰克福,RTT 230ms,带宽 1Gbps。单条 TCP 连接传 10G 文件,实测吞吐只有 80~120Mbps,远没跑满。但多条并发连接加起来能跑满——说明链路本身有带宽,是单连接被限了。
推断:网络有中间限速。
可能推错:多条连接能跑满,说明链路无硬限速。单连接跑不满是接收窗口(rwnd)太小——经典 BDP 计算:带宽 1Gbps × RTT 0.23s = 230 Mbps 的”在途数据”才该填满管道。但默认 TCP 窗口(16 位,最大 65535 字节 ≈ 64KB)在 RTT 230ms 下,理论吞吐上限 = 窗口/RTT = 64KB / 0.23s ≈ 2.2 Mbps——连 100M 都远不够,这才是单连接跑不满的根因。
调整:确认 tcp_window_scaling=1(默认开)。开了窗口缩放,rwnd 最大能到 1GB(窗口缩放因子扩展 16 位→32 位),足够覆盖 BDP。看 ss -tni 里的 wscale(发送/接收缩放因子)和 rwindow(实际接收窗口)。如果 wscale 是 0 或窗口被 tcp_rmem 上限卡住,就跑不满。
再观察:窗口够了,还有第二道关——拥塞控制。默认 cubic/reno 是”丢包驱动”:它靠丢包来探测带宽上限,但长肥管道里丢一个包就窗口减半,恢复慢,且现代网络(有缓冲膨胀)常是”bufferbloat”——丢包前缓冲区已堆满、延迟暴涨。这时换 BBR(Bottleneck Bandwidth and RTT,Google 的”模型驱动”拥塞控制):它主动测带宽和 RTT,不靠丢包探顶,在长肥管道/高 BDP 链路能跑满且延迟稳。看 sysctl net.ipv4.tcp_congestion_control,设 bbr:sysctl -w net.ipv4.tcp_congestion_control=bbr(需内核编译进 bbr 模块)。
改进:
- 长 RTT 链路:确保
tcp_window_scaling=1+tcp_rmem/tcp_wmem上限够覆盖 BDP(如tcp_rmem=4096 87380 67108864)。 - 拥塞控制换 BBR(高 BDP、跨机房、视频/大文件传输收益明显);内网低延迟短连接 cubic 也够。
- 单连接跑不满就并发多连接(应用层分片),但治本是窗口+BBR。
- 监控
ss -tni的rwindow/cwnd/rtt,看窗口是不是瓶颈。
🔧 思考题:为什么”丢包驱动”的 cubic 在 bufferbloat 网络里既跑不满又延迟高?BBR 不靠丢包探顶,它靠什么知道”该发多快”?
【参考答案】
cubic/reno 是基于丢包的拥塞控制:它不断涨窗口(拥塞避免)直到丢包,然后把窗口减半,再慢慢涨。问题在 bufferbloat 网络:中间路由/网卡缓冲区很大,cubic 一直涨窗口把缓冲区填满(延迟因此暴涨,因为包在缓冲里排队),直到缓冲真溢出才丢包——此时延迟已经烂透、吞吐也因反复减半+恢复而没法稳定跑满。它”用延迟换吞吐、又因丢包丢吞吐”,两头不讨好。
BBR 的思路不同:它不依赖丢包当信号,而是主动建模两条关键量——
- BtlBw(瓶颈带宽):通过测”实际成功投递的速率”估计管道最窄处的带宽。
- RTprop(最小 RTT):测”无排队时”的最小往返时延。 BBR 据此算出 BDP = BtlBw × RTprop,把在途数据量(inflight)控制在 ≈ BDP,既填满管道又不堆缓冲(不触发 bufferbloat 的延迟暴涨)。它周期性做”探测”(probe bw / probe RTT)微调,不靠丢包。所以在长肥管道、高 BDP、有缓冲膨胀的链路,BBR 能跑满带宽且保持低延迟——这也是 YouTube/Google 跨洋传输用 BBR 的原因。
注意:BBR 在”浅缓冲、易丢包”的链路(如某些蜂窝网)可能表现得比 cubic 激进、抢带宽,所以不是万能;但在”带宽大、RTT 大、缓冲深”的服务器间链路(你的跨机房大文件场景)几乎总是正收益。窗口缩放(tcp_window_scaling)是前提——BBR 再聪明,rwnd 被 64KB 上限卡死也白搭,两者要配合。
ss -tni 里看:cwnd=拥塞窗口(BBR 下由模型决定)、rwindow=对端接收窗口、rtt=往返、delivery_rate=实际投递速率(BBR 用来估 BtlBw)。窗口瓶颈看 rwindow 是否远小于 BDP,带宽瓶颈看 delivery_rate 是否远低于链路容量。
下一章预告
墨:第 21 章我们收拾了”建不上连”(TIME_WAIT 端口耗尽,tcp_tw_recycle 已死、tw_reuse+连接池是正解)和”重传率 5% 找了一周”(MTU 黑洞,ICMP 被禁废了 PMTU,靠 tcp_mtu_probing 兜底)。
下一章(第 22 章)我们回到内存,专治一个最常被误判的问题:监控告警”内存快满了”,你慌慌张张去查泄漏,结果根本不是泄漏,是 page cache 把 free 挤低了。这一章讲清 free 三态(free/buff/cache)的误读、MemAvailable 才是真话、内存泄漏 vs 缓存挤压怎么一眼区分,以及 PSI(压力失速信息)这个”系统在喊救命”的新指标。
本章完。内存泄漏 vs 缓存挤压见第 22 章。