TCP 连接的时间开销(二)

上⼀节TCP 连接的时间开销(一)可以看到在客户端视⻆,在正常情况下⼀次TCP连接总的耗时也就就⼤约是⼀次⽹络RTT的耗时。如果所有的事情都这么简单,我想我的这次分享也就没有必要了。事情不⼀定总是这么美好,总会有意外发⽣。在某些情况下,可能会导致连接时的⽹络传输耗时上涨、
CPU处理开销增加、甚⾄是连接失败。现在我们说⼀下我在线上遇到过的各种沟沟坎坎。

TCP 连接建⽴时的异常情况

客户端connect系统调⽤耗时失控

正常⼀个系统调⽤的耗时也就是⼏个us(微秒)左右。某次运维同学转达过来说该服务CPU不够⽤了,需要扩容。当时的服务器监控如下图:
在这里插入图片描述
该服务之前⼀直每秒抗2000左右的qps,CPU的idel⼀直有70%+。怎么突然就CPU⼀下就不够⽤了呢。⽽且更奇怪的是CPU被打到⾕底的那⼀段时间,负载却并不⾼(服务器为4核机器,负载3-4是⽐较正常的)。 后来经过排查以后发现当TCP客户端TIME_WAIT有30000左右,导致可⽤端⼝不是特别充⾜的时候,connect系统调⽤的CPU开销直接上涨了100多倍,每次耗时达到了2500us(微秒),达到了毫秒级别。
在这里插入图片描述
在这里插入图片描述
当遇到这种问题的时候,虽然TCP连接建⽴耗时只增加了2ms左右,整体TCP连接耗时看起来还可接受。但是这⾥的问题在于这2ms多都是在消耗CPU的周期,所以问题不⼩。解决起来也⾮常简单,办法很多:修改内核参数net.ipv4.ip_local_port_range多预留⼀些端⼝号、改⽤⻓连接都可以。

半/全连接队列满

如果连接建⽴的过程中,任意⼀个队列满了,那么客户端发送过来的syn或者ack就会被丢弃。客户端等待很⻓⼀段时间⽆果后,然后会发出TCP Retransmission重传。拿半连接队列举例:
在这里插入图片描述
要知道的是上⾯TCP握⼿超时重传的时间是秒级别的。也就是说⼀旦server端的连接队列导致连接建⽴不成功,那么光建⽴连接就⾄少需要秒级以上。⽽正常的在同机房的情况下只是不到1毫秒的事情,整整⾼了1000倍左右。尤其是对于给⽤户提供实时服务的程序来说,⽤户体验将会受到较⼤影响。如果连重传也没有握⼿成功的话,很可能等不及⼆次重试,这个⽤户访问直接就超时了。

还有另外⼀个更坏的情况是,它还有可能会影响其它的⽤户。假如你使⽤的是进程/线程池这种模型提供服务,⽐如php-fpm。我们知道fpm进程是阻塞的,当它响应⼀个⽤户请求的时候,该进程是没有办法再响应其它请求的。假如你开了100个进程/线程,⽽某⼀段时间内有50个进程/线程卡在和redis或者mysql服务器的握⼿连接上了(注意:这个时候你的服务器是TCP连接的客户端⼀⽅)。这⼀段时间内相当于你可以⽤的正常⼯作的进程/线程只有50个了。⽽这个50个worker可能根本处理不过来,这时候你的服务可能就会产⽣拥堵。再持续稍微时间⻓⼀点的话,可能就产⽣雪崩了,整个服务都有可能会受影响。

既然后果有可能这么严重,那么我们如何查看我们⼿头的服务是否有因为半/全连接队列满的情况发⽣呢?在客户端,可以抓包查看是否有SYN的TCP Retransmission。如果有偶发的TCPRetransmission,那就说明对应的服务端连接队列可能有问题了。

在服务端的话,查看起来就更⽅便⼀些了。 netstat -s 可查看到当前系统半连接队列满导致的丢包统计,但该数字记录的是总丢包数。你需要再借助 watch 命令动态监控。如果下⾯的数字在你监控的过程中变了,那说明当前服务器有因为半连接队列满⽽产⽣的丢包。你可能需要加⼤你的半连接队列的⻓度了。

