2.2 心跳与重连:弱网下怎么保活

场景

连接建好了不代表活着。地铁进隧道、手机切蜂窝、公司路由器重启,用户的连接悄无声息地断掉,服务端却毫不知情——TCP 不会主动告诉你对面没了。我们的 App 上线第一周就收到大量反馈:消息延迟十几分钟才到。查下来全是僵尸连接赖在网关上,新消息往一个已经死了的 socket 上写,写失败才走重连。心跳与重连这堂课,是 IM 系统的第一道成年礼,也是所有用户对你系统的第一印象来源:用户不关心你的架构,他只关心隧道里那条消息能不能准时到。

方案

心跳的本质是双方周期性互相确认还活着。核心参数只有一个:间隔多久发一次。间隔太勤费电费流量,手机端被系统杀进程的概率反而升高;间隔太长,僵尸连接存活时间久,离线消息的时效性就差。我们没有照搬任何教程,而是按场景拆成三档,每一档都对应一类用户行为模式,而不是全局一刀切——一刀切的参数在这个行当里迟早要还债。

场景 建议间隔 理由 我们的配置
手机前台 30~60 秒 活跃时优先消息及时性 45 秒
手机后台 3~5 分钟 省电优先,靠系统唤醒 180 秒
网页端 30 秒 页面可见才发,省流量 30 秒
弱网降级 动态退避 避免心跳本身压垮网络 见下文

除了上表四个档,我们后来还给海外用户加了一档:基础设施较弱的地区,网络抖动频繁,退避上限从 60 秒放宽到 240 秒,踢人阈值从三次心跳放宽到五次。参数以地区为维度在线上配置,发布靠的是配置中心而非版本升级,这套能力在 灰度发布 里有讲。

后台与弱网是两套逻辑:后台靠系统级推送通道兜底,弱网靠心跳间隔的自适应退避——连续两次心跳超时就把间隔翻倍,直到恢复。

参数不是拍脑袋定的,每一档都有依据。前台 45 秒是根据我们的移动网络抓包统计:移动网络平均每分钟会有一次 NAT 重映射,心跳必须在一个周期内完成一次双向确认才能维持连接映射。后台 180 秒是手机厂商后台保活策略与电池消耗测试的折中点。网页端 30 秒是因为浏览器对 WebSocket 有 60 秒左右的空闲断连行为(这是 W3C 的约定之外的实现共性,各浏览器略有差异),所以心跳必须短于它。所有参数我们都留了线上开关,能按区域分批调整,而不是写死在代码里。

配置与代码

服务端的心跳检查是一个后台 goroutine,定期扫连接表的 lastSeen。扫描频率与踢人阈值要配套设计:扫得太密费 CPU,扫得太疏僵尸连接活得久。我们最终定为每 30 秒扫一个分片子集,完成全表一轮大约五分钟,兼顾了资源消耗和发现速度,压测显示这个节奏下的 CPU 开销可以忽略不计。

func (g *Gateway) kickoffLoop() {
    t := time.NewTicker(30 * time.Second)
    for range t.C {
        now := time.Now().Unix()
        for _, c := range g.table.All() {
            if now-c.lastSeen > 3*HeartbeatSec { // 三次没收到,判死
                c.Close(CodeTimeout, "heartbeat timeout")
            }
        }
        g.removeExpiredIndex() // 顺便清 Redis 僵尸索引
    }
}

客户端的重连必须有退避,否则十万用户同时断网又同时恢复,重连请求瞬间打爆鉴权服务——这就是传说中的连接风暴:

func (c *Client) reconnect() {
    backoff := 1 * time.Second
    for {
        err := c.dial()
        if err == nil {
            backoff = 1 * time.Second
            return // 重连成功后立刻触发一次 sync 拉离线消息
        }
        if backoff > 60*time.Second {
            backoff = 60 * time.Second
        }
        time.Sleep(backoff + jitter()) // 加随机抖动,避免雪崩同时到达
        backoff *= 2
    }
}

抖动函数返回零到两秒之间的随机值,目的只有一个:让十万客户端的重连请求在时间轴上自然摊开。抖动必须均匀分布,踩过坑的同事提醒:有同事图省事用了固定退避,结果某天恰好赶上所有设备同时掉电重启,十万设备在同一秒拨号,网关 accept 队列直接打满。

