心跳保活与死连接回收: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. 参数取值建议

参数建议值说明
pingPeriod25~30 s小于代理空闲超时的一半(Nginx proxy_read_timeout 默认 60 s)
pongWait2 × pingPeriod官方 chat 示例取 60 s;更大则回收迟钝,更小则弱网误杀
writeWait5~10 s控制帧写超时,不是吞吐指标
maxMsgSize64 KBSetReadLimit 的值须与协议约定一致,防单帧打爆内存
TCP keepaliveTCP_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 根本不会被消费。

笔记加载中…