$ watch 'netstat -s | grep LISTEN'
 8 SYNs to LISTEN sockets ignored

对于全连接队列来说呢,查看⽅法也类似。

$ watch 'netstat -s | grep overflowed'
 160 times the listen queue of a socket overflowed

如果你的服务因为队列满产⽣丢包,其中⼀个做法就是加⼤半/全连接队列的⻓度。 半连接队列⻓度Linux内核中,主要受tcp_max_syn_backlog影响 加⼤它到⼀个合适的值就可以。

# cat /proc/sys/net/ipv4/tcp_max_syn_backlog
1024
# echo "2048" > /proc/sys/net/ipv4/tcp_max_syn_backlog

全连接队列⻓度是应⽤程序调⽤listen时传⼊的backlog以及内核参数net.core.somaxconn⼆者之中较⼩的那个。你可能需要同时调整你的应⽤程序和该内核参数。

# cat /proc/sys/net/core/somaxconn
128
# echo "256" > /proc/sys/net/core/somaxconn

改完之后我们可以通过ss命令输出的 Send-Q 确认最终⽣效⻓度:

$ ss -nlt
Recv-Q Send-Q Local Address:Port Address:Port
0 128 *:80 *:*

Recv-Q 告诉了我们当前该进程的全连接队列使⽤⻓度情况。如果 Recv-Q 已经逼近了Send-Q ,那么可能不需要等到丢包也应该准备加⼤你的全连接队列了。
如果加⼤队列后仍然有⾮常偶发的队列溢出的话,我们可以暂且容忍。如果仍然有较⻓时间处理不过来怎么办?另外⼀个做法就是直接报错,不要让客户端超时等待。例如将Redis、Mysql等后端接⼝的内核参数tcp_abort_on_overflow为1。如果队列满了,直接发reset给
client。告诉后端进程/线程不要痴情地傻等。这时候client会收到错误“connection reset by peer”。牺牲⼀个⽤户的访问请求,要⽐把整个站都搞崩了还是要强的。

TCP 连接耗时实测

我写了⼀段⾮常简单的代码,⽤来在客户端统计每创建⼀个TCP连接需要消耗多⻓时间。

<?php
$ip = {服务器ip};
$port = {服务器端⼝};
$count = 50000;
function buildConnect($ip,$port,$num){
	for($i=0;$i<$num;$i++){
		$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
		if($socket ==false) {
 			echo "$ip $port socket_create() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n";
 			sleep(5);
 			continue;
 		}
 		
 		if(false == socket_connect($socket, $ip, $port)){
 			echo "$ip $port socket_connect() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n";
 			sleep(5);
 			continue;
 		}
 		
 		socket_close($socket);
 } }
 
$t1 = microtime(true);
buildConnect($ip, $port, $count);
echo (($t2-$t1)*1000).'ms';

在测试之前,我们需要本机linux可⽤的端⼝数充⾜,如果不够50000个,最好调整充⾜。

# echo "5000 65000" /proc/sys/net/ipv4/ip_local_port_range

正常情况

注意:⽆论是客户端还是服务器端都不要选择有线上服务在跑的机器,否则你的测试可能会影响正常⽤户访问。

⾸先我的客户端位于河北怀来的IDC机房内,服务器选择的是公司⼴东机房的某台机器。执⾏ping命令得到的延迟⼤约是37ms,使⽤上述脚本建⽴50000次连接后,得到的连接平均耗时也是37ms。这是因为前⾯我们说过的,对于客户端来看,第三次的握⼿只要包发送出去,就认为是握⼿成功了,所以只需要⼀次RTT、两次传输耗时。虽然这中间还会有客户端和服务端的系统调⽤开销、软中断开销,但由于它们的开销正常情况下只有⼏个us(微秒),所以对总的连接建⽴延时影响不⼤。

接下来我换了⼀台⽬标服务器,该服务器所在机房位于北京。离怀来有⼀些距离,但是和⼴东⽐起来可要近多了。这⼀次ping出来的RTT是1.6~1.7ms左右,在客户端统计建⽴50000次连接后算出每条连接耗时是1.64ms。

再做⼀次实验,这次选中实验的服务器和客户端直接位于同⼀个机房内,ping延迟在0.2ms~0.3ms左右。跑了以上脚本以后,实验结果是50000 TCP连接总共消耗了11605ms,平均每次需要0.23ms。

