大师兄

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去直接访问服务器,是正常的,没有超时的报错。服务器上的日志显示,很快收到了客户端发出的请求,花了三百多毫秒就处理完并回复了。比如这个服务器日志:

图片

  • 客户端访问LB VIP的情况

这种情况下,客户端会等待很长的时间才能拿到HTTP响应。日志也印证了这一点:服务器上的日志显示,这个请求的处理耗费了1703毫秒。如下图:

图片

并且,客户端上的日志显示,同样是这个请求,在它看来,从发出请求给LB的VIP,到收到LB的VIP返回的响应,一共消耗了2002毫秒。如下图:

图片

我画了一张示意图,帮助你理解得更加清楚一些:

你也许会问:看起来不是服务器那头本身处理的耗时很长吗?为什么不查查服务器上应用代码的Bug?

我们也一度有这个怀疑,服务器上运行的是Java代码,会不会是GC造成的影响呢?所以我们也去查了当时JVM的运行情况,发现这段时间内并没有GC事件。因此,这个可能性也被排除。并且我们定位到,服务器的耗时主要是花费在了read()调用上,即读取网络I/O上面,所以还是需要回到网络排查的方向上来。

补充:Java有相关的分析工具来定位耗时所在,或者用strace也可以定位系统调用的性能情况。

我们用一个示意图来概括这两种场景下的区别:

由此,我们可以初步判定:问题出在LB,或者LB前后的网络环节。

这里也是我想分享给你的一个小的排查原则:针对客户端看到超时或者响应慢的这类问题,最好也检查下服务器本身花费的时间,两者对比,就能找到问题的方向了。

我给你把整个思路用伪代码的形式组织如下:

if (服务器耗时约等于客户端耗时) {
检查服务器耗时分布
if (服务器耗时在网络I/O) {
检查中间网络或者LB
} else {
检查服务器应用程序或操作系统
}
} else if (服务器耗时远小于客户端耗时) {
检查中间网络或者LB
}

我也画了一个示意图,供你参考:

那么下面,我们就可以把排查重点,转到 LB和网络层面上来。

抓住排查重点:LB和网络层

首先,我们在LB进行了抓包,用Wireshark打开抓包文件,一开始看到的是一帆风顺,全绿:

图片

翻了几页,突然画风一变,全红:

图片

补充:抓包示例文件已经上传至Gitee,建议你结合专栏内容和抓包示例文件一起学习,效果更好。

看到这个景象,你是否也会这样判断:“这肯定是有丢包了,所以接收方一直在回复DupAck!赶快去查网络设备的问题。”

DupAck多半是丢包引起的,但这次不是。为什么呢?我们先来看一下抓包文件展现出来的全貌。

  • 第一阶段:连接建立,正常。
  • 第二阶段:客户端开始发送数据包给LB,正常。
  • 第三阶段:客户端连续发送了约70KB数据(其中大部分数据还未被确认)。
  • 第四阶段:LB发送ACK包确认收到了前面约27KB的数据。
  • 第五阶段:客户端继续发送70~91KB的数据,目前为止也是正常的。
  • 第六阶段:在27KB之后,LB连续发送数十个DupAck,然后客户端发送一次TCP fast retransmission;这样的情况持续到客户端把所有应该发的数据包都发完。

那么,我们可以在Wireshark里打开Expert Information看一下汇总信息。

补充:关于Expert Information的解读,我在第5讲有介绍过,如果你感觉现在印象有点模糊的话,可以回头去温习一下。

图片

从图上看,快速重传有8个,DupAck有567个,平均每个快速重传对应了约70个DupAck。这里,我们来看看其中一段连续的DupAck和随后的一个快速重传:

图片

我给你解读一下:在LB给客户端发送了数十个DupAck之后(比如截图里包号93到104),客户端发送了一次快速重传(包号105),然后LB回复ACK(包号106,针对包号105),接着LB继续新一轮的数十个DupAck(从包号107开始),循环往复。

其实这里已经说明了这个案例的特殊性,也就是有“规律性现象”。如果是网络设备问题导致丢包,那么丢包会是随机现象,不太可能像这样有规律(70个DupAck加一个快速重传,不断循环)。而往往规律的背后,一般潜藏着某种未知的机制

当然,大量的DupAck和重传,确实跟应用层我们看到的严重的延迟现象对上了。也至少能回答“应用为什么变慢”这个问题了。当然,探究根因的话,接下来就是要回答“为什么有重传”这个问题。

网络排查推进:为什么客户端出现了重传?

在上节课,我们学习过两种重传方式,分别是快速重传和超时重传,而这次的重传呢,Wireshark已经提醒我们,属于快速重传。那为什么会有这种快速重传呢?

这里我们先来看一下相关数据包的具体细节。

首先,我们看一下在LB回复大量DupAck之前,客户端的发送情况。

图片

