TCP 长连接 CWND reset 的问题分析

我在公司维护一个 service mesh 的平台,主要是代理用户的请求,用户的服务想调用另一个服务的时候,将请求发给我们的进程,然后我们转发到对应的服务。

今天收到一个用户工单,是说在一个 50ms 延迟的环境中,实际他们调用其他服务的延迟远大于 50ms,有 200ms 左右的延迟。我一看,这个问题和《TCP 拥塞控制对数据延迟的影响》这里的问题一样嘛,应该是个送分题。于是就去验证,如果发生在 TCP 建立连接的时候,有高延迟的话,那就没跑了。结果发现这个用户的 QPS 并没有那么低,我们的 TCP connection keepalive 有 60s,用户几乎每 5s 左右就会有一个请求。

这就麻烦了。这种问题最讨厌,因为运行的环境比较复杂,这种延迟问题可能是用户的问题,可能是我们平台的问题,可能是 SDN(我们用的一种 overlay 网络架构)的问题,也可能是我们的 kernel 的问题,排查起来极为困难。

好在最后定位到了原因,最后的结论让我大跌眼镜,我以为自己对 TCP 比较了解,没有想到竟然是一个我从没有听说过的 TCP 行为导致的!如果你听说过这个参数,1 分钟就可以解决问题。这里先卖个关子,不说是什么参数了,让我从这个问题最初的排查说起。

用户提交的工单里面只说 P99 延迟升高,metrics 监控系统里单个请求就可能波动 P99,所以首先要定位到有慢请求的具体的日志。根据用户的描述(中间经过了来回拉扯),我从日志 grep 到确实有 latency 高的离谱的请求。最终排除了用户那边的问题。

然后我找我们这个平台的开发去分析一下,为什么我们平台内部会额外花费这么多时间?得到的答复是“网络问题”。哎,不知道我在抱什么期望,几乎每次这种问题都是得到“网络问题”的结论(不过这一次可真的是,笑)。如果去找网络团队,说“你们网络有问题,请排查下”,人家能干嘛呢?至少得定位到网络哪里有问题,并且有证据才行。看来还得自己动手。考虑到我 tail -f 的日志也是可以看到类似的慢请求,感觉分析起来并不困难。

前面说过由于用户的请求大于每分钟一个,所以应该不是 TCP 建立连接的 overhead 了。这里,我找到一个 client-server IP 对进行抓包,等从日志观察到出现慢请求的时候,停止抓包,然后通过日志的关键字定位到抓包里面具体的 segment。因为是自己实现的 TCP 协议,所以 Wireshark 并不认识这个协议,我只好用 tcp.payload contains "request header" 来直接从 payload 里面过滤请求。

找到这个慢请求之后,发现这个 response 比较大,于是分析 tcptrace,看到的图如下(如果你看不懂这个图片的话,可以看用 Wireshark 分析 TCP 吞吐瓶颈):

途中,每根竖线的高度可以理解为一次性发送的数据多少:第一个请求中(图中第一次升高),一开始慢慢发,越发越快,这是因为 TCP 要避免出现拥塞,所以在链接刚建立的时候会 slow start,慢慢发送,如果网络能接受,就逐渐加大发送的速度,如果发生拥塞控制了(不通的拥塞控制算法不通,cubic 使用丢包来作为拥塞控制事件)就降低发送的速度。所以开始的时候 cwnd 拥塞控制窗口在慢慢打开。其中,绿线 rwnd 并不是瓶颈,它看起来比较低,是因为我抓包的时候 TCP 连接已经存在了,没有抓到 SYN 包,所以 wireshark 并不知道 window scaling factor 是多少。

但是!!为什么 2 秒之后,在发送第二个请求的时候,这个竖线的高度又从很小开始了呢?这可是同一个 TCP 连接呀,没有重建。并且也没有丢包和重传。

我以前理解的拥塞控制算法 cubic,只会在发生拥塞事件的时候缩小 cwnd。所以我以为我看错了,但是切换到 window scaling 视图,发现 cwnd 确实每个请求都会 reset。

