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 -s | CLOSE_WAIT 不能堆积 |
| 内核丢包 | netstat -s \| grep "listen queue" | overflow 应该为 0 |
如果 listen queue overflow 在压测过程中递增,说明新连接被内核丢了——调大 somaxconn。
七、效果对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 单机 WebSocket 最大连接数 | 65535 | 50万+ |
| 10万连接时 epoll 遍历开销 | 38% CPU | 6% CPU(冷热分离) |
| so_backlog 丢连接 | 压测时丢 15% | 0 |
| 出站连接占用 fd | 230(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://www.jiangyi.space/articles/2026/06/26/1782026673881.html
公众号:服务端技术精选
评论