侧边栏壁纸
博主头像
christopher博主等级

Live and learn

  • 累计撰写 2 篇文章
  • 累计创建 1 个标签
  • 累计收到 1 条评论
标签搜索

目 录CONTENT

文章目录

TCP 内网下载慢速分析

christopher
2023-02-15 / 0 评论 / 0 点赞 / 1,294 阅读 / 5,571 字

经历过校招死记硬背八股文的朋友们,一定都背过 TCP 的各种名词。类似 慢启动拥塞避免拥塞窗口滑动窗口这类概念一定是背了又忘,忘了又背。

本篇,将会结合一次线上下载不满速的情况,对这些概念做一次深入的探索。

当然,对于某些名词可能会假设读者是有所认识,否则若要一一道来,那非得是扒拉着 RFC793 过一遍了。直接进入正题,介绍一下现场(背景)。

故事背景

线上服务多地多节点部署在广州、深圳、天津等城市。服务启动时,会从腾讯云对象存储(COS)拉取文件。文件是统一存放在 广州 地区的存储桶。

观察到的情况是,广深地区拉取文件的速度,相比天津要快上四五倍,见下图。

20230213153054.png

这很不对劲。腾讯云 COS 官网文档上说明,单个存储桶的带宽上限是 15Gbps,不论是哪个节点的速率都远远没有触及存储桶带宽上限。而内网走的都是专线,理论上每个节点的带宽都是一致的。怎么想都不应该唯独天津速度这么慢吧。

节点配额、带宽限速

找到服务容器的宿主机型号,查阅腾讯云后发现,内网带宽限速在 3Gbps。

规格 vCPU 内存(GB) 网络收发包(pps)(出+入) 队列数 内网带宽能力(Gbps)(出+入)
SA2.4XLARGE32 16 32 100万 4 3.0

IPerf3 测速

先排除 COS 的因素,针对天津、广州、深圳三个节点直接测速。

Linux 上,论测速工具,iPerf3 一定独占鳌头。广州的节点模拟 COS 服务,作为 Server,其他两个地区节点分别作为 Client。

可以使用 truncate -s 5G test.file 快速创建一个 5GB 的全 0 文件。

启动 Server 端

# -s 表示启动服务端
# -p 指定启动端口, 默认启动在 5201
# -i 表示日志打印间隔, 单位 (秒)
# -F 指定测速的文件
iperf3 -s -p 5201 -i 0.1 -F test.file

# 最简化的启动
iperf3 -s

Client 端拉取文件开启测速

# -c 表示启动客户端
# -R 表示拉取文件, 而非上传
iperf3 -c [server-ip] -R

多轮测速下来,结果很是符合预期:

地区 速率
广州 → 天津 452 Mbps
广州 → 深圳 2.95 Gbps

好家伙,压根儿不在一个数量级了。广深之间倒是跑满了带宽,广州 → 天津存在明显的慢速。

看着这三个地区发了会呆。明明是一样的配置,一样的内网带宽,为什么天津就比广深要慢这么多?这唯一存在的差异,也就是地理因素了吧。乍得一想,莫非是时延

时延对传输速率的影响

首先看看天津和深圳各自到广州的 ping 值

地区 ping
天津 → 广州 35.7 ms
深圳 → 广州 3.4 ms

基本 10 倍的时延。根据控制变量法,为了验证时延的影响,将深圳节点的延迟手动调整至与天津一致。这可以通过 tc 命令来实现。

# 为 eth1 网卡添加 35ms 的延迟
tc qdisc replace dev eth1 root netem latency 35ms

# 移除延迟
tc qdisc del dev eth1 root

手动添加延迟完成后,再次 ping 一下。可以看到已经有 35ms 的往返时间了。

202302131813192.png

接下来,再次测速 广州 → 深圳,并对比之前 广州 → 天津

地区 速率
广州 → 天津 452 Mbps
广州 → 深圳 350 Mbps

可以发现,广深的传输速率从 2.95 Gbps 降到 350 Mbps 的水平!基本上和 广州 → 天津 差不多。顿时感觉自己找到了结论:高时延会影响传输速率;正准备中场开可乐庆祝下,转念一想,记起了曾经背诵过的 TCP 各个理论,突然又觉得这非常的不合理。