这跟我所理解的拥塞控制算法不一样!于是我搜索“why my cwnd reduced without retrans“,却几乎没有看到有用的信息。

甚至从这个回答里确认:cubic 在 idle 的时候不会 reset cwnd。这个回答倒提到了可能是应用的问题。

于是,下一步我想排出是不是我们平台的代码的 tcp 使用问题。

我写了一段简单的代码来模拟这个行为,它作为客户端,每 10s 发送 1M 的数据,然后空闲 10s,重复发送。

在服务端用 nc 打开一个端口即可:nc -l 10121,然后在客户端运行代码:python tcpsend.py 172.16.1.1 10121,可以看到开始发送数据了:

我写了一个循环,使用 ss 一直打印出来这个连接的 cwnd 值:

while true; do ss -i | grep 172.16.1.1 -A 1; sleep 0.1; done

从这个命令的输出中,成功地复现了问题!排出了我们平台代码的问题:

为了隐藏 IP 信息,这里只截取了部分输出,可以看到在发送完上一次的 1M 数据,10s 之后重新发送的时候 cwnd 又从 10 开始了

到这里我开始怀疑是 SDN 的问题了,为了验证,我去一个简单的网络环境,纯物理网,重新跑了一次我的代码,发现 cwnd 没有重置,说明就是 SDN 的问题。

于是叫来一个网络组的同事,给他看了这个。结果他也感觉很惊讶(再次卖个关子,不是只有我一个人不知道这个行为,有了些许安慰)。认为这不应该发生。但是他又对他们的代码很有信心,认为这不可能是是 SDN 的问题。然后他突然说,这应该是网络延迟造成的问题,SDN 所在的环境本身有 50ms 的延迟,我测试的物理网却没有。

于是我去找了一个有 100ms 延迟的物理网环境测试,结果……果然不是 SDN 的问题,观察到了一模一样的现象。

这时候已经进入到未知领域了。我去打开了 chatGPT 这个废物,它说是拥塞控制算法在 idle 之后会降低 cwnd。多新鲜呐,从没听说过。

我们的拥塞控制算法就用的 cubic,于是我和这个网络的同事一起去看了 cubic 的代码。如果能看到这逻辑的话,也就一切就说得通了。

看了半天,只有几百行,又是用 eBPF 探测又是测试的,愣是没找到这个会 idle 的时候 reset cwnd 的逻辑。感觉进入了死胡同,在网上搜 cubic reset cwnd when idle 也是徒劳无功。

同事想到一个点子:我们换成 BBR,看是否可以解决问题?!

从现象来看,BBR 速度快很多。原本以为问题解决了,但是看了下我的脚本的输出,发现其实问题依然是存在的,发送完数据之后 cwnd 会降到 4,只不过 BBR window scaling 非常快,一开始发数据蹭就跳到 1384 了。

BBR 也会 reset cwnd

不过这倒是提醒我了:要么就是所有的算法都实现了这个 reset cwnd when idle 的鬼逻辑,要么,这根本不是算法实现的,而是系统实现的!

考虑到看了一下午 cubic 的代码了,我更倾向于后者。

接下来该怎么做呢?总不能把协议栈的代码看一遍吧。

这时我靠直觉做了一个动作,我把系统 TCP 相关的配置打印出来,sysctl -a | grep tcp ,想看看有没有线索,结果一个 net.ipv4.tcp_slow_start_after_idle 直接就点醒了我。这不就正是我们遇到的问题么?

net.ipv4.tcp_slow_start_after_idle 参数,默认是 1(打开)

我把它改成 0,问题就完全解决了:

后面再观测 cwnd,稳定在 1000 左右。

有了这个行为,我再去寻找这么做的原因就简单了。

RFC2414 里面提到:

TCP 需要在 3 种情况用到 slow start(从这个表述也可以看出,slow_start_after_idle 不是拥塞控制算法应该做的,拥塞控制算法要做的是实现 slow start,TCP 的实现是使用 slow start):

  1. 连接刚刚建立的时候 (Initial Window)
  2. 连接在 idle 一段时间之后,重新启动的时候 (Restart Window)
  3. 发生丢包的时候 (Loss Window)

