# 13 | 重传的再认识:没有任何丢包却也一直重传? 你好,我是胜辉。 在上节课,我带你深入探讨了TCP重传的知识点,包括超时重传和快速重传。想必你对于重传的现象和背后的原理,也已经有了不少的了解。那么现在,你可以来思考这样一种情况:用Wireshark打开一个抓包文件,你看到了满屏的TCP Retransmission,第一感觉会是什么? 你应该会认为是掉包了,所以客户端重传了对吧?可能是网络路径上出了状况。 但实际上,网络状况是重传的一个重要因素,却不是唯一。另外一个因素也同样重要:**操作系统对TCP协议栈的实现**。 这是因为,TCP等传输协议不是无根之木,它们必须依托于操作系统而存在,包括各种客户端、服务端、网络设备等等。就以重传为例,表面上看是由于网络状况而引发的,但其实真正操控重传行为自身的,还是操作系统,确切地说,是TCP通信两端的操作系统。 所以,在这节课里,我会给你再介绍一个十分特殊的案例,带你用一种全新的视角来审视TCP重传。通过这节课的学习,你将会对TCP的基本设计,特别是其中最复杂的知识点之一的重传部分,有更加深刻的理解。这样即使以后你在工作中遇到各种奇怪的TCP问题的时候,也不会再轻易被它们的表面所迷惑,而是能有更加准确的判断了。 ## eBay的HTTP请求慢的问题是怎么解决的? 开头我们假设的那个场景,是一上来就直接分析TCP的重传问题,好像这个问题刚冒头,就是以“TCP重传”的形式出现的一样。但在真实的生产环境当中,问题出现的时候就不会那么直接了,而是以应用层的某种形式,比如以“事务处理慢”这种形式出现的。 下面,我们看一个实际的案例。 ### 应用层分析:应用为什么变慢了? eBay的应用大部分都是基于微服务进行设计和开发的。有一天,一个业务开发团队向我们基础架构团队报告了一个情况:从他们客户端集群向服务端(LB上的VIP)发送的请求,遇到了大量的Response Timeout(返回超时)的报错。这里的Timeout是一个应用层的超时设置,如果客户端无法在2秒钟(2000ms)之内收到返回信息,就会抛出超时报错。 在我们内网,同数据中心的时延基本在1ms以内,跨数据中心的时延在10ms上下,都很快。这里设置的2000ms的超时,事实上大部分都是预留给了应用程序。这个应用的超时机制,跟TCP超时重传机制类似,应用也不想“干等”。 所以,我们首先采用了挨个测试的方法。因为整个路径是: > **客户端 -> LB -> 服务器(就是LB后面的机器)** 而报错是在客户端观察到的,那么我们可以对比两种路径: > * **客户端 -> LB -> 服务器** > * **客户端 -> 服务器(绕过LB)** 看看在这两种情况下传输的请求都有什么不同。 * **客户端直接访问服务器的情况** 我们发现,如果客户端绕过LB VIP去直接访问服务器,是正常的,没有超时的报错。服务器上的日志显示,很快收到了客户端发出的请求,花了三百多毫秒就处理完并回复了。比如这个服务器日志: ![图片](https://static001.geekbang.org/resource/image/a1/49/a15b98f0232e30c60522863b21ab2249.png?wh=1920x41) * **客户端访问LB VIP的情况** 这种情况下,客户端会等待很长的时间才能拿到HTTP响应。日志也印证了这一点:服务器上的日志显示,这个请求的处理耗费了1703毫秒。如下图: ![图片](https://static001.geekbang.org/resource/image/bb/30/bbaeb4c8e9f2c01c35c718c53df49b30.png?wh=1920x39) 并且,客户端上的日志显示,同样是这个请求,在它看来,从发出请求给LB的VIP,到收到LB的VIP返回的响应,一共消耗了2002毫秒。如下图: ![图片](https://static001.geekbang.org/resource/image/bc/7a/bc3623317b15d122fbd1414b69e3c47a.png?wh=1920x40) 我画了一张示意图,帮助你理解得更加清楚一些: ![](https://static001.geekbang.org/resource/image/a3/f4/a32407c628f7105702a0469269f66ff4.jpg?wh=1928x910) 你也许会问:看起来不是服务器那头本身处理的耗时很长吗?为什么不查查服务器上应用代码的Bug? 我们也一度有这个怀疑,服务器上运行的是Java代码,会不会是GC造成的影响呢?所以我们也去查了当时JVM的运行情况,发现这段时间内并没有GC事件。因此,这个可能性也被排除。并且我们定位到,服务器的耗时主要是花费在了read()调用上,即读取网络I/O上面,所以还是需要回到网络排查的方向上来。 > 补充:Java有相关的分析工具来定位耗时所在,或者用strace也可以定位系统调用的性能情况。 我们用一个示意图来概括这两种场景下的区别: ![](https://static001.geekbang.org/resource/image/e6/e8/e6bde04778244ccdabd66b6339e703e8.jpg?wh=2000x1125) 由此,我们可以初步判定:问题出在LB,或者LB前后的网络环节。 这里也是我想分享给你的一个小的排查原则:**针对客户端看到超时或者响应慢的这类问题,最好也检查下服务器本身花费的时间,两者对比,就能找到问题的方向了。** 我给你把整个思路用伪代码的形式组织如下: ```plain if (服务器耗时约等于客户端耗时) { 检查服务器耗时分布 if (服务器耗时在网络I/O) { 检查中间网络或者LB } else { 检查服务器应用程序或操作系统 } } else if (服务器耗时远小于客户端耗时) { 检查中间网络或者LB } ``` 我也画了一个示意图,供你参考: ![](https://static001.geekbang.org/resource/image/08/c1/08ff1feb493dc5e314f0d723b5de45c1.jpg?wh=2000x1125) 那么下面,我们就可以把排查重点,转到 **LB和网络层面**上来。 ## 抓住排查重点:**LB和网络层** 首先,我们在LB进行了抓包,用Wireshark打开抓包文件,一开始看到的是一帆风顺,全绿: ![图片](https://static001.geekbang.org/resource/image/b7/79/b7eebffbbfc9b574b7daf4eefb2a3a79.png?wh=1920x265) 翻了几页,突然画风一变,全红: ![图片](https://static001.geekbang.org/resource/image/33/22/339c5120eb663f3yyd3a5ee6ac6f9122.png?wh=1920x431) > 补充:抓包示例文件已经上传至[Gitee](https://gitee.com/steelvictor/network-analysis/tree/master/13),建议你结合专栏内容和抓包示例文件一起学习,效果更好。 看到这个景象,你是否也会这样判断:“这肯定是有丢包了,所以接收方一直在回复DupAck!赶快去查网络设备的问题。” DupAck多半是丢包引起的,但这次不是。为什么呢?我们先来看一下抓包文件展现出来的全貌。 * 第一阶段:连接建立,正常。 * 第二阶段:客户端开始发送数据包给LB,正常。 * 第三阶段:客户端连续发送了约70KB数据(其中大部分数据还未被确认)。 * 第四阶段:LB发送ACK包确认收到了前面约27KB的数据。 * 第五阶段:客户端继续发送70~91KB的数据,目前为止也是正常的。 * 第六阶段:在27KB之后,LB连续发送数十个DupAck,然后客户端发送一次TCP fast retransmission;这样的情况持续到客户端把所有应该发的数据包都发完。 那么,我们可以在Wireshark里打开Expert Information看一下汇总信息。 > 补充:关于Expert Information的解读,我在[第5讲](https://time.geekbang.org/column/article/481042)有介绍过,如果你感觉现在印象有点模糊的话,可以回头去温习一下。 ![图片](https://static001.geekbang.org/resource/image/c3/52/c394008ef274737yyc16024yy23ed852.png?wh=1858x296) 从图上看,快速重传有8个,DupAck有567个,平均每个快速重传对应了约70个DupAck。这里,我们来看看其中一段连续的DupAck和随后的一个快速重传: ![图片](https://static001.geekbang.org/resource/image/0f/55/0f384babccf2fa56cf49f81fb2430555.png?wh=1920x482) 我给你解读一下:在LB给客户端发送了数十个DupAck之后(比如截图里包号93到104),客户端发送了一次快速重传(包号105),然后LB回复ACK(包号106,针对包号105),接着LB继续新一轮的数十个DupAck(从包号107开始),循环往复。 其实这里已经说明了这个案例的特殊性,也就是有“**规律性现象**”。如果是网络设备问题导致丢包,那么丢包会是随机现象,不太可能像这样有规律(70个DupAck加一个快速重传,不断循环)。而**往往规律的背后,一般潜藏着某种未知的机制**。 当然,大量的DupAck和重传,确实跟应用层我们看到的严重的延迟现象对上了。也至少能回答“**应用为什么变慢**”这个问题了。当然,探究根因的话,接下来就是要回答“**为什么有重传**”这个问题。 ## 网络排查推进:为什么客户端出现了重传? 在上节课,我们学习过两种重传方式,分别是快速重传和超时重传,而这次的重传呢,Wireshark已经提醒我们,属于快速重传。那为什么会有这种快速重传呢? 这里我们先来看一下相关数据包的具体细节。 首先,我们看一下在LB回复大量DupAck之前,客户端的发送情况。 ![图片](https://static001.geekbang.org/resource/image/7e/y0/7e2c5707bd693f37361eecc3c87e4yy0.png?wh=1920x153) 可以看到,在第一个LB DupAck包之前,客户端发送的最后一个数据包是包号67,它的信息是: * 序列号为91305,表明从握手开始有91304(减去握手阶段的1)字节的TCP载荷,从客户端发出了; * 确认号为1,因为LB还没回复HTTP响应,也就是没有握手以外的更多数据,所以客户端还是保持握手阶段的确认号1。 然后我们再来看一下第一个LB DupAck包,其包号为68: * 序列号为1,因为LB还没回复应用层的HTTP响应,所以还是保持握手阶段的序列号1; * 确认号为27741,表示LB收到了27740(减去握手阶段的1)字节的数据,而第27741字节之后的数据并没有收到。 要知道,TCP协议规定:**接收方回复的ACK包的确认号=发送方数据包的序列号+TCP载荷字节数**。如果接收方回复了DupAck,假设这个DupAck的确认号为n,那么其含义是:我只收到发送方给我的序列号为n之前的数据包,而序列号为n(及其之后)的数据包,我都没有确认。 所以,LB就通过DupAck包向客户端宣告:“我这边只确认收到序列号27741之前的数据包。” 而这里出现几十次DupAck的原因是,一旦LB认为某个数据包我没有收到(此处是序列号为27741的数据包),那么数据就“断档”了,之后客户端送过来的每个数据包,LB都无法ACK这些数据包的序列号+TCP载荷字节数。所以虽然ACK包还是要发,但确认号却只能“停留”在丢包处的确认号,并且这样重复的ACK会有很多个。 为了便于理解,我把这个过程换一种方式给你展示一遍。除了27741这个号以外,其他序列号是为方便举例而编出来的: ![](https://static001.geekbang.org/resource/image/4b/39/4ba01c8e51bd63d115883127493a5639.jpg?wh=2000x922) 上面是客户端来一个报文,LB回复一次DupAck。当然也可能像下面这样,连续来多个报文,LB回复连续的多个DupAck: ![](https://static001.geekbang.org/resource/image/98/52/9870bdfb5fbcf9f0c8a681dc01f30952.jpg?wh=2000x914) 好了,现在情况就比较清楚了,虽然“丢包”的根因还没找到,但整个排查工作的脉络已经相对清晰了,即:**某处丢包-> TCP重传-> TCP传输速度下降->应用层超时报错**。 看起来,我们离成功只剩一步了,也就是,在抓包文件中**找到那个丢失的序列号为27741的数据包**! 只要证明这个数据包确实是在网络上丢失的,那么我们就去修复网络。TCP不丢包了不重传了,速度就上来了,应用就不超时了。逻辑圆满自洽,感觉胜利已经在向我们招手了,是不是? 可是峰回路转。我们去翻前面数据包的时候,发现根本就**没有那个“序列号为27741”的数据包**! ![图片](https://static001.geekbang.org/resource/image/de/a2/de903d7a576b5e680b2e195385c048a2.png?wh=1662x80) 上图是最接近27741序列号的附近的数据包,有序列号27229的包,也有序列号28689的包,但就是没有位于这两个数中间的27741的包。 这个时候,恍惚中有点感觉在看悬疑小说:一桩案件的元凶被查出是某某某,结果发现某某某这个人压根不存在。 你如果有跑步的爱好,应该会知道:跑步过程中会有一个极限区,此时我们的心肺会遇到很大的压力,这种难受的感觉很容易让人放弃。但是,如果继续坚持挺过这个极限区,身体就能提升到一个新的平台上继续平衡运转。进而,我们就可以继续快乐地跑下去了。 显然,我们的排查进入了“极限区”了。止步还是进步,就在一念之间。 #### TCP的本质:再次思考什么是确认号? 那么,这个序列号27741的包是消失了吗?还是说它就在那里,只是我们忽视了它的存在? TCP序列号、Payload(载荷)、TCP确认号,一般情况下就是一个A+B=C的关系。但是,**确认号必须是序列号+全部载荷吗?它可以是序列号+部分载荷吗?** ![](https://static001.geekbang.org/resource/image/24/5f/242d107b8deb994649774d5200yy525f.jpg?wh=2000x727) 举个例子,如果我从网上购买了一套衣服(上衣+裤子),我也收到了全套。但我觉得裤子尺码不对,上衣还挺合身,我可以只确认我收到了上衣(当然裤子还是要退回的),让卖家重新发裤子给我吗?这是可以的。 如果TCP也可以这样呢?那么,**“寻找序列号为27741的包”也许就是个伪命题,这个“包”实际上并不是独立的一个包,它只是某一个TCP包的一部分(前半部分)。**我们来看一下包号20(也就是前面找27741的时候,关注的两个报文之一)的详情: ![图片](https://static001.geekbang.org/resource/image/ff/f3/ff25bc8e4e3fa37a1c9f97f945b459f3.jpg?wh=572x295) 这个包是从客户端发给LB的,它的序列号为27229,载荷为1460字节。Wireshark也告诉我们,客户端将要发的下一个包的序列号会是28689。然而,LB回复的ACK(后续同样的ACK就是DupAck)却是这样: ![图片](https://static001.geekbang.org/resource/image/f7/5f/f73936085eb6edd61ffae08e5731c95f.jpg?wh=572x267) 我们再看那个最为可疑的数字27741。显然,27741=27229+512。到这里看清楚了吗?这次LB确认的是一件“上衣”(512字节),而余下的“裤子”(另外的1460-512=948字节),LB并没有确认。没有被确认的数据,在客户端看来,是需要重新发送的。 我们先看看正常情况(即每次确认1460字节 )下的数据包交换过程: ![图片](https://static001.geekbang.org/resource/image/de/7a/de421d69585d066372a67936af0e387a.png?wh=1048x708) 然后再来看一下这次异常交换的过程。 * 客户端:我发给你从27229开始的1460字节,下一次你懂的,我将要发的是28689开始的数据。 * LB: 我也不知道最近怎么搞的,记性不好,你这次给我这些数据,我好像只认得前512字节,其他的我认不出来了,先确认这512字节吧。 * 客户端:怎么回事?只确认前512字节?麻烦了,我为了保证这次发送的TCP载荷依然能用足一个MSS即1460字节,必须**把前一个包的后948字节和下一个包的前512字节,组合在一起,变成一个新的1460字节的包**,再发送给你。不过还好,所有未被确认的数据还都在我的发送缓存(send buffer)里面,没有丢失。不过原先计算好的安排都要改掉了,我的CPU开销很大啊! 这次异常通信的过程如下图所示: ![图片](https://static001.geekbang.org/resource/image/bd/07/bdd85530dc112bc9283322c0cda3e407.png?wh=1256x680) 这里,我再提醒你一个关键点:**确认号本身代表字节数,所以它是字节级别的,而不是报文级别**。也就是说,确认号是精确到某个字节的,而不是某个报文。 说到这里,我们再回顾下前面提到过的现象:平均每个快速重传对应了约70个DupAck,而每次重传都需要客户端把发送缓冲区里面打包好的数据包,挨个拆开,重组成LB想要的样子(512字节的位移的关系)。 想必,现在你已经清楚为什么应用会超时(超过2s)了:因为时间都花费在了各种包的分拆、重组上面了。光是客户端想成功发完一个POST请求,都花费了远远超出预期的时长​。就如同一辆车频繁熄火,还怎么可能高速行驶呢? 我画了一张更详细的图,来帮助你理解这个复杂的过程: ![图片](https://static001.geekbang.org/resource/image/48/5f/48cc15a770219761ae21dc293c0a895f.png?wh=1726x1350) * 客户端发送HTTP POST请求,在TCP层面体现为一系列数据包(30KB以内)给LB,LB转发给服务器,在30KB之前一切正常。客户端应用程序的Timeout计时器,也从POST请求发送的那一刻开始计时。 * 大约在客户端发送数据到30KB左右时,LB回复的ACK包确认的数据,不再是数据包分界点的字节数,而是**位于中间的某个字节数**(这个行为比较罕见)。 * 客户端累积收到3个这样的DupAck(在这个案例里达到70多个),认为该包丢失。加上这个包的特殊性(是之前某个完整包的一部分),客户端会从缓冲区找出对应的字节数,拼凑上后续包的数据,组装成一个新的MSS大小(1460字节)的数据包,并且在此处消耗了可观的时间。 * LB继续发送类似的“中间确认”包,客户端继续进行“拆包、重组”的操作,此处**持续消耗客户端的时间**。 * 服务器的时间都花在read()调用上(因为数据还在重传和重组过程中),无法及时收取完整的POST请求并计算处理,此时已经无法在客户端预定的Timeout时限内完成任务,于是客户端报错。 为了更加方便你的理解,我把这个过程概括成了下面这张图: ![](https://static001.geekbang.org/resource/image/cc/e0/cc6c3ae1df5f11edc486c6973a18efe0.jpg?wh=2000x1125) 真相大白了!凭借这些详细的分析和充足的证据,我们说服了LB厂商并确认这是一个Bug,它容易在请求尺寸比较大的情况下被触发。比如在这个案例里,一个POST请求平均大小在100KB,也就触发了这个Bug。 实际上,在Bug修复之前,我们通过扩大TCP receive buffer size,使得缓冲区足够大(你HTTP POST请求大,我缓冲区更大),也做到了对Bug的规避。 ## 小结 今天介绍的确实是一个比较罕见的案例,也是我处理过的众多网络排查案例中,遇到的仅有的一次“确认号在中间位置”的情况。那么从协议规范来说,这样“确认中间位置”的做法,到底是否违规呢? 我们看一下TCP协议的第一版规范[RFC793](https://www.ietf.org/archive/id/draft-ietf-tcpm-rfc793bis-25.html#section-3.10.7.4-2.1.2.1.9.7.2.1),看看它对确认号的要求是什么: ```plain if the ACK bit is on ...... ESTABLISHED STATE If SND.UNA < SEG.ACK =< SND.NXT then, set SND.UNA <- SEG.ACK. ``` 其中几个缩写的含义如下: ```plain SND.UNA - send unacknowledged #这是指已发送的但未被确认的TCP段的位置 SND.NXT - send next #这是已经发送的TCP段的下个序列号 ``` > “下个序列号”这个知识点在[第10讲](https://time.geekbang.org/column/article/485689)介绍过,你可以回头复习一下。 TCP应该接受 **SND.UNA < SEG.ACK =< SND.NXT** 这样的情形,也就是收到的报文的确认号,应该大于已经被确认的数据的位置,并且小于等于(要发送的)下个序列号。 一般来说,我们看到的大部分是“确认号等于下个序列号”的情况,如下图: ![](https://static001.geekbang.org/resource/image/c5/82/c5b77c0bb57cbb062d34108f1f2eba82.jpg?wh=2000x499) 但是从这个案例的情况来看,就是**确认号是在中间位置**。这虽然很少见,但也不违规,也可以被操作系统接纳并处理。只不过,引起的开销有点过大了。 ![](https://static001.geekbang.org/resource/image/1d/32/1da50dd84147b3739d7fee5be312d232.jpg?wh=2000x502) 除了上面的知识点以外,我也建议你务必关注整个排查过程带来的启发: * **网络排查过程中要仔细核对各种事实和数据,避免仅根据表面现象轻易下结论。**比如,在这个案例里,很多的TCP重传很容易让我们把关注点错引到网络状况上面去。所以只有仔细核对这些数据,发现其中的问题,才不会被自己的思维惯性所误导。 * **对于各种重传的现象和成因应该有充分的了解,这样对排查方向的确定有很大的帮助。**特别是重传的两个大类,即快速重传和超时重传,它们的特征和应对策略,你最好熟记于心,这样等你遇到类似情况时,很快可以对症下药,提高解决问题的效率。 * **基于前面两点做细致踏实的分析,即使得出的结论比较意外,也应该保持实事求是的态度去看待和验证。**在这次案例中,TCP确认号没有像常规的那样ACK=RCV.NXT,这也是出乎意料的事情。正是因为我们充分尊重这样的事实,并进行推理,才能突破既有的思维,找到了真正的原因。 * **对于“超时、处理慢”这类问题,建议你对比客户端和服务端的耗时,这有利于你找到正确的排查方向。**在这个案例中,我们比较了两端的耗时,发现两者接近;然后,通过在服务器上做系统排查,发现时间主要花费在read()上,这就说明,问题很可能出在网络或者LB上。课程中我给你整理了一段伪代码,梳理了这种排查思路,你可以拿来参考。 * 最后,**对于排查期间发现的规律性的现象,可以重点关注**。规律性的背后藏着的东西,跟问题的根因,多半有着密切的联系。所以这种规律性问题,也许正是我们排查的突破口。 ## 思考题 最后还是给你留两道思考题: * 如果接收端收到一个确认包,其确认号为200,而当前的未被确认的位置在500,那么接收端会怎么处理这个看起来“迟到并且重复”的确认包呢? * 你有没有遇到过这种“确认号在中间位置”的情况?当时有没有引起什么问题呢? 欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。 ## 附录 抓包示例文件:[https://gitee.com/steelvictor/network-analysis/tree/master/13](https://gitee.com/steelvictor/network-analysis/tree/master/13)