本地跑得飞快,上线就超时——网络编程里 5 个本地测试永远测不出来的坑

🔑 关键词:网络编程,TCP_NODELAY,netem,TIME_WAIT,慢启动

📖 摘要:为什么代码本地压测好好的,一上线就超时?从 lo 接口 65536 的 MTU、40ms 延迟 ACK 魔咒、RFC 6928 的初始拥塞窗口 10 MSS,到 TIME_WAIT 占满 28000 个本地端口,聊聊网络编程里 5 个本地环境永远复现不了的坑,以及怎么用 tc/netem 把真实坏网络搬进本地开发机。

本地跑得飞快,上线就超时——网络编程里 5 个本地测试永远测不出来的坑

图片

有一次给一个数据上报 SDK 加批量发送,本地压测每秒能推 8 万条,上线之后掉到每秒 3 千。代码逻辑没改过一行,网络库也没换版本。最后抓包才看明白:我每批发 200 字节左右的小包,对端要等 ACK 才继续收,Nagle 算法和延迟 ACK 撞在一块儿,每个来回白等差不多 40ms。

那次之后我养成一个习惯:任何涉及网络的代码,本地能跑通只证明我逻辑没错,跟它在真实链路里的表现没有半毛钱关系。这篇文章想聊的就是这件事——网络编程里最难的部分不是你会不会写 socket,而是你根本不知道线上那条路上发生了什么。

一、localhost 从头到尾在骗你

回环接口和真实网卡是两个东西。lo 的 MTU 默认是 65536,以太网默认 1500。你在本地发一个 8KB 的包,内核在 lo 上直接递过去,永远不分片;真到了线上,这个包被切成 6 个 IP 分片,丢任何一个整包重传。更别提有的中间设备见了分片直接丢。

RTT 的差距更离谱。本机回环往返延迟大概在 0.03 到 0.1 毫秒之间,同机房两台机器 0.3 到 1 毫秒,跨地域 20 到 60 毫秒,跨太平洋 130 到 200 毫秒。差了三个数量级。任何"等对方回一个包我再发下一个"的串行逻辑,本地测和线上测是两种人生。

再补一刀:TCP 慢启动。RFC 6928 把初始拥塞窗口从 2 个 MSS 提到 10 个 MSS,按 1460 字节算大约 14.6KB。你要传 1MB 数据,前几个 RTT 全在热身,窗口得翻倍六七次才跑得起来,算下来光热身就是六七个 RTT。本地 RTT 0.1ms 你完全感觉不到;跨洲 150ms 的 RTT,光这一截就是一秒左右。这就是为什么有些 API 在本地 P99 只有 3ms,上线 P99 变成 900ms——不是代码慢,是窗口还没长开。

图片

二、40 毫秒魔咒

Nagle 算法默认开着。它干的事是:有未确认的小包时,先把后面的小数据攒着,攒到 MSS 或者收到 ACK 再发。对端那边延迟 ACK 默认也开着,收到数据后不立刻回 ACK,而是等一小会儿,看有没有数据要捎带回去。

这俩凑一起就是互相等:你等我 ACK,我等你攒够包。Linux 上延迟 ACK 的超时是 40 毫秒左右。我在抓包文件里见过太多次这种规律的 40ms 间隔,看到基本就不用再怀疑代码了。

怎么处理,看场景分:

  • 交互频繁的小包场景,直接 setsockopt 设 TCP_NODELAY,把 Nagle 关掉,一行代码的事。
  • 但如果你在做批量传输,关掉 Nagle 反而会让小包数量暴涨,可能更慢。这时候该做的是应用层攒批——比如等到 200 字节或者 20 毫秒再发一次,把节奏攥在自己手里。
  • 还有个 TCP_QUICKACK,但它在 Linux 上不是永久生效的,需要一个包一个包地设,实际用起来很别扭,我不推荐依赖它。

三、并发模型别抄别人的

图片

网上聊网络编程,动不动就"因为 C10K 所以要 epoll"。但你的服务真的有 1 万个并发连接吗?

