一粟 · yisu.app
· 14 min read
WebSocket

WebSocket 连接池优化:百万并发的连接治理

WebSocket 不是「长连接就完事」。本文分享一次从单机 3 万连接到 35 万的压测调优过程,包括 fd 限制、握手风暴、心跳分组、压缩算法等问题的工程方案。

为什么 WebSocket 是个不一样的挑战

HTTP 是「请求 - 响应」模型,每个连接服务一个请求。WebSocket 则是「长连接 - 双工」模型,每个连接要占用服务端 fd 与内存直到连接关闭。这种差别决定了 WebSocket 服务的容量规划与 HTTP 完全不同。

我在做某 IM 推送系统时,目标是「单机房承载 80 万 WebSocket 连接」。这是一个相当大的体量,对每一层都有挑战:

  • 内核:fd 上限、TCP 半连接队列。
  • 运行时:协程调度、内存占用、握手 storm。
  • 协议:心跳、分片、压缩。

文件描述符:机器上限的硬伤

默认 Linux 单进程 fd 上限是 1024,这是历史包袱。要承接 80 万连接,必须改:

cat /proc/sys/fs/file-max                 # 系统级 fd 上限
ulimit -n                                  # 单进程 fd 上限

# 修改 /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
# 注意:必须改 systemd.conf 的 LimitNOFILE

接下来要让进程 fd 上限真的生效,还需要重新登录或重启后由 systemd 注入。这是很多团队踩过的坑。

握手风暴:连接建立那 200ms

真实世界的 80 万连接不是「慢慢增长」而是「秒杀瞬间涌入」。这带来握手风暴:

  • TLS 握手成本(每次 2–5ms CPU)。
  • 升级协议 Upgrade 请求的握手。
  • 认证(JWT / Cookie / 风控)。

一次 80 万的连接涌入在 5 分钟内完成,相当于每秒 2700 个。优化方案:

  1. TLS 会话票据复用(Session Ticket)。
  2. 用 ECDHE + ChaCha20 减少握手计算。
  3. 认证降级为「连接后 first packet」承载,而不是握手阶段。

协程模型:每连接 1 协程 vs N:M

Go 的 goroutine 看起来很轻量,但每个连接叠加 net/conn 缓冲区还是几十 KB。80 万 × 50KB ≈ 40GB 内存,这是不可接受的。

优化手段:

  • 使用 epoll / io_uring 单线程多连接模型。
  • 协程池(goroutine pool)限制单实例 goroutine 数量。
  • 在 Rust / C++ 服务里用 tokio 的 per-thread reactor。
// Go 中用 worker pool 限制协程数
var sem = make(chan struct{}, 1000)  // 最多 1000 个写协程

func write(conn *websocket.Conn, msg []byte) {
    sem <- struct{}{}
    defer func() { <-sem }()
    conn.WriteMessage(websocket.BinaryMessage, msg)
}

心跳:最容易踩的几个坑

WebSocket 必须有心跳,否则 NAT 表老化或代理超时会让连接「假死」。但心跳本身有几个坑:

  • 心跳太频繁:手机端耗电与流量突增。
  • 心跳没分组:所有连接同一时刻 PING,服务端瞬时 CPU 高峰。
  • 心跳协议不一致:服务端发 PING、客户端回 PONG,但丢失时客户端不退出。
心跳设计的本质是「统计学」:让客户端的失活事件能在 P95 时间内被服务端识别。

压缩:permessage-deflate

WebSocket 子协议支持 permessage-deflate 压缩。文本消息压缩后体积可降 70%。代价是:

  • CPU 增加。
  • 延迟增加(压缩 / 解压)。
  • 不适合高频小包。

经验值:内容平均 > 256 字节建议开启;< 64 字节建议关闭。

连接分配策略:把 80 万均匀分布

80 万连接不是一台机器能扛的,必须 N 台机器做集群。三类分配:

策略特点适用
Hash by user_id同用户落到同机器用户粘性场景
一致性 hash扩容迁移少通用推送
按地域 / ISP延迟低实时游戏

我们最终选择「一致性 hash + 备用机器」组合。扩容时只迁移 ~1/10 连接,业务无感知。

灰度与回滚

WebSocket 的版本发布比 HTTP 难,因为它不能「keep old + new together」太久。常见做法:

  1. 所有握手带 version=...
  2. 网关识别 version,路由到对应实例。
  3. 老连接维持不动,新连接走新版本,老连接空闲过期后掉。

这种「冷静」回滚保证升级过程不强行切断存量用户。

监控:WebSocket 的核心指标

  • 当前连接数:每实例的活跃连接。
  • 握手成功率:5xx、TLS 错误、Auth 错误分类。
  • Ping/Pong 失效率:代表 NAT 老化频次。
  • 流量进出:每秒消息数、平均消息大小。
  • 半连接 / full connection ratio

容量规划:从数字到带宽

把 80 万连接换算到每个组件的容量:

  • CPU:单实例 8 核可服务 8–10 万连接。
  • 内存:每连接 ~10KB(粗算),80 万 ≈ 8GB。
  • 带宽:每连接 1KB/s 心跳,80 万 ≈ 800 MB/s。

这意味着至少需要 10 个 8 核实例、千兆内网、专门的边缘节点。

一些非技术但很关键的事

  • 连接治理的「命门」是断开能力。要能在 5 秒内批量踢掉指定用户的所有连接。
  • 心跳协议需要在客户端、服务端、SDK 三方对齐。
  • 突发事件可能要短时间强切,必须有「主动 close」通道。

WebSocket 是个看似简单、实际比 HTTP 更复杂的协议。它每个连接都像「管理一条小专线」,是另一种工程哲学。做好它,才能让实时交互成为常态。