问题背景:
我 们一个企业用户准备把线上业务从共享的mysql服务迁移到独立型mysql rds上。企业用户那边先搞了一个test版本到我们rds环境,发现网站响应时间从3s变为40s。由于是php应用,故我们让用户应用开启  xhprof调试后,看一次请求有1200多次mysql查询,对mysql的查询量非常大,并且请求的时间90%以上都花在了mysql上。而用户使用 共享性mysql和使用rds的区别,是用户runtime到rds之间多了一个proxy服务,通过proxy服务把用户请求代理到了rds。

问题跟踪:
1,由于用户使用共享mysql和rds的区别,就是一个proxy服务。故就把问题定位在proxy这个服务上。
2,使用tcpdump在proxy机器(15.212)上抓runtime(21.102)到proxy,proxy到rds(144.139)的数据包,具体如下:

网络40ms延迟问题_第1张图片

proxy收到runtime发过来的查询数据包到proxy给runtime ack确认,花费了40ms,这个时间太长。并且,proxy把runtime发的查询数据包从一个截断为了2个数据包。  
1,id为22953:runtime102发送select语句到proxy 212。数据包长度为296byte 时间:23.877515
2,id为22954:proxy 212发送了一部分select语句128byte到rds 139。时间花销(秒):23.877611-23.877515=0.000096
3,id为22955:proxy 212回 runtime 103的ack这一步时间花销(秒):23.917294-23.877611=0.039683(这次时间花销过大,一千次请求就是39s
4,id为22956:rds 139回proxy  212的ack。这一步时间花销(秒):23.918398-23.917294=0.001104
5,id为22957:proxy 212发送剩余部分select语句168byte到rds  139。这一步时间花销(秒):23.918415-23.918398=0.000017

3,又在proxy机器上进行了strace跟踪,结果如下:

11:00:57.923865 epoll_wait(7, {{EPOLLIN, {u32=12025792, u64=12025792}}}, 1024, 500) = 1
11:00:57.923964 recvfrom(8, "\237\0\0\0\3SELECT cat_id, cat_name, parent_id, is_show FROM `jiewang300`.`jw_category`WHERE parent_id = '628' AND is_show = 1 ORDER BY", 128, 0, NULL, NULL) = 128
11:00:57.924041 recvfrom(8, " sort_order ASC, cat_id ASC limit 8", 128, 0, NULL, NULL) = 35
11:00:57.924102 recvfrom(8, 0xb7b0b3, 93, 0, 0, 0) = -1 EAGAIN (Resource temporarily unavailable)
11:00:57.924214 epoll_wait(7, {{EPOLLOUT, {u32=12048160, u64=12048160}}}, 1024, 500) = 1
11:00:57.924340 sendto(9, "\237\0\0\0\3SELECT cat_id, cat_name, parent_id, is_show FROM `jiewang300`.`jw_category`WHERE parent_id = '628' AND is_show = 1 ORDER BY", 128, 0, NULL, 0) = 128
11:00:57.924487 sendto(9, " sort_order ASC, cat_id ASC limit 8", 35, 0, NULL, 0) = 35
11:00:57.924681 epoll_wait(7, {{EPOLLIN, {u32=12048160, u64=12048160}}}, 1024, 500) = 1
11:00:57.964162 recvfrom(9, "\1\0\0\1\4B\0\0\2\3def\njiewang300\vjw_category\vjw_category\6cat_id\6cat_id\f?\0\5\0\0\0\2#B\0\0\0F\0\0\3\3def\njiewang300\vjw_category\vjw_category\10cat_name\10", 128, 0, NULL, NULL) = 128

发现是proxy机器在write write read操作的时候,write write和read操作之间,epoll_wait了40ms才read到数据。

结论:
为什么延迟不高不低正好 40ms 呢?果断 Google 一下找到了答案。原来这是 TCP 协议中的 Nagle‘s Algorithm 和 TCP Delayed Acknoledgement 共同起作 用所造成的结果。
 
Nagle’s Algorithm 是为了提高带宽利用率设计的算法,其做法是合并小的TCP 包为一个,避免了过多的小报文的 TCP 头所浪费的带宽。如果开启了这个算法 (默认),则协议栈会累积数据直到以下两个条件之一满足的时候才真正发送出去:
1,积累的数据量到达最大的 TCP Segment Size
2,收到了一个 Ack
 
TCP Delayed Acknoledgement 也是为了类似的目的被设计出来的,它的作用就 是延迟 Ack 包的发送,使得协议栈有机会合并多个 Ack,提高网络性能。

如果一个 TCP 连接的一端启用了 Nagle‘s Algorithm,而另一端启用了 TCP Delayed Ack,而发送的数据包又比较小,则可能会出现这样的情况:发送端在等 待接收端对上一个packet 的 Ack 才发送当前的 packet,而接收端则正好延迟了 此 Ack 的发送,那么这个正要被发送的 packet 就会同样被延迟。当然 Delayed Ack 是有个超时机制的,而默认的超时正好就是 40ms。
 
现代的 TCP/IP 协议栈实现,默认几乎都启用了这两个功能,你可能会想,按我 上面的说法,当协议报文很小的时候,岂不每次都会触发这个延迟问题?事实不 是那样的。仅当协议的交互是发送端连续发送两个 packet,然后立刻 read 的 时候才会出现问题。
 
为什么只有 Write-Write-Read 时才会出问题,维基百科上的有一段伪代码来介绍 Nagle’s Algorithm:

if there is new data to send
  if the window 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

可以看到,当待发送的数据比 MSS 小的时候(外层的 else 分支),还要再判断 时候还有未确认的数据。只有当管道里还有未确认数据的时候才会进入缓冲区, 等待 Ack。

所以发送端发送的第一个 write 是不会被缓冲起来,而是立刻发送的(进入内层 的else 分支),这时接收端收到对应的数据,但它还期待更多数据才进行处理, 所以不会往回发送数据,因此也没机会把 Ack 给带回去,根据Delayed Ack 机制, 这个 Ack 会被 Hold 住。这时发送端发送第二个包,而队列里还有未确认的数据 包,所以进入了内层 if 的 then 分支,这个 packet 会被缓冲起来。此时,发 送端在等待接收端的 Ack;接收端则在 Delay 这个 Ack,所以都在等待,直到接 收端 Deplayed Ack 超时(40ms),此 Ack 被发送回去,发送端缓冲的这个 packet 才会被真正送到接收端,从而继续下去。

再看我上面的 strace 记录也能发现端倪,因为设计的一些不足,我没能做到把 短小的 HTTP Body 连同 HTTP Headers 一起发送出去,而是分开成两次调用实 现的,之后进入 epoll_wait 等待下一个 Request 被发送过来(相当于阻塞模 型里直接 read)。正好是 write-write-read 的模式。

那么 write-read-write-read 会不会出问题呢?维基百科上的解释是不会:
    “The user-level solution is to avoid write-write-read sequences on sockets. write-read-write-read is fine. write-write-write is fine. But write-write-read is a killer. So, if you can, buffer up your little writes to TCP and send them all at once. Using the standard UNIX I/O package and flushing write before each read usually works.”
我的理解是这样的:因为第一个 write 不会被缓冲,会立刻到达接收端,如果是 write-read-write-read 模式,此时接收端应该已经得到所有需要的数据以进行 下一步处理。接收端此时处理完后发送结果,同时也就可以把上一个packet 的 Ack 可以和数据一起发送回去,不需要 delay,从而不会导致任何问题。


解决办法:
在proxy服务上开启 TCP_NODELAY选项,这个选项的作用就是禁用 Nagle’s Algorithm。


plus:

tcpdump和strace结果对比:

可以看到strace结果中是proxy机器先write write两次,然后等待大概40ms。而tcpdump看到的则是proxy机器先write了一次到rds,等待40ms收到rds的ack,再write了剩余的数据到rds。strace这个结果是正常的,因为strace只能看到的是系统调用,实际上第二次write被缓冲了(strace是看不到的),等待了40ms后rds的ack到来,这第二次write才被真正的发送出去。这样strace就和tcpdump的结果一致了。