3.3 离线消息:拉取与确认机制

场景

推送只对在线用户有效,而我们的用户有八成时间手机锁屏、应用后台、或者进了电梯。用户重新打开应用的那一瞬间,最关心两件事:离线期间错过了什么,以及我发出去的对方到底收到没有。如果离线消息做不好,用户会陷入一个恶性循环:每次打开应用先手动往下翻聊天记录排查,体验割裂,信任崩塌。离线拉取的设计目标,就是让重连后的第一屏永远是对的。

除了补齐离线消息,还有一类离线形态容易被忽略:多端离线同步。用户在手机上把会话看到了 Seq 一百二,切到电脑端打开同一个会话,电脑端的本地游标可能还停在一百。如果让电脑端从一百开始整段重拉,既费流量又慢;如果只推增量,又可能漏掉手机端特有的会话状态变更(比如草稿和已读位置)。端维度的游标管理是这一节后半部分的主题。

最早我们低估了这件事:第一版协议里没有确认机制,客户端离线期间的未推消息靠服务端记一张待推表,用户上线后整卷下发。用户规模小的时候能用,三个月后用户量上一个量级,待推表的行数膨胀到数亿,清理脚本成了新瓶颈,每天凌晨的批量删除又把数据库 IO 打高,影响早高峰首屏。这次教训让我们把整个设计换成拉取模型:服务端不记谁没收到,只记消息本身;要不要、什么时候要,由客户端的游标决定。状态的归属权从服务端转移到端上,服务端从此只需要保证存储可靠与拉取接口够快。

方案

核心机制是拉取加确认:客户端维护每个会话的已确认 maxSeq,重连后用它向服务端发起拉取;服务端返回从 maxSeq 加一到最新 Seq 的全部消息,客户端渲染完毕后回执确认。确认是点对点而非会话级:客户端只确认自己确实渲染过的最大 Seq,而不是服务端推到哪算哪。这样即便拉取过程中网络断开,下一次重连仍从旧的确认点继续,不会丢。

整套协议的客户端侧还有一个边界要处理:本地存储放不下全部历史怎么办。我们的客户端只保留最近三十天的消息落盘,更早的记录随翻页随拉。本地存储与确认游标是解耦的:本地消息被清理不影响游标的值,因为游标存的是服务端位置,而不是本地有没有。

机制 触发时机 确认对象 断网时的行为 存储成本
在线推送 收到即处理 不单独确认 消息丢失,靠拉取补 无
离线拉取 重连首屏 已渲染最大 Seq 下次重连继续 已落库消息
心跳对账 每分钟一次 未读数与 maxSeq 延后到下次连接 无
客服导出 用户投诉时 不确认 人工核对 历史快照

拉取接口有两条硬约束。第一是分页:一次最多返回五十条,更多按游标继续翻。第二是排序与去重:按 (ConvID, Seq) 升序返回,重复消息靠 MsgID 幂等。整个机制能成立的前提是消息已经落库,这正是 写入链路:先落库再推送 建立的基础——拉取之所以敢做兜底,是因为数据在存储里而不是在内存里。

推与拉的协作还有一个细节:客户端重连后的前两秒是空窗,此时既不能只等推送(可能错过了离线期的消息),也不能盲目全量拉。我们的重连协议里带了一个轻量的握手:客户端上报各会话的已确认 maxSeq,服务端比对当前最新 Seq,只对有差额的会话触发首次拉取。客户端收到的第一个响应里明确区分推送类型:新消息推送还是补齐离线,前者直接插入,后者进入拉取流程按页渲染。这样在线与离线两条通路在协议层就分得清清楚楚,而不是混在一个事件里靠客户端猜。

配置与代码

服务端的拉取接口,游标校验与分页是两个要点:

const fetchLimit = 50

func (h *SyncHandler) Fetch(uid int64, convID string, maxSeq int64) ([]Message, error) {
    rows, err := h.store.ListAfter(convID, maxSeq, fetchLimit)
    if err != nil {
        return nil, err
    }
    if len(rows) == 0 {
        return nil, nil // 已是最新,空结果也走缓存
    }
    return rows, nil // 客户端渲染后按末条 Seq 回执
}

客户端的确认回执,注意确认的是渲染完成的 Seq 而不是拉取到的 Seq:

// 渲染完成后回执,渲染前断网则不更新游标
func (c *Client) ack(convID string, rendered int64) {
    c.local.SetCursor(convID, rendered) // 本地先落,失败重试有依据
    c.send(proto.Ack{ConvID: convID, Seq: rendered})
}

