setkeepalive不够用,因其默认2小时才首探,而nat/防火墙常5–30分钟即断连;它仅探测链路连通性,无法感知对端进程卡死或业务阻塞,且探测包易被中间设备丢弃,write成功不代表数据可达。

必须引入应用层心跳包——仅靠 SetKeepAlive 无法防止 NAT/防火墙静默断连,也无法验证对端应用进程是否存活。
为什么 SetKeepAlive 不够用
TCP 层的 keepalive 是操作系统内核行为,Linux 默认首次探测在空闲 2 小时后才触发,且中间设备(如家用路由器、云厂商 NAT 网关)普遍在 5–30 分钟就回收空闲连接。这些探测包常被丢弃,conn.Write 仍可能成功(数据进了发送缓冲区),但对端早已收不到任何字节。
-
SetKeepAlive(true)+SetKeepAlivePeriod(30 * time.Second)仅在 Go 1.19+ 可用,旧版本需 syscall 配置,且最小周期受 OS 限制(Linux 通常 ≥ 1s) - 即使设成 30 秒,它只探测“链路是否通”,不关心服务端进程是否 panic、goroutine 是否卡死、业务 handler 是否阻塞
- 你看到连接
Write成功但后续业务请求全卡住,大概率就是 keepalive 没穿透,而应用层心跳早该发现并重连了
怎么用 time.Ticker 发心跳而不 drift
别用 time.AfterFunc 递归调用——上一次 Write 阻塞 2 秒,下一次就晚于计划时间;time.Ticker 提供固定节奏,适合保活这种强周期性任务。
- 启动:
ticker := time.NewTicker(20 * time.Second),间隔建议略小于中间设备超时阈值(如设为 20s 对应常见 30 分钟 NAT 超时) - 在
for range ticker.C循环里发包,不要 sleep 后再写,否则无法响应conn.Close() - 每次发包前必须调
conn.SetWriteDeadline(time.Now().Add(5 * time.Second)),防止单次网络抖动导致整个 goroutine hang 住 - 心跳内容用固定长度二进制,比如
[]byte{0x01},禁用json.Marshal或fmt.Sprintf—— 长度不定、含空格/换行的字符串易被代理截断
如何判断连接真断了:只看 Read,不看 Write
Write 失败不等于连接断了:可能是对方接收缓冲区满、临时丢包,返回 io.ErrShortWrite 或 syscall.EAGAIN;真正失效往往先体现在 Read 返回 io.EOF 或超时。
- 核心原则:每次成功
Read(无论业务包还是心跳)后,立刻重置conn.SetReadDeadline(time.Now().Add(25 * time.Second)),超时值略大于心跳间隔 - 心跳逻辑要混入主读循环,不能单独起 goroutine 去
Write—— 否则ReadDeadline不会被更新,连接挂着不释放 - 服务端无需响应心跳包,但必须更新最后活跃时间戳;客户端若要求双向验证,则发完
PING后立即Read期待PONG,超时或io.EOF才关闭连接
大规模连接下 CPU 异常升高的坑
10 万连接时,每连接一个 time.Ticker 会让 runtime.timerproc 高频抢占,CPU 占用飙升;频繁 SetWriteDeadline 和运行时拼接心跳内容也会放大开销。
- 避免 per-connection goroutine:用单个后台 goroutine 每 100ms 扫描
sync.Map中的lastReadTime,只读不操作连接 - 心跳响应统一用预分配字节切片,如
var heartbeatResp = []byte{0x02},禁止每次构造新对象 - 禁用冗余系统调用:如果已启应用层心跳,
SetKeepAlivePeriod设为 0 或干脆不设,避免 TCP 层和应用层双重重叠探测 - 服务端清理动作要异步:扫描到超时连接,仅标记状态,交由专用队列处理
conn.Close()
最容易被忽略的一点:所有 SetReadDeadline 和 SetWriteDeadline 都是绝对时间,且只对**下一次** IO 生效。漏掉一次重设,整个连接就失去保活能力——这不是理论风险,是线上高频故障点。