那么为什么需要 (2) idle 后要 slow start 呢?我找到了 RFC 5681,有关 “Restarting Idle Connections“ 的部分,大意是:

拥塞控制算法使用收到的 ACK 来确认网络质量从而避免拥塞,但是在 idle 了一段时间之后,没有 ACK 在传输,拥塞控制算法所保存的状态可能已经不能反映此时的网络情况了,比如发送了一波数据,直到发送完成的时候网络都比较空闲,然后连接 idle 了 10s,又要开始发数据,此时网络恰好负载比较高,如果 TCP 使用 10s 之前的知识去重新开始发送数据的话,就会造成拥塞;另外,10s 之后即使网络负载没变,网络环境可能变了(路由表切换之类的?),同样会造成 10s 之前的知识已经不适用了。

所以,这个 RFC 里面定义:

在 RTO(Retransmission Timeouts) 时间内,如果发送端没有发送任何 segment 出去,那么下次发送数据的时候,cwnd 要降低到 RW(Restart Window, RW = min(IW,cwnd)).

到此,以上的行为就解释的通了。

那么解决方法呢?因为这个参数是 by network namespace 的,我们打算在高延迟的网络中对我们这个进程关闭这个参数。

关闭 tcp_slow_start_after_idle 的效果,可以看到在业务低峰,QPS 比较低的情况下,延迟有很大改善

另一个思路是,既然 TCP 连接在 idle 的时候会 reset,那么我一直在这个连接上发送数据,不让它达到 idle 的条件,那么自然就不会被 reset cwnd 了。

将测试代码稍微修改一下,打开一个线程,每 100ms (只要低于 RTT 就可以) 发送一个 byte:

然后用这个命令一直打印出来 cwnd 的参数:

while true; do ss -i | grep 10121 -A 1 | grep -Eo "cwnd:[0-9]+"; done

结果发现,cwnd 果然保持不变了。

可以看到 cwnd 一直保持在相同的数值,a 是正常数据,而 x 是无论如何每 100ms 发送一次的冗余数据。

如果无法修改系统参数的话(比如,同一个系统上运行了很多服务,不想去影响所有的服务),可以通过这种 keepalive 的方法来解决。代价就是需要稍微消耗一些带宽。

几个细节:

为什么我一开始在延迟低的物理网测试的时候没能复现呢?

其实也是有一样的问题的,只不过我上面那个 while 循环,有 sleep 0.1,因为环境的延迟太低,所以只是命令无法显示出来而已。cwnd 也会 reset to zero 的。

RFC 5681 提到:Van Jacobson 最初的提议是:当发送端在 RTO 时间内没有收到 segment,那么在下次发送数据的时候,cwnd 降低到 RW。这样是有问题的,比如,在 HTTP 长连接中,idle 一段时间之后,Server 端开始发送数据,本应该 slow start,这时候 client 突然又发送过来一个请求,会导致 server 端不再 slow start。故改成了目前这样:数据的发送端如果在 RTO 那没有发送过数据,要 slow start。

前面的文章中,TCP 拥塞控制对数据延迟的影响 我们使用长连接试图解决长肥管道慢启动延迟高的问题,是错误的。

这个参数目前看只是一个系统参数。应用不可以通过 setsockopt() 来修改,原因可以看这个讨论。本质上跟我们在 TCP 拥塞控制对数据延迟的影响 中讨论的一样,这个参数和网络环境有关,而不能让应用自己去设置。

 

TCP 拥塞控制对数据延迟的影响

这是上周在项目上遇到的一个问题,在内网把问题用英文分析了一遍,觉得挺有用的,所以在博客上打算再写一次。

问题是这样的:我们在当前的环境中,网络延迟 <1ms,服务的延迟是 2ms,现在要迁移到一个新的环境,新的环境网络自身延迟(来回的延迟,RTT,本文中谈到延迟都指的是 RTT 延迟)是 100ms,那么请问,服务的延迟应该是多少?

