凌晨两点,我被一个TCP重传逼疯了
凌晨两点,我被一个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,我想起了刚入行时死记硬背的三次握手。那时候只觉得这是面试题,现在才明白,每个标志位都关乎生死。
三次握手:
- 客户端发SYN (seq=x) —— 我来了,我的初始序号是x
- 服务端回SYN+ACK (seq=y, ack=x+1) —— 收到,我的序号是y
- 客户端发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 立即止血:限带宽、调超时
凌晨三点,我做出几个决定:
-
限制备份任务的带宽,不能让它独占网卡:
# rsync 限速 100Mbps rsync --bwlimit=102400 ... -
调整应用层的超时时间,从3秒增加到10秒,给TCP重传留足时间。
-
临时切换到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 功能