IM即时通讯的核心结构体设计,写到了第四篇。前三篇我们把消息收发、会话管理、用户关系这些基础模型拆完了,这一篇进入直接决定用户体验的地带:消息状态、已读回执、多端同步。
先给没看过前面内容的读者交代一下背景。我在做IM系统时,把核心结构体拆成了几套来设计。第一套是消息本身,第二套是会话容器,第三套是用户与关系链。这三套算地基,负责把业务数据存下来、发出去。而今天要讲的这一套,是“状态与同步层”,负责回答三个问题:消息到底处于什么阶段、对方到底读过没有、换一台设备之后会话该怎么接上。
这三个问题要是回答不好,IM产品就会出现很怪异的现象。消息明明发出去了,对面也收到了,界面上却一直转圈;两台设备登录同一个账号,A设备读了消息,B设备还是红点;群聊里两百人集中已读,服务器CPU马上飙高。说白了,这些问题大多不是网络质量造成的,而是状态结构体的设计出了问题。
这一篇适合正在开发IM后端、要设计消息状态和同步协议的同学参考。阅读时不用急着照着抄,我更建议你先理解每个结构体为什么要这样拆,再结合自己项目的并发模型做取舍。
1. 为什么“状态与同步”要从消息模型里拆出来
1.1 前三篇的基础结构体解决不了状态问题
之前定义的消息结构体,通常长这样:
type Message struct { MessageID string ConvID string SenderID string ContentType int32 Content []byte CreatedAt int64 }它能回答“一条消息是什么”,但回答不了“这条消息现在怎么样了”。如果你图省事,在同一个结构体里继续加状态字段,比如“是否已读”“已读人数”“各端删除情况”,短期看是省事,长期一定会被并发写搞炸。
举个典型例子。群聊里一条消息被300个人陆续已读,如果已读状态都集中在Message结构体上,每次有人已读都要update这一行。300个用户同时在线的场景,这条热门消息就是整个库的锁竞争热点。我见过一个项目就是把已读人数直接写在消息详情表里,上线后群消息一热门,数据库主从延迟直接飙到几秒。
所以状态一定要从消息结构体里拆出去,按独立的维度建模。消息结构体负责描述内容,状态结构体负责描述流转过程,职责分开,后面才好扩展。
1.2 同步的本质是让所有端看到同一个故事
IM的多端体验,本质上是给客户端讲故事。服务端要保证,不管你在手机、平板还是电脑上,看到的会话列表、未读数、已读位置,都来自同一套事实。
为了讲好这个故事,一般会引入两个概念:快照和增量。快照回答“现在世界是什么样”,增量回答“从上次之后发生了什么”。这两个概念落到结构体上,就是我下面要展开的内容。
这两个概念也是很多新人容易忽略的。你可能觉得,客户端断线重连时,全量拉一遍会话列表不就行了?小项目可以,但用户会话一多、消息一多,全量数据包大得吓人,拉一次要好几十秒,极其影响体验。快照加增量的核心目的,就是让客户端在大多数情况下只拉“变化的部分”,省流量、省时间、省服务器资源。
2. 消息状态与确认结构体:先把状态机定清楚
2.1 状态定义:五种状态足够覆盖绝大多数场景
先看状态机。一条IM消息从发送到被阅读,我一般拆成这几个阶段:发送中、发送成功、到达接收方、已读、被撤回。
type MsgState int32 const ( StateUnknown MsgState = 0 StateSending MsgState = 1 StateSent MsgState = 2 // 消息已经到达服务端 StateDelivered MsgState = 3 // 消息已经到达接收方 StateRead MsgState = 4 // 接收方已读 StateRecalled MsgState = 5 // 已撤回 )这里有个容易踩的坑:不要用“最后转变时间”来代替状态本身。很多项目为了省字段,只存一个LastStatusTime,客户端根据时间自己猜状态,结果就是状态显示不稳定,同一个会话在不同端上看到的发送状态不一致。我建议状态字段和时间戳分开,状态字段用int32而不是string。用int32既省空间,又方便做位运算和协议映射,比如把状态直接转成客户端协议里的枚举值。
之所以不把状态设计成一个bitmap,是出于表达力考虑。理论上可以用一个uint32表达“有哪些状态发生过”,但实际业务中客户端关心的只是当前状态,不是状态的历史集合。而且bitmap扩展起来非常别扭,比如以后要加“被引用”“被收藏”,你得重新定义位,旧客户端还要兼容,成本很高。
2.2 服务端与客户端的两次确认:Ack包怎么设计
通信协议需要确认。一条IM消息从客户端A发给服务器,服务器再发给客户端B,中间至少要两次确认。第一次是客户端A发消息后,服务器告诉A“我收到了”;第二次是服务器推给B之后,B回复“我收到了”。我把这两次确认统一设计成一个Ack包。
type AckType int32 const ( AckClientToServer AckType = 0 // 上行消息确认,服务器发给发送方 AckServerToClient AckType = 1 // 下行消息确认,接收方发给服务器 ) type MessageAck struct { MessageID string ConvID string AckType AckType AckTime int64 Code int32 // 非0表示错误码 }上行确认比较简单:客户端发出消息,服务器落库之后回一个Ack,客户端把状态从“发送中”改成“已发送”。下行确认稍微复杂,它要求接收方的客户端在成功收到并落盘消息后,回一个Ack给服务器。服务器拿到这个Ack,就知道可以更新这条消息在接收方的送达状态了。
这里我想多说一句Code字段。Code不只是错误码,它还可以携带一些业务语义。比如接收方不在线时,服务器不必等待客户端Ack,而是直接把消息转成离线任务。等对方上线后再推送,到时候再走下行确认。所以Code这个字段要留好,不能只设计成0和1两态。
2.3 幂等去重:MessageID必须由客户端生成
设计MessageAck时有一个经验点:MessageID必须由客户端生成,而不是服务端下发。这样做的原因是,客户端在超时重发时可以用同一个MessageID,服务端通过MessageID做幂等去重。如果MessageID是服务端生成的,客户端在超时重发时根本拿不到ID,只能生成一条新消息,最终导致用户看到消息发送了两次。
我遇到过不止一次这种线上事故。用户在弱网环境发消息,点击发送后没有立刻反馈,用户再点一次,结果对方收了两条一模一样的消息。查日志时发现,两条MessageID都是服务端生成的。后来改成客户端生成ID,服务端在写入前先查一下去重表,这个问题就基本消失了。
幂等去重还有一个细节:去重表不能只存MessageID,最好把发送者ID也放进去。因为不同用户各自生成MessageID,客户端生成方案大多基于UUID或者“时间戳+随机数”,碰撞概率极低但不是零。用“UserID+MessageID”作为去重键,基本就是双保险。
3. 已读回执结构体:单聊与群聊分开设计
3.1 单聊回执:一个LastReadMsgID就够用
单聊场景里,已读回执是一个“事件”。A给B发消息,B读了,服务端需要给A推一个事件,A才知道要更新界面。事件结构体:
type ReadReceipt struct { ReceiptID string ConvID string ReaderID string LastReadMsgID string LastReadAt int64 }单聊只需要记“读到哪一条”。B读了最新的一条,说明之前所有消息都读了,所以单聊的已读事件里,携带LastReadMsgID就够了,不需要回传一个已读列表。
这里有个细节:LastReadMsgID不是消息的创建时间,也不是消息序号,而是消息在会话里的递增序号对应的ID。因为如果直接用消息字符串ID,没法判断大小关系。客户端要拿到“B读到了第几条”,才知道该把哪条之前的消息显示成已读。所以结构体里虽然写的是MsgID,内部实现一定要能快速映射到会话序号。
3.2 群聊已读:整体位置与明细记录两条链路
群聊就复杂了。一个200人群,一条消息被150个人读了,如果产生150个已读回执事件广播给发消息的人,发消息的人瞬间被打爆。这些事件在客户端也很难合并处理,界面上会频繁刷已读头像。
我见过两种主流方案。第一种是“整体已读位置”,并不记录每个消息读了哪些人,而是记录每个群成员已经读到了哪一条消息。
type GroupReadPos struct { ConvID string MemberID string ReadSeq int64 UpdatedAt int64 }配合这个结构,客户端查询“这条消息有哪些人已读”时,服务端扫描成员表里ReadSeq大于等于该消息序号的成员即可。这个方案的优点是写入量小,一个成员在一个群里只维护一个数字;缺点是查询已读列表时要扫描,群成员到几千人时有点吃力。
第二种方案是针对单条消息的记录表:
type MessageReadState struct { MessageID string ConvID string ReaderID string ReadAt int64 }每次已读都往表里插一行,适合做“已读人数统计”“谁读了谁没读”这类运营功能。缺点也很明显:热点消息写入量大。我通常的用法是,普通群只维护GroupReadPos;运营型群或需要展示已读明细的群,才额外写MessageReadState,两条链路并存。
讲真,绝大多数群聊产品只需要GroupReadPos。用户点进群聊,看到“已读 128/200”,这个数字就是扫描或聚合出来的。只有类似企业办公软件那种需要精确到人、按头展示的场景,才值得上MessageReadState。上线前想清楚自己到底需要哪种,能省不少事。
3.3 会话已读位置:把并发写从会话里拆出去
无论是单聊还是群聊,客户端界面上最核心的一个数字是“这个会话我读到哪了”。结构体:
type ConvRead struct { ConvID string UserID string LastReadSeq int64 LastReadAt int64 }我见过有人把LastReadSeq放在会话结构体里,跟会话数据一起读写。在单聊低并发下没问题,但在群聊高频已读同步下会成为瓶颈,因为同一个会话结构体要被几百人竞争写。拆成ConvRead单独存,按UserID分片,写压力就分散了。
这个结构体还有个容易被忽略的点:LastReadSeq的更新是“只前进、不后退”的。如果用户从群聊的第100条消息翻到第200条,LastReadSeq要更新到200;但如果用户又往上翻回第50条,LastReadSeq不能回退到50,否则其他端就会看到未读数又变大了。这个约束要在服务端做,不能靠客户端自觉。
4. 多端同步的结构体设计:快照加增量
多端同步是IM里比较费脑子的一块。登录新设备、切后台再回来、网络断了重连,客户端都要能快速对齐服务端状态。
4.1 全量快照:新设备冷启动时给什么
新设备第一次登录时,没有本地数据,服务端要给一份全量快照。
type SyncSnapshot struct { Cursor int64 Conversations []*ConversationBrief Messages []*MessageBrief GroupReadPosMap map[string]int64 }快照不需要把历史消息全给出去,给最近N条就够。如果用户需要更早历史,客户端再走专门的历史拉取接口。快照太大,网络层容易超时;太小,客户端要频繁拉取。我一般按“一个会话最近20条”来生成,具体数值要看消息体大小。如果消息体普遍带图片缩略图,20条可能就上MB了,那就要减少到10条。
快照里的Cursor是整个同步机制的锚点。客户端拿到快照后,记住Cursor,后续增量同步都基于这个数字。所以快照生成时,Cursor必须是服务端当前已经持久化的最大同步序号,不能是生成快照那一刻的实时序号,不然客户端会漏掉快照生成过程中产生的新事件。
4.2 增量事件:断线重连后拉什么
客户端有了快照之后,后续靠增量事件更新。
type EventType int32 const ( EventNewMessage EventType = 0 EventReadChanged EventType = 1 EventRecall EventType = 2 EventConvDeleted EventType = 3 ) type SyncEvent struct { EventID string EventType EventType TargetID string Data []byte Timestamp int64 }增量事件的关键是顺序。客户端必须能判断“我是不是漏了事件”,所以同步游标很重要。游标可以是单调递增的全局序号,也可以是时间戳加偏移量。我强烈推荐全局单调序号,因为时间戳在分布式环境下并不可靠,时钟回拨一次,客户端就分不清哪个事件更晚了。
每个SyncEvent携带的Data,最好是一段完整的JSON或Protobuf消息,而不是一个ID。为什么?因为客户端拉取增量时,可能事件1是“新消息”,事件2是“这条消息被撤回”。如果Data只给ID,客户端就要再发请求查详情。IM场景对时延很敏感,多一次请求就多一次失败概率。直接把数据塞进事件里,客户端拿解析就能渲染。
4.3 游标选型:全局序号为什么更适合大部分项目
这是多端同步设计里争议最大的点。全局游标的优点是简单,一个客户端只管记住一个数字;缺点是一切状态变化都产生全局序号,服务端要维护一个全局递增发号器,存在单点压力。
会话级游标的优点是压力分散,各会话独立推进;缺点是要维护每个会话的游标,客户端拉取时要对多个游标做合并,逻辑更复杂。
我个人的习惯是:中小型IM,甚至几十万在线的项目,直接用全局游标。用一个Redis INCR或者一个中心化发号服务就够了。全局游标扛住日常业务场景完全没问题,反而是合并游标这种逻辑更容易出BUG。除非做到千万级在线,再去考虑会话级游标。
不要被“全局发号器是单点风险”这句话吓尿。实际上Redis单实例的INCR可以支撑每秒几万次,绝大多数IM项目的消息峰值根本到不了这个量级。真到了,可以再升级为分段发号或者雪花算法,而不是一开始就把客户端同步协议搞复杂。
4.4 拉取接口参数:用Cursor不要用Page
最后是客户端主动拉取同步的接口。参数看起来简单,但陷阱不少。
type SyncRequest struct { Cursor int64 Limit int32 WaitTime int32 } type SyncResponse struct { Cursor int64 Events []*SyncEvent HasMore bool }这里我要专门提醒一个坑:Limit和Cursor要搭配,不能用分页的page字段。如果客户端按page=1、page=2这样拉增量,服务端在两次请求之间状态变了,客户端很容易漏数据或重复数据。用Cursor的好处是,服务端只需要返回“从第N个事件之后的事件”,天然幂等。
WaitTime是做长轮询用的。客户端没有新事件时,不必立即返回空包,可以挂住几秒,服务端有新事件再推回来。这样可以明显降低客户端频繁轮询造成的服务器压力。参数设计上,WaitTime=0表示不等待,立即返回;WaitTime>0表示最多等待N秒。这里要设置上限,比如30秒,否则网关层容易超时断开。
5. 存储与缓存:结构体落到库表怎么设计
光有逻辑结构体还不够,落到线上还要解决存储和缓存的问题。
5.1 缓存Key与版本控制
已读状态和同步游标是典型的高频读场景,适合放缓存。缓存Key我习惯这样设计:
read_pos:{conv_id}:{user_id} conv_unread:{user_id}:{conv_id} sync_cursor:{user_id}对应的缓存结构体:
type CacheValue struct { Value string Version int64 ExpireAt int64 }Version字段用来做并发控制。两个客户端同时更新同一个已读位置,后写不应该覆盖先写,必须比对Version。这个设计在Redis里通常用WATCH/MULTI实现,或者直接用Lua脚本做原子更新。
5.2 乐观锁更新SQL
表结构其实很简单:
CREATE TABLE `conv_read` ( `user_id` varchar(64) NOT NULL, `conv_id` varchar(64) NOT NULL, `last_read_seq` bigint(20) NOT NULL, `version` bigint(20) NOT NULL, PRIMARY KEY (`user_id`, `conv_id`) ) ENGINE=InnoDB;更新时用乐观锁:
UPDATE conv_read SET last_read_seq = ?, version = version + 1 WHERE user_id = ? AND conv_id = ? AND version = ?;这里有个很重要的经验:已读位置只能单向递增,不能因为网络乱序把已读位置回退。所以更新前一定要判断新值比旧值大,否则直接丢弃。用普通UPDATE语句很容易把这个约束写漏,我建议把“新值大于旧值”的判断直接写在WHERE条件里。
UPDATE conv_read SET last_read_seq = ?, version = version + 1 WHERE user_id = ? AND conv_id = ? AND version = ? AND ? > last_read_seq;这一条SQL就能挡住乱序更新,省掉不少判断逻辑。很多线上“已读回退”的事故,就是少了最后那个条件。
5.3 结构体扩展性:预留字段与版本号
这个经验是从踩坑里总结出来的:IM结构体一定要预留字段扩展空间。用Protobuf协议时,给每个结构体加保留字段编号;用JSON时,增加版本号字段。
type MessageBrief struct { Version int32 `json:"version"` MessageID string `json:"message_id"` ConvID string `json:"conv_id"` Extra map[string]string `json:"extra,omitempty"` }一旦客户端上线后,你真没法控制它什么时候升级。结构体少一个字段,老客户端反序列化就会崩。比如上线时MessageBrief里只有MessageID和ConvID,后来想加一个sender_name。服务端把sender_name直接放在结构体顶层,老客户端解析时遇到未知字段还好说,但如果你把字段位置调换,老客户端按index取值就可能错位。所以新字段要么通过Extra这种映射传,要么通过版本号控制解析逻辑。
Protobuf场景下还要注意字段编号的预留。字段编号就是契约,一旦发布,永远不要复用已删除的编号。服务端新增字段时,从高位编号开始加,不要把编号重排。
6. 线上问题排查实录:状态同步常见的四个坑
这一节我整理一下最近排查过的几个和这些结构体相关的线上问题。虽然都以伪代码呈现,但场景参考价值很高。
6.1 已读位置回退:先查版本比较
现象:用户反映,在电脑上把某个群聊全部已读了,回到手机上看,未读数字又冒出来了,点进群聊又变已读,反复横跳。
排查思路:先看两个端是不是走了同一条更新链路。如果电脑端更新的是服务端的ConvRead,手机端也更新服务端的ConvRead,理论上应该是一致的。出现回退,多半是其中一个端用旧值覆盖了新值。后来看了日志,发现电脑端长连接断线重连后,本地缓存的LastReadSeq没有重新拉取,直接把一个过期的Seq提交上去了。根因就是服务端UPDATE没有加“新值大于旧值”的判断。
修复方法就是前面提过的,UPDATE语句里加上AND ? > last_read_seq。这个条件能挡住所有类似乱序问题。
6.2 多端重复拉消息:游标推进时机错
现象:手机和电脑上同时收到一条新消息的推送,电脑上打开聊天列表,手机也打开聊天列表,结果两个端在同步时拉到了重发消息。
排查思路:一开始以为去重逻辑有问题,后来发现是服务端把游标推进的时机弄错了。实现时先把Cursor更新到最新,再生成SyncEvent返回给客户端。如果生成SyncEvent和更新Cursor之间没有事务保证,客户端拉完一批事件后,服务端Cursor已经推进得更远了,客户端下一次拉取时从比实际更大的游标开始,中间的事件就丢了。重连之后客户端发现游标比服务端小,又从头拉,于是出现重复。
解决方式:生成SyncEvent和更新Cursor必须在一个事务里完成,或者对着一个唯一ID做幂等。先写事件表,再从事件表里生成响应,最后更新用户游标。三个步骤串起来,不能乱序。
6.3 群聊已读风暴:该不该走异步
现象:大群里用户集中已读,消息服务所在机器的CPU和数据库负载飙升,用户反馈聊天卡顿。
排查思路:看日志发现,已读回执的写入量是正常消息量的几十倍,每个成员每次已读都直接写库加推送。这种场景不能同步处理所有已读事件。我把GroupReadPos的更新改成了异步合并:多个客户端同时更新同一个成员的ReadSeq,在内存里按MemberID合并,批量写库。已读明细的MessageReadState则降级为运营查询服务单独处理,不占主链路。
所以建议群聊已读回执的生产和消费完全解耦。用户已读是一个高频且允许延迟的动作,晚几百毫秒显示已读没有任何问题,没必要为了实时性牺牲数据库稳定性。
6.4 用并发模拟脚本测结构体
最后分享一个我自己的测试习惯。所有状态相关的结构体,在提交代码之前我会先写一个小并发模拟:模拟两个客户端、一个服务端,反复乱序推送SyncEvent和Ack,看最后能不能收敛到一致状态。
我经常用Go写这种模拟,逻辑很简单:
for i := 0; i < 10; i++ { go func() { for j := 0; j < 1000; j++ { client.UpdateReadSeq(convID, randomSeq) } }() }跑完检查最后的ReadSeq必须是所有写入里的最大值,不能出现回退。这个测试看起来糙,但比我用过的任何lint工具都好使,能直接暴露并发更新时的竞态。如果你也在改IM状态同步这块,强烈建议试一下。结构体设计得再漂亮,并发模拟跑不过就是不能上线,这是我在实际项目中反复验证过的准则。