长连接断开通常分为两类:一类是服务端主动通过 FIN 或 RST 报文关闭连接,客户端能立即感知;另一类是中间网络设备悄悄切断链路,两端却都认为连接仍然可用。后者在流式推送中更常见,危害也更大,因为不会触发错误回调,直到业务层尝试发送数据才暴露。要解决这类问题,必须在应用层加入心跳包与断线重连机制。

心跳包解决的是如何发现静默断开,断线重连解决的是发现之后如何最快、最稳地恢复通信。两者配合才能真正降低流中断对业务的影响。下面从保活探测、重连退避、状态恢复三个角度展开。
一、为什么需要心跳保活
TCP 本身有 keepalive 机制,但默认配置下探测周期很长,例如 Linux 默认 tcp_keepalive_time 为 7200 秒,中间设备可能早在几分钟内就回收空闲连接。对于 WebSocket、MQTT、gRPC 这类长连接,应用层不能把存活判断完全交给内核。NAT 设备、负载均衡、家用路由器都有自己的空闲超时时间,一旦超时就会删除映射表项,后续数据包无法送达。
心跳包的核心目标不是传输业务数据,而是周期性产生一个小流量报文,让整条链路上的设备认为连接处于活跃状态。它还能帮助客户端快速发现网络变化,例如从 Wi-Fi 切换到移动网络时,发送心跳会因链路失效而失败,客户端就能提前触发重连,而不是等用户操作时才发现。
设计心跳时首先需要分清探测方向:可以由客户端单向发送,也可以双方互相探测。客户端单向发送实现简单,服务端只需设置读取超时;双向探测则能覆盖服务端发往客户端方向被单向切断的场景。在 WebSocket 中,协议本身定义了 Ping 和 Pong 控制帧,优先级高于普通数据帧,适合承载心跳。
// Go 服务端:借助读取截止时间实现心跳超时判定
func (c *Conn) readLoop() {
for {
// 每次收到任意数据都刷新最后活跃时间
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
_, msg, err := c.conn.ReadMessage()
if err != nil {
c.close()
return
}
c.handle(msg)
}
}
二、心跳包设计:间隔、超时与多级探测
心跳间隔的选取需要折中。间隔太短会增加带宽和电池消耗,间隔太长又会错过中间设备超时。常见做法是客户端每 20 到 30 秒发送一次心跳,服务端在 60 到 90 秒内没有收到任何数据就判定连接失效。如果是移动端,还要考虑系统休眠策略,可以把心跳间隔与推送通道结合起来。
除了固定间隔,还可以设置容忍次数。服务端不要求每个周期都必须收到心跳,只要在 N 个周期内收到过数据即可。这种设计能容忍偶尔的网络抖动,避免因为一次延迟就误杀连接。客户端同理:连续 2 到 3 次心跳没有收到 Pong,才认为连接断开。
报文格式方面,轻量级系统建议使用定长二进制协议,例如第一字节表示类型,后续字节携带时间戳或序列号。文本协议可以使用 JSON,但要控制体积。下面是一个基于 WebSocket 的浏览器端心跳检测逻辑,发送自定义 ping 消息,并监听 pong 消息更新超时。
const HEARTBEAT_INTERVAL = 25000;
const HEARTBEAT_TIMEOUT = 10000;
let missedPong = 0;
function startHeartbeat(ws) {
setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
missedPong += 1;
if (missedPong >= 3) {
ws.close();
scheduleReconnect();
}
}, HEARTBEAT_INTERVAL);
}
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
missedPong = 0;
}
});
三、断线重连策略:指数退避与随机抖动
发现连接断开后,立刻无限循环重连往往会加剧服务端压力,造成雪崩。更合理的是指数退避:第 1 次失败后等待 1 秒,第 2 次等待 2 秒,第 3 次等待 4 秒,直到达到上限。引入随机抖动可以避免大量客户端在同一时刻同时重连,例如在计算出的等待时间上增加 0 到 1 秒的随机值。
重连逻辑需要区分网络闪断和服务端主动关闭。服务端返回 1001 表示业务层关闭,客户端可以降低重连频率或提示用户;如果是网络异常,则按退避策略快速恢复。重连成功后还要重放订阅操作,因为新连接不会自动继承旧连接的服务端状态。例如聊天室需要重新发送 join 请求,行情客户端需要重新订阅合约列表。
下面是一个带退避和抖动的重连调度器示例。核心是先清理旧连接的所有监听器,再创建新连接,避免旧连接的回调污染新状态。
let reconnectAttempt = 0;
const MAX_DELAY = 30000;
function scheduleReconnect() {
const base = Math.min(1000 * Math.pow(2, reconnectAttempt), MAX_DELAY);
const jitter = Math.random() * 500;
const delay = base + jitter;
reconnectAttempt += 1;
setTimeout(() => {
const ws = createConnection();
bindEvents(ws);
}, delay);
}
ws.addEventListener('close', (event) => {
if (event.code === 1000) {
// 服务端正常关闭,不自动重连
return;
}
scheduleReconnect();
});
四、服务端如何识别旧连接与重复消息
客户端重连后,服务端会遇到一个典型问题:旧连接可能还没被回收,新连接已经建立。如果服务端没有连接标识,就可能把同一个用户当成两个在线终端。解决方法是在握手阶段生成 sessionId 或 deviceId,服务端用 userId 加 deviceId 作为唯一键维护连接映射,新连接建立时主动关闭旧连接,或者让旧连接进入待清理状态。
重复消息同样需要处理。客户端在断线前可能已经收到消息 100,但还没确认,服务端重发后客户端可能重复消费。可以在消息头加入单调递增的 lastEventId,客户端重连后携带这个值,服务端据此从断点续推。对于不能续推的场景,则在业务表加幂等键,收到重复消息时直接返回成功。
以 WebSocket 网关为例,服务端维护每个连接的 session,收到心跳时只更新时间戳,收到业务消息时校验序列号。下面这段 Go 代码演示了如何在服务端清理旧连接并记录最后事件 ID。
type Session struct {
UserID string
DeviceID string
Conn *websocket.Conn
LastEventID int64
}
func (m *Hub) Register(s *Session) {
key := s.UserID + ":" + s.DeviceID
if old, ok := m.sessions[key]; ok {
old.Conn.Close() // 新连接替换旧连接
}
m.sessions[key] = s
}
心跳包与断线重连不是分别独立的补丁,而是一套完整的连接生命周期管理方案。先把探测做到可配置、可观测,再让重连逻辑具备退避和幂等能力,流中断对业务的影响就能被控制在很小范围内。