我们的预期是 102ms 左右,但是现实中,发现实际的延迟涨了不止 100ms,P99 到了 300ms 左右。

从日志中,发现有请求的延迟的确很高,但是模式就是 200ms, 300ms 甚至 400ms 左右,看起来是多花了几个 RTT。

接下来就根据日志去抓包,最后发现,时间花在了 TCP 本身的机制上面,这些高延迟的请求都发生在 TCP 创建连接之后。

首先是 TCP 创建连接的时间,TCP 创建连接需要三次握手,需要额外增加一个 RTT。为什么不是两个 RTT?因为过程是这样的:

即第三个包,在 A 发给 B 之后,A 就继续发送下面的数据了,所以可以认为这第三个包不会占用额外的时间。

这样的话,延迟会额外增加一个 RTT,加上本身数据传输的一个 RTT,那么,我们能观察到的最高的 RTT 应该是 2 个 RTT,即 200ms,那么为什么会看到 400ms 的请求呢?

从抓包分析看,我发现在建立 TCP 连接之后,客户端并不是将请求一股脑发送给服务端,而是只发送了一部分,等到接收到服务端的 ACK,然后继续在发送,这就造成了额外的 RTT。看到这里我恍然大悟,原来是 cwnd 造成的。

cwnd 如何分析,之前的博文中也提到过。简单来说,这是 TCP 层面的一个机制,为了避免网络赛车,在建立 TCP 连接之后,发送端并不知道这个网络到底能承受多大的流量,所以发送端会发送一部分数据,如果 OK,满满加大发送数据的量。这就是 TCP 的慢启动。

那么慢启动从多少开始呢?

Linux 中默认是 10.

也就是说,在小于 cwnd=10 * MSS=1448bytes = 14480bytes 数据的情况下,我们可以用 2 RTT 发送完毕数据。即 1 个 RTT 用于建立 TCP 连接,1个 RTT 用于发送数据。

下面这个抓包可以证明这一点,我在 100ms 的环境中,从一端发送了正好 14480 的数据,恰好是用了 200ms:

100ms 用于建立连接,100ms 用于发送数据

如果发送的数据小于 14480 bytes(大约是 14K),那么用的时间应该是一样的。

注意,图中虽然 TCP 在我手的时候,双方协商的 MSS 是 1460bytes,但是由于 TCP 的 Timestamps 会在 options 中占用 12 bytes,所以实际上发送的数据,payload 最大为 1448bytes. 在本文中,可以理解为实际的数据段的 maximum segment size 是 1448 bytes。

Timestamps 在 Options 中占用 12 bytes

但是,如果多了即使 1 byte,延迟也会增加一个 RTT,即需要 300ms。下面是发送 14481 bytes 的抓包情况:

多出来一个 100ms 用于传输这个额外的 byte

慢启动,顾名思义,只发生在启动阶段,如果第一波发出去的数据都能收到确认,那么证明网络的容量足够,可以一次性发送更多的数据,这时 cwnd 就会继续增大了(取决于具体拥塞控制的算法)。

这就是额外的延迟的来源了。回到我们的案例,这个用户的请求大约是 30KiB,响应也大约是 30KiB,而 cwnd 是双向的,即两端分别进行慢启动,所以,请求发送过来 +1 RTT,响应 +1 RTT,TCP 建立连接 +1 RTT,加上本身数据传输就有 1 RTT,总共 4RTT,就解释的通了。

解决办法也很简单,两个问题都可以使用 TCP 长连接来解决。

PS:其实,到这里读者应该发现,这个服务本身的延迟,在这种情况下,也是 4个 RTT,只不过网络环境 A 的延迟很小,在 1ms 左右,这样服务自己处理请求的延迟要远大于网络的延迟,1 个 RTT 和 4 个 RTT 从监控上几乎看不出区别。

PPS:其实,以上内容,比如 “慢启动,顾名思义,只发生在启动阶段“,以及 ”两个问题都可以使用 TCP 长连接来解决“ 的表述是不准确的,详见我们后面又遇到的一个问题:TCP 长连接 CWND reset 的问题分析