时延真的会影响传输速率吗?

我敢肯定甚至几乎确信,我学过的 TCP 里,时延不会影响传输速率,这是两个维度的概念。

时延和传输速率

首先要明确这两个概念:

  • 时延(Latency):从一端主机到另一端主机的总耗时。这个概念受物理因素如光速、两端距离、中间跨过的路由等影响。通常来说,我们可以用 ping 来测试一端到另一端往返的时延。

  • 传输速率(Data transfer rate):通常理解的带宽。表示单位时间内,在数据传输系统设备之间传送比特,字符,或者块的平均值。

我们的下载速度,是和传输速率直接挂钩,和时延没有关系!

时延,只影响了 rtt(Round trip time)。背过的八股文里,rtt 会影响 TCP 三次握手,以及开始传播数据后双边 ACK 的效率。高时延意味着高 rtt,因为一端必须等另一端 SYN 后再 ACK,这是受到 rtt 影响的。

排除了时延带来的影响,意味着我们再次需要找其他的原因了。

其实仔细观察,不论是 广州 → 天津 还是 广州 → 深圳 (高时延),最后的速度都是稳定的。

202302132000112.png

这很像是被什么因素限速了,因为如果按照正常的 TCP 协议,不论是在指数型增长的 慢启动还是之后各种算法的 拥塞避免快重传 等阶段,都不会有如此稳定不变的速率。

tcpdump 抓包

唯有抓包,才能彻底分析出这里头的诡异了。在 Linux 上,我们可以使用 tcpdump 来抓取 tcp 包。这里指定 -s 65535 是为了将抓包的日志打包成 wireshark 可以读取的格式,后续导出到办公机上使用 wireshark 做进一步分析。

# -i 指定监听的网卡
# host 指定监听的 域名/ip
# -s 65535 是为了导出 wireshark 支持的格式
# -w 导出到指定文件
sudo tcpdump -i eth1 host [ip] -s 65535 -w gz-tj.log

接下来,再运行一次 广州 → 深圳广州 → 天津 两条链路的 iperf3 测试,这一次用 tcpdump 抓取了 tcp 包并导入到 wireshark 中。需要注意的是,抓包需要在 Server 侧进行。设定 Ip 如下

  • 广州 (Server): 10.0.0.1
  • 天津 (Client): 10.0.0.2
  • 深圳 (Client): 10.0.0.3

就 wireshark 的日志页面简单分析,算是回顾 TCP 的一些理论知识。导入 wireshark 后,会显示完整的 TCP 包传输日志。wireshark 贴心地将 SYN 包置灰,可以看到在 广州 → 天津 的传输过程中,天津 (10.0.0.2) 分别通过 5151851524 端口与 广州 (10.0.0.1:5201) 建立 TCP 连接。

202302141141626.png

查阅 iperf 的源码后发现,在 iperf_client_api.c#iperf_run_client 中,客户端会先后启动 ctrl_sckmsg_sck 两个 socket 与 Server 侧的端口建立连接,分别负责控制流数据流。事实上,FTP 也类似,一个 socket 负责控制流,另一个 socket 负责数据流。

Wireshark 日志分析

数据传输链路的全貌完整展现。简单标注 TCP 的三次握手,数据传送。

202302151449413.png

这张图里有许多可以深入研究的地方,我们稍微拓展延伸:

MTU、MSS、Len 和 Length 的关系

  • MTU (Maximum Transmission Unit),是链路层允许通过的最大数据包大小,这个值包括了 TCP 包头长度、IP 包头长度 以及 传输数据。可以通过 ifconfig 查看网卡的 MTU,一般来说 MTU 都是 1500 Bytes
  • TCP 包头大小。标准 TCP 头大小为 20Bytes。但是按照 RFC793 的规范,TCP 头的 0x20~0x24 位是 Data Offset 表示了该 TCP 头的大小。在我们测试的链路中,Data Offset 是 1000,也就是 TCP 头大小为 32Bytes.
    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • MSS (Maximum Segment Size),是 TCP 报文中,所能携带最大的载荷大小。按照 指示的 MSS=1460MSS = 1460,其实是根据 MSS=MTU(1500B)TCP(20B)IP(20B)=1460BMSS = MTU (1500B) - TCP (20B) - IP (20B) = 1460B 计算而来的。

