客户端断线重连:退避、会话恢复与消息补拉

地铁进隧道、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. 参数取值建议

参数建议值说明
baseDelay500 ms ~ 1 s首次重连要快,给用户「秒回」体感
factor2常用值;大于 2 会让弱网恢复变慢
maxDelay前台 30 s / 后台 5 min后台可放长,避免长时间挂机白耗电
jitterequal 或 full抖动区间至少覆盖 [delay/2, delay]
stableTime60 s存活超过该时长才把 attempt 归零
心跳超时判定2 × pingPeriod(如 60 s)半开连接的唯一探测手段
resumeTTL5 min会话保留时长,可按业务「离线可见」要求调整
maxBacklog500 条 / 会话超窗即走 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 补拉解决「漏了什么」,三者缺一都会以丢消息或雪崩的形式在线上暴露出来。

笔记加载中…