这是上周在项目上遇到的一个问题,在内网把问题用英文分析了一遍,觉得挺有用的,所以在博客上打算再写一次。
问题是这样的:我们在当前的环境中,网络延迟 <1ms,服务的延迟是 2ms,现在要迁移到一个新的环境,新的环境网络自身延迟(来回的延迟,RTT,本文中谈到延迟都指的是 RTT 延迟)是 100ms,那么请问,服务的延迟应该是多少?
我们的预期是 102ms 左右,但是现实中,发现实际的延迟涨了不止 100ms,P99 到了 300ms 左右。
从日志中,发现有请求的延迟的确很高,但是模式就是 200ms, 300ms 甚至 400ms 左右,看起来是多花了几个 RTT。
接下来就根据日志去抓包,最后发现,时间花在了 TCP 本身的机制上面,这些高延迟的请求都发生在 TCP 创建连接之后。
首先是 TCP 创建连接的时间,TCP 创建连接需要三次握手,需要额外增加一个 RTT。为什么不是两个 RTT?因为过程是这样的:
1 2 3 4 |
+0 A -> B SYN +0.5RTT B -> A SYN+ACK +1RTT A -> B ACK +1RTT A -> B Data |
即第三个包,在 A 发给 B 之后,A 就继续发送下面的数据了,所以可以认为这第三个包不会占用额外的时间。
这样的话,延迟会额外增加一个 RTT,加上本身数据传输的一个 RTT,那么,我们能观察到的最高的 RTT 应该是 2 个 RTT,即 200ms,那么为什么会看到 400ms 的请求呢?
从抓包分析看,我发现在建立 TCP 连接之后,客户端并不是将请求一股脑发送给服务端,而是只发送了一部分,等到接收到服务端的 ACK,然后继续在发送,这就造成了额外的 RTT。看到这里我恍然大悟,原来是 cwnd 造成的。
cwnd 如何分析,之前的博文中也提到过。简单来说,这是 TCP 层面的一个机制,为了避免网络赛车,在建立 TCP 连接之后,发送端并不知道这个网络到底能承受多大的流量,所以发送端会发送一部分数据,如果 OK,满满加大发送数据的量。这就是 TCP 的慢启动。
那么慢启动从多少开始呢?
Linux 中默认是 10.
1 2 3 |
/usr/src/linux/include/net/tcp.h: /* TCP initial congestion window as per draft-hkchu-tcpm-initcwnd-01 */ #define TCP_INIT_CWND 10 |
也就是说,在小于 cwnd=10 * MSS=1448bytes = 14480bytes 数据的情况下,我们可以用 2 RTT 发送完毕数据。即 1 个 RTT 用于建立 TCP 连接,1个 RTT 用于发送数据。
下面这个抓包可以证明这一点,我在 100ms 的环境中,从一端发送了正好 14480 的数据,恰好是用了 200ms:
如果发送的数据小于 14480 bytes(大约是 14K),那么用的时间应该是一样的。
注意,图中虽然 TCP 在我手的时候,双方协商的 MSS 是 1460bytes,但是由于 TCP 的 Timestamps 会在 options 中占用 12 bytes,所以实际上发送的数据,payload 最大为 1448bytes. 在本文中,可以理解为实际的数据段的 maximum segment size 是 1448 bytes。
但是,如果多了即使 1 byte,延迟也会增加一个 RTT,即需要 300ms。下面是发送 14481 bytes 的抓包情况:
慢启动,顾名思义,只发生在启动阶段,如果第一波发出去的数据都能收到确认,那么证明网络的容量足够,可以一次性发送更多的数据,这时 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) 上的特点:
initcwnd
是一个路由链路上的特点,不是 by application 决定的;- 只有人才可能清楚整个链路的质量,所以这个选项只能由人 by route 设置。
所以现在只能 by route 设置。
我实验了一下,将 cwnd 设置为 40:
然后在实验,可以看到这时候,client 发送的时候,可以一次发送更多的数据了。
后记
现在看这个原因,如果懂一点 TCP,很快就明白其中的原理,很简单。
但是现实情况是,监控上只能看到 latency 升高了,但是看不出具体是哪一些请求造成的,只知道这个信息的话,那可能的原因就很多了。到这里,发现问题之后,一般就进入了扯皮的阶段:中间件的用户拿着监控(而不是具体的请求日志)去找平台,平台感觉是网络问题,将问题丢给网络团队,网络团队去检查他们自己的监控,说他们那边显示网络没有问题(网络层的延迟当然没有问题)。
如果要查到具体原因的话,需要:
- 先从日志中查找到具体的高延迟的请求。监控是用来发现问题的,而不是用来 debug 的;
- 从日志分析时间到底花在了哪一个阶段;
- 通过抓包,或者其他手段,验证步骤2 (这个过程略微复杂,因为要从众多连接和数据包中找到具体一个 TCP 的数据流)
我发现在大公司里面,这个问题往往牵扯了多个团队,大家在没有确认问题就出现在某一个团队负责的范围内的时候,就没有人去这么查。
我在排查的时候,还得到一些错误信息,比如开发者告诉我 TCP 连接的保持时间是 10min,然后我从日志看,1min 内连续的请求依然会有高延迟的请求,所以就觉得是 TCP 建立连接 overhead 之外的问题。最后抓包才发现明显的 SYN 阶段包,去和开发核对逻辑,才发现所谓的 10min 保持连接,只是在 Server 侧一段做的,Client 侧不关心这个时间会将 TCP 直接关掉。
幸好抓到的包不会骗人。
hahhh 最近怨气很大
cwnd之前看HPBN提到过https://hpbn.co/building-blocks-of-tcp/,好像也是建议修改默认值。不过那本书有一定年头了,可能有过时的部分。
“抓到的包不会骗人”,点赞!
膜拜大佬
请教一下博主:文章中的 MSS 为 1448, 但是看抓包中的 tcp mss 是 1460,所以 1448 是如何来的
是因为 TCP 的 option 占用了 12 bytes,我在原文中用斜体解释补充了。感谢指出,看的真仔细呢!
TCP 创建连接需要三次握手,需要额外增加一个 RTT tcp fast open 是不是就不多这个了
是一样的, 现在最后的ack也是没有延迟了
我理解是可以减少一个 RTT 的吧。因为有了 TCP Fast Open,那么在下一次连接的时候,第一个 SYN 就开始给服务器发送时数据了,Server 端回复的第一个 SYN+ACK 也带有数据,相当于一个 RTT 都不需要了。
参考:https://lwn.net/Articles/508865/
TCP 本身 RFC 793 中,SYN 就可以携带数据的,只不过只有完成 3 次握手之后(因为安全因素),这些数据才会交给应用层,所以还是有额外的 RTT 握手延迟。
TFO 是解决了这些安全问题,所以在未完成 3 次握手,数据就已经交给应用层了。所以 TFO 相比于传统 TCP,是可以减少一个 RTT 的。
回到我们的案例,这个用户的请求大约是 30K,响应也大约是 30K,而 cwnd 是双向的,即两端分别进行慢启动,所以,请求发送过来 +1 RTT,响应 +1 RTT,TCP 建立连接 +1 RTT,加上本身数据传输就有 1 RTT,总共 4RTT,就解释的通了。
有两点疑问:
1.这儿的30K应该理解为30KBit,如果按照cwnd 为10MSS 则数据刚好在一个RTT传完;
2.建立连接和数据传输各使用一个RTT表示认同,请求和响应还要用各用一个RTT能展开说明下?
我的理解两边可以同时发送请求,这样请求和响应就在一个RTT传完,总共耗时就2RTT不会是4TTT
1. 其实是想说 30KiB 不是 30Kbit,原文已修改。
2. 不是两边同时发送请求,这个是请求-响应,所以必须等客户端发送完成请求,服务端才能开始处理,返回响应。请求响应是1个 RTT,另外的2个 RTT 是 cwnd 慢启动带来的,请求会带来一个额外的 RTT,响应是一个额外的 RTT。