在上图的 TCP 日志中,三次握手中,确定了本次会话的固定 MSS=min(MSSrcv,MSSsdr)=min(1424B,1460B)=1424BytesMSS = min(MSS_{rcv}, MSS_{sdr}) = min(1424B, 1460B) = 1424Bytes。换句话说,这次会话中,每个 TCP 包最大有效载荷为 1424Bytes。

可是,根据 来看,实际载荷仅为 1412Bytes,这中间的 12Bytes 上哪去了?

这是因为,TCP 还有一个 Options 字段,它在原来 20Bytes 的基础上,额外占用了 12Bytes 用于存放 Timestamps 等信息。

最终一张图总结 Header。

202302151135239.png

发送窗口图表分析

跑偏了,我们继续分析慢速的原因。在 wireshark 的菜单栏选择 Statistics → Tcp Stream Graphs → Window Scaling

图中蓝线表示发送端发送窗口一次发送的包大小,绿线表示接收端的接收窗口大小变化。

202302151156329.png

我们可以立即判断出,蓝线,也就是发送窗口,在 0.3 秒之前是符合 慢启动 标准的。它指数型上涨,每波发送间隔大约 35ms(正好是 广州 →天津 的 rtt),并且每隔一个 rtt 后发送窗口涨一倍。

但是在 0.3 秒后,发送窗口大小像是撞到了无形屏障似的,被死死固定在一条横线上。

为什么发送窗口不再增长了?

我想这个问题的答案,肯定是解决限速问题的关键。

发送窗口限制因素

对于发送端来说,有三样东西影响了自己的发送速率:

  1. 网络链路的传输能力
  2. 接收端的接收能力
  3. 发送端的发送能力

其实大半个 TCP 设计规范都在围绕 1 做文章,因为对于一次 TCP 会话,两端都没办法知道当前网络链路质量情况。网络链路质量的不透明,直接催生出 慢启动拥塞避免快重传快恢复 等一系列拥塞控制机制。一言以蔽之,几乎所有的拥塞控制,都是在不断地试探并且逼近网络链路所能承担的吞吐最大值。

想一想为什么慢启动 cwnd 要指数增长,为什么 cwnd 到达 ssthresh 后要触发拥塞避免,为什么发明了各类诸如 RENO,BIC,CUBIC,BBR 等拥塞避免算法。这都是因为 TCP 希望在通信过程时,从复杂且不透明的网络中,一点点将自己的传输能力提升到网络所能承担的最大值。

在广域网的世界里,相比 1 (网络链路能力)2 (接收端接收能力)3 (发送端发送能力) 并不太受到人们的关注,因为 2 & 3 实在是太简单,太稳定,太高效了。在广域网上,一个数据包可能要穿越过五湖四海(过太平洋的确是跨海了),路过数不尽的路由,冒着随时走丢的风险,历经千辛万苦才能来到接收端的门口。而发送端和接收端,仅仅需要与磁盘和内存打交道。

发送端和接收端的送客(揽客)效率,只受到网卡、内存、磁盘速率的影响。这个速度相比于网络链路上的传输速率来说,实在是过于稳定且高效了。正因为此,我们往往忽略了发送端、接收端对于传输速率的影响。

首先说结论,发送窗口的大小取自 min(cwnd,rwnd)min(cwnd, rwnd)而这次的问题,正是出在接收端 rwnd 上。

  • cwnd (Congestion Window),拥塞窗口,诞生于拥塞控制,是发送端为了趋近又不超过网络链路所能。cwnd 受到拥塞算法和发送缓冲区大小的限制。
  • rwnd (Receive Window),接收窗口,TCP 包头字段,表明自己的接收缓存大小,即自身还能接收多少数据。这个字段在上面 TCP 日志图中 有体现。

TCP RcvBuffer

在 Linux 中,TCP rwnd (Receive Window) 与 TCP receive buffer 有关。下图摘自 Computer Networking: Top Down Approach 6th edition。