Initial CWND 如果修改的话也有办法。

这里的 thread 的讨论,有人提出了一种方法:大意是允许让应用程序通过 socket 参数来设置 CWND 的初始值:

setsockopt(fd, IPPROTO_TCP, TCP_CWND, &val, sizeof (val))

——然后就被骂了个狗血淋头。

Stephen Hemminger 说 IETF TCP 的家伙已经觉得 Linux 里面的很多东西会允许不安全的应用了。这么做只会证明他们的想法。这个 patch 需要做很多 researech 才考虑。

如果 misuse,比如,应用将这个值设置的很大,那么假设一种情况:网络发生拥堵了,这时候应用不知道网络的情况,如果建立连接的话,还是使用一个很大的 initcwnd 来启动,会加剧拥堵,情况会原来越坏,永远不会自动恢复。

David Miller 的观点是,应用不可能知道链路 (Route) 上的特点:

  1. initcwnd 是一个路由链路上的特点,不是 by application 决定的;
  2. 只有人才可能清楚整个链路的质量,所以这个选项只能由人 by route 设置。

所以现在只能 by route 设置。

我实验了一下,将 cwnd 设置为 40:

通过 ip route 命令修改

然后在实验,可以看到这时候,client 发送的时候,可以一次发送更多的数据了。


后记

现在看这个原因,如果懂一点 TCP,很快就明白其中的原理,很简单。

但是现实情况是,监控上只能看到 latency 升高了,但是看不出具体是哪一些请求造成的,只知道这个信息的话,那可能的原因就很多了。到这里,发现问题之后,一般就进入了扯皮的阶段:中间件的用户拿着监控(而不是具体的请求日志)去找平台,平台感觉是网络问题,将问题丢给网络团队,网络团队去检查他们自己的监控,说他们那边显示网络没有问题(网络层的延迟当然没有问题)。

如果要查到具体原因的话,需要:

  1. 先从日志中查找到具体的高延迟的请求。监控是用来发现问题的,而不是用来 debug 的;
  2. 从日志分析时间到底花在了哪一个阶段;
  3. 通过抓包,或者其他手段,验证步骤2 (这个过程略微复杂,因为要从众多连接和数据包中找到具体一个 TCP 的数据流)

我发现在大公司里面,这个问题往往牵扯了多个团队,大家在没有确认问题就出现在某一个团队负责的范围内的时候,就没有人去这么查。

我在排查的时候,还得到一些错误信息,比如开发者告诉我 TCP 连接的保持时间是 10min,然后我从日志看,1min 内连续的请求依然会有高延迟的请求,所以就觉得是 TCP 建立连接 overhead 之外的问题。最后抓包才发现明显的 SYN 阶段包,去和开发核对逻辑,才发现所谓的 10min 保持连接,只是在 Server 侧一段做的,Client 侧不关心这个时间会将 TCP 直接关掉。

幸好抓到的包不会骗人。

 

Golang 程序 crash 的时候自动 core dump

前段时间遇到一个问题,程序莫名其妙 crash 了,stack 也没看出什么端倪来。今天改了一个参数,让 golang 程序在崩溃的时候 core dump。

其实核心就是加一个环境变量就可以了,GOTRACEBACK=1. 但是还有一些其他跟系统相关的问题,这篇文章简单记录一下。

Golang 1.6 之后,这个环境的变量可选值有了一些变化,新的值如下:

  • GOTRACEBACK=none will suppress all tracebacks, you only get the panic message.
  • GOTRACEBACK=single is the new default behaviour that prints only the goroutine believed to have caused the panic.
  • GOTRACEBACK=all causes stack traces for all goroutines to be shown, but stack frames related to the runtime are suppressed.
  • GOTRACEBACK=system is the same as the previous value, but frames related to the runtime are also shown, this will reveal goroutines started by the runtime itself.
  • GOTRACEBACK=crash is unchanged from Go 1.5.

一些要注意的点:

首先,介绍下除了这个 GOTRACEBACK 参数,还有其他一些很有用的能控制 golang 运行时的环境变量,这篇文章总结的很好。

