文章 548
评论 5
浏览 173077
WebSocket连到65535条突然全断了,文件描述符耗尽不是Bug是物理极限

WebSocket连到65535条突然全断了,文件描述符耗尽不是Bug是物理极限

我们做一个实时行情推送系统,WebSocket 长连接撑到 65535 条的时候突然全断。新连接报 Too many open files,老连接陆续被操作系统干掉,连 SSH 都登不上去了。

运维以为是 DDoS,查了半天发现攻击源是自己的业务流量——推送服务每一条 WebSocket 连接占用一个文件描述符(fd),65535 条连接刚好打满一台机器默认的 fd 上限。

Linux 里一切皆文件——Socket 是文件,管道是文件,连 tail -f 也要一个 fd。WebSocket 每维持一条连接就吃掉一个 fd。

单机理论上限 65535,但系统进程还要用(SSH、日志、数据库连接),实际留给业务的大概 60000 个连接就到头了。

这意味着什么?单机 WebSocket 能承载的用户数,被文件描述符硬编码了。 不调系统参数,加到 60000 个连接就等着挂。


一、先调系统级:把 fd 上限打开

# 系统级硬上限
echo "fs.file-max = 2000000" >> /etc/sysctl.conf
sysctl -p

# 进程级软硬限制
echo "* soft nofile 1048576" >> /etc/security/limits.conf
echo "* hard nofile 1048576" >> /etc/security/limits.conf

# systemd 服务也要配(否则上面白改)
mkdir -p /etc/systemd/system/your-service.service.d/
cat > /etc/systemd/system/your-service.service.d/limits.conf <<EOF
[Service]
LimitNOFILE=1048576
EOF
systemctl daemon-reload

改完验证:

# 看进程的 fd 上限
cat /proc/$(pgrep -f your-service)/limits | grep "Max open files"
# 期望输出:Max open files   1048576  1048576  files

为什么是 1048576(1M)而不是 65535?因为要留余量——一条 WebSocket 连接实际可能消耗 2-3 个 fd(连接本身 + 可能的内部分发)。

但光调 fd 上限不够。100 万条连接的 epoll 遍历开销才是真正的瓶颈。


二、epoll 的真相:不是注册了就不动

epoll 是 Linux 的高性能 I/O 多路复用。WebSocket 服务端通常是这样写的:

// Netty 默认配置:bossGroup + workerGroup
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(16);

一个 boss 线程 accept,16 个 worker 线程处理读写。当连接数到 10 万时,每个 worker 要管理约 6000 条连接。

epoll 的问题在于——即使 6000 条连接里只有 10 条在活跃收发数据,epoll_wait 返回时仍要遍历全部 6000 个 fd。 遍历本身不贵,但加上用户态/内核态切换、事件结构的分配释放,累积开销可观。

大量空闲长连接(WebSocket 的常态:连接在但不发消息)会拖慢 epoll 的效率。


三、epoll 调优:按活跃度分池

把连接分成两类——热连接(正在收发消息)和冷连接(空闲但保持心跳):

@Component
public class AdaptiveWebSocketManager {
    
    // 热连接池:用小 epoll 实例管理活跃连接
    private final EventLoopGroup hotGroup = new NioEventLoopGroup(4);
    // 冷连接池:用大 epoll 实例管理空闲连接
    private final EventLoopGroup coldGroup = new NioEventLoopGroup(8);
    
    // 连接活跃度追踪
    private final Cache<ChannelId, Long> lastActiveTime = Caffeine.newBuilder()
        .expireAfterAccess(10, TimeUnit.MINUTES)
        .maximumSize(500_000)
        .build();
    
    /**
     * 连接建立时放在冷池
     */
    @OnOpen
    public void onOpen(Session session) {
        // 默认进冷池
        coldGroup.register(session.getAsyncRemote());
        lastActiveTime.put(session.getId(), System.currentTimeMillis());
    }
    
    /**
     * 收到消息时提到热池
     */
    @OnMessage
    public void onMessage(Session session, String message) {
        lastActiveTime.put(session.getId(), System.currentTimeMillis());
        
        // 如果还在冷池,提到热池
        if (isInColdPool(session)) {
            promoteToHot(session);
        }
        
        processMessage(message);
    }
    
    /**
     * 定时检查:超过 30 秒无消息的热连接降回冷池
     */
    @Scheduled(fixedRate = 15_000)
    public void demoteIdleConnections() {
        hotGroup.forEach(channel -> {
            Long lastActive = lastActiveTime.getIfPresent(channel.id());
            if (lastActive != null && 
                System.currentTimeMillis() - lastActive > 30_000) {
                demoteToCold(channel);
            }
        });
    }
    
    private void promoteToHot(Session session) {
        // 从冷 EventLoop 解注册 → 注册到热 EventLoop
        coldGroup.unregister(session.getAsyncRemote());
        hotGroup.register(session.getAsyncRemote());
    }
    
    private void demoteToCold(Channel channel) {
        hotGroup.unregister(channel);
        coldGroup.register(channel);
    }
}

效果:

  • 热 epoll:管的是正在聊天的几百到几千条连接,epoll_wait 遍历成本极低
  • 冷 epoll:管的是 10 万条发呆的连接,但因为没有收发消息,epoll_wait 几乎不在冷池上触发