202302151537313.png

rwnd 其实在每次 TCP 包头中都会传递,也就是 wireshark 日志中标记的 ,表示接受方当前剩余的 buffer 空间。

而 TCP receive buffer 受 Linux 内核中的 net.ipv4.tcp_rmemnet.core.rmem_maxnet.ipv4.tcp_adv_win_scale 三个参数影响。可以通过 sudo sysctl -a | grep "rmem" 来确认这三个参数的值。需要注意,net.ipv4.* 的优先级高于 net.core.*

TCP Linux manual page 中总结这几个参数:

参数 类型 说明
tcp_rmem [min, default, max] 用来调节接收缓冲区大小,分别表示接收缓冲区的最小、默认和最大值
tcp_moderate_rcvbuf Boolean 如果启用,将执行接收缓冲区自动调优,调整后的大小不会比 tcp_rmem[2] 更大;默认启用
tcp_adv_win_scale Integer 用于计算 rwnd,见下文;默认为 2
tcp_window_scaling Boolean 启用 rfc 1323 TPC 窗口缩放;默认启用
tcp_timestamps Integer 0 表示关闭;1 表示开启;开启后会在 TCP 包头多占用 12Bytes,默认 1

TCP receive buffer 其实并不直接等于 rwnd,rwnd 有一个计算公式:

rwnd=tcp_rmem×(112tcp_adv_win_scale)rwnd = tcp\_rmem \times (1- \frac{1}{2^{tcp\_adv\_win\_scale}})

在我的服务中,这两个内核参数的值分别为

net.ipv4.tcp_adv_win_scale = 1
net.ipv4.tcp_rmem = 8192	87380	4194304

由此可以计算出,默认会话中 rwndmax=4194304×(112)=2MBrwnd_{max}=4194304 \times (1-\frac{1}{2})=2MB。这个值,和发送窗口图表中的绿线最终稳定的值,一样。现在基本可以确定,接收端的内核接收内存大小参数是这次慢速的根本原因

请确认 net.ipv4.tcp_window_scaling 为 1,否则 rwnd 将会被限制在 64KB

Window Scaling

上文说到 rwnd 在不开启 net.ipv4.tcp_window_scaling 的情况下,会被限制在 64KB,那么这个 window scaling 参数是什么东西呢?

通过 TCP 报文规范,包头 Window 字段只有 16 位长度,意味着 Window 大小最大为 2161=65535B64KB2^{16} - 1 = 65535B ≈ 64KB。这个 64KB 的窗口大小放在 50 年前 TCP 诞生的时候恰恰好,但是放在带宽(特别是局域网)指数级增长的现在,已经完全不够用了。但是 TCP 的规范不能随意更改,只能另外扩展字段。因此在 RFC 7323 中提出,新增一个 14 位的 Window scaling 的字段,表示接收窗口的放大倍数。至此,接收窗口最大值被放大到 (2161)×2141GB(2^{16} - 1) \times 2^{14} ≈ 1GB

再次测速

为了验证 rwnd 对传输速率的影响,我们将 windows size 调大。可以通过 sudo sysctl -w net.ipv4.tcp_rmem="8192 87380 33554432" 将接收 buffer 调大 8 倍;如果是容器环境无法调整,iperf3 同样提供 -w 参数指定 windows size。

温馨提醒,tcp_rmem 和 rwnd 不相等。
iperf3 的 -w 参数是指直接设定 rwnd;
tcp_rmem 需要经过上文的公式计算得到最终的 rwnd

再次运行 广州 → 天津 的测速。

地区 速率
广州 → 天津 (rwnd=2MB) 452 Mbps
广州 → 天津 (rwnd=16MB) 2.89 Gbps

对劲儿!速度直接拉满了。再到 wireshark 中看看发送窗口图表。

202302151611058.png

可以看到 rwnd 一路指数上涨到 16MB 后稳定,而发送窗口在慢启动后,终于没有被平稳限制,最终因为带宽限制在 3Gbps 而不断上下波动。慢启动的有非常典型的行为,一个是 cwnd 指数上升,一个是间隔 rtt 后上升。由于 广州 → 天津 有 35ms 的时延,慢启动过程中的等待在图中还是清晰可见的。

