必须用 SetReadDeadline,不能只设 SetDeadline;前者仅影响读操作,后者干扰写和连接建立,易致心跳误判;每次读前设 ReadDeadline,发包时单独设 WriteDeadline;用 context.WithCancel 防协程泄漏;conn.Close() 后仍可读缓冲区残留数据,但后续 Read 立即返回 io.EOF;推荐二进制心跳结构体而非字符串,便于携带时间戳、防篡改;失效判定需结合 deadline、RTT 与业务容忍度。

心跳包该用 SetDeadline 还是 SetReadDeadline?
必须用 SetReadDeadline,不能只设 SetDeadline。前者只影响读操作(比如 conn.Read()),后者会同时干扰写和连接建立,导致心跳超时误判或写失败。
常见错误是把心跳检测逻辑塞进一个全局 SetDeadline,结果发心跳时被自己设的 deadline 拦住 —— 写操作直接返回 io.ErrDeadline,但连接其实完好。
- 每次读心跳前调用
conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - 心跳响应不依赖写 deadline,但发心跳包建议加
conn.SetWriteDeadline单独控制,超时设短些(如 5s) - 注意:deadline 是绝对时间,不是相对 timeout,重复设置前无需重置
如何避免协程泄漏导致心跳 goroutine 积压?
每个连接启动独立心跳 goroutine 很自然,但连接异常断开后,若没显式退出机制,goroutine 就永远卡在 conn.Read() 或定时器等待里 —— Go runtime 不回收它们。
典型泄漏场景:客户端断网后服务端未收到 FIN,连接处于半开状态,心跳 goroutine 持续轮询、不断重试、越积越多。
立即学习“go语言免费学习笔记(深入)”;
- 用
context.WithCancel绑定连接生命周期,断连时调用cancel() - 心跳循环内用
select同时监听ctx.Done()和timer.C,任一触发即退出 - 不要用
time.Ticker长期运行;改用time.AfterFunc或重置time.Timer,避免 ticker 泄漏
net.Conn 关闭后还能读到数据吗?
能,但仅限于关闭前已到达内核缓冲区的数据。一旦 conn.Close() 被调用,后续 Read() 立即返回 io.EOF,哪怕缓冲区还有未读字节 —— 这点和 TCP FIN 的语义不一致,容易误判连接状态。
所以不能靠 Read() 返回 io.EOF 当作“对方主动下线”的唯一依据;更可靠的方式是结合心跳超时 + syscall.ECONNRESET / io.ErrUnexpectedEOF 等错误综合判断。
- 读循环中遇到
io.EOF,先检查conn.RemoteAddr()是否仍有效(非 nil) - 对端异常断开常伴随
read: connection reset by peer,这个比EOF更可信 - 务必在
defer conn.Close()前完成所有读写,否则可能 panic:「use of closed network connection」
心跳协议用固定字符串还是二进制结构体?
线上系统强烈推荐二进制格式(如 binary.Write 序列化小 struct),不用 "PING"/"PONG" 这类字符串。看似省事,实则埋坑:
- 字符串易被中间设备(如某些防火墙、代理)静默丢弃或篡改,而二进制 payload 更难被识别和干预
- 无法携带时间戳、序列号等字段,导致无法检测乱序、重放或单向链路故障
- Go 中字符串比较(
==)比bytes.Equal开销略高,虽可忽略,但设计上已暴露随意性
一个最小可行心跳结构体示例:
type Heartbeat struct {
Seq uint32
Time int64 // UnixMilli()
Flags uint8
}发送前 binary.Write(enc, binary.BigEndian, hb),接收端反序列化解析。
真正麻烦的从来不是发心跳,而是怎么定义「失效」—— 客户端连续 3 次没回 PONG?服务端连续 2 次发不出?这些阈值必须和你的 SetReadDeadline、网络 RTT、业务容忍度对齐,而不是拍脑袋定 30s。


















