最稳妥的Go WebSocket连接管理方式是用map[string]*websocket.Conn配合sync.RWMutex,键名用业务唯一ID,增删用Lock、读用RLock,断开时需显式删除并校验,广播前设WriteDeadline并处理关闭错误,最终应封装为单例结构体统一管理。

用 map + sync.RWMutex 管理连接最常用也最稳妥
Go 的 net/http 本身不提供连接池或会话管理,WebSocket 连接(*websocket.Conn)是长生命周期对象,必须自行维护。直接用 map[string]*websocket.Conn 存储连接是主流做法,但必须配 sync.RWMutex —— 否则并发读写会 panic:fatal error: concurrent map read and map write。
关键点:
- 键名建议用业务唯一 ID(如用户 ID),而不是随机 UUID 或 conn.RemoteAddr(),后者在 NAT/代理后不可靠
- 写操作(增/删)用
mutex.Lock(),读操作(广播、查单个)用mutex.RLock(),避免读多时阻塞 - 务必在
defer mutex.Unlock()前检查是否已加锁,避免死锁
var (
clients = make(map[string]*websocket.Conn)
mutex = &sync.RWMutex{}
)
func addClient(id string, conn *websocket.Conn) {
mutex.Lock()
defer mutex.Unlock()
clients[id] = conn
}
func getClient(id string) (*websocket.Conn, bool) {
mutex.RLock()
defer mutex.RUnlock()
conn, ok := clients[id]
return conn, ok
}
连接断开时必须从 map 中显式删除
很多人以为连接关闭后 conn.ReadMessage() 返回 error 就自动“失效”,其实 map 里还存着已关闭的指针 —— 不仅浪费内存,后续误发消息还会触发 write tcp ...: use of closed network connection 错误。
正确做法是在每个连接的处理 goroutine 末尾(无论正常退出还是 panic recover 后)执行清理:
立即学习“go语言免费学习笔记(深入)”;
- 用
defer包裹删除逻辑,确保执行 - 删除前再次加锁,并检查该 conn 是否仍是 map 中的值(防止重复删除或竞态)
- 调用
conn.Close()并非必须(HTTP 升级后底层 TCP 已由 Go 自动管理),但显式调用更清晰
func handleConnection(conn *websocket.Conn) {
id := extractUserID(conn) // 你的业务逻辑提取 ID
addClient(id, conn)
defer func() {
mutex.Lock()
if clients[id] == conn { // 防止其他 goroutine 已删过
delete(clients, id)
}
mutex.Unlock()
conn.Close()
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
log.Printf("read error from %s: %v", id, err)
return
}
// 处理消息...
}}
广播消息时要跳过已关闭连接
即使你及时删除了 map 中的连接,仍可能有 goroutine 正在向刚关闭的 *websocket.Conn 写入 —— 因为 delete 和 conn.WriteMessage() 是异步的。直接遍历 map 广播大概率遇到 use of closed network connection。
安全广播的关键是:每次写前先做一次 WriteDeadline 检查 + 捕获 error:
- 设置极短 deadline(如 10ms)避免阻塞
- 忽略
websocket.ErrCloseSent和io.ErrUnexpectedEOF,它们表示对方已断开 - 遇到其他 error(尤其是 net.OpError)应立即从 map 删除该连接
func broadcast(msg []byte) {
mutex.RLock()
conns := make([]*websocket.Conn, 0, len(clients))
for _, conn := range clients {
conns = append(conns, conn)
}
mutex.RUnlock()
for _, conn := range conns {
conn.SetWriteDeadline(time.Now().Add(10 * time.Millisecond))
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
if websocket.IsUnexpectedCloseError(err) ||
err == websocket.ErrCloseSent ||
strings.Contains(err.Error(), "use of closed") {
// 忽略可预期关闭错误,不处理
} else {
// 其他错误视为连接异常,尝试清理
removeClientByConn(conn)
}
}
}}
别用全局变量存连接,考虑封装成结构体
随着功能增加(比如按房间分组、心跳检测、消息队列),裸 map 很快难以维护。把连接管理逻辑封装进一个结构体,能自然隔离状态、复用锁、支持扩展:
- 字段包含
clients map[string]*client,其中client可嵌入*websocket.Conn并附加字段(如joinTime、roomID) - 方法统一处理加锁/解锁,外部无需关心 mutex 细节
- 便于单元测试(可 mock client 行为)和注入依赖(如日志、metrics)
最容易被忽略的一点:连接管理器本身不是线程安全的 —— 如果你在多个 HTTP handler 里 new 出不同实例,那根本没意义。必须保证整个服务中只有一个管理器实例,通常作为包级变量或通过依赖注入传入 handler。










