You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

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以内给LBLB转发给服务器在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