线上架构提示:这⾥看到同机房延迟只有零点⼏ms,但是跨个距离不远的机房,光TCP握⼿耗时就涨了4倍。如果再要是跨地区到⼴东,那就是百倍的耗时差距了。线上部署时,理想的⽅案是将⾃⼰服务依赖的各种mysql、redis等服务和⾃⼰部署在同⼀个地区、同⼀个机房(再变态⼀点,甚⾄可以是甚⾄是同⼀个机架)。因为这样包括TCP链接建⽴啥的各种⽹络包传输都要快很多。要尽可能避免⻓途跨地区机房的调⽤情况出现。

连接队列溢出

测试完了跨地区、跨机房和跨机器。这次为了快,直接和本机建⽴连接结果会咋样呢?Ping本机ip或127.0.0.1的延迟⼤概是0.02ms,本机ip⽐其它机器RTT肯定要短。我觉得肯定连接会⾮常快,嗯实验⼀下。连续建⽴5W TCP连接,总时间消耗27154ms,平均每次需要
0.54ms左右。嗯!?怎么⽐跨机器还⻓很多?

有了前⾯的理论基础,我们应该想到了,由于本机RTT太短,所以瞬间连接建⽴请求量很⼤,就会导致全连接队列或者半连接队列被打满的情况。⼀旦发⽣队列满,当时撞上的那个连接请求就得需要3秒+的连接建⽴延时。所以上⾯的实验结果中,平均耗时看起来⽐RTT⾼很多。

在实验的过程中,我使⽤tcpdump抓包看到了下⾯的⼀幕。原来有少部分握⼿耗时3s+,原因是半连接队列满了导致客户端等待超时后进⾏了SYN的重传。
在这里插入图片描述
我们⼜重新改成每500个连接,sleep 1秒。嗯好,终于没有卡的了(或者也可以加⼤连接队列⻓度)。结论是本机50000次TCP连接在客户端统计总耗时102399 ms,减去sleep的100秒后,平均每个TCP连接消耗0.048ms。⽐ping延迟略⾼⼀些。这是因为当RTT变的⾜够⼩的时候,内核CPU耗时开销就会显现出来了,另外TCP连接要⽐ping的icmp协议更复杂⼀些,所以⽐ping延迟略⾼0.02ms左右⽐较正常。

结论

TCP连接建⽴异常情况下,可能需要好⼏秒,⼀个坏处就是会影响⽤户体验,甚⾄导致当前⽤户访问超时都有可能。另外⼀个坏处是可能会诱发雪崩。所以当你的服务器使⽤短连接的⽅式访问数据的时候,⼀定要学会要监控你的服务器的连接建⽴是否有异常状态发⽣。如果
有,学会优化掉它。当然你也可以采⽤本机内存缓存,或者使⽤连接池来保持⻓连接,通过这两种⽅式直接避免掉TCP握⼿挥⼿的各种开销也可以。

再说正常情况下,TCP建⽴的延时⼤约就是两台机器之间的⼀个RTT耗时,这是避免不了的。但是你可以控制两台机器之间的物理距离来降低这个RTT,⽐如把你要访问的redis尽可能地部署的离后端接⼝机器近⼀点,这样RTT也能从⼏⼗ms削减到最低可能零点⼏ms。

最后我们再思考⼀下,如果我们把服务器部署在北京,给纽约的⽤户访问可⾏吗?前⾯的我们同机房也好,跨机房也好,电信号传输的耗时基本可以忽略(因为物理距离很近),⽹络延迟基本上是转发设备占⽤的耗时。但是如果是跨越了半个地球的话,电信号的传输耗时我们可得算⼀算了。

北京到纽约的球⾯距离⼤概是15000公⾥,那么抛开设备转发延迟,仅仅光速传播⼀个来回(RTT是Rround trip time,要跑两次),需要时间 = 15,000,000 *2 / 光速 = 100ms。实际的延迟可能⽐这个还要⼤⼀些,⼀般都得200ms以上。建⽴在这个延迟上,要想提供⽤户能访问的秒级服务就很困难了。所以对于海外⽤户,最好都要在当地建机房或者购买海外的服务器。

[推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值