单机扛不住5000+ WebSocket连接广播,根本原因在于gorilla/websocket默认for-range+WriteMessage模型引发系统调用频繁、锁竞争激烈及无错误隔离;必须改用writePump+channel解耦写路径、hub.run()串行操作clients map、Redis PUB/SUB跨节点分发消息。

单机扛不住 5000+ 连接的 WebSocket 广播,不是代码写得慢,而是 gorilla/websocket 默认广播模型在系统调用、锁竞争和错误隔离三方面存在硬伤。
为什么 for-range + conn.WriteMessage 在 5000+ 连接时必然卡顿
每次调用 conn.WriteMessage() 都会触发一次系统调用、一次内存拷贝、一次 TCP 缓冲区写入;连接数上万后,CPU 时间全耗在 syscall.write 和锁争抢上,而不是业务逻辑里。
- 同步阻塞:一个慢连接(如弱网或未响应 FIN)会让整轮广播停住,后续连接全被拖累
- Nagle 算法被频繁触发:大量小包导致 TCP 层合并延迟,实测端到端延迟从 20ms 涨到 300ms+
- 无错误隔离:某个连接 write 失败(比如
use of closed network connection),默认逻辑常直接break,其余连接彻底收不到消息
必须用 writePump + channel 剥离写路径
把“发送”从消息接收主流程中完全解耦,每个连接独占一个 writePump goroutine,只负责从自己的 send channel 取数据、攒帧、发包。
-
sendchannel 必须带缓冲,例如make(chan []byte, 64),防突发消息压垮 pump - 在
writePump中优先用conn.NextWriter()获取底层 writer,手动控制帧边界,减少小包 - 务必设置写超时:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second)),避免 goroutine 永久挂起 - 每次
WriteMessage()后检查 error:遇到websocket.ErrCloseSent或连接关闭类错误,就close(client.send)并通知 hub 注销
并发读写 clients map 会直接 panic
Go 运行时禁止并发读写 map,fatal error: concurrent map iteration and map write 不是偶发 bug,而是确定性崩溃。
- 所有对
clients map[*Client]bool的操作——增、删、遍历——必须收束到同一个 goroutine(通常是hub.run())中串行处理 - 注册/注销不能直接操作 map,而应发
register/unregister消息到 hub 的 channel - 广播消息也走 channel:
broadcast chan []byte,由hub.run()统一拉取、遍历本地 clients、投递到各 client 的sendchannel - 别用
sync.RWMutex包 map:它挡不住底层哈希表扩容时迭代器失效,仍会 panic
跨进程广播必须绕过本地 map
单机 hub.clients 再稳,也无法让其他节点上的客户端收到消息。硬靠 Redis 同步 map 是错路,正确路径是「元数据中心化 + 消息分发解耦」。
- Redis 存可序列化元数据:
user_id、node_id、room_ids、last_heartbeat,用SET user:123 "{...}" EX 3600,别用 HASH - 业务层不直连连接,而是
PUBLISH chat:room:123 "{...}";各 ws 节点SUBSCRIBE chat:room:*,收到后只推给本节点内存中匹配room_ids的连接 - PUB/SUB 不落地,节点重启即丢——必须用
LPUSH chat:room:123:buffer缓存最近 N 条,做兜底重放 - 连接注册/注销操作必须串行化:用 channel 或 Redis Lua 脚本保证
register和unregister不并发改同一 key
真正卡脖子的从来不是吞吐量数字,而是错误传播路径是否可控、节点故障是否可收敛、连接状态是否可重建。这些细节不提前设计,等压测崩了再补,代价远高于初期多写几行 channel 和 Redis 操作。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