可以看到,在第一个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这个号以外,其他序列号是为方便举例而编出来的:

上面是客户端来一个报文,LB回复一次DupAck。当然也可能像下面这样,连续来多个报文,LB回复连续的多个DupAck:

好了,现在情况就比较清楚了,虽然“丢包”的根因还没找到,但整个排查工作的脉络已经相对清晰了,即:某处丢包-> TCP重传-> TCP传输速度下降->应用层超时报错

看起来,我们离成功只剩一步了,也就是,在抓包文件中找到那个丢失的序列号为27741的数据包

只要证明这个数据包确实是在网络上丢失的,那么我们就去修复网络。TCP不丢包了不重传了,速度就上来了,应用就不超时了。逻辑圆满自洽,感觉胜利已经在向我们招手了,是不是?

可是峰回路转。我们去翻前面数据包的时候,发现根本就没有那个“序列号为27741”的数据包

图片

上图是最接近27741序列号的附近的数据包,有序列号27229的包,也有序列号28689的包,但就是没有位于这两个数中间的27741的包。

这个时候,恍惚中有点感觉在看悬疑小说:一桩案件的元凶被查出是某某某,结果发现某某某这个人压根不存在。

你如果有跑步的爱好,应该会知道:跑步过程中会有一个极限区,此时我们的心肺会遇到很大的压力,这种难受的感觉很容易让人放弃。但是,如果继续坚持挺过这个极限区,身体就能提升到一个新的平台上继续平衡运转。进而,我们就可以继续快乐地跑下去了。

显然,我们的排查进入了“极限区”了。止步还是进步,就在一念之间。

TCP的本质:再次思考什么是确认号?

那么,这个序列号27741的包是消失了吗?还是说它就在那里,只是我们忽视了它的存在?

TCP序列号、Payload(载荷)、TCP确认号,一般情况下就是一个A+B=C的关系。但是,确认号必须是序列号+全部载荷吗?它可以是序列号+部分载荷吗?

举个例子,如果我从网上购买了一套衣服(上衣+裤子),我也收到了全套。但我觉得裤子尺码不对,上衣还挺合身,我可以只确认我收到了上衣(当然裤子还是要退回的),让卖家重新发裤子给我吗?这是可以的。

如果TCP也可以这样呢?那么,**“寻找序列号为27741的包”也许就是个伪命题,这个“包”实际上并不是独立的一个包,它只是某一个TCP包的一部分(前半部分)。**我们来看一下包号20(也就是前面找27741的时候,关注的两个报文之一)的详情:

图片

这个包是从客户端发给LB的,它的序列号为27229,载荷为1460字节。Wireshark也告诉我们,客户端将要发的下一个包的序列号会是28689。然而,LB回复的ACK(后续同样的ACK就是DupAck)却是这样:

图片

我们再看那个最为可疑的数字27741。显然,27741=27229+512。到这里看清楚了吗?这次LB确认的是一件“上衣”(512字节),而余下的“裤子”(另外的1460-512=948字节),LB并没有确认。没有被确认的数据,在客户端看来,是需要重新发送的。

我们先看看正常情况(即每次确认1460字节 )下的数据包交换过程:

图片

然后再来看一下这次异常交换的过程。

  • 客户端:我发给你从27229开始的1460字节,下一次你懂的,我将要发的是28689开始的数据。
  • LB: 我也不知道最近怎么搞的,记性不好,你这次给我这些数据,我好像只认得前512字节,其他的我认不出来了,先确认这512字节吧。
  • 客户端:怎么回事?只确认前512字节?麻烦了,我为了保证这次发送的TCP载荷依然能用足一个MSS即1460字节,必须把前一个包的后948字节和下一个包的前512字节,组合在一起,变成一个新的1460字节的包,再发送给你。不过还好,所有未被确认的数据还都在我的发送缓存(send buffer)里面,没有丢失。不过原先计算好的安排都要改掉了,我的CPU开销很大啊!

这次异常通信的过程如下图所示:

图片

这里,我再提醒你一个关键点:确认号本身代表字节数,所以它是字节级别的,而不是报文级别。也就是说,确认号是精确到某个字节的,而不是某个报文。

说到这里,我们再回顾下前面提到过的现象:平均每个快速重传对应了约70个DupAck,而每次重传都需要客户端把发送缓冲区里面打包好的数据包,挨个拆开,重组成LB想要的样子(512字节的位移的关系)。

想必,现在你已经清楚为什么应用会超时(超过2s)了:因为时间都花费在了各种包的分拆、重组上面了。光是客户端想成功发完一个POST请求,都花费了远远超出预期的时长​。就如同一辆车频繁熄火,还怎么可能高速行驶呢?

我画了一张更详细的图,来帮助你理解这个复杂的过程:

图片

  • 客户端发送HTTP POST请求,在TCP层面体现为一系列数据包(30KB以内)给LB,LB转发给服务器,在30KB之前一切正常。客户端应用程序的Timeout计时器,也从POST请求发送的那一刻开始计时。
  • 大约在客户端发送数据到30KB左右时,LB回复的ACK包确认的数据,不再是数据包分界点的字节数,而是位于中间的某个字节数(这个行为比较罕见)。
  • 客户端累积收到3个这样的DupAck(在这个案例里达到70多个),认为该包丢失。加上这个包的特殊性(是之前某个完整包的一部分),客户端会从缓冲区找出对应的字节数,拼凑上后续包的数据,组装成一个新的MSS大小(1460字节)的数据包,并且在此处消耗了可观的时间。
  • LB继续发送类似的“中间确认”包,客户端继续进行“拆包、重组”的操作,此处持续消耗客户端的时间
  • 服务器的时间都花在read()调用上(因为数据还在重传和重组过程中),无法及时收取完整的POST请求并计算处理,此时已经无法在客户端预定的Timeout时限内完成任务,于是客户端报错。

为了更加方便你的理解,我把这个过程概括成了下面这张图:

真相大白了!凭借这些详细的分析和充足的证据,我们说服了LB厂商并确认这是一个Bug,它容易在请求尺寸比较大的情况下被触发。比如在这个案例里,一个POST请求平均大小在100KB,也就触发了这个Bug。

实际上,在Bug修复之前,我们通过扩大TCP receive buffer size,使得缓冲区足够大(你HTTP POST请求大,我缓冲区更大),也做到了对Bug的规避。

小结

今天介绍的确实是一个比较罕见的案例,也是我处理过的众多网络排查案例中,遇到的仅有的一次“确认号在中间位置”的情况。那么从协议规范来说,这样“确认中间位置”的做法,到底是否违规呢?

我们看一下TCP协议的第一版规范RFC793,看看它对确认号的要求是什么:

if the ACK bit is on
......
ESTABLISHED STATE
If SND.UNA < SEG.ACK =< SND.NXT then, set SND.UNA <- SEG.ACK.

其中几个缩写的含义如下:

SND.UNA - send unacknowledged #这是指已发送的但未被确认的TCP段的位置
SND.NXT - send next #这是已经发送的TCP段的下个序列号

“下个序列号”这个知识点在第10讲介绍过,你可以回头复习一下。

TCP应该接受 SND.UNA < SEG.ACK =< SND.NXT 这样的情形,也就是收到的报文的确认号,应该大于已经被确认的数据的位置,并且小于等于(要发送的)下个序列号。

一般来说,我们看到的大部分是“确认号等于下个序列号”的情况,如下图:

但是从这个案例的情况来看,就是确认号是在中间位置。这虽然很少见,但也不违规,也可以被操作系统接纳并处理。只不过,引起的开销有点过大了。

除了上面的知识点以外,我也建议你务必关注整个排查过程带来的启发:

  • **网络排查过程中要仔细核对各种事实和数据,避免仅根据表面现象轻易下结论。**比如,在这个案例里,很多的TCP重传很容易让我们把关注点错引到网络状况上面去。所以只有仔细核对这些数据,发现其中的问题,才不会被自己的思维惯性所误导。
  • **对于各种重传的现象和成因应该有充分的了解,这样对排查方向的确定有很大的帮助。**特别是重传的两个大类,即快速重传和超时重传,它们的特征和应对策略,你最好熟记于心,这样等你遇到类似情况时,很快可以对症下药,提高解决问题的效率。
  • **基于前面两点做细致踏实的分析,即使得出的结论比较意外,也应该保持实事求是的态度去看待和验证。**在这次案例中,TCP确认号没有像常规的那样ACK=RCV.NXT,这也是出乎意料的事情。正是因为我们充分尊重这样的事实,并进行推理,才能突破既有的思维,找到了真正的原因。
  • **对于“超时、处理慢”这类问题,建议你对比客户端和服务端的耗时,这有利于你找到正确的排查方向。**在这个案例中,我们比较了两端的耗时,发现两者接近;然后,通过在服务器上做系统排查,发现时间主要花费在read()上,这就说明,问题很可能出在网络或者LB上。课程中我给你整理了一段伪代码,梳理了这种排查思路,你可以拿来参考。
  • 最后,对于排查期间发现的规律性的现象,可以重点关注。规律性的背后藏着的东西,跟问题的根因,多半有着密切的联系。所以这种规律性问题,也许正是我们排查的突破口。

思考题

最后还是给你留两道思考题:

  • 如果接收端收到一个确认包,其确认号为200,而当前的未被确认的位置在500,那么接收端会怎么处理这个看起来“迟到并且重复”的确认包呢?
  • 你有没有遇到过这种“确认号在中间位置”的情况?当时有没有引起什么问题呢?

欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。

附录

抓包示例文件:https://gitee.com/steelvictor/network-analysis/tree/master/13