
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或者“时间戳随机数”碰撞概率极低但不是零。用“UserIDMessageID”作为去重键基本就是双保险。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字段。如果客户端按page1、page2这样拉增量服务端在两次请求之间状态变了客户端很容易漏数据或重复数据。用Cursor的好处是服务端只需要返回“从第N个事件之后的事件”天然幂等。WaitTime是做长轮询用的。客户端没有新事件时不必立即返回空包可以挂住几秒服务端有新事件再推回来。这样可以明显降低客户端频繁轮询造成的服务器压力。参数设计上WaitTime0表示不等待立即返回WaitTime0表示最多等待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) ) ENGINEInnoDB;更新时用乐观锁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状态同步这块强烈建议试一下。结构体设计得再漂亮并发模拟跑不过就是不能上线这是我在实际项目中反复验证过的准则。