TCP 是一个复杂的协议, 每个机制在带来优势的同时也会引入其他的问题. Nagel 算法和 delay ack 机制是减少发送端和接收端包量的两个机制, 可以有效减少网络包量, 避免拥塞. 但是, 在特定场景下, Nagel 算法要求网络中只有一个未确认的包, 而 delay ack 机制需要等待更多的数据包, 再发送 ACK 回包, 导致发送和接收端等待对方发送数据, 造成死锁, 只有当 delay ack 超时后才能解开死锁, 进而导致应用侧对外的延时高. 其他文字已经介绍了相关的机制, 已经有一些文章介绍这种时延的场景. 本文结合具体的 tcpdump 包, 分析触发 delay ack 的场景, 相关的内核参数, 以及规避的方案.
背景
给 Redis 加了一个 proxy 层, 压测的时候发现, 对写入命令, 数据长度大于 2k 后, 性能下降非常明显, 只有直连 Redis-server 的 1/10. 而 get 请求影响并不是那么明显.
分析
观察系统的负载和网络包量情况, 都比较低, 网络包量也比较小, proxy 内部的耗时也比较短. 无赖只能祭出 tcpdump 神奇, 果然有妖邪.
22 号 tcp 请求包, 42ms 后服务端才返回了 ack. 初步怀疑是网络层的延时导致了耗时增加. Google 和 km 上找资料, 大概的解释是这样: 由于客户端打开了 Nagel 算法, 服务端未关闭延迟 ack, 会导致延迟 ack 超时后, 再发送 ack, 引起超时.
原理
Nagel 算法, 转自维基百科
- if there is new data to send
- if the Windows size>= MSS and available data is>= MSS
- send complete MSS segment now
- else
- if there is unconfirmed data still in the pipe
- enqueue data in the buffer until an acknowledge is received
- else
- send data immediately
- end if
- end if
- end if
简单讲, Nagel 算法的规则是:
如果发送内容大于 1 个 MSS, 立即发送;
如果之前没有包未被确认, 立即发送;
如果之前有包未被确认, 缓存发送内容;
如果收到 ack, 立即发送缓存的内容.
延迟 ACK 的源码如下: net/ipv4/tcp_input.c
基本原理是:
如果收到的数据内容大于一个 MSS, 发送 ACK;
如果收到了接收窗口以为的数据, 发送 ACK;
如果处于 quick mode, 发送 ACK;
如果收到乱序的数据, 发送 ACK;
其他, 延迟发送 ACK
其他都比较明确, quick mode 是怎么判断的呢? 继续往下看代码:
影响 quick mode 的一个因素是 ping pong 的状态. Pingpong 是一个状态值, 用来标识当前 tcp 交互的状态, 以预测是否是 W-R-W-R-W-R 这种交互式的通讯模式, 如果处于, 可以用延迟 ack, 利用 Read 的回包, 将 Write 的回包, 捎带给发送方.
如上图所示, 默认 pingpong = 0, 表示非交互式的, 服务端收到数据后, 立即返回 ACK, 当服务端有数据响应时, 服务端将 pingpong = 1, 以后的交互中, 服务端不会立即返回 ack, 而是等待有数据或者 ACK 超时后响应.
问题
按照前面的的原理分析, 应该每次都有 ACK 延迟的, 为什么我们测试小于 2K 的数据时, 性能并没有受到影响呢?
继续分析 tcpdump 包:
按照 Nagel 算法和延迟 ACK 机制, 上面的交互如下图所示, 由于每次发生的数据都包含了完整的请求, 服务端处理完成后, 向客户端返回命令响应时, 将请求的 ACK 捎带给客户端, 节约一次网络包.
再分析 2K 的场景:
如下表所示, 第 22 个包发送的数据小于 MSS, 同时, pingpong = 1, 被认为是交互模式, 期待通过捎带 ACK 的方式来减少网络的包量. 但是, 服务端收到的数据, 并不是一个完整的包, 不能产生一次应答. 服务端只能在等待 40ms 超时后, 发送 ACK 响应包.
同时, 从客户端来看, 如果在发送一个包, 也可以打破已收数据 > MSS 的限制. 但是, 客户端受 Nagel 算法的限制, 一次只能有一个包未被确认, 其他的数据只能被缓存起来, 等待发送.
触发场景
一次 tcp 请求的数据, 不能在服务端产生一次响应, 或者小于一个 MSS
规避方案
只有同时客户端打开 Nagel 算法, 服务端打开 tcp_delay_ack 才会导致前面的死锁状态. 解决方案可以从 TCP 的两端来入手.
服务端:
关闭 tcp_delay_ack, 这样, 每个 tcp 请求包都会有一个 ack 及时响应, 不会出现延迟的情况. 操作方式: echo 1> /proc/sys.NET/ipv4/tcp_no_delay_ack 但是, 每个 tcp 请求都返回一个 ack 包, 导致网络包量的增加, 关闭 tcp 延迟确认后, 网络包量大概增加了 80%, 在高峰期影响还是比较明显.
2. 设置 TCP_QUICKACK 属性. 但是需要每次 recv 后再设置一次. 对应我们的场景不太适合, 需要修改服务端 Redis 源码.
客户端:
关闭 nagel 算法, 即设置 socket tcp_no_delay 属性. static
void _set_tcp_nodelay(int fd) { int enable = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void*)&enable, sizeof(enable)); }
避免多次写, 再读取的场景, 合并成一个大包的写; 避免一次请求分成多个包发送, 最开始发送的包小于一个 MSS, 对我们的场景, 把第 22 号包的 1424 个字节缓存起来, 大于一个 MSS 的时候, 再发送出去, 服务端立即返回响应, 客户端继续发送后续的数据, 完成交互, 避免时延.
来源: https://www.cnblogs.com/qcloud1001/p/10170254.html