心跳保活与死连接回收:ping/pong 与读超时
客户端拔网线、进程被 kill -9、NAT 表项过期,这几种情况下 TCP 都不会立刻通知服务端:连接仍停在 ESTABLISHED,Write 甚至还会返回成功(数据只是进了内核发送缓冲区)。这类「半开连接(half-open connection)」不清掉,在线数会虚高,广播要白白遍历几万个死连接,句柄与内存持续泄漏。心跳就是把这些连接变成可判定事件的那一步。
1. 三层探测,职责不同
- TCP 保活(keepalive):
net.TCPConn.SetKeepAlive(true)配合SetKeepAlivePeriod(30*time.Second),只解决「对端主机彻底消失」。它由内核在空闲后按TCP_KEEPIDLE(Linux 默认 7200 秒,Go 会改为 15 秒)开始探测,对端进程假死、或有中间设备代答 ACK 时无能为力。 - WebSocket 控制帧 Ping/Pong(opcode 0x9 / 0xA):应用层活性探测,能穿透到对端读循环,是主力手段。
- 业务心跳帧:客户端定期上报序号,用来统计客户端到服务端的真实往返延迟(RTT)。它不能替代 Ping/Pong——业务帧可能被背压(backpressure)排队,延迟不代表链路状态。
2. ping/pong 的真实语义
服务端发 websocket.PingMessage,对端必须回一个 payload 完全相同的 Pong(RFC 6455 §5.5.3)。gorilla/websocket 里所有控制帧都只在 ReadMessage / NextReader 内部被消费,因此有个经典误区:只发 Ping 而不读连接,Pong 永远没人处理,读超时照样触发,死连接检测形同虚设。
正确写法是:读循环进入前设一次读超时,每次读到消息或收到 Pong 都把超时往后推。
const (
writeWait = 10 * time.Second // 单次写控制帧的超时
pongWait = 60 * time.Second // 读超时:超过即认定连接已死
pingPeriod = 30 * time.Second // 发 Ping 的周期,必须小于 pongWait
maxMsgSize = 64 << 10 // 单帧上限,与协议约定保持一致
)
func readPump(c *Client) {
defer c.close()
c.conn.SetReadLimit(maxMsgSize)
if err := c.conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
c.logger.Warn("设置读超时失败", "err", err)
return
}
c.conn.SetPongHandler(func(payload string) error {
c.markActive() // 记录活性时间,供连接质量统计使用
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
c.conn.SetPingHandler(func(payload string) error {
// 覆盖默认 handler 后必须自己回 Pong,否则对端会被我们的沉默拖死
c.markActive()
if err := c.conn.WriteControl(websocket.PongMessage, []byte(payload),
time.Now().Add(writeWait)); err != nil {
return err
}
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure) {
c.logger.Info("读循环退出", "err", err)
}
return
}
c.markActive()
c.handleMessage(data)
}
}
写侧用 ticker 驱动,Ping 走 WriteControl——它允许与业务写并发调用,并自带独立写超时:
func writePump(c *Client) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case msg, ok := <-c.send:
if !ok {
_ = c.conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(writeWait))
return
}
if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
if err := c.conn.WriteControl(websocket.PingMessage, nil,
time.Now().Add(writeWait)); err != nil {
c.logger.Info("心跳发送失败,回收连接", "err", err)
return
}
case <-c.done:
return
}
}
}
3. 参数取值建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| pingPeriod | 25~30 s | 小于代理空闲超时的一半(Nginx proxy_read_timeout 默认 60 s) |
| pongWait | 2 × pingPeriod | 官方 chat 示例取 60 s;更大则回收迟钝,更小则弱网误杀 |
| writeWait | 5~10 s | 控制帧写超时,不是吞吐指标 |
| maxMsgSize | 64 KB | SetReadLimit 的值须与协议约定一致,防单帧打爆内存 |
| TCP keepalive | TCP_KEEPIDLE 30 s、TCP_KEEPCNT 默认 9 | 用 SetKeepAlivePeriod(30*time.Second) 覆盖 Go 的 15 s 默认值 |
| 掉线告警阈值 | 3 × pingPeriod | 监控上区分「偶发丢包」与「大面积掉线」 |
4. 常见坑与排查
- 读超时只在读循环里设:
SetReadDeadline是绝对时间点,每次读成功后都要重设;别处设置会被覆盖或干脆不生效。 - pingPeriod ≥ pongWait:必然误杀,务必保证
pongWait ≥ 2 × pingPeriod。 - 覆盖
SetPingHandler后忘记回 Pong:默认 handler 会自动回,自定义后责任转移给了你。 - 代理层先断:Nginx 需
proxy_read_timeout、proxy_send_timeout均大于 pongWait,且透传Upgrade/Connection头;云负载均衡空闲超时常为 60 s,心跳要留足余量。 - 浏览器后台节流:后台标签页定时器被压到约 1 分钟一次,客户端心跳周期不要小于 30 s,并监听
visibilitychange立即校正。 - 把心跳当业务活跃度:心跳只证明连接活着,活跃度要用单独的
lastActiveAt统计。
排查顺序:先看在线数曲线是否「只涨不跌」,再 ss -tn state established '( sport = :8080 )' | wc -l 与进程内在线数对比(差值大即有死连接),最后看 netstat -s | grep -iE 'timeout|retransmit' 与 goroutine 数(go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine)。
小结:心跳的本质是把「连接是否还活着」转成可判定的读超时事件——服务端定时发 Ping,Pong handler 刷新 SetReadDeadline,超时即回收。参数遵循 pongWait ≥ 2 × pingPeriod 且小于代理空闲超时,而读超时的设置必须发生在读循环内部,否则 Pong 根本不会被消费。