然后这个参数在 macOS 上是无效的,就不要在 MAC 上白费力气了。

Linux 上还受到 ulimit 的限制。可以用 ulimit -c 查看对 Core dump 的大小限制。如果是 0 是 dump 不出来了,也不建议设置成 ulimited。我改成了 50G。如果程序使用 systemd 启动的,可以设置 service unit 文件中的 LimitCORE= 参数,效果等同。

产生的 core dump 存放在哪里了呢?

可以通过这里查看:

这里定义了 core dump 文件的命名方式。

但是在 ubuntu 里面,会看到这样的输出:

意思是通过 pipe 定向到了 apport. apport 是 ubuntu 发行版选择使用的 core dump 管理服务。

默认情况下,用户程序是不会有 core dump 的。然后我们有两个解决办法:

  1. 关闭 apport,使用系统的 core dump 直接写在磁盘上
  2. 配置 apport,让它也写用户的 core dump 文件

第一种方法比较简单,直接修改上文中的 /proc/sys/kernel/core_pattern 文件即可:

注意这里有一个小小的问题要注意一下:这个配置是全局的,只有 root 账户才能编辑。如果在普通用户下执行 sudo echo "kernel.core_pattern=/tmp/%e.%t.%p.%s.core" > /proc/sys/kernel/core_pattern 是不行的,因为在这行命令中,echo 是用 sudo 执行的,但是重定向确实 shell (bash) 来完成的,重定向,即真正的写入工作,实际上没有在 root 下,所以你会得到错误:Permission denied, 或者 Destination /proc/sys/kernel not writable. 解决办法是用这个命令:sudo bash -c 'echo "kernel.core_pattern=/tmp/%e.%t.%p.%s.core" > /proc/sys/kernel/core_pattern'.

然后可以 disable apport:

 

第二种方法,首先要确保 apport 在运行。可以通过 systemctl status apport 查看。也可以看下 apport 日志:

触发一次 core dump,会看到:

意思是 core dump 不是来自于 ubuntu 打包的软件,忽略。

配置方法是,修改 ~/.config/apport/settings (如果没有,手动创建)文件,写入:

再触发一次 core dump,这次日志里就会有写入的信息了:

还要注意的是,这个文件不是 core dump 文件,而是 apport 打包的 debug 文件,可以使用 apport-unpack 解包:

解包出来的 CoreDump 就可以用 gdb 分析了。其他的文件记录了一些系统相关的信息。(感觉是 Ubuntu 用来让用户报告 bug 的)

 

最后,如果进程的 workdir 下没有生成 core dump 的话,可以看下是不是在 /var/lib/systemd/coredump/,网上说用 systemd 的系统会存放在这里,不过我没遇到。

 

用油猴制作一个 Jenkins 日志窗口

上次介绍了油猴脚本的基本使用方法,这篇文章简单记录一下今天用油猴提高 Quality of Life 的一个脚本。

先描述一下我要解决的问题:

我们平时很多线上操作是通过 Jenkins 执行的,要在大规模的机器上运行任务,有时候,这些任务要运行很长时间。我一般会关注着这些自动化操作,一般进行其他的工作。我想让日志一直出现在屏幕上,但是又不影响我其他工作。

解决方法是,我在 Jenkins 页面上加了一个按钮,通过这个按钮可以打开一个最小化的窗口,效果如下:

这是新添加的按钮,点击这里,可以弹出一个日志窗口

 

弹出来的日志窗口位于左上角,没有菜单栏,没有书签栏,也没有 Extentions,基本上所有的空间都用来显示日志了。

这样做操作的时候,有实时的日志一直在滚动着,放心多了。


源代码如下:

直接粘贴到自己的油猴就能用。

