一次API请求,平均延迟50毫秒,P99却飙到了800毫秒。排查应用代码、数据库查询、缓存命中,一切正常。最后发现问题出在TCP层——一次重传超时。
这不是个例。Microsoft对全球四个生产应用的研究显示,在印度地区,TCP重传超时贡献了15%-20%的性能差异;而在95分位延迟上,美国用户的响应速度是印度用户的三倍。问题的根源不是应用代码,而是TCP在丢包面前的行为模式。
两类重传,两种命运
TCP检测丢包有两种方式,它们的延迟代价天差地别。
超时重传(RTO):发送方为每个发出的数据包启动一个计时器,如果超时未收到确认,就认为数据包丢失,重新发送。问题是,这个超时值(RTO)通常在数百毫秒到数秒之间——对于一次普通的HTTP请求来说,这已经是灾难性的延迟。
快速重传(Fast Retransmit):接收方每收到一个失序的包,就会发送一个重复确认(Duplicate ACK)。当发送方连续收到三个重复确认时,不需要等待超时,立即重传丢失的包。这种方式可以在一个往返时间(RTT)内完成恢复,延迟代价要小得多。
关键区别在于时间尺度:快速重传在RTT级别完成,超时重传则要等待RTO——而RTO往往是RTT的数倍甚至数十倍。
sequenceDiagram
participant Sender
participant Network
participant Receiver
Sender->>Network: Segment 1 (seq=1000)
Network->>Receiver: Segment 1 delivered
Receiver->>Sender: ACK 2000
Sender->>Network: Segment 2 (seq=2000) LOST
Sender->>Network: Segment 3 (seq=3000)
Network->>Receiver: Segment 3 delivered
Receiver->>Sender: DupACK 2000 (期待2000)
Sender->>Network: Segment 4 (seq=4000)
Network->>Receiver: Segment 4 delivered
Receiver->>Sender: DupACK 2000
Sender->>Network: Segment 5 (seq=5000)
Network->>Receiver: Segment 5 delivered
Receiver->>Sender: DupACK 2000 (第3个重复ACK)
Note over Sender: 收到3个DupACK,触发快速重传
Sender->>Network: Retransmit Segment 2
Network->>Receiver: Segment 2 delivered
Receiver->>Sender: ACK 6000 (确认所有数据)
但现实是残酷的:如果丢失的是数据传输末尾的包,或者丢失的包数量不足以触发三个重复确认,快速重传就无法启动,只能等待RTO超时。这就是所谓的"尾部丢失"问题,也是长尾延迟的主要来源。
RTO是如何计算的
RTO的计算经历了三十多年的演进,核心思想来自Van Jacobson在1988年发表的经典论文《Congestion Avoidance and Control》。
Jacobson观察到,当时的TCP实现存在一个致命缺陷:RTT估计器只跟踪均值,不考虑方差。当网络负载从0升到75%时,RTT和RTT方差都会增长约四倍。如果超时值固定为均值的某个倍数,在高负载时就会出现大量伪超时——数据包只是被延迟了,却被错误地认为丢失。
Jacobson提出的算法同时估计RTT的均值(SRTT)和方差(RTTVAR):
SRTT = (1 - α) × SRTT + α × R
RTTVAR = (1 - β) × RTTVAR + β × |SRTT - R|
RTO = SRTT + max(G, 4 × RTTVAR)
其中R是新测量的RTT样本,α=1/8,β=1/4,G是时钟粒度。
这个公式的直觉是:超时值应该等于均值加上四倍方差。四倍系数的来源是,在慢启动阶段窗口每轮翻倍,RTT也会翻倍,所以R+4V > 2R,足以覆盖下一轮的RTT增长。
RFC 6298在2011年将这套算法标准化,并规定初始RTO为1秒(早期版本是3秒),最小RTO也是1秒。为什么是1秒而不是更小?因为研究表明,约97.5%的连接RTT小于1秒,较小的超时值可以减少真正的丢包恢复延迟,同时只有约2.5%的连接会因为RTT>1秒而产生伪重传。
Karn算法:避免重传歧义
还有一个问题:当发生重传时,收到的ACK是对应原始传输还是重传?这就是"重传歧义"问题。
Phil Karn和Craig Partridge在1987年提出的解决方案很简单:不使用重传数据包的RTT样本。只有那些未被重传过的数据包,其RTT测量才用于更新SRTT和RTTVAR。
这个规则在RFC 6298中被称为"Karn算法",至今仍是TCP实现的标配。
指数退避:当网络严重拥塞时
如果第一次重传仍然没有收到确认怎么办?TCP采用指数退避策略:每次超时后,RTO值翻倍。
第一次超时RTO=1秒,第二次=2秒,第三次=4秒,依此类推。Linux默认最多重试15次,总超时时间可达数分钟。这种设计是为了在网络严重拥塞时给网络喘息的时间,避免"火上浇油"。
但这也意味着,一旦触发超时重传,延迟会急剧增长。一个初始RTO为200毫秒的连接,三次超时后等待时间已经超过1.4秒。
长尾延迟的真实代价
Microsoft在2019年发表的研究提供了生产环境的实证数据。他们分析了Azure Front Door(Microsoft的CDN)上四个生产应用在不同地区的性能:
| 地区 | 整体丢包率 | RTO占比 |
|---|---|---|
| 美国/欧洲 | 基准 | 40-70%的丢包连接可通过快速重传恢复 |
| 巴西 | 高30-40% | RTO占比适中 |
| 印度 | 高30-40% | 60-80%的丢包连接需要RTO恢复 |
关键发现:
- 在75分位,慢速重传(RTO)贡献了印度与美国之间15%-20%的性能差异
- 在95分位,印度用户的响应时间是美国的3倍
- 即使排除所有发生RTO的请求,印度用户的95分位延迟仍比美国高30%
这说明TCP重传是性能差异的重要因素,但不是唯一因素。网络基础设施的整体差异同样重要。
为什么印度更依赖RTO?
研究指出,印度地区的网络特点是丢包率高、且丢包模式不利于快速重传。当丢失的是传输末尾的包,或者窗口较小导致发出的包不足以产生三个重复确认时,只能依赖RTO。
这种情况在短请求/响应模式下尤其常见——而这正是大量API调用的典型模式。
现代TCP的改进
过去十年,TCP社区发展出多项改进措施来减少重传延迟。
SACK:选择性确认
RFC 2018定义的选择性确认(SACK)允许接收方告知发送方具体哪些数据段已收到。这使得发送方能够精确重传丢失的数据,而不是从丢失点开始重传所有数据。
SACK是现代TCP实现的标准配置,但它的作用主要在于减少不必要的重传,而非加快丢包检测。
F-RTO:检测伪超时
RFC 4138定义的Forward RTO-Recovery算法用于检测"伪超时"——当网络突然延迟增加导致超时,但数据包实际上并未丢失时。
F-RTO的思路是:超时后重传第一个包,然后观察下一个ACK。如果ACK确认的是新数据(而非重传的数据),说明原始数据已经到达,这是一次伪超时。此时可以避免不必要的窗口减半。
TLP:尾部丢失探测
Tail Loss Probe(TLP)是解决尾部丢失问题的关键技术。当发送方完成数据发送后,不会被动等待RTO,而是在两个RTT后主动发送一个探测包。
这个探测包可以是新数据(如果应用层还有数据要发),也可以是最后一个包的重传。探测包的目的不是传输数据,而是触发ACK反馈——通过ACK,发送方可以推断哪些包丢失,然后进入快速重传而非RTO恢复。
研究表明,TLP可以将高速网络中的总传输时间减少38%,将重传等待时间减少81%。
RACK:基于时间的丢包检测
RFC 8985定义的RACK(Recent ACKnowledgment)算法是对传统重复ACK计数的重大改进。
传统方法的问题是:它依赖包计数,而非时间。当窗口大小变化、或存在重排序时,固定的"三个重复ACK"阈值可能过低(导致伪重传)或过高(延迟真正的重传)。
RACK的思路是:记录每个包的发送时间戳。如果某个包T1发送,而比它晚发送的包T2已经被确认,那么T1要么丢失,要么被严重重排序。RACK会等待一个"重排序窗口"(通常是RTT的一小部分),然后标记T1为丢失。
RACK的优势在于:
- 不依赖窗口大小,适用于应用受限的流量
- 可以检测重传包的再次丢失
- 对重排序更有弹性
RACK-TLP已成为现代Linux内核的默认TCP丢包检测算法。
P99延迟的工程含义
对于在线服务,P99延迟往往比平均延迟更重要。原因在于分布式系统的"放大效应":如果一个服务有10个串行依赖,每个依赖的P99延迟是100毫秒,那么整体P99延迟可以高达数百毫秒——因为只要有一个依赖变慢,整体就会变慢。
TCP重传对P99的影响尤为显著:
- 快速重传增加约1个RTT的延迟
- RTO重传增加约1秒的延迟(初始值)
- 多次超时重传会导致指数级延迟增长
在微服务架构中,一次TCP重传可能会被上层重试机制放大。如果超时设置不当,一次RTO可能触发多次重试,形成级联效应。
生产环境的调优策略
1. 监控TCP重传指标
Linux系统提供了丰富的TCP统计信息:
# 查看TCP重传统计
netstat -s | grep -i retrans
# 或使用nstat
nstat -az | grep -i retrans
# 关键指标:
# TcpRetransSegs: 重传段总数
# TcpExtTCPSlowStartRetrans: 慢启动期间的重传
# TcpExtTCPFastRetrans: 快速重传次数
# TcpExtTCPLostRetransmit: 重传包再次丢失
2. 调整TCP参数
对于特定场景,可以调整以下内核参数:
# 减少TCP重试次数(缩短故障恢复时间)
sysctl -w net.ipv4.tcp_retries2=5
# 启用F-RTO(Linux默认启用)
sysctl -w net.ipv4.tcp_frto=2
# 启用SACK(Linux默认启用)
sysctl -w net.ipv4.tcp_sack=1
# 降低最小RTO(谨慎使用)
# Linux默认最小RTO为200ms(HZ=1000时)
注意:降低tcp_retries2会缩短连接断开时间,但也可能导致在丢包率较高的网络中过早放弃。需要根据实际场景权衡。
3. 应用层超时设置
应用层超时应该大于TCP层的重传时间,否则会过早触发应用层重试。一个粗略的经验公式:
应用超时 > RTT + (tcp_retries2 对应的超时时间)
Linux的tcp_retries2=15对应约924秒的总超时(指数退避),这显然不现实。实践中,tcp_retries2=5对应约130秒,tcp_retries2=3对应约12秒。
4. 选择合适的拥塞控制算法
不同的拥塞控制算法对丢包的反应不同:
- CUBIC(Linux默认):丢包时窗口减半,适合大多数场景
- BBR:不依赖丢包信号,在丢包率较高的网络中表现更好,但可能导致更高的重传率
- DCTCP(数据中心TCP):对延迟更敏感,适合数据中心内部
对于跨数据中心通信,BBR可能是更好的选择。但需要注意BBR v1在高丢包率下会产生更多重传。
TCP重传不是bug
理解TCP重传的关键在于认识到:它不是bug,而是设计使然。TCP的目标是可靠传输,重传是实现可靠性的核心机制。问题在于,这个设计假设了某些网络特性——适度的丢包率、相对稳定的RTT、充足的窗口大小——而这些假设在现代网络环境中并不总是成立。
当API延迟出现异常高的P99时,除了检查应用代码,也应该看看网络层。一次TCP重传超时可能意味着数秒的额外延迟,这对于用户体验来说是难以接受的。通过启用现代TCP特性(SACK、F-RTO、RACK-TLP)、合理设置超时参数、选择合适的拥塞控制算法,可以显著减少重传对延迟的影响。
但更重要的是,在系统设计层面,要对网络不可靠性有充分预期。重传会发生,延迟会波动,应用层应该有相应的容错和降级机制——这才是构建高可用系统的正确姿势。
参考资料
- RFC 6298: Computing TCP’s Retransmission Timer (June 2011)
- RFC 8985: The RACK-TLP Loss Detection Algorithm for TCP (February 2021)
- RFC 5682: Forward RTO-Recovery (F-RTO) (September 2009)
- RFC 2018: TCP Selective Acknowledgment Options (October 1996)
- Jacobson, V. & Karels, M. “Congestion Avoidance and Control”, SIGCOMM 1988
- Karn, P. & Partridge, C. “Improving Round-Trip Time Estimates in Reliable Transport Protocols”, SIGCOMM 1987
- Gao, R. et al. “Impact of TCP Loss on Regional Application Performance”, Microsoft Research, 2019
- Flach, T. et al. “Reducing Web Latency: the Virtue of Gentle Aggression”, Google Research
- Dukkipati, N. et al. “An Argument for Increasing TCP’s Initial Congestion Window”, Google Research