算一笔粗账:POSIX 线程在 Linux 上默认栈大小 8MB(ulimit -s 显示 8192)。这是虚拟地址空间不是物理内存,但 1 万个线程就是 80GB 虚拟地址空间,加上内核里每个线程 16KB 左右的内核栈,再加上调度开销。真跑到三四千线程,光上下文切换就能吃掉一整个核。

反过来,epoll 那一套,一个连接一个状态机,内存开销能压到几百字节到几 KB,但代码复杂度上去了:业务逻辑被拆成一个个回调或者状态,调试时栈里看不到完整调用链,出问题只能靠日志。

这两条路没有绝对对错。我的判断标准很土:如果单连接请求处理时间超过 10 毫秒,而且连接数不到 2000,就用阻塞 + 线程池,别折腾。这种场景下你的瓶颈是业务逻辑,不是 I/O。反过来,长连接推送、网关这类场景,连接数轻松上万,那才值得上多路复用。

顺便说一句 Go 的 goroutine,起始栈只有几 KB,按需增长。这就是为什么用 Go 写网络服务看起来这么轻松——不是语言有多神,是它把栈这个成本压下去了。

四、TIME_WAIT 和端口耗尽

图片

主动关闭连接的一方会进 TIME_WAIT,持续时间是 2 倍 MSL,Linux 上是 60 秒。

如果你的服务是短连接客户端,每秒发起 500 个连接,60 秒就是 3 万个 TIME_WAIT。而本地端口范围默认大概是 32768 到 60999,不到 3 万个,一占满就报 cannot assign requested address。

三个方向:

  1. 用连接池复用连接。这是最正经的解法,但很多人就是不做。
  2. 调 net.ipv4.tcp_tw_reuse=1,允许出方向复用 TIME_WAIT 状态的连接,需要开启时间戳配合。
  3. 扩 net.ipv4.ip_local_port_range,比如改成 1024 65535。

tcp_tw_recycle 千万别开,它在 NAT 环境下会随机丢包,Linux 4.12 之后这个参数已经被内核删掉了。

另外提一句 SO_REUSEPORT,Linux 3.9 之后就有了。多个进程可以绑同一个端口,内核帮你做负载均衡。比起以前那种"一个进程 accept 再分发给 worker"的模型,省掉了一次进程间传递。但要注意它按四元组哈希分发,如果连接生命周期差异很大,可能出现负载倾斜。

图片

五、把坏网络搬到本地来

说回开头那个观点——我认为网络编程真正稀缺的能力不是写代码,是观测。

CPU 高了你能 perf,内存涨了你能 dump,但网络出问题你只能靠猜,除非你提前埋好了观测点,提前在劣化环境下跑过。

所以我的建议是:从第一天起就把网络劣化搬进本地测试环境。Linux 自带 tc,几行命令就能加延迟和丢包:

# 加 100ms 基础延迟,20ms 抖动,正态分布
tc qdisc add dev eth0 root netem delay 100ms 20ms distribution normal

# 1% 丢包
tc qdisc add dev eth0 root netem loss 1%



![图片](http://img1.baidu.com/it/u=83358118,1235931757&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=514)


# 延迟 + 丢包 + 25% 重排,更接近移动网络
 tc qdisc add dev eth0 root netem delay 50ms loss 0.5% reorder 25% 50%

Windows 上没这么方便,可以拿 Clumsy 凑合,或者干脆在 Docker 里跑 Linux 做测试。如果是 HTTP 服务,还有 Toxiproxy 这种专门做故障注入的代理,能模拟超时、断连、带宽限制。

把单元测试跑在正常网络上,把集成测试跑在加了 100ms 延迟和 1% 丢包的网络上。你会发现一堆从来没想过的问题:重试逻辑没做幂等、超时设成 30 秒导致请求堆积、心跳间隔比 RTT 还短、连接池拿不到连接直接抛异常。

最后

网络编程入门门槛很低,socket API 就那么几个函数。难的是它不像本地代码,你能看着内存一步步走。中间隔着一堆你控制不了的东西——NAT、防火墙、负载均衡、运营商路由、对端进程的调度。

所以别信"本地测试通过"这五个字。多花点时间在真实网络条件下测,比你多写两百行业务逻辑有用得多。

🏷️ 标签: