返回 筑基・网络云路秘径

凌晨两点,我被一个TCP重传逼疯了

博主
大约 10 分钟

凌晨两点,我被一个TCP重传逼疯了

一、一个诡异的超时故障

1.1 凌晨两点半的告警电话

手机在床头疯狂震动,屏幕上闪烁着“P1告警”四个字。我迷迷糊糊地接起来,电话那头是值班同事急促的声音:“订单服务调用库存服务超时率飙升到15%了,你快看看!”

我看了眼时间:凌晨2:37。心里一边咒骂一边打开电脑。双11刚过两周,系统还算稳定,怎么这会儿出事了?

监控大屏上,订单服务的错误率曲线像被谁捅了一刀,从0.1%直冲到15%。但奇怪的是,CPU、内存、磁盘都正常,两台服务之间的ping也通,延迟只有0.5ms。

“这他妈是什么鬼问题?”我揉着眼睛,点开日志。

1.2 看似正常的表象下暗流涌动

日志里满是“Read timed out”异常,但没有任何其他错误。我用 netstat -an | grep 8080 看了一眼连接数,也没有爆增。

“不会是网络问题吧?”我嘀咕着,ping了一下库存服务的IP:

ping 10.0.1.100 -c 10

10个包全部返回,平均延迟0.5ms,0丢包。网络看起来很正常。

但我总觉得哪里不对。凌晨两点,正是服务器平静的时候,怎么会突然超时?我决定抓包看看。

sudo tcpdump -i eth0 host 10.0.1.100 -w order.pcap

等了一分钟,抓了几千个包。我用Wireshark打开,开始这场深夜的探案之旅。


二、TCP重传:网络无声的呐喊

2.1 第一次发现“Retransmission”

Wireshark里,我加了一个过滤:tcp.analysis.retransmission

屏幕瞬间被黄色标记刷屏。大量TCP重传包,密密麻麻。

“原来问题在这儿!”我瞬间清醒了。TCP重传意味着数据包丢失了,需要重新发送。但为什么ping没丢包?因为ping是ICMP协议,和TCP走不同的优先级,路由器可能优先丢弃TCP包。

我顺着一个重传包往前翻,看到了这样的序列:

  • 订单服务发送了一个数据包 (Seq=1000)
  • 等了大概200ms,没收到ACK
  • 订单服务重传这个包 (Seq=1000)
  • 又等了约400ms,才收到ACK (Ack=1000+len)

这就是TCP重传的典型模式——超时重传,且重传时间指数退避。

2.2 拥塞控制:TCP的自保机制

为什么会丢包?我继续分析,发现重传之前还有“Dup ACK”——接收方反复确认同一个序号,告诉发送方“我缺了某个包”。

更重要的是,我看到了大量“TCP Window Full”的提示。这意味着发送方的窗口被填满了,不能再发数据。

TCP滑动窗口 的原理在我脑中闪过:发送方不能无限发送,接收方会通告自己的接收窗口(rwnd),告诉对方“我只能收这么多”。如果窗口满了,发送方就得等待。

但在我的抓包里,接收窗口并没有满,而是拥塞窗口(cwnd)被触发了——这是TCP的拥塞控制算法在起作用。当TCP检测到丢包,它会认为网络拥塞,主动缩小拥塞窗口,降低发送速率,然后慢慢恢复。这个机制保证了网络不会被压垮,但也导致吞吐量骤降。

我翻出当年的学习笔记,TCP Tahoe、Reno、CUBIC的演进历历在目:

  • Tahoe:丢包后cwnd直接降到1,重新慢启动
  • Reno:快速重传+快速恢复,cwnd减半
  • CUBIC:三次函数增长,适合高带宽长链路
  • BBR:Google的模型算法,不依赖丢包

我们的服务器用的是默认的CUBIC。凌晨的丢包导致cwnd急剧下降,传输速率跟不上,而应用层的超时时间是3秒——恰好小于TCP的重传超时(RTO)指数退避后的某个值,导致连接被应用层强行关闭。

2.3 谁是罪魁祸首?

我继续深挖抓包,发现丢包集中在凌晨2:00到4:00。这个时间段有什么特别?我打开运维的定时任务列表,看到了一个熟悉的脚本:

0 2 * * * /usr/local/bin/backup.sh --full --target=/backup

全量备份任务!它每天凌晨2点开始,备份大量数据,占满服务器带宽。备份任务和业务流量抢带宽,导致TCP丢包。

我用 iftop 看了眼实时流量,备份时网卡出方向冲到900Mbps,接近千兆网卡的上限。业务流量被挤得无路可走,开始丢包。


三、深入TCP内核:理解才能解决

3.1 重温三次握手与四次挥手

看着抓包里的SYN、ACK、FIN,我想起了刚入行时死记硬背的三次握手。那时候只觉得这是面试题,现在才明白,每个标志位都关乎生死。

三次握手

  1. 客户端发SYN (seq=x) —— 我来了,我的初始序号是x
  2. 服务端回SYN+ACK (seq=y, ack=x+1) —— 收到,我的序号是y
  3. 客户端发ACK (ack=y+1) —— 收到,连接建立

如果握手丢包,整个请求就挂了。我们这次故障里,新建连接的三次握手包也可能被丢,导致连接超时。

四次挥手更复杂,尤其是TIME_WAIT状态:

  • 主动关闭方最后要等2MSL(最大段生存时间),确保ACK能被对方收到
  • 如果TIME_WAIT过多,会耗尽端口资源

我突然想到,我们的服务用了连接池,避免了频繁创建和关闭连接,这在一定程度上减少了TIME_WAIT,也减少了握手次数——这是正确的设计。

3.2 滑动窗口:流量控制的艺术

抓包里有很多“TCP Window Update”包,这是接收方在通知新的窗口大小。我回忆起滑动窗口的精髓:

  • 发送窗口:已发送未确认、可发送、不可发送
  • 接收窗口:已接收、可接收(窗口大小)
  • 接收方通过ACK通告窗口大小,发送方动态调整

窗口大小受限于接收缓冲区(由系统参数控制)。如果缓冲区太小,即使网络带宽再大,吞吐量也上不去。我当时检查了系统参数:

sysctl net.ipv4.tcp_rmem  # 接收缓冲区:4096 87380 6291456
sysctl net.ipv4.tcp_wmem  # 发送缓冲区:4096 16384 4194304

对于1Gbps、100ms延迟的网络,带宽延迟积(BDP)大约是12.5MB。而最大接收缓冲区只有6MB,显然不够。这会限制TCP的吞吐量。

3.3 拥塞控制:网络的自愈机制

为什么丢包后性能下降?因为拥塞控制算法会自动降低发送速率。CUBIC的窗口增长曲线是一个三次函数,丢包后窗口骤降,然后慢慢恢复。这个过程可能需要几十秒甚至几分钟,而我们的业务请求却在这期间超时了。

更高级的BBR算法会通过测量带宽和RTT来估计可用带宽,不以丢包为拥塞信号,在高丢包率网络中表现更好。但当时我们用的还是CUBIC。


四、解决问题:从抓包到优化

4.1 立即止血:限带宽、调超时

凌晨三点,我做出几个决定:

  1. 限制备份任务的带宽,不能让它独占网卡:

    # rsync 限速 100Mbps
    rsync --bwlimit=102400 ...
    
  2. 调整应用层的超时时间,从3秒增加到10秒,给TCP重传留足时间。

  3. 临时切换到BBR拥塞控制(如果内核支持):

    sysctl -w net.ipv4.tcp_congestion_control=bbr
    

改了之后,我盯着监控,超时率逐渐下降,从15%降到5%,再降到1%,十分钟后稳定在0.05%以下。

4.2 长期优化:系统参数调优

第二天,我拉上运维和开发,做了一系列TCP优化:

1. 增大TCP缓冲区,匹配高带宽延迟网络:

sysctl -w net.ipv4.tcp_rmem="4096 87380 12582912"
sysctl -w net.ipv4.tcp_wmem="4096 65536 12582912"
sysctl -w net.ipv4.tcp_window_scaling=1

2. 优化连接池参数,复用连接,减少握手开销。在Java代码中,我们调整了OkHttp的连接池:

new OkHttpClient.Builder()
    .connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES))
    .connectTimeout(5, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .build();

3. 启用TCP_NODELAY,禁用Nagle算法,降低小包延迟:

socket.setTcpNoDelay(true);

4. 实现重试和熔断,让应用层更健壮,不至于一次网络抖动就挂掉。

4.3 监控体系建设

那次故障之后,我们建了一套网络监控系统,重点看这几个指标:

  • TCP重传率(cat /proc/net/snmp | grep Tcp
  • 连接状态分布(ESTABLISHED、TIME_WAIT)
  • 网卡丢包和错误计数
  • 实时带宽使用(iftop

还用Grafana做了可视化,每天凌晨2点重点观察。再也没有出现过类似的批量超时。


五、Wireshark实战:抓包分析技巧

5.1 常用过滤表达式

从那以后,Wireshark成了我的随身工具。我总结了几个最实用的过滤:

# 重传和重复确认
tcp.analysis.retransmission
tcp.analysis.duplicate_ack

# 零窗口(接收端满)
tcp.analysis.zero_window

# 特定IP和端口
ip.addr == 10.0.1.100 && tcp.port == 8080

# 特定标志位
tcp.flags.syn == 1  # SYN包
tcp.flags.reset == 1 # RST包

5.2 分析一次慢请求

有一次,用户反馈某个接口偶尔很慢。我抓包后,用“TCP Stream Graph”里的“Time-Sequence Graph”一看,发现中间有一段长时间没有数据传输——那是应用层在处理业务逻辑。然后突然发了几个包,接着又停了。我判断是数据库查询慢导致,果然优化SQL后解决了。

还有一次,发现“TCP Window Full”频繁出现,说明接收方处理不过来,或者缓冲区太小。调整了接收缓冲区后,吞吐量翻倍。


六、TCP参数调优清单

后来我整理了一份TCP调优清单,每次新项目上线前都对着过一遍:

【系统参数】
□ 根据带宽延迟积(BDP)设置tcp_rmem/wmem
□ 启用窗口缩放(tcp_window_scaling=1)
□ 选择合适拥塞算法(BBR推荐)
□ 扩大端口范围(ip_local_port_range=1024 65535)
□ 调整TIME_WAIT复用(谨慎)

【应用层】
□ 使用连接池
□ 启用TCP_NODELAY
□ 合理设置读写超时(大于RTO)
□ 实现重试和熔断
□ 监控TCP重传率

七、写在最后

那天凌晨的经历,让我对TCP从“背面试题”变成了“战友”。每一个SYN、ACK、重传,背后都是网络在无声地与我们沟通。理解TCP,就是理解互联网的底层语言。

如果你也遇到网络问题,别慌,打开Wireshark,耐心分析,你会听到它在说什么。那次之后,我常常想起一句话:网络不是不可知的黑盒,它只是在等一个懂它的人


系列上一篇HTTP协议演进:从1.0到3.0的性能革命

系列下一篇TLS/SSL安全传输原理与实践

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能