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 分配间隔,正常每条消息间隔为一,间隔异常说明写入链路有跳号,比等用户投诉再排查快得多。
