1.1 需求拆解:消息必达与多端同步

场景

产品评审会上,老板只提了一个要求:消息不能丢。听着简单,砸到我们头上全是细节。用户在地铁上发了一条消息,网络断了,手机上显示已发送,对方却没收到——这就是事故。更麻烦的是多端:同一个账号同时登着手机和网页端,手机上看过的消息,网页端不能再弹一遍未读;在电脑上回复了一句,手机上要立刻看到那句回复。

我们把这些场景一条条列在白板上,最后归成两条硬指标。第一是必达:消息只要服务器收到了,就一定要让接收方看到,当前在线就推,不在线就攒着等他来拉,客户端显示已发送不代表服务器真的收到。第二是多端同步:任意一端产生的状态变化,其他端要在合理时间内一致,未读数、最新消息、已读位置都要对齐,不能出现手机上读完了、网页上还红点的情况。

除了这两条,还有几条软性需求:消息在会话内要有严格顺序,不能因为网络延迟把昨天的话顶到今天前面;同一条消息不能重复展示,弱网重传时客户端要去重;最后是已读回执,对方读没读、读到哪一条,双方都要能看到。这些软需求后面都成了踩坑大户,此处先埋个伏笔。

方案

必达这件事业界其实是有共识的:先落库,再推送。消息先进存储,再决定怎么投递。这样即使推送那一刻接收方掉线了,消息也已经在库里躺着,等他重连自己来拉。这等于是把可靠性的责任从推送通道搬到了存储系统上,代价是每条消息都要走一次存储,写入压力直接上来了,高峰期存储成了瓶颈,这才有了后面引入 RocketMQ 削峰 的故事。

多端同步的常见做法是给每个会话维护一个版本号或序号(我们叫 sequence,简称 seq)。消息写入时 seq 递增,客户端每次上来带上自己各端的同步点,服务器把大于该值的消息全部返回。未读数单独维护,不依赖拉取动作,读多写多的场景放 Redis 里做计数(见 未读数与会话列表:Redis 加速)。

这套模型有个隐含前提:seq 必须严格单调且不重用。会话内也许有消息被撤回、被删除,撤回不是把 seq 抹掉,而是把那条消息体替换成撤回占位符,seq 照样占着位置。这样客户端按 seq 排序永远稳定,不会因为一条消息消失导致后面的 seq 全部前移。

需求 核心矛盾 我们的选型 代价
消息必达 推送通道不可靠 先落库再推送,离线拉取补偿 写入链路变长,RTT 增加
多端同步 各端状态不一致 会话级 seq 加客户端上报同步点 需要保证 seq 单调递增
未读数 读多写多 Redis 独立计数,落库做兜底 存在短暂不一致窗口
消息顺序 并发写入乱序 会话内严格按 seq 排序 写入要争锁或走队列
去重 弱网重传 客户端按 MsgID 幂等 需要全局唯一 ID

配置与代码

消息表是整个设计的地基。我们最早的版本只有一张 message 表,后来拆成单聊和群聊分表,这里给一个简化后的结构:

type Message struct {
    MsgID      int64  `json:"msg_id"`      // 全局唯一,雪花 ID
    ConvID     string `json:"conv_id"`     // 会话 ID,单聊=双方UID拼接,群聊=群ID
    SenderID   int64  `json:"sender_id"`
    MsgType    int8   `json:"msg_type"`    // 文本/图片/文件/系统
    Content    string `json:"content"`
    Seq        int64  `json:"seq"`         // 会话内序号,决定展示顺序
    CreateTime int64  `json:"create_time"` // 毫秒
}

客户端同步的逻辑很薄:连上来时先报自己的同步点,服务器返回所有 seq 大于它的新消息,客户端把本地序号更新为返回里最大的那个:

// 拉取新消息,cSeq 为客户端当前已同步的最大 seq
func (h *SyncHandler) Sync(uid int64, convID string, cSeq int64) ([]Message, error) {
    msgs, err := h.store.AfterSeq(convID, cSeq, 50) // 每次最多 50 条
    if err != nil {
        return nil, err
    }
    if len(msgs) == 50 {
        // 还有更多,通知客户端继续翻页,避免一次拉穿
    }
    return msgs, nil
}

除了同步,落库前还要做一层去重,防止弱网下客户端把同一条消息发两遍。我们去重表只记最近一段时间,用的是写后过期策略,不占长期空间:

// 写入前按客户端生成的 clientMsgID 去重
func (s *MsgStore) SaveIfAbsent(m *Message) (bool, error) {
    ok, err := s.du.SetNX(ctx, "du:"+m.ConvID+":"+m.ClientMsgID, 1, 24*time.Hour).Result()
    if err != nil || !ok {
        return false, err // 已存在,丢弃重复消息
    }
    return s.db.Insert(m)
}

去重键里带上了 ConvID,因为不同会话的 clientMsgID 是各自生成的,不能跨会话比较。这套去重逻辑是后面 全链路幂等设计 的雏形。

另外还有一对概念必须在此澄清:已送达与已读是两回事。已送达指接收方的客户端确认收到了这条消息(收到 ack),已读指用户真的把这条消息点开看了。很多新团队把两者混在一起实现,后面撤回、回执、通知全都跟着乱。我们一开始就把这两条状态分开存在消息表的两个字段里,省了无穷无尽的麻烦。

踩坑

第一个坑是 MsgID 和 Seq 混用。早期我们直接拿全局雪花 ID 当前端排序依据,结果同一个会话里两条消息相隔一秒发出,雪花 ID 大的那条其实先入库——时钟回拨导致的。前端排序就乱了,用户截图投诉说聊天记录在跳。教训:展示顺序只认会话内 Seq,MsgID 只负责去重和幂等,两套 ID 各司其职,不能省。这个排查花了足足两天,因为没人怀疑到 ID 策略上,全在查网络层。

第二个坑是多端已读不同步。用户在手机上读了消息,网页端的未读数没减。我们最初只在客户端拉取时减未读数,没想到已读也是个多端事件。后来加了一个 read_report 上行指令,客户端读到哪条 seq 就上报,服务器收到后把该账号其他端的未读数一起刷新。即便如此,跨端仍有一秒级的不一致,我们选择容忍并在文档里写明。

第三个坑是拉取的死循环风险。客户端报上来的同步点比库里还大(比如测试环境造的脏数据),第一次写的时候 AfterSeq 直接返回错误,客户端不断重试形成死循环。改成兜底策略:同步点异常时返回该会话最新 20 条并强行把客户端对齐到最新 seq。

第四个坑和已读回执有关。我们最初把已读回执做成消息附带字段,发送方拉消息时顺带读到对方已读位置。结果多端场景下,手机端读了消息,发送方那端要等到下次主动拉取才能看到回执变化,延迟几分钟。后来把已读变更改成了独立的下行通知,服务器一旦收到 read_report 就向会话另一方推一条轻量的已读变更事件,不再依赖拉取。

小结

必达的核心是先落库再推送,别指望任何推送通道是可靠的;多端同步的核心是会话内单调 seq,所有端都围绕它对齐;去重交给全局 MsgID。这三个概念会贯穿全书,后面 先落库再推送、离线消息拉取 都是围绕它们展开的。存储侧的选型可以先看 存储扩容:先走读写分离。

如果团队只能记住一件事,请记住:永远不要信任网络。客户端显示已发送只是告诉你它把字节交给了操作系统,后面任何一跳都可能失败。设计上假定每一层都会失败,系统反而最稳。

指标定下来之后还有一件事容易被忽略:怎么验收。我们的做法是端到端埋点加月度对账——客户端在消息渲染完成时上报消息 ID、发送时间、渲染时间三元组,服务端按月统计必达率与端到端延迟分布,与产品承诺的指标口径对账。必达率按发送方去重而非按消息条数,否则群消息天然把指标拉高。每季度拉着产品把指标重新评一次,业务变了口径也要跟着变,不调整的承诺会慢慢变成没人信的装饰品。另一个实用做法是给指标配一个负向案例库:每条未达标事件附上根因分类,积攒半年就能看出系统最脆弱的环节集中在哪里,优化预算花在哪里最有数。

还有一个线上踩出来的经验:seq 连续性本身可以作为免费的丢消息探针。客户端每收到一条消息就把新 seq 与本地最大 seq 比较,如果出现跳变(新 seq 大于本地加一),说明中间有消息在传输链路上丢了,这时候不要傻等,直接用本地 seq 加一主动向服务端发起定向拉取,补齐缺口再继续。这套机制上线后,弱网用户的丢消息客诉下降了一大半,因为它把被动等待变成了主动补救。服务端同样按会话监控 seq 分配间隔,正常每条消息间隔为一,间隔异常说明写入链路有跳号,比等用户投诉再排查快得多。

延伸阅读