0%

TCP(一)三次握手

引言

为了让大家对三次握手有个更深入的理解,这里选取了一个经典的案例。

案例

Java 的客户端与服务端使用 socket 进行通讯。本例中,使用 NIO 发生了以下问题:

  • 在客户端与服务端之间间歇的进行三次握手以创建连接,而监听套接字却没有任何响应。
  • 问题同时出现在许多其他连接中。
  • NIO 的 select 并没有摧毁重建,用到的总是第一个。
  • 问题在程序启动的时候出现,并在此后断断续续。

三次握手

我们回顾一下三次握手的过程:

  1. 客户端发送 SYN 包给服务器以此初始化握手。
  2. 收到 SYN 之后,服务端响应 SYN-ACK 包。
  3. 最后,客户端发送 ACK 包给服务器,告知它收到了服务器发来的 SYN-ACK 包(此时,服务端已经通过端口 61270 与客户端建立连接)。


快速解决问题

根据问题现象分析,很像是当建立 TCP 连接时 TCP 已完成连接队列已经满了(我们稍后讨论它)。为了确认,我们通过以下命令查看一下队列的溢出信息 netstat -s | egrep 'listen'

1
667399 times the listen queue of a socket overflowed

尝试了三次之后,发现该值在持续增加。到这里其实已经很明确了,服务器的已完成连接队列(accept 队列)已经溢出了。我们看下系统是怎么处理溢出问题的。

1
2
[root@server ~]#  cat /proc/sys/net/ipv4/tcp_abort_on_overflow
0

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 握手机制与队列



如上图,有两个队列:

  • 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
2
[root@server ~]# cat /proc/sys/net/ipv4/tcp_synack_retries
5

一种新方法

以上的解决办法可能会让人感到困惑,有没有简单快速的方法解决这个问题呢?开始之前,我们先看几个有用的命令:

  • 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 处理失败导致的。



📚 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
    3
    Fri 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
2
3
[root@server ~]# ss -lnt
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 100 *:8080 *:*

Nginx 默认值为 511。

1
2
3
4
[root@server ~]# sudo ss -lnt
State Recv-Q Send-Q Local Address:PortPeer Address:Port
LISTEN 0 511 *:8085 *:*
LISTEN 0 511 *:8085 *:*

Nginx 运行在多进程模式下,所以这里有多个进程监听在 8085 端口下。这也意味着避免了上下文切换的开销,相应地提升了性能。

总结

一旦发生溢出,虽然 CPU 跟线程状态看起来很正常,但是压力指数很低。从客户端层面分析,响应时间很长,但是服务日志中,真实的服务时间很短。一些网络层框架如:JDK、Netty 由于 backlog 的值很小,这也可能导致性能问题。


参考