在日常的互联网后台接口开发中,无论使用C、Java、PHP还是Golang,都不可避免地需要调用mysql、redis等组件获取数据,可能还需要进行一些rpc远程调用,或者调用一些其他的restfulapi。在这些调用的底层,基本都是使用TCP协议进行传输。这是因为在传输层协议中,TCP协议具有可靠连接、错误重传、拥塞控制等优点,因此目前比UDP应用更广泛。相信大家一定听说过,TCP也有一些缺点,就是老话说的开销稍大。但在各种技术博客中,只说成本高或成本小,很少有没有给出具体的量化分析。不客气,这都是废话,没有什么营养。想想我的日常工作,我想了解的是成本是多少。建立TCP连接需要多长时间,多少毫秒,或者多少微秒?甚至可以有一个粗略的定量估计吗?当然影响TCP耗时的因素还有很多,比如网络丢包等等。今天只分享我工作实践中比较高的各种情况。正常的TCP连接建立过程要弄清楚建立TCP连接需要多长时间,我们需要详细了解连接建立过程。在上一篇《图解Linux网络包接收过程》中,我们介绍了接收端是如何接收数据包的。数据包从发送端出来,经过网络到达接收端的网卡。接收网卡将数据包DMA到RingBuffer后,内核通过硬中断和软中断等机制进行处理(如果发送用户数据,最后会发送到socket的接收队列,用户进程将被唤醒)。在软中断中,当内核从RingBuffer中取出一个数据包时,在内核中用structsk_buff结构体表示(参见内核代码include/linux/skbuff.h)。数据成员是接收到的数据。当对协议栈进行逐层处理时,通过修改指针指向数据的不同位置,就可以找到每一层协议所涉及的数据。对于TCP协议包,在其Header中有一个重要的字段-flags。如下图所示:通过设置不同的标志位,将TCP报文分为SYNC、FIN、ACK、RST等类型。客户端通过connect系统调用发出SYNC、ACK等数据包,命令内核与服务器建立TCP连接。在服务器端,可能会收到很多连接请求,内核也需要用到一些辅助数据结构——半连接队列和全连接队列。下面看一下整个连接过程:在这个连接过程中,我们简单分析一下客户端发送SYNC包的耗时:客户端通常会通过connect系统调用发送一个SYN,这涉及到本地系统的CPU耗时调用和软中断的开销SYN传输到服务端:SYN从客户端网卡发出,开始“翻山越海,也翻人海……”,这是一个漫长的过程-远程网络传输服务器处理SYN包:内核通过软中断接收包,放入半连接队列,然后发送SYN/ACK响应。再次,CPU耗时开销SYC/ACK发送给客户端:SYC/ACK从服务器发出后,同样跨越千山万水到达客户端。又一次长途网络跋涉到client处理SYN/ACK:clientkernel收到包并处理SYN后,经过几个us的CPU处理后,发送一个ACK。同样是软中断处理开销ACK被传送到服务器:它和SYN包一样,然后被传送了几乎相同的距离。另一个长途网络跋涉服务器接收ACK:服务器端内核接收并处理ACK,然后从半连接队列中取出相应的连接,然后放入全连接队列。软中断CPU开销服务器端用户进程唤醒:唤醒正在被accpet系统调用阻塞的用户进程,然后从全连接队列中取出已建立的连接。上下文切换的CPU开销以上步骤可以简单分为两类:第一类是内核消耗CPU进行接收、发送或处理,包括系统调用、软中断和上下文切换。他们的耗时基本上是几个us左右。具体分析过程可以参考《一次系统调用开销到底有多大?》、《软中断会吃掉你多少CPU?》、《进程/线程上下文切换会用掉你多少CPU?》三篇文章。第二种是网络传输。当一个数据包从一台机器发出时,需要经过各种网线和各种交换机、路由器。因此,网络传输的耗时远高于本地CPU处理。根据网络的远近,一般在几毫秒到几百毫秒不等。.1ms等于1000us,所以网络传输耗时比双端CPU开销高1000倍左右,甚至更高可能是100000倍。因此,在建立正常TCP连接的过程中,一般可以考虑网络延迟。RTT是指数据包从一个服务器到另一个服务器的往返延迟时间。所以从全局来看,一个TCP连接的建立大概需要3次传输,再加上两边的一点CPU开销,加起来比RTT的1.5倍大一点点。但是从client的角度来说,只要发送了ACK包,内核就认为连接建立成功了。因此,如果在客户端计算建立TCP连接的耗时,只需要2次传输——也就是1个RTT多一点。(从服务器端来看也是一样,从收到SYN包到收到ACK,中间需要一个RTT时间。)TCP连接建立时的异常情况可以在上一节中看到。从客户端的角度来看,在正常情况下,一个TCP连接的总耗时大约是一个网络RTT的耗时。如果一切都这么简单,我想我这次的分享就没有必要了。事情并不总是那么美好,总会有意外发生。在某些情况下,可能会导致连接时网络传输时间消耗增加,CPU处理开销增加,甚至连接失败。下面说说我在网上遇到的各种坎坷。1)客户端耗时的connect系统调用失控。通常情况下,一次系统调用的耗时约为几个us(微秒)。然而在《追踪将服务器CPU耗光的凶手!》文章中,作者的其中一台服务器当时遇到了一个情况。某运维同学传达业务CPU不够用,需要扩容。当时的服务器监控如下:服务之前一直抗住2000qps左右,cpu的id??el一直在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保留更多的端口号,使用长连接。2)半/全连接队列已满如果在连接建立过程中有任何一个队列已满,客户端发送的syn或ack将被丢弃。客户端等待很久无果后,会发出TCPRetransmission重传。以半连接队列为例:你需要知道的是,上面的TCP握手超时重传时间是秒级的。也就是说,一旦服务器端的连接队列导致连接建立失败,至少需要一秒的时间才能建立连接。正常情况下,在同一个机房??的情况下,只有不到1毫秒,高出1000倍左右。尤其是对用户提供实时服务的程序,用户体验会受到很大的影响。如果重传后握手仍不成功,很可能用户等不及第二次重试,用户的访问会直接超时。还有一种更糟糕的情况,它可能还会影响到其他用户。如果使用进程/线程池模型提供服务,比如php-fpm。我们知道fpm进程被阻塞了。当它响应用户请求时,进程没有办法响应其他请求。假设你开启了100个进程/线程,在一定时间内有50个进程/线程卡在与redis或mysql服务器的握手连接上(注意:此时你的服务器是TCP连接的客户端)。在这段时间里,只有50个正常工作的进程/线程可以使用。而50个worker可能根本无法处理,这时候你的服务可能就拥堵了。如果持续时间稍长,可能会发生雪崩,整个服务可能会受到影响。既然后果可能这么严重,那么我们如何检查手头的服务是否因为半连接/全连接队列而满了呢?在客户端可以抓包看是否有SYNTCPRetransmission。如果偶尔出现TCPRetransmission,说明对应的服务器连接队列可能有问题。在服务器端,检查起来更方便。netstat-s可以查看当前系统全半连接队列导致的丢包统计,但是number记录的是总丢包。需要使用watch命令进行动态监控。如果在你的监控过程中以下数字发生变化,则说明当前服务器由于半连接队列满而出现丢包。您可能需要增加半连接队列的长度。$watch'netstat-s|grepLISTEN'8SYNstoLISTENsocketsignored对于全连接队列,查看方法类似。$watch'netstat-s|grepoverflowed'160timesthelistenqueueofasocketoverflowed如果您的服务因为队列已满而丢失数据包,一种方法是增加半/全连接队列的长度。在Linux内核中,半连接队列的长度主要受tcp_max_syn_backlog的影响,增加到一个合适的值即可。#cat/proc/sys/net/ipv4/tcp_max_syn_backlog1024#echo"2048">/proc/sys/net/ipv4/tcp_max_syn_backlog完整的连接队列长度是应用调用listen时传入的backlog和内核参数net.core.somaxconn两者中较小的一个。您可能需要同时调整您的应用程序和此内核参数。#cat/proc/sys/net/core/somaxconn128#echo"256">修改/proc/sys/net/core/somaxconn后,我们可以通过ss命令的Send-Q输出来确认最终的有效长度:$ss-nltRecv-QSend-QLocalAddress:PortAddress:Port0128*:80*:*Recv-Q告诉我们进程当前全连接队列使用长度。如果Recv-Q已经接近Send-Q,那么你可能不需要等待丢包,你应该准备增加你的全连接队列。如果增加队列后仍然有非常零星的队列溢出,我们暂时可以容忍。万一还有很长的时间要处理呢?另一种方式是直接报错,不要让客户端等待超时。例如将Redis、Mysql等后端接口的内核参数tcp_abort_on_overflow设置为1,如果队列已满,直接向客户端发送reset。告诉后端进程/线程不要痴心等待。这时客户端会收到“connectionresetbypeer”的错误。牺牲一个用户的访问请求总比让整个网站崩溃好。连接耗时测试我写了一个很简单的代码来统计在客户端创建一个TCP连接需要多长时间。
