TCP连接耗时
开发过程中调用mysql, redis等组件获取数据或者执行rpc远程调用以及调用restful api的时候底层使用的是TCP协议。这是因为在传输层协议中,TCP协议具备可靠的连接,错误重传,拥塞控制等优点。
正常的TCP连接建立过程
在软中断中,当一个包被内核从RingBuffer
中摘下来的时候,在内核中是用struct sk_buff
结构体来表示的(参见内核代码include/linux/skbuff.h
)。其中的data成员是接收到的数据,在协议栈逐层被处理的时候,通过修改指针指向data的不同位置,来找到每一层协议关心的数据。
对于TCP协议包来说,它的Header中有一个重要的字段:flags,也就是标志位。
通过设置不同的标记为,可以将TCP包分成SYNC、FIN、ACK、RST
等类型。客户端通过connect
系统调用命令内核发出SYNC、ACK
等包来实现和服务器TCP连接的建立。在服务器端,可能会接收许许多多的连接请求,内核还需要借助一些辅助数据结构-半连接队列和全连接队列。看一下整个连接过程:
简单分析每一步的耗时:
- 客户端发出SYNC包:客户端一般是通过connect系统调用来发出SYN的,这里牵涉到本机的系统调用和软中断的CPU耗时开销
- SYN传到服务器:SYN从客户端网卡被发出,开始长途远距离的网络传输
- 服务器处理SYN包:内核通过软中断来收包,然后放到半连接队列中,然后再发出SYN/ACK响应。又是CPU耗时开销
- SYC/ACK传到客户端:SYN/ACK从服务器端被发出后,同样进行一次长途网络跋涉
- 客户端处理SYN/ACK:客户端内核收包并处理SYN后,经过几us的CPU处理,接着发出ACK。同样是软中断处理开销
- ACK传到服务器:和SYN包,一样,再经过几乎同样远的路,传输一遍。 又一次长途网络跋涉
- 服务端收到ACK:服务器端内核收到并处理ACK,然后把对应的连接从半连接队列中取出来,然后放到全连接队列中。一次软中断CPU开销
- 服务器端用户进程唤醒:正在被accpet系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。一次上下文切换的CPU开销
以上操作主要可以分为两类:
- 第一类是内核消耗CPU进行接收、发送或者是处理,包括系统调用、软中断和上下文切换。它们的耗时基本都是几个us左右
- 第二类是网络传输,当包被从一台机器上发出以后,中间要经过各式各样的网线、各种交换机路由器。所以网络传输的耗时相比本机的CPU处理,就要高的多了。根据网络远近一般在几ms~到几百ms不等。
1ms等于1000us,因此网络传输耗时比双端的CPU开销要高1000倍左右,甚至更高可能还到100000倍。所以,在正常的TCP连接的建立过程中,一般可以考虑网络延时即可。一个RTT指的是包从一台服务器到另外一台服务器的一个来回的延迟时间。所以从全局来看,TCP连接建立的网络耗时大约需要三次传输,再加上少许的双方CPU开销,总共大约比1.5倍RTT大一点点。不过从客户端视角来看,只要ACK包发出了,内核就认为连接是建立成功了。所以如果在客户端打点统计TCP连接建立耗时的话,只需要两次传输耗时-既1个RTT多一点的时间。(对于服务器端视角来看同理,从SYN包收到开始算,到收到ACK,中间也是一次RTT耗时)
TCP连接建立时的异常情况
在正常情况下一次TCP连接总的耗时也就就大约是一次网络RTT的耗时。但在某些情况下,可能会导致连接时的网络传输耗时上涨、CPU处理开销增加、甚至是连接失败。
客户端connect系统调用耗时失控
正常一个系统调用的耗时也就是几个us(微秒)左右。但当TCP客户端TIME_WAIT有30000左右,导致可用端口不是特别充足的时候,connect系统调用的CPU开销直接上涨了100多倍,会达到毫秒级别。
查看本机的端口内核参数配置:
1 | $ sudo sysctl -a | grep ip_local_port_range |
可以看到内核分配的端口其实就3万个左右,当端口快占满的时候(TIME_WAIT过多),端口的选择会十分耗时,且该段时间内cpu一直处于找空闲端口阶段,一直在占用CPU。
主要原因:临时端口选择过程是生成一个随机数,利用随机数在ip_local_port_range
范围内取值,如果取到的值在ip_local_reserved_ports
范围内 ,那就再依次取下一个值,直到不在ip_local_reserved_ports
范围内为止。原来临时端口竟然是随机撞出来的。也就是说假如就有range里配置了5W个端口可以用,已经使用掉了49999个。那么新建立连接的时候,可能需要调用这个随机函数5W次才能撞到这个没用的端口身上。
解决办法
保证可用临时端口的充裕,避免你的connect系统调用进入反复查找模式。正常端口充足的时候,只需要微秒几倍。但是一旦出现端口紧张,则一次系统调用耗时会上升到毫秒几倍,整整多出100倍。这个开销比正常tcp连接的建立吃掉的cpu时间(每个30usec左右)的开销要大的多。
修改方法:
1
2# vim /etc/sysctl.conf
net.ipv4.ip_local_port_range = 10000 65000可以考虑设置net.ipv4.tcp_tw_recycle和net.ipv4.tcp_tw_reuse这两个参数,避免端口长时间保守地等待2MSL时间。
- 参考https://blog.csdn.net/enweitech/article/details/79261439
半/全连接队列全满
如果连接建立的过程中,任意一个队列满了,那么客户端发送过来的syn或者ack就会被丢弃。客户端等待很长一段时间无果后,然后会发出TCP Retransmission重传。
TCP握手超时重传的时间是秒级别的。也就是说一旦server端的连接队列导致连接建立不成功,那么光建立连接就至少需要秒级以上。而正常的在同机房的情况下只是不到1毫秒的事情,整整高了1000倍左右。尤其是对于给用户提供实时服务的程序来说,用户体验将会受到较大影响。如果连重传也没有握手成功的话,很可能等不及二次重试,这个用户访问直接就超时了。
更坏的情况是,它还有可能会影响其它的用户。假如使用的是进程/线程池这种模型提供服务,比如php-fpm。fpm进程是阻塞的,当它响应一个用户请求的时候,该进程是没有办法再响应其它请求的。假如开了100个进程/线程,而某一段时间内有50个进程/线程卡在和redis或者mysql服务器的握手连接上了(注意:这个时候服务器是TCP连接的客户端一方)。这一段时间内相当于可以用的正常工作的进程/线程只有50个了。而这个50个worker可能根本处理不过来,这时候服务可能就会产生拥堵。再持续稍微时间长一点的话,可能就产生雪崩了,整个服务都有可能会受影响。
解决办法
查看服务是否有因为半/全连接队列满的情况发生。在客户端,可以抓包查看是否有SYN的TCP Retransmission。如果有偶发的TCP Retransmission,那就说明对应的服务端连接队列可能有问题了。
在服务端的话,查看起来就更方便一些。netstat -s
可查看到当前系统半连接队列满导致的丢包统计,但该数字记录的是总丢包数。需要再借助watch
命令动态监控。如果下面的数字在监控的过程中变了,那说明当前服务器有因为半连接队列满而产生的丢包。可能需要加大半连接队列的长度。
1 | $ watch 'netstat -s | grep LISTEN' |
对于全连接队列来说,查看方法也类似:
1 | $ watch 'netstat -s | grep overflowed' |
如果服务因为队列满产生丢包,其中一个做法就是加大半/全连接队列的长度。 半连接队列长度Linux内核中,主要受tcp_max_syn_backlog
影响 加大它到一个合适的值就可以。
1 | # cat /proc/sys/net/ipv4/tcp_max_syn_backlog |
全连接队列长度是应用程序调用listen
时传入的backlog
以及内核参数net.core.somaxconn
二者之中较小的那个。可能需要同时调整应用程序和该内核参数。
1 | # cat /proc/sys/net/core/somaxconn |
改完之后可以通过ss
命令输出的Send-Q
确认最终生效长度:
1 | $ ss -nlt |
Recv-Q
告知当前该进程的全连接队列使用长度情况。如果Recv-Q
已经逼近了Send-Q
,那么可能不需要等到丢包也应该准备加大全连接队列。
如果加大队列后仍然有非常偶发的队列溢出的话,可以暂且容忍。如果仍然有较长时间处理不过来怎么办?另外一个做法就是直接报错,不要让客户端超时等待。例如将Redis、Mysql等后端接口的内核参数tcp_abort_on_overflow
为1。如果队列满了,直接发reset给client。告诉后端进程/线程不要痴情地傻等。这时候client会收到错误“connection reset by peer”。牺牲一个用户的访问请求,要比把整个站都搞崩了还是要强的。
实际测试
参考张彦飞的博客.可以得出的结论主要有:
TCP连接建立异常情况下,可能需要好几秒,一个坏处就是会影响用户体验,甚至导致当前用户访问超时都有可能。另外一个坏处是可能会诱发雪崩。所以当服务器使用短连接的方式访问数据的时候,一定要学会要监控服务器的连接建立是否有异常状态发生。如果有,学会优化掉它。当然可以采用本机内存缓存,或者使用连接池来保持长连接,通过这两种方式直接避免掉TCP握手挥手的各种开销也可以。
当连接队列溢出时需要特别注意:一旦发生队列满,当时撞上的那个连接请求就得需要长时间的连接建立延时。
正常情况下,TCP建立的延时大约就是两台机器之间的一个RTT耗时,这是避免不了的。但是可以控制两台机器之间的物理距离来降低这个RTT,比如把要访问的redis尽可能地部署的离后端接口机器近一点,这样RTT也能从几十ms削减到最低可能零点几ms。
线上部署时,理想的方案是将自己服务依赖的各种mysql、redis等服务和自己部署在同一个地区、同一个机房(再变态一点,甚至可以是甚至是同一个机架)。因为这样包括TCP链接建立啥的各种网络包传输都要快很多。要尽可能避免长途跨地区机房的调用情况出现。