TCP连接的可靠传输依靠的是数据确认与重传机制。当发送方发送的数据在一定时间内没有得到对方的确认时,发送方就会认为发送的数据丢失而触发重传。这个等待的时间长度就是RTO(Retransmission TimeOut)。当然在快速重传等机制的作用下,很多情况下数据重传不需要真正等待RTO时间这么久,但在很多场景特别是单报文交互式的场景下,RTO仍然是报文重传的主要触发方式。
在RFC6298中,规定了RTO的计算方法。从原理上说,RTO是和TCP连接两端的报文往返交互时延(RTT)相关的,可以认为是RTT加上一些预留的抖动余量。因此RTO和RTT应该是在一个数量级上的。但事实上,不管是协议标准还是实际实现,RTO都会有一个很大的下限,例如在RFC6298中要求RTO不能小于1s;在linux内核协议栈中TCP_RTO_MIN一直被设置成200ms(200ticks/1000HZ)。显然这个最小RTO是远大于许多场景下的RTT的。在数据中心内部,两台物理节点间的RTT一般在100us作用,最多不会超过1ms;即使在公网环境下,国内跨省节点间的RTT也就是30-40ms,仍然远小于200ms/1000ms这个量级。这就导致在数据中心内部,一旦某次数据交互式请求遇到丢包,这次请求的时延就必然会超过200ms。这个结论显然让人有些难以接受,在平均时延只有100us的环境下,部分请求时延居然超过200ms,时间差距超出了2000倍?而这个问题显然不是硬件性能或软件处理能力的限制造成的,似乎只要人为缩短预定义的最小RTO就能大幅减小这种不必要的时延。
那么问题就来了,为什么RFC和主流的协议栈都没有缩小最小RTO值,仍然要设置成超过200ms呢?原因就在于TCP Delay-ACK(RFC1122)这个TCP协议栈广泛使用的性能优化特性。TCP Delay-ACK的目的是为了减少不必要的ACK来降低协议栈的性能开销和网络上的数据流量,其原理是让数据的接收方在收到数据报文后不立即发送ACK包确认,而是等待一段时间。如果接收方又收到了新的报文,或者是接收方有响应数据要发送,那么就可以把之前收到的报文ACK附带在这次的报文中,这样就省去了一次ACK包的发送。如果实在没有报文要发送,接收方才会再等待时间耗尽时发送ACK包。根据RFC1122,Delay ACK的等待时间最长不超过500ms,在Linux中这个时间大约是50ms但实际实现不保证实际时延是一个精确值。它也不是一个可以交互协商的值,因此理论上TCP连接双方都不知道对方的Delay ACK等待时长。因此,协议栈必须要将RTO设置为一个大于Delay ACK的时长来避免产生不必要的重传和性能损失。
通过上面的分析我们发现,RTO设置严重偏离RTT的根本原因是Delay ACK这个标准特性。连接双方都处于一个“我知道你会Delay ACK,但我不知道你啥时候Delay ACK,所以我就多等会”的状态。因此,如果让连接双方在连接建立时就互相约定Delay ACK是否开启,或者Delay ACK最多延迟多久,就可以打破这个双方互相猜测的状态,从而让RTO重新变成一个可度量、可计算的参数。
基于这个思路,google在2016/2017年提出了一种解决方案:https://datatracker.ietf.org/doc/html/draft-wang-tcpm-low-latency-opt-00
这个方案的原理就是在TCP三次握手时主动携带一个新的TCP选项——最大延迟确认时间(Maximum Ack Delay,MAD)。通过这个选项,双方就可以了解到对方的Delay ACK最多会延迟多久,从而计算出一个更精确的RTO值。Google提到他们公司内部已经使用了这个选项并将内部网络的RTO降到5ms。遗憾的是5年过去了,这个功能并没能进入linux upstream,在最新版本的linux内核中TCP_RTO_MIN仍然是200ms。
我们再具体考虑一下这个问题的解决方案和实际使用方式。
解决方案其实很简单:
- TCP建连前指定本端的DelayACK最长延时
- TCP连接双方在三次握手发送SYN、SYNACK报文时,携带一个TCP选项说明本端的Delay ACK最长延时
- 如果收到了对端的DelayACK延时选项,则本端的RTO可以就通过RTT+DelayACK+抖动时间的算法来计算,而不再需要取一个估计的最小值
但这个特性如何使用,DelayACK最长延时如何设置,其实也是一个问题。对于不同形式的网络应用来说,是否开启这个选项,或者这个选项的最优设置可能是不同的,需要综合考虑多种因素。如果设置的过大,那么计算出来的RTO和当前的最小RTO限制也没什么区别;如果设置的过小,又可能产生过多的额外ACK增加网络开销和负载。大体上我们可以把网络连接按接收方的交互模型和delayACK的实际生效情况分为几类来分析:
- 接收短请求,发送响应。这种情况下,DelayACK的主要用途是如果响应在DelayACK产生前发出,就可以减少一次ACK包的发送。因此DelayACK时延的设置和应用响应时延有关。如果业务响应时延很长(比如>50ms),那么DelayACK报文基本上每次都会发送,本来就没什么意义,那就可以将其直接关闭或者设置为一个很小的值。如果应用响应时延较短(例如<5ms),那就可以将DelayACK设为一个略大于业务时延的值,这样既能让DelayACK生效又能让对端RTO较小。如果应用响应时延不长不短,那就要根据连接的时延敏感性、这类连接的总数量、以及可能增加的DelayACK数量综合评估了,一般来说这种连接收发频率不高,可以将DelayACK设为较小值来减少对端RTO。
- 接收长请求,发送响应。如果请求由多个连续报文构成,那么DelayACK主要体现在每收到两个报文发一个ACK,这时DelayACK可以设为较小值,因为请求的报文是连续的,DelayACK的发出和时延无关。
- 发送请求,接收响应。这种情况下,本端接收响应后不会再发送请求(或者很久之后才发送),因此ACK是必然要发送的,可以将DelayACK设置为一个较小值。
- 仅接收数据,很少发送。这种连接一般用于音视频点播,数据收集/存储等场景,对于个别报文的RTO时延不太敏感,DelayACK还是可以设置为较大的值来减少ACK数量。其实这种连接上一般会有较多的连续数据,因此DelayACK超时触发的概率也不大。
- 双方非交互式收发。这种连接一般用于聊天(文本、音视频)场景。对于个别报文的RTO时延也不太敏感,DelayACK还是可以设置为较大的值来减少ACK数量。
从上面的分析可以看出,在不同的使用场景下DelayACK的作用和最佳配置是不同的,不存在一个通用的最佳配置。因此在一个通用的tcp协议栈中不能设置一个固定的全局DelayACK时延,需要提供配置接口供使用者设置一个适合使用场景的时延。如果协议栈用于内核等多用户多应用场景,还需支持更细粒度的配置能力,例如每netns、每连接单独配置。
因此,理想的支持DelayACK时延配置的方式是通过sysfs或ioctl接口提供netns级别的范围配置和开关,再通过setsockopt接口支持指定连接上的配置选项。通过这两种方式的结合可以在大部分复合使用场景下获得最佳的时延和性能。