多端情形下的服务端游标按设备维度存:(UID, 设备ID, ConvID, maxSeq)。同一用户手机和电脑各有各的进度,互不覆盖,换设备登录时从头按需拉取。

服务端处理确认回执,要点是只前进不后退:

func (h *SyncHandler) HandleAck(uid int64, dev string, convID string, seq int64) error {
    cur, err := h.store.GetCursor(uid, dev, convID)
    if err == nil && seq <= cur {
        return nil // 回退请求直接吞掉,见踩坑一
    }
    return h.store.SetCursorIfGreater(uid, dev, convID, seq)
}

游标存储选用 Redis 的哈希结构,key 是 UID 加设备 ID,field 是 ConvID,value 是已确认 Seq。持久化由后台任务定期刷到 MySQL,落地频率不需要太高——游标丢了顶多多推一些已读消息,客户端有 MsgID 幂等挡着,不会造成错误显示。归档链路的接口与热表接口共用同一个协议,只是背后查的是冷存储,超时时间放宽到三秒,并在客户端侧用骨架屏提示加载中。

踩坑

第一个坑是确认语义写成拉取即确认。客户端拉到了五十条,服务端立刻推进游标,结果客户端渲染到第三十条时崩溃,剩下二十条下次重连再也拿不到。修复办法就是确认必须由渲染完成事件触发,游标只随确认走。

第二个坑是拉取接口被打成同步接口。有客户端在拉取循环里一次只拉十条、渲染完再拉,制造了连续高频率请求,高峰期数据库压力叠加上行推送,形成放大效应。服务端加了令牌限频,并且把首屏拉取与后续翻页的优先级分开:首屏走高优先级队列,翻页走普通队列,防止翻页用户挤占首屏用户的资源。

第三个坑是群消息的确认点跳跃。群里同时有十条未读,客户端按页拉取时第一条可能正是一条撤回占位消息,旧版本客户端解析失败直接整批丢弃,后面九条也不确认了。修复是客户端单条容错:一条解析失败就跳过它继续,只确认成功渲染的最大 Seq,并把失败的 MsgID 上报服务端单独排查。

第四个坑是历史消息的存储分层。三十天内的消息支持分页拉取,更早的历史要走归档链路(冷存储,接口 RT 预算不同)。最初我们没做区分,用户翻到两年前的聊天记录时一次查询把数据库 IO 打满,连带影响了在线用户。分层之后接口先查热表,热表没有返回指引让客户端走归档接口,慢但隔离。归档表的索引也与热表不同:热表按 (ConvID, Seq) 走联合索引,归档表按 (UID, CreateAt) 走时间索引,因为翻历史的人是按时间轴浏览而不是按会话。冷热分层的迁移任务本身也是个大活,我们用按天切分的批处理任务在业务低峰慢慢搬,搬完热表只保留四十五天,消息留在归档里随时可查。

第五个坑是确认回执本身的丢失。回执包丢了,服务端不知道客户端已经看到,于是反复下发已读消息,用户被同一条旧消息刷屏。兜底是服务端在每次会话激活时返回服务端侧记录的游标,客户端发现本地游标更新就主动通知服务端更正,而不是等下一次回执。

小结

离线拉取的本质是把可靠性从推送通道转移到存储和游标协议上。四个细节值得抄进自己的设计:确认的是渲染而非拉取、游标按设备维度分、群消息按会话而非按消息确认、历史消息分冷热两层。还有一个工程层面的提醒:拉取接口的监控要看分页深度分布,大部分请求两页内结束是健康的信号,如果大量请求翻到第十页以上,说明首次握手漏掉了该拉的会话。整个机制与在线推送是互补关系,下一篇 未读数与会话列表:Redis 加速 讲拉取结果如何在缓存层被快速消费,全链路的幂等兜底见 消息防丢:全链路幂等设计。

首次登录的拉取要控量。一个三十天没上线的用户,未读消息可能上千条,一口气全拉到端上既费流量渲染也慢。我们的做法是首屏只拉最近二十条让用户立刻能看,剩余的等用户上滑到列表顶部再分页懒加载,分页大小固定为五十。配合的细节是 ack 改成按 seq 的滑动窗口确认:客户端回报当前已连续收到的最大 seq,服务端据此批量清理离线表,而不是每条消息一个 ack 把存储打爆。窗口内允许乱序到达,缺口的处理上一节已经讲过,两套机制是咬合的。

延伸阅读