gorilla/websocket默认for-range广播在5000+连接时卡顿,因逐个writemessage触发频繁系统调用、内存拷贝和tcp写入,引发锁竞争与syscall瓶颈;同步阻塞无法并行,慢连接拖垮整批,且无错误隔离。

gorilla/websocket 默认广播循环在 5000+ 连接时就会明显卡顿,不是代码写得不够“快”,而是模型本身不适用高并发场景。
为什么 for-range + conn.WriteMessage 会越来越慢
每次调用 conn.WriteMessage 都会触发一次系统调用、一次内存拷贝、一次 TCP 发送缓冲区写入;当连接数上升到万级,单纯遍历 map 并逐个写入,CPU 时间全耗在锁竞争和 syscall 上,而不是业务逻辑里。
- 同步广播阻塞整个 goroutine,无法利用多核
- 每个连接的网络延迟不同,慢连接会拖垮整批发送
- 频繁调用
WriteMessage导致大量小包(tiny packets),触发 Nagle 算法,进一步放大延迟 - 没有错误隔离:一个连接 write 失败(如 EOF),默认逻辑常直接 break 或 panic,导致后续连接被跳过
用 writePump + channel 实现非阻塞批量写入
核心是把“写”从消息接收路径中彻底剥离,让每个连接独占一个 writePump goroutine,只负责从自己的 send channel 拉消息、攒批、发包。
- 每个客户端连接启动独立
writePump,避免相互干扰 -
sendchannel 设为带缓冲(如make(chan []byte, 64)),缓解突发消息压力 - 在
writePump中使用NextWriter获取底层 writer,手动控制帧边界,减少小包 - 写失败时关闭 channel 并标记连接异常,不中断其他连接的 pump
示例关键片段:
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case message, ok :=
<h3>启用 WriteBufferPool 和压缩降低 GC 压力</h3>
<p><code>WriteBufferPool</code> 不是锦上添花,而是万级连接下避免 GC 频繁触发的关键。默认每调用一次 <code>WriteMessage</code> 就分配新 buffer,GC 会疯狂扫描这些短期对象。</p>
- 必须自定义
sync.Pool,且New函数返回固定大小切片(如make([]byte, 4096)) -
EnableCompression: true对文本类消息(如 JSON payload)可降低 30%~50% 传输体积,但需客户端支持Sec-WebSocket-Extensions - 注意:压缩对小消息(websocket.IsCompressed 动态判断
Upgrader 示例:
upgrader := websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
WriteBufferPool: &sync.Pool{
New: func() interface{} {
return make([]byte, 4096)
},
},
EnableCompression: true,
}
广播不走连接池,改走 Pub/Sub 中间件
真正卡住百万级广播的,从来不是单机写能力,而是“所有节点都要维护一份完整连接列表”。一旦引入 Redis Pub/Sub 或 NSQ,广播就变成一次 publish,各节点只管自己辖区内的连接。
- 业务层调用
redis.Publish("broadcast:room:general", payload),不感知连接数 - 每个 WebSocket 节点订阅对应频道,收到后只推给本机在线用户
- 连接状态用 Redis Hash 存储(
HSET ws:node1 conn:abc "online"),跨节点踢人或同步状态不再需要 RPC - 避免用全局
map[*websocket.Conn]bool+sync.Mutex,那是单机玩具方案
容易忽略的一点:Pub/Sub 的消息投递不是原子的,如果某节点宕机,它错过的广播消息不会自动重放——所以关键业务消息仍需走可靠队列(如 Kafka + offset 管理),而 WebSocket 广播只承担“尽力而为”的实时通道角色。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











