问题
最近一段时间在做下载速度优化,排查到一个很奇怪的问题,即AWS的EC2机器,通过TCP下载RTT较高(>100ms)的数据时,下载速度会非常慢(<3mbps)。
这究竟是怎么一回事呢?
排查
排除外部因素
已经检查过网络本身的速度可以达到200~300mbps,也并非对方网站的问题,确确实实是本机导致的下载速度缓慢。
抓包捕获现象
网络相关的问题,遇事不决先抓包。
(以下只列出本地向远端发的数据包)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
47238 → 8080 [SYN] Seq=0 Win=62727 Len=0 MSS=8961 SACK_PERM=1 WS=128 47238 → 8080 [ACK] Seq=1 Ack=1 Win=62848 Len=0 47238 → 8080 [PSH, ACK] Seq=1 Ack=1 Win=62848 Len=40 47238 → 8080 [ACK] Seq=41 Ack=43 Win=62848 Len=0 47238 → 8080 [PSH, ACK] Seq=41 Ack=43 Win=62848 Len=19 47238 → 8080 [ACK] Seq=60 Ack=2939 Win=60032 Len=0 47238 → 8080 [ACK] Seq=60 Ack=5835 Win=57216 Len=0 ... (省略一堆数据) 47238 → 8080 [ACK] Seq=79 Ack=522771 Win=56704 Len=0 47238 → 8080 [ACK] Seq=79 Ack=525667 Win=56704 Len=0 47238 → 8080 [ACK] Seq=79 Ack=528563 Win=56704 Len=0 47238 → 8080 [ACK] Seq=79 Ack=531459 Win=56704 Len=0 47238 → 8080 [ACK] Seq=79 Ack=534355 Win=56704 Len=0 |
上面的抓包能看出啥? 我从中提取出几个要点
- 通告窗口从一开始不仅一直没变大, 反而变小后稳定保持
- 我方通告MSS是8961,不是一般情况下的1460
- 对面通告的MSS是1460, 实际抓包每次收到数据长度大都为2896, 偶尔有1448
明确直接原因
只有接收方通告的窗口不断增长,发送端才能增加发送速度,接收方的接收速度才能继续增长。所以从这个逻辑看,其实TCP接收速度不增长的直接原因已经确定了,即:
本端通告接收窗口不增长
确定潜在问题点
那为什么接收窗口不增长呢? 按照计算机网络理论的介绍,至少在慢启动阶段,收到一个数据段,通告窗口怎么着也得增大一个或更多数据段才对。
结合刚才找到的几个跟平时不太一样的地方,直觉告诉我,本端通告的MSS 8961很可能是问题所在。这里的MSS来自于网卡的MTU减去从以太网到TCP每一层的协议开销,MSS是8961,说明网卡MTU更大。确认了下网卡的MTU
1 2 3 |
[root@ip-172-31-16-8 centos]# ifconfig eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 9001 |
网卡MTU是9001,比1500大。经过确认这是开启了巨帧特性。具体参见AWS的文档说明。
这个巨帧特性的好处是在都具有巨帧特性的机器间,可以以更少的包数量传输数据,降低机器以及网络设备的性能开销,但是可能会带来兼容性问题
尝试解决
因此尝试解决的办法也很直接,调整mtu到1500。
1 2 |
ifconfig eth0 mtu 1500 |
再次测试,果然问题解决! 下载速度很快就提升上去了。
最后实际还多做了一步,就是关闭了网卡的GRO功能
1 2 |
/sbin/ethtool -K eth0 gro off |
做这一步的原因是,TCP的发送速度增长的逻辑实际上是由ACK驱动的,而GRO功能导致在接收端网卡将多个收到的TCP包合成一个,这也是我们在收到大于1500大小的TCP数据段的根本原因。
GRO的本意是为了节省CPU,但是我们不是那种大型数据中心,而且单个TCP连接上的最高速率也不会到上百兆的程度,节约CPU对我们实际意义不大。
GRO因为把TCP数据包合并了,导致实际上发送给对端的ACK变少了,而且是量级的变少。由之前每MSS发送一个,几乎变成了Linux与与网卡交互的最短周期发送一个。在我这里的场景下,实际上在慢启动阶段ACK少了一半还多。这降低了高RTT的公网下提速的可靠性。这里选择更多发送ACK,更为合适。
溯源
问题虽然解决了,但是总要搞清楚根本原因才行!
我们看一下调整MTU到1500后的抓包结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
34576 → 8080 [SYN] Seq=0 Win=64240 Len=0 MSS=1460 SACK_PERM=1 WS=128 34576 → 8080 [ACK] Seq=1 Ack=1 Win=64256 Len=0 34576 → 8080 [PSH, ACK] Seq=1 Ack=1 Win=64256 Len=40 34576 → 8080 [ACK] Seq=41 Ack=43 Win=64256 Len=0 34576 → 8080 [PSH, ACK] Seq=41 Ack=43 Win=64256 Len=19 34576 → 8080 [ACK] Seq=60 Ack=2939 Win=61440 Len=0 ...(省略一堆数据包) 34576 → 8080 [ACK] Seq=79 Ack=30451 Win=81536 Len=0 34576 → 8080 [ACK] Seq=79 Ack=33347 Win=87296 Len=0 34576 → 8080 [ACK] Seq=79 Ack=34795 Win=90240 Len=0 34576 → 8080 [ACK] Seq=79 Ack=37691 Win=95104 Len=0 ...(省略一堆数据包) 34576 → 8080 [ACK] Seq=79 Ack=57662299 Win=428672 Len=0 34576 → 8080 [ACK] Seq=79 Ack=57670987 Win=446080 Len=0 34576 → 8080 [ACK] Seq=79 Ack=57699947 Win=417152 Len=0 34576 → 8080 [ACK] Seq=79 Ack=57702579 Win=451328 Len=0 34576 → 8080 [RST, ACK] Seq=79 Ack=57702579 Win=451328 Len=0 |
可以看到这次MTU调整为1500后,收到一个长度1446
的数据包,公告窗口增大了2*1472
。
为何在本地MSS为8961的情况下,收到长度为1446或2896的数据包不能让窗口增大呢?
鉴于自己直接看linux内核代码的效率不够高,搜索了下关于linux TCP协议栈的别人的解析。关于公告窗口增长的逻辑,有两篇文章讲到比较到位
相关影响的因素有,「接收到包的尺寸」,「本地MSS尺寸」,「TCP接收缓冲区」。其中比较关键的问题在于,linux在接收到尺寸小于本地MSS的包时,选择了一个绝对安全的处理方法,不增长窗口。
我们重新明确下一些基本情况。
- 网卡MTU为9001,因此MSS为8961
- linux内核参数
net.ipv4.tcp_rmem
值为4096 131072 4194304
,即默认TCP连接的读缓冲区会分配131072字节的内存 - 一开始,MSS为8961,公告窗口为62727
目标机器的Linux内核是5.8.14,实际这个TCP连接的初始接收窗口是62727/8961=7
段,比我之前在路由表上配置的initrwnd=16的值要小,结合默认的接收缓冲区是131072,另外加上为协议栈预留的1/2的空间,这代表一件事情:此时因MSS过大, 受限于接收缓冲区尺寸,rcv_wnd的初始值小于预设值。
随着连接建立,已经公告出去的容纳7*8961
的67227字节的窗口,已经耗尽了预分配的接收缓冲区。根据linux的估计,只有在接收到长度为大于等于8961或只比8961小一点点的数据段,公告窗口才有可能增长。而现状是「Linux接收到的数据段比公告MSS小太多,无法保证按照大MSS预先申请的(足够容纳公告出的窗口的数据+每包协议栈开销的)TCP接收缓冲区可以装的下包变小后,多出包的额外协议栈开销,进而选择绝对安全的策略而不增长公告窗口」,并进而导致接收速度不增长。
另外还有一个点存在疑问,就是「TCP接收缓存如何增长」。因为如果TCP接收缓存及时增大,那窗口还是有机会增大。linux内核有一个地方
tcp_rcv_space_adjust
是负责调整调整接收缓冲区的,这里应当仔细搜一下或读一下看看为什么在问题场景下没有增长到足够大小
总结
至此问题的原因已经清楚,即过大的MTU配合相对大小不够的接收缓冲区尺寸,结合Linux本身接收小包时的安全策略,导致「接收窗口」不再增长,再导致结合大的RTT下,inflight的数据量变小,最终导致传输速度缓慢。
而处理方法也比较简单,即将巨帧的MTU9001直接调整回标准MTU 1500即可。另外为了提高提速的稳定性,这里关闭了网卡的GRO功能。
另外如果是一定要在MTU9001巨帧下解决窗口不增长的问题,可以通过调节默认TCP接收缓冲区到大于
MSS比值(8961/1460) * MSS大小(8961) * 初始接收窗口大小(一般为2倍initcwnd, 为20) * 协议栈开销占比的倒数(2)
,也可以达到效果。但这个解决方案需要默认TCP缓冲区巨大,不适合我的场景