核心逻辑是,如果当前页面是 Jenkins 原生的 URL(Params 没有 view_window=minimized),就在页面上插入一个链接 <a>,目标是 当前的 URL + 参数view_window=minimized 。通过代码,设置打开这个 URL 的时候关闭 menubar,toolbar,以及设置好窗口大小,位置等等。 打开一个基本上只有日志的窗口,一致放在屏幕旁边。可以用 Mac 上的 Rectangle 软件,将这个窗口固定在 Top。从这个窗口打开链接(按住 Cmd),还是用 Chrome 正常的窗口打开的,很方便。

如果监测到 URL 中有 view_window=minimized  这个参数,就删除页面内 sidebar,footbar 等,让所有的空间都用来展示日志。

操作的时候最需要的按钮是停止键,但是默认的 Jenkins 把这个按钮放到了页面的最上面,这样在底部看滚动日志,如果需要停止的话,还要拖到页面顶部去找按钮,太慢了。我用 JQuery 把它放到日志滚动下面了。

本来想做成一个 Jenkins 插件直接把公司的 Jenkins 给改了,但是看了下 Jenkins 发布插件还是挺复杂的,还得写点 Java 和 XML,可能要花上一两天。所以就直接用油猴实现了,花了半小时。

 

MTU 和 UDP (以及基于 UDP 的协议)

上次在写了之后《有关 MTU 和 MSS 的一切》之后,最近又有了一个问题,苦苦思索了一个周,终于得到了答案。现在一想问题的答案简单而有效,但是中午吃饭的时候和几个同事讨论,我们都没有很快想到这个,所以还是觉得值得记录一下。

首先我要花一些篇幅来描述一下这个问题。因为和同事交流的时候发现大家会以为我在问另一个问题。

我们知道如果 IP 包的 size 整个大于 MTU 的话,那么 3 层就会负责 fragmentation,即讲一个大包拆成多个小包单独发送。那么我的问题是,三层在将数据从自己这边传给下一个 hop 的时候,只知道自己的 MTU,而不知道对方的 MTU,那么如果对方的 MTU 小于自己的时候,怎么拆包发给它呢?

如何将数据发给 MTU 比自己小的另一侧?

在之前的文章中,我们知道,TCP 因为是有连接的协议,连接在建立的时候,就有 MSS 的协商,如果中间设备的 MTU 比较小,就会 MSS clamping,这样就能保证两端都不会发送超过 MTU 的数据。

但是对于面向无连接的协议,比如 UDP,怎么处理这个问题呢?

首先,不处理肯定是不行的,因为理论上收到了 MTU 比自己能接受的 MTU 还要大的包,就会被丢弃。UDP 有没有重传机制,那么就一直发,一直丢,发送端也不知道发生了什么事情。

然后想到了了 PMTUD,那篇文章也提到过。但是 PMTUD 的目的是:避免进行 IP fragmentation,先通过 PMTUD 得知链路上的 MTU 是多少,然后在后续的通讯中保证不发送大于 MTU 大小的包。这不是我问的问题,我的问题是,如果对方的 MTU 比较小,这时候 Don’t Fragmentation 又没有设置,三层是怎么拆包的。并且,向 UDP 这种协议,有一些场景也不现实,难道 DNS 一次请求之前我都要发送多个包去探测 MTU 吗?效率也太低了。我还实际去抓了包,确实是没有 PMTUD 的。

下一个得到的答案是:对方会把包丢弃,然后发送一个 ICMP 回来,Type=3 (Destination Unreachable) and code=4, packet too big and DF is set. 表示我收到一个包,大的我无法转发,但是这个包又设置了 DF,让我不要拆包,没办法,只好丢了,你要知道。这个答案我也不是很满意,因为按照语义,这个 Code=4 的意思是 DF is set 我才丢的。而我想问的是,DF 没有 set,你随便拆,你要怎么知道对方的 MTU 然后拆包呢?

这些答案是我和同事们讨论过的,好像都可以解决问题,但是又好像都不太合理。

其中搜索了一些资料,感觉都没有直接回答这个问题,大部分文章提到这部分的时候好像都直接略过了,只是介绍如何根据自己这一端的 MTU 进行 fragmentation。有一些感觉比较离谱,比如这里,说路由器知道对方的 MTU。我就好奇了,它怎么知道的?IP 协议没有任何机制协商 MTU 呀。