整套心跳的收发还做了双通道确认:服务端收到客户端心跳后必须回一个 heartbeat_ack,客户端把 lastSeen 更新的权利交回服务端。这一来一回看似冗余,实际上它是区分活的连接与半死连接(对端不可达但 TCP 未感知)的唯一手段。线上的心跳日志我们也全部送进了 CLS,专门用来统计不同地区运营商的链路质量,这套数据反过来又指导了分档参数的调整,详见 日志与审计:CLS 两套索引。

踩坑

第一个坑就是前文埋的伏笔:连接风暴。某次机房抖动导致全网断连五秒,恢复瞬间鉴权 QPS 飙到平峰五十倍,鉴权服务 502 了十分钟。加上指数退避加抖动之后,恢复期的曲线从一根针变成平缓的小坡。另外鉴权接口本身也做了 限流与降级,即使退避没完全兜住,服务端也有第二道防线:把突发流量拒绝一部分,总比整个服务雪崩强。这两道防线加上之后,我们又经历过三次全网断连,一次都没有再让鉴权服务倒下。

第二个坑是心跳方向选错。我们最初让服务端主动发心跳,结果弱网下客户端为了回心跳反而被系统唤醒,耗电榜上我们 App 排第一。改成客户端主动发、服务端只回应之后,耗电立刻恢复正常。经验:心跳永远是客户端发起,服务端是被动的。展开讲一点:服务端主动心跳还会造成反向 NAT 保活的失效——客户端那一侧的 NAT 映射可能对入向包不敏感,服务端喋喋不休地发包并不能维持这条链路,反而让客户端错过了自己保活的最佳时机。

第三个坑是重连后的 seq 对齐。客户端重连成功如果不同步,服务端按新连接处理,之前的消息全部卡在旧 seq 之后。所以重连流程里必须先做一次 sync(见 离线消息:拉取与确认机制),重建同步点再开始收新消息。我们曾经把这步漏掉过整整一天,表现很有意思:重连后的用户能收到新消息,但中间断连那几分钟的消息永远不出现,非得杀进程重登才补回来。加了重连后的强制 sync 之后,这类案例绝迹。

第四个坑是手机后台的假连接。应用切到后台后 socket 还在但已被系统挂起,心跳发不出去。服务端按超时踢掉是正确的,但客户端一旦回前台要立刻发起重连,而不是等下一个心跳周期,这样用户感受不到延迟。

第五个坑是心跳与耗电的平衡在国产手机上尤其难做。各家省电策略激进程度不同,同样的 180 秒后台心跳,有的手机能活三小时,有的二十分钟就被杀。最终我们与客户端团队达成共识:后台心跳只是手段之一,真正保命的是系统推送通道(见 自研还是买云 IM 服务 里说的核心自研、边缘外采),IM 厂商没有谁能纯靠 TCP 在国产手机后台一直活着。

小结

心跳的诀窍是客户端发起、按场景分档、超时三次再踢;重连的诀窍是指数退避加随机抖动、恢复后先同步再收消息。这套组合拳是弱网体验的性命交关之处,做不好它,前面所有架构上的努力都会被一笔勾销。和 登录鉴权 的票据有效期设计也直接相关。想看这套机制压到极限的样子,翻 单机压测。顺带一提,心跳参数的选型没有标准答案,只能靠抓包和耗电实测,一定要给运营留出线上调参的开关,别让一个常量卡死整个迭代节奏。最后强调一句,重连退避与踢人阈值必须在客户端和服务端两侧做联合压测,单侧调参永远调不出真实分布,单机压测 里我们就压过一次完整的断网恢复过程。

心跳参数不是一套打天下,我们的客户端按网络环境分了三档:Wi-Fi 走九十秒、移动网络六十秒、弱网识别后(连续两次重连失败)临时切到二十秒。分档的依据是后台实测的保活成功率:同一参数在移动网络下的掉线率比 Wi-Fi 高三倍,加快心跳后差距收窄到一成以内。另一个容易忽略的消耗是心跳对端上电量的影响,心跳频率翻倍会让部分中端安卓机型待机功耗明显上升,所以参数改动都要先在灰度桶跑三天看崩溃率与电量反馈,确认无回归再全量。

心跳超时阈值也是个坑:固定阈值在弱网下误判频繁,移动网络切换基站时心跳回复慢到一两秒,网关以为掉线就踢人,客户端又立刻重连,反复折腾费电还费流量。后来我们改成动态阈值,网关对每条连接维护最近二十次心跳的 RTT 序列,取 P95 再放一个一点五倍系数作为这条链路的超时线,慢链路自动获得更长的容忍窗口。只有连续两个心跳周期完全没有回复才真正判死。上线后这一点上误杀率降到接近零,弱网用户在线时长统计明显改善。

延伸阅读