必须用 net.DialContext 主动建连 + 全链路 context.WithTimeout + 状态去抖,因 HTTP 心跳延迟高、失败路径多,仅 TCP 可达即可判定节点存活,且需防 DNS 卡住、connect 队列满及网络抖动误判。

Go 分布式长连接的心跳检测,不能只靠 time.Ticker + http.Get("/health"),必须用 net.DialContext 主动建连 + 全链路 context.WithTimeout + 状态去抖,否则极易把 DNS 解析慢、connect 队列满、短暂丢包误判为节点宕机。
为什么不用 HTTP 心跳做集群节点探测
HTTP 请求要走完整 TCP 握手 + TLS(如果启用)+ HTTP 头解析 + 服务端路由 + handler 执行,延迟高、失败路径多。比如反向代理超时、中间 WAF 拦截、服务端 goroutine 阻塞,都会让 http.Client.Do 卡住或返回假阴性。而集群节点存活只需验证“TCP 层可达”,net.Conn 建连成功就足够——三次握手完成即代表对端内核协议栈响应正常。
- 避免复用
http.Client:它自带连接池和重试,会掩盖真实连通性 - 禁用域名直连:如
"node-2:8080",DNS 解析卡住时net.DialTimeout不生效 - 必须用
net.DialContext+context.WithTimeout包裹全链路,覆盖 DNS + connect
如何用 net.DialContext 实现低延迟探测
目标是 600ms 内明确判定节点是否可连,且不阻塞主逻辑。关键不是“连得上”,而是“连不上时能快速退出”。
- 超时设为
600 * time.Millisecond:比DialTimeout的 500ms 略长,确保 context 能兜底 DNS 解析 - 连接成功后立刻调
conn.Close(),不发任何数据——只要三次握手完成就算 UP - 每个节点独立 goroutine 运行,不共享连接或上下文
- 错误只关心
ctx.Err() == context.DeadlineExceeded或net.OpError,其他如connection refused也归为 DOWN
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
defer cancel()
conn, err := net.DialContext(ctx, "tcp", addr)
if err != nil {
node.SetStatus(Down)
return
}
conn.Close()
node.SetStatus(Up)
状态变更必须带去抖和最小间隔
网络抖动常见于跨可用区、云厂商 VPC 边界或内核 connect 队列饱和。连续失败 1 次就切 down,100ms 后又恢复 up,会导致服务发现频繁震荡、下游重平衡雪崩。
- DOWN 判定:需连续
failCount >= 3且最近一次失败距今 - UP 判定:需连续
successCount >= 2且两次成功间隔> 500 * time.Millisecond - 每次失败更新
lastFailTime,每次成功更新lastSuccessTime,不依赖绝对时间戳 - 状态变更事件需异步通知(如通过 channel),避免阻塞探测 goroutine
长连接保活和心跳包处理要分开
集群节点探测(谁活着)和单连接保活(这根 TCP 是否还通)是两个不同层级的问题。前者用 net.DialContext,后者必须用应用层心跳 + SetReadDeadline/SetWriteDeadline。
-
SetKeepAlive无效:默认 2 小时才触发,且 NAT/防火墙常丢弃底层探测包 - 心跳包建议固定长度(如 4 字节
0x00000001),避免粘包和编解码开销 - 每次
conn.Read成功后,必须立刻重置SetReadDeadline,否则下一次读直接超时 - 服务端应分 goroutine 专责识别心跳包,主读循环只处理业务帧,防止心跳解析阻塞业务
最易被忽略的是:DNS 解析失败、connect 队列满、以及状态去抖的时间窗口——它们不会报错,但会让探测结果完全失真。别省那几十毫秒的 context 超时,也别信“连一次就够了”。











