WebSocket连接管理必须用sync.Map等并发安全结构,因原生map不支持并发读写;房间归属须服务端强验证,禁止依赖客户端room_id;Upgrade后应移交goroutine并设心跳超时;广播需通过消息队列缓冲避免阻塞。

WebSocket连接管理必须用并发安全的结构
Go的map本身不支持并发读写,直接在HandleWebSocket里用map[string]*websocket.Conn存用户连接,不出三秒就会触发fatal error: concurrent map writes。别信“我只在一个goroutine里操作”的自我安慰——HTTP handler每次请求都启新goroutine,而心跳、消息广播、断开回调全可能并发触发。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
sync.Map替代原生map,键用用户ID(如"user_123"),值存*websocket.Conn和元数据(上线时间、房间名)组成的结构体 - 连接建立时调用
sync.Map.Store(),断开前务必Delete(),否则内存泄漏比群聊消息还快 - 广播消息前,用
Range()遍历所有连接,但注意:遍历时连接可能正在关闭,需对conn.WriteMessage()加recover或检查conn.IsClosed()
消息路由不能靠客户端传来的room_id硬转发
前端JS随便改个room_id字段就能往别人频道塞消息,这不是群聊是漏洞聊天室。真实服务里,房间归属必须和服务端状态强绑定,且验证要发生在消息写入前。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用户连接成功后,立刻发一个
{"type":"join","room":"general"}控制帧,服务端解析后检查该用户是否有权进这个房间(比如查DB或Redis缓存的user:123:rooms集合) - 把房间映射关系存在另一个
sync.Map里:roomName → map[userID]struct{},每次join/leave都原子更新 - 收到普通消息时,先从连接上下文里取出它所属的
room(不是从消息体里取!),再广播给该room下所有在线连接
别用net/http默认的goroutine池处理长连接
http.Server默认配置下,每个WebSocket Upgrade请求会占用一个HTTP worker goroutine,而这个goroutine在连接存活期间不会释放——几千人在线就几千goroutine常驻,GC压力爆炸,还容易触发too many open files。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- Upgrade完成后立即用
go handleConnection(conn)把连接移交到新goroutine,原HTTP handler函数立刻return - 给
handleConnection加超时控制:用time.AfterFunc设30秒无心跳自动断连,避免僵尸连接堆积 - 心跳包必须双向:服务端定时
conn.WriteMessage(websocket.PingMessage, nil),客户端回Pong;若连续2次没收到Pong,主动conn.Close()
生产环境必须加消息队列缓冲广播压力
当一个用户发消息,服务端要遍历当前房间所有连接逐个WriteMessage。如果房间有500人,这500次写操作串行执行,后续消息全排队——延迟飙升,连接还可能因超时被Nginx或云LB断掉。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 引入
chan []byte作为房间级消息队列,每个房间启动一个专属goroutine消费该chan,负责批量写入 - 广播时只做
select { case roomChan ,非阻塞投递,失败则记录日志并跳过该用户(比卡住整个房间强) - 队列长度设上限(如100),满时
len(roomChan) == cap(roomChan)就丢弃旧消息,防止OOM——群聊消息本来就不保证100%送达
真正的难点不在代码几行写完,而在连接断开时的清理时机:HTTP handler return、goroutine panic、网络闪断、客户端强制关页……这些场景下defer conn.Close()不一定来得及执行,得靠心跳超时兜底,还得定期扫sync.Map里陈旧连接。


