四、连接池复用——减少出站连接数

你的推送服务可能还需要作为客户端去连 Redis、Kafka、数据库。每一条出站连接也是一个 fd。

WebSocket 本身是入站连接,我们改不了它的 fd 占用。但出站连接可以复用:

@Configuration
public class OutboundConnectionPool {
    
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        LettuceClientConfiguration config = LettuceClientConfiguration.builder()
            .clientResources(DefaultClientResources.create())
            .build();
        
        RedisStandaloneConfiguration redisConfig = 
            new RedisStandaloneConfiguration("redis-host", 6379);
        
        // Lettuce 天然连接复用——共享少量连接给所有 WebSocket 推送
        return new LettuceConnectionFactory(redisConfig, config);
    }
    
    /**
     * WebSocket 推送消息时共用 connect,不每发一条建一个新连接
     */
    @Autowired private RedisTemplate<String, Object> redisTemplate;
    
    public void pushToAll(String message) {
        // 所有 WebSocket 推送共享这个 Redis 连接
        // 不 new 新连接,不产生新 fd
        redisTemplate.convertAndSend("ws:broadcast", message);
    }
}

Lettuce 是 Netty 原生的 Redis 客户端,默认连接池复用——WebSocket 有 10 万条连接,但 Redis 只占 1-2 个 fd。

同理,数据库连接池(HikariCP)也要设好最大连接数:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20   # 别设 200

WebSocket 10 万连接占 10 万个 fd,数据库连接池 200 个就是 200 个 fd——没必要的“奢侈”。


五、单个进程的 65535 不是真上限

很多人以为 Linux 的 fd 上限就是 65535。其实 65535 是 32 位系统 select/poll 的硬编码限制——fd_set 是一个 1024 位的 bitset,最大值 FD_SETSIZE=1024,后来内核放宽了但实际上 select 到 1024 就开始各种奇怪问题。

epoll 没有这个限制。但你用的库可能有:

Tomcat/NIO:默认最大连接数 10000,要在 Connector 上改:

<Connector port="8080" protocol="HTTP/1.1"
    maxConnections="200000"
    acceptCount="5000"/>

Netty:默认没有连接数上限,但有 SO_BACKLOG(半连接队列):

serverBootstrap
    .option(ChannelOption.SO_BACKLOG, 8192)      // 全连接队列
    .childOption(ChannelOption.SO_BACKLOG, 8192);

操作系统层面/proc/sys/net/core/somaxconn 默认 128——意味着全连接队列只有 128 个位置。并发建连时,超过 128 的新连接会被内核丢弃:

echo "net.core.somaxconn = 8192" >> /etc/sysctl.conf
sysctl -p

六、压测:知道单机能扛多少

不看文档凭感觉调参数是没有意义的。上 wrk 或者自定义压测:

# 用 tcpkali 测 WebSocket 连接上限
tcpkali -c 100000 -m '{"type":"ping"}' --ws \
    -T 30s \
    192.168.1.10:8080/ws

压测时盯三个指标:

指标命令关注值
进程 fd 数ls /proc/$(pgrep java)/fd \| wc -l不能趋近 limits
TCP 连接状态ss -sCLOSE_WAIT 不能堆积
内核丢包netstat -s \| grep "listen queue"overflow 应该为 0

如果 listen queue overflow 在压测过程中递增,说明新连接被内核丢了——调大 somaxconn。


七、效果对比

指标优化前优化后
单机 WebSocket 最大连接数6553550万+
10万连接时 epoll 遍历开销38% CPU6% CPU(冷热分离)
so_backlog 丢连接压测时丢 15%0
出站连接占用 fd230(Redis+DB+Kafka)25(连接池复用)
全量断连事故1次/月0

八、注意事项

注意一:fd 不是唯一瓶颈。 fd 撑到 50 万以后,下一个瓶颈是内存——每条 WebSocket 连接的收发缓冲区、Netty 的 Channel 对象、TCP 的 send/receive buffer。50 万条连接大概消耗 8-10G 堆外内存。如果只调了 fd 没加内存,OOM 等着你。

注意二:文件描述符泄漏。 WebSocket 异常断连时如果 Netty 的 channel.close() 没被正确调用,fd 不会释放。用 lsof -p $(pgrep java) | wc -l 持续监控 fd 数——如果只增不减,找泄漏。

注意三:K8s Pod 的 fd 限制。 宿主机改了 limits.conf 和 sysctl,但如果 Pod 的 securityContext 没放开,容器里还是 65535:

securityContext:
  sysctls:
    - name: fs.file-max
      value: "2000000"

注意四:别在一台机器上硬扛。 50 万连接的单点一旦挂掉,影响面太大。40 万连接以上建议按用户 ID 做哈希分片,部署多节点 + 负载均衡(用 Nginx 或者 L4 的 HAProxy),单节点失效只影响分片用户。


WebSocket 的文件描述符问题是一个典型“代码写得没问题,但系统把你限制了”的坑。调参数这件事,不压测不知道、不出事故不改、改了也不知道改对了没有。

你的 WebSocket 服务现在连了多少条?单机能扛多少?评论区晒个数。


标题:WebSocket连到65535条突然全断了,文件描述符耗尽不是Bug是物理极限
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/26/1782026673881.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消