WebSocket断开后ReadMessage()立即返回错误而非重试,需显式判断关闭原因并用退避策略重连,同时分离读写逻辑、管理发送队列与心跳定时器。

WebSocket连接断开后,conn.ReadMessage() 会直接报错
Go 的 gorilla/websocket 库中,一旦底层 TCP 连接断开(比如网络抖动、服务端重启、NAT 超时),conn.ReadMessage() 不会阻塞等待,而是立即返回 *websocket.CloseError 或 io.EOF。很多人误以为它会“自动重试”,其实不会——它只是读操作,不负责连接生命周期管理。
常见错误现象:
• 程序静默退出读循环,不再收消息
• 没有触发重连,客户端彻底失联
• 日志里反复出现 read tcp: use of closed network connection
- 必须在
ReadMessage()的 error 分支里显式判断是否为连接已断(如websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway)) - 不要只检查
err != nil就 panic 或 return,要区分临时错误(如net.OpError)和永久断连 - 读循环外层建议用
for+select控制,避免 goroutine 泄漏
重连逻辑不能写在 ReadMessage() 错误处理里硬循环
如果在读失败后直接 while(true) { dial(); time.Sleep() },容易卡死 goroutine、耗尽资源,还可能掩盖真实错误(比如证书过期、域名解析失败)。
正确做法是把重连抽成独立控制流,和读/写解耦:
立即学习“go语言免费学习笔记(深入)”;
- 用一个单独的 goroutine 管理连接状态,监听
ctx.Done()或自定义的reconnectCh - 每次重连前加退避(backoff),比如从 100ms 开始,指数增长到最大 30s,避免雪崩式重连
- 重连成功后,重新启动读/写 goroutine,并同步重置心跳计时器(否则旧 timer 可能还在往已关闭的 conn 写 ping)
- 务必在重连前调用
conn.Close(),否则旧连接 fd 不释放,Linux 下很快 hit ulimit
websocket.DefaultDialer 缺省配置不支持长连接保活
默认 dialer 不设 Proxy、TLSClientConfig、HandshakeTimeout,更关键的是没配 KeepAlive 和 WriteDeadline,导致 NAT 中间件(如家用路由器、云厂商 LB)在 60s 左右静默 kill 连接。
实操建议:
- 设置
Dialer.HandshakeTimeout = 5 * time.Second防卡死握手 - 启用
http.Transport级 keep-alive:&http.Transport{IdleConnTimeout: 90 * time.Second}(需自定义Dialer.Proxy透传) - 写消息前必须设
conn.SetWriteDeadline(time.Now().Add(10 * time.Second)),否则WriteMessage()可能永久阻塞 - 服务端若要求 ping/pong,客户端得自己起 goroutine 定期发
conn.WriteMessage(websocket.PingMessage, nil)
重连时未清空发送队列,导致消息乱序或重复
很多实现把待发消息缓存在内存 channel 或 slice 里,但重连后直接全量重发,结果老消息插在新消息中间,或者服务端因幂等缺失重复处理。
关键点:
- 发送队列不是“越积越多越好”,要按语义分级:普通通知类可丢;订单类必须带唯一
msg_id并由服务端去重 - 重连成功后,只重发「未确认」的消息(即发了但没收到服务端 ack 的),而不是所有缓存消息
- 建议在
WriteMessage()后立刻起一个 goroutine 等 ack,超时则标记为 pending,重连后仅重发 pending 列表 - 别用
time.AfterFunc做超时,它不随 conn 生命周期销毁,易泄漏
最麻烦的其实是心跳与业务消息的 deadline 冲突——同一个 conn 上,ping 要快,业务消息可稍慢,但共用一个 WriteDeadline 就会互相干扰。这事得拆成两个独立 timer,或者用 SetWriteDeadline 动态覆盖。


















