心跳必须用 time.ticker 而非 time.timer,配合 select 监听 ticker.c 和 ctx.done(),退出前调用 ticker.stop();需叠加应用层心跳与 tcp keepalive,设置读写 deadline,websocket 优先使用原生 pingmessage。

心跳包发送必须用 time.Ticker 而非 time.Timer
单次 time.Timer 触发后就停止,无法维持周期性心跳;而长连接要求稳定、可恢复的定时探测。用 time.Ticker 才能持续触发,且需配合 select 防止 goroutine 泄漏。
常见错误是把心跳逻辑写在 for 循环里手动 sleep,这会导致精度差、难以取消、且阻塞退出路径。
- 正确做法:启动独立 goroutine 运行
ticker := time.NewTicker(30 * time.Second),在select中监听ticker.C和连接关闭信号(如ctx.Done()) - 务必在退出前调用
ticker.Stop(),否则 ticker 会持续持有 goroutine 和 timer 资源 - 心跳间隔建议设为 30–60 秒;太短增加服务端压力,太长(>120s)可能被中间设备(NAT、负载均衡器)主动断连
net.Conn.SetKeepAlive 是基础但不够用
开启 TCP 层 keepalive(conn.SetKeepAlive(true))能让内核在无数据时发探测包,但它不保证应用层感知断连——比如对方进程崩溃但 TCP 连接未 FIN,内核 keepalive 可能要数分钟才判定失败。
因此必须叠加应用层心跳:发送自定义 ping 帧(如 JSON {"type":"ping"} 或二进制协议中的固定字节),并等待 pong 响应。
- 设置
net.Conn.SetKeepAlivePeriod(45 * time.Second)可缩短内核探测间隔,但仅作兜底,不能替代应用层心跳 - 务必同时设置读写 deadline:
conn.SetReadDeadline(time.Now().Add(45 * time.Second)),否则Read可能永久阻塞 - 注意:Windows 默认 keepalive 时间长达 2 小时,不显式设置会严重拖慢断连发现
心跳超时判断必须区分“未响应”和“写失败”
收到 pong 是健康信号;没收到 pong 不等于断连——可能是网络抖动、对方延迟处理、或 pong 包丢失。直接关闭连接容易误杀。
推荐策略:连续 2–3 次心跳未收到 pong 后才标记异常,并尝试一次重发;若仍无响应,再关闭连接。
- 维护一个计数器
missedPings,每次发送 ping 后递增,收到 pong 时清零 - 写心跳包失败(
conn.Write返回 error)说明连接已不可写,应立即关闭,无需等待 pong - 读超时(
io.EOF或net.OpError)也代表连接失效,直接退出 - 避免在心跳 goroutine 中直接 close(conn),应通过 channel 通知主协程统一处理,防止并发 close panic
WebSocket 场景下优先用 websocket.PingMessage
如果底层用的是 gorilla/websocket,别自己封装 ping/pong 字符串——它原生支持标准 WebSocket 心跳机制,且自动处理帧格式、掩码、错误抑制。
手动发 {"type":"ping"} 不仅冗余,还可能因未正确设置 websocket.TextMessage 类型导致服务端解析失败。
- 启用自动心跳:
conn.SetPingHandler(func(appData string) error { return conn.WriteMessage(websocket.PongMessage, nil) }) - 设置 write deadline 并定期调用
conn.WriteMessage(websocket.PingMessage, nil)即可 - 注意:
SetPingHandler必须在连接建立后立即注册,否则首次 pong 可能丢失 - 若服务端不支持标准 ping/pong(如某些私有协议网关),才退回到自定义心跳逻辑
真正麻烦的不是发心跳,而是如何让心跳逻辑与业务读写共存又互不干扰——所有读写操作必须共享同一套 deadline 控制,且心跳失败后的清理动作(如从连接池移除、触发重连)往往比心跳本身更易出错。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











