摘要

在压测我写的内网穿透工具时,遇到了一个很神奇的现象。客户端连接服务端,客户端当前连接已处于ESTABLISHED状态,但是服务端却没有该连接,并且程序也没有收到该连接的日志。

本文记录下该神奇现象的成因,以及背后的原理。

正文

一、原因定位

该问题是偶然发现的,起源于内网穿透在高并发时,DataProxyServer返回给User数据时,偶现数据丢失。 · Issue #8 · meethigher/tcp-reverse-proxy

复现步骤

  1. 开启TunnelServer
  2. 开启TunnelClient
  3. 开启自定义BackendServer。简单实现一个长连接TcpServer,连接进来之后,返回一段字符串即可。

BackendServer源码如下

java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetServer;

public class BackendServer {
    public static void main(String[] args) {
        Vertx vertx = Vertx.vertx();
        NetServer netServer = vertx.createNetServer();
        netServer.connectHandler(socket -> {
            socket.remoteAddress();
            socket.write(Buffer.buffer("SSH-2.0-OpenSSH_8.7")).onComplete(ar -> {
                System.out.println(socket.remoteAddress().toString() + " write " + ar.succeeded());
            });
        }).listen(23).onComplete(ar -> {
            if (ar.succeeded()) {
                System.out.println("Server started on port " + ar.result().actualPort());
            } else {
                System.err.println("Server failed to start");
                ar.cause().printStackTrace();
                System.exit(1);
            }
        });
    }
}

客户端与TunnelServer建立2000个长连接,会出现一种情况:客户端显示连接已经处于ESTABLISHED,但是服务端并没有该连接。

image-20250531205504146.png

那简单,tcp抓包。发现客户端和服务端的三次握手均成功了。

image-20250531205922562.png

那么,就得去了解三次握手建立连接的这个过程了。

image-20250531210851006.png

对于客户端来说,当三次握手后,连接就会进入ESTABLISHED。

对于服务端来说,当三次握手后,操作系统内核会将连接放入到全连接队列AcceptQueue,此时连接会进入ESTABLISHED。若放入失败,则丢弃该连接或者发送RST。如果accept()过慢、连接建立过快。就会出现连接丢失的问题。而客户端的连接却仍然处于ESTABLISHED。

解决办法如下

  1. 优化程序,提升accept()速度。
  2. 调大全连接队列AcceptQueue的参数值。临时立即生效sysctl -w net.core.somaxconn=5000 ,重启会失效。不过切记在Linux中,全连接队列的大小为min(backlog,net.core.somaxconn)
  3. 设置若超过AcceptQueue最大值,则向客户端发送RST中止连接。临时立即生效sysctl -w net.ipv4.tcp_abort_on_overflow=1,重启会失效。

但是第三个方法有点鸡肋,即使AcceptQueue满了,也不一定会触发。因此保险起见,还是调大AcceptQueue比较好。

二、简单复现

上面的复现步骤较为麻烦,我这边直接写一个更简单的示例。

源码meethigher/bug-test at close-wait-server

服务端10.0.0.10代码

java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.util.concurrent.locks.LockSupport;

public class AcceptQueueTest {
    public static void main(String[] args) throws Exception {
        /**
         * Windows与Linux的机制不同,使用Linux进行复现
         */
        ServerSocket serverSocket = new ServerSocket();
        // net.core.somaxconn=128,这时候全连接队列为min(5,net.core.somaxconn)=5,也就是说,同时只允许5个连接ESTAB
        // 实际由于Linux的延迟判定,可能会多于5个
        serverSocket.bind(new InetSocketAddress("0.0.0.0", 6666), 5);


        LockSupport.park();
    }
}

客户端10.0.0.20代码

batch_telnet.sh

sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash

# 连接数
connNum=20

# host:port 列表
target="10.0.0.10:6666"

for ((i = 0; i < $connNum; i++)); do
  host="${target%%:*}"
  port="${target##*:}"
  echo "telnet $host $port..."
  nohup ./telnet_keepalive.expect "$host" "$port" >/dev/null 2>&1 &
  echo "$!"
done


echo "all started"

telnet_keepalive.expect

sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/bin/env expect

# 获取命令行参数
set timeout -1
set host [lindex $argv 0]
set port [lindex $argv 1]

# 开始 telnet 连接
spawn telnet $host $port

# 保持连接:每隔一段时间发送一个空命令或心跳
while {1} {
    sleep 30
    # send "\r"
}

服务端启动服务,客户端执行./batch_telnet.sh

image-20250714004509538.png

上图,就出现了“客户端连接已建立,但服务端却说没连上。”

我们通过ss -nplt命令查看,可知全连接队列数5,等待accpet()的连接数6。这个命令的使用可以参考TCP与UDP的端口连接 - 言成言成啊

image-20250714005952148.png

已经超过了允许的最大全连接队列容量,因此后续的连接内核直接丢弃了,这就出现服务端不存在这个连接的现象。