单机 websocket 广播在集群环境下必然失效,因为 *websocket.conn 仅存于本进程内存,a 节点无法触达 b 节点连接;需用 redis pub/sub 中转消息,各节点订阅后向本地存活连接投递,并以 sync.map + done 通道安全遍历。

单机 WebSocket 广播在集群环境下必然失效,因为 *websocket.Conn 只存在于本进程内存中,Nginx 或 Kubernetes 负载均衡后,A 节点发的消息根本触达不了 B 节点上的连接——这不是代码 bug,是架构隔离的必然结果。
为什么 gorilla/websocket 自带的 broadcast channel 不能跨节点
gorilla/websocket 的 broadcast 通常是一个 chan []byte,所有连接共用一个读端,但它的消费逻辑只在当前进程内运行。一旦服务部署多实例:
- 每个实例维护独立的
map[*websocket.Conn]bool,彼此无感知 -
conn.WriteMessage()只能写本机连接,对其他机器的 socket 完全无效 - 本地测试一切正常,上线后出现“部分用户收不到”——这是最典型的集群广播缺失现象
- 试图序列化
*websocket.Conn过 Redis 或 MQ?会 panic:它不可序列化
Redis Pub/Sub 是当前最轻量可靠的跨节点中继方案
不用 Kafka、NATS 或 RocketMQ,Redis 的 PUBLISH/SUBSCRIBE 已足够支撑万级连接的实时广播,前提是正确使用:
- 用
github.com/go-redis/redis/v9,避免 v8 版本的 context 泄漏问题 - 订阅必须用
pubsub := rdb.Subscribe(ctx, "ws:global:broadcast"),再调pubsub.Channel()消费,不要反复Receive() - 每条消息建议带
room_id和exclude_conn_id字段,支持房间级广播和发信人免回显 - 务必设
pubsub.ReceiveTimeout = 5 * time.Second,否则网络抖动会让 goroutine 卡死 -
client.Publish(ctx, "ws:global:broadcast", msg)必须带 context 超时(如 3 秒),防止阻塞整个 readPump
广播协程里怎么安全遍历本机所有连接
不能直接 for _, conn := range clients 后调 conn.WriteMessage(),WebSocket 连接非线程安全,且 map 迭代时并发修改会 panic:
- 每个连接封装成
type client struct { conn *websocket.Conn; done chan struct{} },done用于通知关闭 - 用
sync.Map存conn_id → *client,注册/注销无需手动加锁 - 广播时用
clients.Range()遍历,对每个c.send(独立缓冲 channel)做select { case c.send - 客户端 writePump 一旦遇到
io.EOF或写错误,必须主动clients.Delete(conn)并close(c.send) - 绝对别用无缓冲
chan []byte当 send 管道——一个卡住的客户端会拖垮整个广播循环
Redis Pub/Sub 不解决连接状态同步问题
它只负责“把消息传出去”,不负责“谁在线、谁该收”。很多团队踩坑在于误以为订阅了 Redis 就能自动知道全网连接数:
- 每个节点仍需独立维护本机
clients映射,Redis不替代这个职责 - 你无法从 Redis Pub/Sub 里查到“B 节点现在连了哪些用户”,只能靠各节点自己上报心跳或用 Redis Set 做辅助(但那是另一层设计)
- 广播消息到达后,只做一件事:遍历本机
clients,往每个存活连接的sendchannel 投递 - 如果某节点临时宕机,它错过的消息不会补推——
Redis Pub/Sub是“发后即焚”,聊天室可接受;订单类强一致场景得换Redis Stream + ACK
真正难的不是发消息,而是让每个节点在高并发下既不丢连接、也不卡死广播、还不 panic——这要求对 sync.Map、select 超时、context 生命周期、以及 websocket.Conn 的写并发边界有清晰认知。细节松一扣,线上就是部分用户静音。