我还自己做了一个实验,进行验证。搞了两个虚拟机,A MTU=1000,B MTU=500,然后用 A 去 ping B,size=800,结果发现 A 到 B 没有 fragmentation,B 到 A 有 fragmentation。但是两边都能收到包(我猜这个是实验环境的问题,因为两个 VM 中间的网络比较简单,所以网卡都能处理这种不合理的包?)至少,我们证明了在 IP 这一层,它不会去关心对方的 MTU 是什么,只会根据自己这边的 MTU 去 fragmentation.

某天有同事从深圳来新加坡出差,我们一起吃饭,又提起这个话题,他直接说:UDP 不管这个问题呀!

对哦,这就是我为什么在 UDP 相关的资料中都没发现和 MTU 有关的东西。这个协议太简单了,不处理这个问题。如果你要基于 UDP 实现一个协议,就要自己处理超过 MTU 的问题。

这是我基于自己读了一些 RFC 之后认为的答案,如果有错误,欢迎指出。

比如:

DNS 协议规定:RFC 1035 DNS 响应不能超过 512 bytes(UDP message),如果超过 512 bytes,在 512 bytes 之后的内容就会被截断。512 bytes 的内容是安全的吗?(链路上所有的节点都能正常接受这个 size?),我们来算一下:2 层 Ethernet 最小的 Frame 是 576 bytes, IP header 20 bytes + IP option 0-40 bytes, UDP header 8 bytes, 所以在 IP option =0 的时候,512 bytes 的 UDP message 最终的 Ethernet Frame 是: 512 bytes + 20 bytes + 0 + 8 = 540 bytes, 小于 576 bytes。是安全的。IP option 在小于 576 – 540 = 36 bytes 的时候是安全的,可以说,在绝大部分情况,这个大小是安全的。

这是 DNS 对 MTU 问题的解决办法:我只发送全世界最小的二层包,总没问题了吧?

与之类似解决方法的是 TFTP 协议(RFC 1350),默认是 512 bytes,但是可以配置。不过用户要自己对配置负责,配置不当就直接丢包

KCP 也是有一个默认值 1400 bytes,但是支持通过函数 ikcp_setmtu 来设置。因为本质上这个是 “Pure algorithm protocol”,你可以有自己的 MTU 探测实现。

最后是 QUIC,这个最具有代表性。它的处理方法是:

  1. QUIC 的实现应该(RFC 用的是 SHOULD)使用 PMTUD,并且应该记录每一个 source ip + dest ip 的 MTU
  2. 但是如果没有 PMTUD 的话,也可以认为 MTU=1280,协议设置 max_udp_payload_size = 1200 bytes,如此,按照上面的算法的话,IPv4 的 header 最多可以有 52 bytes,IPv6 的 header 可以有 32 bytes,正常情况下也够用
  3. 如果链路上连 1280 的 PDU 都支持不了,QUIC 就会这个 UDP 无法使用(和端口连不上等同),然后会 fallback 到 TCP

对于3,还有一个问题,就是 QUIC 如何知道 1280 的 MTU 能不能传呢?我发现了这个协议一个很神奇的设置,就是它的每一个 IP 包大小都是一样的,比如 MTU=1280,那么发送的每一个二层包都是 1280 bytes,不够的就 padding 到 1280,如果传不过去,那么握手包也传不过去,一开始就被丢弃了。

QUIC 所有的包都一样大

很绝妙,不过我觉得有一点要注意的是,中间 overlay 协议在设计的时候可能要注意这一点:比如 Overlay 要在中间插入 100 bytes 的数据,MTU 设置为 1400,那么就不应该接收 1450 的包。即,即使有时候没有 100 bytes 的数据要插入的时候,也应该 padding 100 bytes 进去。否则的话,像 QUIC 这种协议,就可能握手阶段没问题,让它过去了,协议认为 MTU=1450,但是后面可能会频繁丢包。

最后,重申一下我对 QUIC 不是很了解,只是浅读了一些资料。如果读者发现本文错误,欢迎指出。