引言
为了让大家对三次握手有个更深入的理解,这里选取了一个经典的案例。
案例
Java 的客户端与服务端使用 socket 进行通讯。本例中,使用 NIO 发生了以下问题:
- 在客户端与服务端之间间歇的进行三次握手以创建连接,而监听套接字却没有任何响应。
- 问题同时出现在许多其他连接中。
- NIO 的 select 并没有摧毁重建,用到的总是第一个。
- 问题在程序启动的时候出现,并在此后断断续续。
三次握手
我们回顾一下三次握手的过程:
- 客户端发送 SYN 包给服务器以此初始化握手。
- 收到 SYN 之后,服务端响应 SYN-ACK 包。
- 最后,客户端发送 ACK 包给服务器,告知它收到了服务器发来的 SYN-ACK 包(此时,服务端已经通过端口 61270 与客户端建立连接)。
Three-Way-Handshake/01.jpg)
快速解决问题
根据问题现象分析,很像是当建立 TCP 连接时 TCP 已完成连接队列已经满了(我们稍后讨论它)。为了确认,我们通过以下命令查看一下队列的溢出信息 netstat -s | egrep 'listen'
:
1 | 667399 times the listen queue of a socket overflowed |
尝试了三次之后,发现该值在持续增加。到这里其实已经很明确了,服务器的已完成连接队列(accept 队列)已经溢出了。我们看下系统是怎么处理溢出问题的。
1 | [root@server ~]# cat /proc/sys/net/ipv4/tcp_abort_on_overflow |
当 tcp_abort_on_overflow
为 0 时,如果 accept 队列在三次握手时已经满了,那么服务端就会丢掉客户端发来的 ACK 包。也就说在服务端并没有建立好连接。
为了证实该问题与完成连接队列的相关性,我们把 tcp_abort_on_overflow
设置为 1。如果在握手的第三步时完成连接队列已满,那么服务端就会发送一个 reset 包给客户端,用以告知结束两边的握手以及连接(事实上,在服务端连接还并没有完成)。
继续测试,发现在客户端会出现很多 connection reset by peer
异常。这也就能得出结论,完成队列溢出导致客户端出现错误信息,这也帮我们快速定位了问题的关键部分。
通过查看 java 源码发现,listen 函数 backlog 参数的默认值为 50(该值控制着完成队列的大小)。提高该值的大小,再次运行程序,进行 12 小时的压力测试,我们发现,错误不再发生,溢出问题也不再递增。
这个例子很简单,该问题只是因为三次握手时完成队列被填满导致的,而只有进入到队列的,服务才能从 listen 转变为 accept。对于默认值只有 50 的 backlog 来说,溢出容易发生。一旦发生溢出,三次握手第三步,服务端就会忽略客户端发过来的 ACK 包。服务端将会定期重复第二步(发送 SYN-ACK 包给客户端)。如果连接没有进入队列,这就会导致一个异常。
尽管问题解决了,但是这并没有让人满意,接下来我们更深入的了解一下整个过程。
深入话题:TCP 握手机制与队列
Three-Way-Handshake/02.jpg)
如上图,有两个队列:
- SYN 队列(未连接队列)
- Accept 队列(已完成连接队列)
三次握手中,在服务端收到 SYN 包之后,服务端将连接信息放到 SYN 队列里,接着发送 SYN-ACK 包给客户端。
服务端收到客户端发送来的 ACK 包。如果 Accept 队列未满,那么将会从 SYN 队列中删除,并将它放到 Accept 队列中。或者按照 tcp_abort_on_overflow
指示执行。
此时,如果 Accept 队列已满而 tcp_abort_on_overflow
为 0,一段时间后,服务端就会再次发送一个 SYN-ACK 包给客户端(也就是,它会重复握手的第二步)。即使客户端超时很短,这也很容易导致客户端异常。
CentOS 下,第二步会重试 5 次。
1 | [root@server ~]# cat /proc/sys/net/ipv4/tcp_synack_retries |
一种新方法
以上的解决办法可能会让人感到困惑,有没有简单快速的方法解决这个问题呢?开始之前,我们先看几个有用的命令:
netstat –s
1
2
3[root@server ~]# netstat -s | egrep "listen|LISTEN"
667399 times the listen queue of a socket overflowed
667399 SYNs to LISTEN sockets ignored该例子中,有 667399 次 Accept 队列溢出。每几秒钟执行一次该命令,观察该次数是否递增,如果是,那么说明 Accept 队列肯定满了。
ss
1
2
3[root@server ~]# ss -lnt
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 50 *:3306 *:*这里,Send-Q 为 50 说明 3306 监听端口的 Accept 队列能接受的连接最多 50。Recv-Q 表示当前 Accept 队列使用了多少。
Accept 队列的大小依赖于
min(backlog, somaxconn)
。backlog 参数是创建 socket 时,listen 函数的第二个参数。somaxconn 则是一个系统级的参数。SYN 队列的大小依赖于
max(64, /proc/sys/net/ipv4/tcp_max_syn_backlog)
,不同的系统版本可能会有不同。📚 Tips
backlog 在 Linux 2.2 之后表示的是已完成三次握手但还未被应用程序 Accept 队列的长度。在这之前则表示未完成的连接可能增长到的最大队列长度。
netstat
Send-Q 以及 Recv-Q 信息也可以通过 netstat 命令显示。如果连接并非处于监听状态,Recv-Q 表示收到的数据仍然在缓存里并没有被程序读取。Recv-Q 对应的数值表示未读取的字节数。Send-Q 对应的数值表示发送队列中未被远程主机确认的字节数。
1
2
3
4
5
6[root@server ~]# netstat -tn
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp0 0 100.81.180.187:8182 10.183.199.10:15260 SYN_RECV
tcp0 0 100.81.180.187:43511 10.137.67.18:19796 TIME_WAIT
tcp0 0 100.81.180.187:2376 100.81.183.84:42459 ESTABLISHED有一点需要特别注意,通过
netstat -tn
命令展示的 Recv-Q 数据与 Accept 队列或者 SYN 队列无关。这里需要特别强调,以免与ss -lnt
展示的 Recv-Q 数据相混淆。下图
netstat -t
展示的 Recv-Q 已经积累了大部分数据,这些数据一般是由 CPU 处理失败导致的。
Three-Way-Handshake/03.jpg)
📚 Tips
LISTEN 状态: Recv-Q 表示的当前等待服务端调用 accept 完成三次握手的 listen backlog 数值,也就是说,当客户端通过 connect() 去连接正在 listen() 的服务端时,这些连接会一直处于这个 queue 里面直到被服务端 accept();Send-Q 表示的则是最大的 listen backlog 数值,这就就是上面提到的 min(backlog, somaxconn) 的值。
其余状态: Recv-Q 表示 receive queue 中的 bytes 数量;Send-Q 表示 send queue 中的 bytes 数值。
验证过程
为了验证以上描述的信息,将 backlog 大小设置为 10(当然,值越小,越容易溢出),继续运行测试程序。当客户端出现异常信息后,在服务端通过 ss 命令观察到的信息如下:
1
2
3Fri May 5 13:50:23 CST 2017
Recv-Q Send-Q Local Address:Port Peer Address:Port
11 10 *:3306 *:*我们看到监听在 3306 上的服务 Accept 队列最大为 10,但是在队列中已经有 11 个连接。那么肯定会有一个无法入队,并且发生溢出。同时,溢出的数量值也在逐渐增加。
Tomcat and Nginx 中的 Accept 队列大小
Tomcat 默认是瞬态连接,默认值为 100。
1 | [root@server ~]# ss -lnt |
Nginx 默认值为 511。
1 | [root@server ~]# sudo ss -lnt |
Nginx 运行在多进程模式下,所以这里有多个进程监听在 8085 端口下。这也意味着避免了上下文切换的开销,相应地提升了性能。
总结
一旦发生溢出,虽然 CPU 跟线程状态看起来很正常,但是压力指数很低。从客户端层面分析,响应时间很长,但是服务日志中,真实的服务时间很短。一些网络层框架如:JDK、Netty 由于 backlog 的值很小,这也可能导致性能问题。