其实细心的读者现在肯定有另一个疑问,深圳的节点也同样是 4MB 的接收内存,为什么没有被限速呢?在讨论这个问题前,我们再回过头来看看 时延

再谈时延——带宽时延积

随着带宽的增长,RFC 很快提出了带宽时延积(BDP, bandwidth-delay product)的概念。BDP 对于长肥网络来说是一个非常重要的概念,长肥网络是指 带宽 (Bandwidth) 与 时延 (Rtt) 乘积很大的网络。一般体现在跨城(海)专线或者卫星通信上,这类网络带宽非常大,同时时延也比较高。

BDP=Bandwidth(bits/s)×rtt(s)BDP = Bandwidth(bits/s) \times rtt(s)

我们假设以下的极端场景,接收端只有缓存 64KB 数据包的 RcvBuffer 大小,接收端与发送端的 RTT 是 1000ms。在 TCP 握手时,发送端就确认了接收端的接收窗口大小为 64KB。因此发送端一次只会发送 64KB 大小的数据包,在这之后发送端需要等待接收端的 ACK 回包,这就需要 1s。因此,整个链路的传输速率被死死限制在了可怜的 64KB/s。

而如果接收端的 RcvBuffer 提升到 64MB,那么这次 TCP 会话的传输速率起码提升了 1000 倍!

一般推荐窗口大小设置为 BDP。在我们的场景中,带宽是 3Gbps,rtt 是 35ms。可以计算出合适的窗口大小为 13MB 左右。

此时回过头来看看深圳的 2MB 接收缓存,在仅仅 3.5ms 的时延下,接收端 ACK 回包的速度很快,发送端能很快感应到并发送数据。

1676739463093.png

上图截取了发送端在发送 Seq 为 200000000 左右时的交互记录,可以看到 广州 → 深圳中,深圳回 ACK 的 Lag 在 985036Byte 差不多 960KB 左右。也就是说,发送端此刻认为 bytes-on-flight 为 960KB,深圳此刻的接收窗口为 2MB,所以发送端可以立马发送 1MB 的包过去。

反观 广州 → 天津,在 RcvBuffer 为 2MB 时,天津回 ACK 的 Lag 在 2060108Bytes 差不多 1.96MB。发送端此刻认为 bytes-on-flight 为 1.96MB,结合天津此刻的 2MB 接收窗口,发送端只会发送 40KB 的包。

而当 RcvBuffer 设置为 16MB 时,天津回 ACK 的 Lag 虽然有 6.55MB,但是发送端认为除了在路上的这 6.55MB,接收端的 16MB RcvBuffer 还能再收 9.45MB。所以立马,发送端会发送 9.45MB 的数据。

解决方案

分析这么一大通,根本原因在于接收缓冲区的大小设置过小,所以根据带宽调整缓冲区的大小即可。

这时候有的同学就要问了,如果容器环境,没有 privileged 权限,无法调整内核参数怎么办呢?

其实,接收缓冲区是 Socket 的概念,也就是对一次 TCP 会话生效。既然在一次 TCP 连接中因为接收缓冲区的大小导致限速,那么启动多个 Socket 连接就好了嘛。换句话说,用多线程结合 HTTP 的 Range 属性,分 Range 同时下载,这可是最简单的提速方式了。

总结

在网络链路简单纯粹的内网环境中,丢包重传的概率是很低的,因此大部分情况下都还没到拥塞避免的阶段。出现网络异常的问题,可以优先从链路双端开始排查。

本文主要讨论了接收端窗口设置过小时延过大的情况;而当发送端窗口过小时,又会发生什么呢,这个就留给读者们思考探索一下了。

当然,如果涉及到丢包启动拥塞避免算法,这又是另外一个长篇才能解决的问题了。

参考

  1. 优化 TCP 以提高 Google Cloud 和混合方案中的网络性能
  2. TCP性能和发送接收窗口、Buffer的关系
  3. How to Increase TCP Transmission Throughput Efficiency
  4. How to determine TCP initial window size and scaling option?
  5. 带宽时延乘积 - wikipedia
0

评论区