WebSocket长连接在即时通讯、行情推送、实时协作这些场景里几乎是标配,但一条TCP连接挂在那里并不代表它一直活着。家用路由器的NAT映射有存活周期,机房之间的链路会抖动,客户端进程可能直接被杀掉,这些情况下服务端如果不主动探测,就会握着一条早已死亡的连接傻等消息。心跳机制就是解决这个问题的标准做法,再配上一套靠谱的断线重连策略,才算构成完整可用的长连接方案。本文用Golang加Gorilla WebSocket库,把服务端心跳检测、客户端保活和断线重连三个环节的代码与原理完整讲一遍。

为什么WebSocket长连接必须配心跳
先看一个最典型的问题:TCP半开连接。客户端突然断电、拔网线或者从WiFi切换到4G,服务端的TCP协议栈收不到FIN包也收不到RST包,ReadMessage会一直阻塞在那里,表面看起来一切正常。等你下次往这条连接写数据触发失败时,可能已经过去好几分钟,这期间推送的消息全部石沉大海,用户侧的表现就是消息莫名其妙丢了。
除了对端异常,中间设备也会主动杀连接。家用路由器的NAT表项一般几十秒到几分钟就会老化,云厂商的四层负载均衡普遍有空闲超时(常见60秒),超时后映射被直接丢弃,通信双方都不会收到任何通知。也就是说,一条完全没有流量的WebSocket连接,实际存活时间往往撑不过几分钟。
WebSocket协议在设计时就考虑到了这个问题,RFC 6455定义了两种控制帧:Ping帧(opcode为0x9)和Pong帧(opcode为0xA)。任何一端发送Ping,对端必须尽快回复Pong,这两个帧不承载业务数据,开销极小。当然你也可以在应用层发一条JSON格式的自定义心跳消息,效果类似,但协议层的Ping和Pong由库内部处理,不占用业务消息的解析逻辑,是更轻量的选择。三种常见保活方案的对比见下表。
| 方案 | 实现层 | 开销 | 特点 |
|---|---|---|---|
| TCP Keepalive | 内核 | 极低 | 默认2小时才探测,周期难以按需调整,不适合应用层长连接 |
| 协议层Ping/Pong | WebSocket帧 | 低 | 通用推荐方案,库原生支持,不干扰业务消息 |
| 业务层心跳消息 | 应用消息 | 中 | 灵活,可在心跳里携带在线状态等业务字段 |
Golang里的实现思路可以概括为读超时加超时刷新。SetReadDeadline接收一个绝对时间点,过了这个时间点ReadMessage还没读到任何数据就返回超时错误。初始时把deadline设为当前时间加一个窗口,之后每收到一次Pong就往后推一个窗口;对端一旦死亡,deadline不再被刷新,ReadMessage超时报错,连接随即被回收。整个探测的灵敏度就取决于这个窗口的大小。
服务端心跳检测的完整实现
服务端是心跳的发起方,负责周期性发Ping并监督Pong。核心参数有两个:pongWait是等待Pong的最长时间窗口,超过即判定连接死亡;pingPeriod是Ping的发送周期,取pongWait的十分之九,确保在一个等待窗口内至少能发出一次Ping,避免出现Ping还没来得及发、连接就被判超时的尴尬情况。完整代码如下。
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
const (
// 等待Pong的最长时间窗口,超过即判定连接死亡
pongWait = 10 * time.Second
// Ping发送周期,必须小于pongWait
pingPeriod = (pongWait * 9) / 10
)
func serveWs(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("升级失败:", err)
return
}
defer conn.Close()
// 初始化读超时,超时未收到任何数据则判定连接死亡
conn.SetReadDeadline(time.Now().Add(pongWait))
// 每次收到Pong都把读超时往后推一个窗口
conn.SetPongHandler(func(appData string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go pingLoop(conn)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
log.Println("读消息失败,连接关闭:", err)
return
}
log.Printf("收到业务消息: %s", msg)
}
}
func pingLoop(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Println("发送Ping失败:", err)
return
}
}
}
func main() {
http.HandleFunc("/ws", serveWs)
log.Fatal(http.ListenAndServe(":8080", nil))
}
这段代码有几个值得展开的点。第一,SetPongHandler注册的回调会在收到Pong帧时被库内部触发,注意Pong帧不会让ReadMessage返回,它是控制帧,会被库拦截处理掉,所以刷新deadline这件事只能放在handler里做,放在读循环里是没用的。第二,pingLoop跑在独立的goroutine里,用time.NewTicker控制节奏,写Ping失败时直接退出,此时读循环那边大概率也会因为deadline到期而退出,双方各自完成清理。第三,升级连接时记得处理跨域问题,本地调试阶段可以直接放开CheckOrigin。
还有一个细节值得注意:如果业务侧也会往连接写消息,那么任何一条业务消息本身就证明连接活着,理论上可以在每次写操作后顺带刷新期待窗口。不过更简单的做法是不做区分,反正一个Ping帧只有几个字节,开销完全可以忽略。
客户端如何响应心跳并感知连接状态
好消息是Gorilla WebSocket库在ReadMessage内部收到Ping帧时会自动回一个Pong,这是默认PingHandler的行为,所以客户端只要保持读循环不停,就已经能正确响应服务端的心跳了,不需要额外编写应答代码。客户端真正要做的是两件事:一是自己也发心跳,用于感知服务端是否存活,有些架构里只有客户端发Ping、服务端被动回Pong;二是连接挂掉后触发重连流程。下面是带心跳和重连的客户端骨架代码。
package main
import (
"log"
"math/rand"
"time"
"github.com/gorilla/websocket"
)
type WSClient struct {
conn *websocket.Conn
url string
retry int
}
func (c *WSClient) connect() error {
conn, _, err := websocket.DefaultDialer.Dial(c.url, nil)
if err != nil {
return err
}
c.conn = conn
c.retry = 0
return nil
}
// 指数退避加随机抖动,避免大量客户端同时重连造成惊群
func (c *WSClient) backoff() time.Duration {
base := time.Second
maxWait := 30 * time.Second
d := base << uint(c.retry)
if d > maxWait {
d = maxWait
}
jitter := time.Duration(rand.Int63n(int64(d / 4)))
return d + jitter
}
func (c *WSClient) readLoop() {
const readWait = 60 * time.Second
c.conn.SetReadDeadline(time.Now().Add(readWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(readWait))
})
ticker := time.NewTicker(50 * time.Second)
defer ticker.Stop()
done := make(chan struct{})
defer close(done)
// 心跳协程:周期发Ping,读循环退出时通过done通道通知它一起退出
go func() {
for {
select {
case <-ticker.C:
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-done:
return
}
}
}()
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return
}
log.Printf("收到消息: %s", msg)
}
}
func (c *WSClient) Run() {
for {
if err := c.connect(); err != nil {
wait := c.backoff()
c.retry++
log.Printf("连接失败,%v后重试", wait)
time.Sleep(wait)
continue
}
log.Println("连接成功,进入读循环")
c.readLoop()
c.retry++
wait := c.backoff()
log.Printf("连接断开,%v后重连", wait)
time.Sleep(wait)
}
}
func main() {
client := &WSClient{url: "ws://127.0.0.1:8080/ws"}
go client.Run()
select {}
}
这段代码里有一个容易被忽略的设计:done通道。读循环退出时通过defer close(done)通知心跳goroutine退出,否则那个goroutine会永远阻塞在ticker上,连接一多goroutine就泄漏了。心跳goroutine里用select同时监听ticker和done,谁先来听谁的,这是Go里非常经典的协程退出模式。
客户端的心跳周期和服务端对齐或者错开都可以,但要保证小于读超时窗口。上面代码里读超时60秒、心跳50秒,留了10秒余量,网络稍微抖动也不至于误杀健康连接。移动网络环境下建议整体缩短,因为部分运营商的NAT超时只有一分钟左右,心跳太慢连接会被中间设备悄悄掐掉。
断线重连的指数退避策略
心跳负责发现问题,重连负责恢复服务,但重连不能无脑立刻重试。想象一个场景:服务端发布新版本重启,三万个客户端同时发现连接断了,同时发起重连,刚启动的服务瞬间被打满,健康检查失败又被编排系统判定异常,如此往复循环,这就是经典的惊群效应,也叫重连风暴。
标准解法是指数退避加随机抖动。第n次重试前的等待时间按1秒、2秒、4秒、8秒这样翻倍增长,同时封顶一个最大值(比如30秒),避免极端情况下等太久;在这个基础上叠加一个随机抖动量,把所有客户端的重连时间点打散到不同时刻。前面的客户端代码里backoff函数已经实现了这套逻辑,核心就几行:左移一位实现翻倍,封顶判断防止无限增长,rand生成抖动值。
重连成功之后还有一件容易漏掉的事:状态恢复。长连接应用里客户端通常带着订阅关系、登录态、消息游标这些上下文,连接重建后这些状态全部丢失,必须重新执行认证、重新订阅主题、按游标补拉断线期间的消息。建议把这类逻辑统一收敛到一个onConnected回调里,让连接成功的入口只有一个,状态恢复的代码就不会散落在各处难以维护。
常见坑:并发写与资源泄漏
第一个大坑是并发写。Gorilla WebSocket的连接不支持并发调用WriteMessage,官方文档写得很明确:一条连接同一时刻只允许一个并发读者和一个并发写者。心跳goroutine在写Ping,业务goroutine在写消息,两个写操作交错时WebSocket帧会被拆坏,对端解析失败直接断连,而且这种问题极难复现排查。最省事的修法是加一把全局互斥锁,把写操作包装一层。
var writeMu sync.Mutex
// 包装一层写操作,所有写Ping和写业务消息的地方都改调这个函数
func safeWrite(conn *websocket.Conn, msgType int, data []byte) error {
writeMu.Lock()
defer writeMu.Unlock()
return conn.WriteMessage(msgType, data)
}
更优雅的做法是把所有写操作收敛到单一goroutine:业务代码只往channel里投递消息,由唯一的writer协程统一写出,天然串行化,还能顺带做批量合并写优化。规模不大的服务用互斥锁方案就完全够用了。
第二个坑是前面反复提过的goroutine泄漏,读循环退出后一定要给心跳goroutine留出退出的通道。第三个坑是参数顺序,pingPeriod必须小于pongWait,写反了连接永远活不过一个探测周期。最后一个建议是把这些参数全部配置化,心跳周期、超时窗口、退避上限都做成可动态调整的配置项,线上遇到问题时不至于改代码重新发版,运维同学也会感谢你。
WebSocket心跳Golang WebSocket断线重连修改时间:2026-10-02 19:34:19