客户端断线重连:退避、会话恢复与消息补拉
地铁进隧道、Wi-Fi 切 4G、代理空闲超时、服务端发布重启,都会让长连接断开。浏览器和多数移动端的 WebSocket 对象不会自动重连,而断线期间服务端发出的推送如果没人补,用户就会永久看不到那条消息。所以重连不是一个 setInterval(new WebSocket),它要回答三个问题:什么时候重连(退避)、连上之后我是谁(会话恢复)、断开期间漏了什么(消息补拉)。
1. 重连状态机
idle → connecting → online → backoff → connecting,其中 backoff 是唯一允许等待的状态。触发重连的入口有四个:
onclose/onerror:最常见的显式断开。- 客户端心跳超时:
2 × pingPeriod内没有收到任何帧就主动close()并重连。半开连接不会触发onclose,只能靠这个判定。 online事件:网络恢复立刻打断退避,提前重连。visibilitychange:页面从后台回到前台时立即校时,别等定时器(后台标签页定时器会被节流到分钟级)。
每个连接实例要带一个自增的 generation,回调里先比对再处理:旧连接迟到的 onmessage、onclose 一律丢弃,否则会出现「双连接重复收消息」和「新连接被旧连接的 close 逻辑关掉」这两类难查的 bug。
2. 指数退避与抖动
等待时长取 delay = min(maxDelay, base × factor^attempt),再叠加抖动(jitter)。没有抖动的话,服务端一次发布就会让十万客户端在同一秒重连,形成惊群(thundering herd)——刚启动的进程会在完成初始化前被打死,这就是连接风暴。
// DialWithBackoff 以指数退避 + 抖动重连,直到 ctx 结束或遇到不可重试的错误。
func DialWithBackoff(ctx context.Context, url string, hdr http.Header) (*websocket.Conn, error) {
const (
baseDelay = 500 * time.Millisecond
maxDelay = 30 * time.Second
)
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
EnableCompression: false, // 压缩开销常大于收益,IM 小包场景默认关闭
}
for attempt := 0; ; attempt++ {
conn, resp, err := dialer.DialContext(ctx, url, hdr)
if err == nil {
return conn, nil
}
if resp != nil && (resp.StatusCode == http.StatusUnauthorized ||
resp.StatusCode == http.StatusForbidden) {
return nil, fmt.Errorf("握手鉴权失败,放弃重连: %w", err)
}
delay := backoff(attempt, baseDelay, maxDelay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
}
// backoff 计算第 attempt 次重试的等待时长:指数增长后截断,再做「等分抖动」。
func backoff(attempt int, base, max time.Duration) time.Duration {
if attempt > 10 { // 2^10 × 500ms 已远超 max,截断以防位移溢出
attempt = 10
}
d := base * time.Duration(1<<attempt)
if d > max || d <= 0 {
d = max
}
half := int64(d) / 2
return d/2 + time.Duration(rand.Int63n(half+1)) // #nosec:抖动不需要密码学随机
}
抖动有三种常见取法:rand(0, delay)(full jitter,分散最好)、delay/2 + rand(0, delay/2)(equal jitter,上面实现的这种,保留退避下限)、delay + rand(0, base)(decorrelated,适合长周期重试)。IM 客户端推荐前两种。
还有一条容易被忽略:重连成功不等于连接可用。如果服务端正在滚动发布,连接可能建立后 3 秒又被踢。工程做法是「稳定时长」判定——连接存活超过 stableTime(如 60 秒)才把 attempt 归零,否则继续按退避重试。
3. 会话恢复与消息补拉
重连后第一帧发 hello{clientID, sessionToken, lastSeq}:
clientID:客户端生成并持久化(localStorage / Keychain)的安装级标识,服务端据此找到设备级会话。lastSeq:客户端已确认收到的最大会话内序号,服务端保留的序号是单调递增的。- 服务端在保留窗口内能续上就
resume,直接把(lastSeq, curSeq]的积压消息按序推下来;否则返回resume=false,客户端改走 HTTP 拉历史(长连接只负责增量)。
// Resume 处理重连首帧:能续上就返回积压消息,续不上则让客户端走 HTTP 拉历史。
func (s *SessionStore) Resume(ctx context.Context, clientID string, lastSeq uint64) (Backlog, error) {
sess, err := s.load(ctx, clientID)
if err != nil {
return Backlog{}, fmt.Errorf("加载会话失败: %w", err)
}
if sess == nil || time.Since(sess.UpdatedAt) > resumeTTL {
return Backlog{Resumed: false}, nil
}
// 客户端序号超前(换设备/数据被清)或积压超出保留窗口,都不能续
if lastSeq > sess.Seq || sess.Seq-lastSeq > maxBacklogGap {
return Backlog{Resumed: false, LatestSeq: sess.Seq}, nil
}
msgs, err := s.rangeAfter(ctx, sess.ID, lastSeq, maxBacklog)
if err != nil {
return Backlog{}, fmt.Errorf("读取积压消息失败: %w", err)
}
return Backlog{Resumed: true, SessionID: sess.ID, Seq: sess.Seq, Messages: msgs}, nil
}
投递语义是至少一次(at-least-once):补拉可能重发客户端已收到的消息,客户端必须按 msgID 去重;上行方向在断线期间进本地队列,恢复后按序重发,每条带 clientMsgID,服务端用它做幂等去重并回同一个 msgID。
4. 参数取值建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| baseDelay | 500 ms ~ 1 s | 首次重连要快,给用户「秒回」体感 |
| factor | 2 | 常用值;大于 2 会让弱网恢复变慢 |
| maxDelay | 前台 30 s / 后台 5 min | 后台可放长,避免长时间挂机白耗电 |
| jitter | equal 或 full | 抖动区间至少覆盖 [delay/2, delay] |
| stableTime | 60 s | 存活超过该时长才把 attempt 归零 |
| 心跳超时判定 | 2 × pingPeriod(如 60 s) | 半开连接的唯一探测手段 |
| resumeTTL | 5 min | 会话保留时长,可按业务「离线可见」要求调整 |
| maxBacklog | 500 条 / 会话 | 超窗即走 HTTP 全量拉取 |
| 客户端去重窗口 | 10 min 或最近 1000 条 | 内存与准确性的折中 |
5. 常见坑与排查
- 无抖动:发布即雪崩。抖动是重连设计的硬要求,不是优化项。
- 未校验 generation:旧连接回调污染新连接,表现为重复推送、随机断开。
- 只重连不补拉:静默丢消息,用户投诉「消息晚到或没有」,日志里却一切正常。
- 补拉不去重:一条消息刷出两遍,客户端体验比丢消息还糟。
- 把鉴权失败也无限重试:401/403,以及关闭码 4001(被踢下线)要区分处理,否则死循环打满服务端。
- 只监听
onclose:半开连接不触发任何事件,必须有「N 秒未收帧」的自检。 - 上行只做下行补拉:断线期间用户发的消息必须有本地队列,否则真丢数据。
- 重连成功但状态没恢复:房间订阅、在线状态、未读计数都要随
hello重建,只恢复传输层是不够的。
排查时看四个指标:单位时间内的重连次数直方图(长尾说明弱网用户占比高)、seq 空洞(相邻消息序号不连续即补拉失败)、服务端同一 clientID 的并发连接数(大于 1 说明有僵尸连接)、握手 resume=false 的比例(升高说明保留窗口偏短)。复现弱网用 Chrome DevTools 的 Offline 或 tc qdisc add dev eth0 root netem delay 300ms loss 5%。
小结:重连 = 状态机(含 generation 校验)+ 指数退避叠加抖动 + 稳定时长归零 + 会话恢复 + 按序号补拉与去重。退避解决「什么时候连」,hello/resume 解决「连上我是谁」,lastSeq 补拉解决「漏了什么」,三者缺一都会以丢消息或雪崩的形式在线上暴露出来。