IM即时通讯这块,做久了才发现一个扎心的事实:线上百分之七八十的疑难杂症,追溯到最后都不是网络问题、不是并发问题,而是最底层那几个结构体设计错了。字段加得随意、边界划得模糊、语义没定清楚,后面所有的路由、存储、推送、同步全跟着遭殃。这个系列前面几篇聊了整体架构、网络层、存储选型,今天这篇集中把核心结构体的设计思路拆开讲,重点说清楚每个关键字段为什么存在、怎么定、有哪些坑是看着不起眼但能让你熬夜排查的。
1. 字段的边界,就是系统的边界:结构体设计的第一性原理
很多人写结构体是"用到什么加什么",今天要展示个昵称就加个 nickname,明天要搞个已读就加个 read_time,半年下来一个消息结构体三四十个字段,大部分字段只在某个特定接口里用一次。这个习惯在 IM 里是致命的,因为 IM 的链路太长了:发消息要过接入层、逻辑层、存储层、推送层、同步层,每一层都要拷贝、序列化、落库、投递,结构体每多一个字段,整个链路的解析、存储、传输成本都在涨,而且最可怕的是字段之间的隐形耦合。
我自己在项目里定过一个规矩:结构体字段分四类,消息本身不可拆分的属性、客户端展示需要的属性、服务端路由和存储需要的属性、扩展预留属性,这四类物理分开。用一个实际例子说明,早期版本把消息的发送者昵称直接塞进了消息体,后来产品要求昵称可以修改、历史消息要显示修改后的昵称,结果整个历史消息存储全部要刷一遍,那个周末我现在想起来还后怕。
设计结构体的核心原则应该是:一个结构体只服务一个明确的使用场景,字段的归属由读取方决定,而不是由写入方决定。发送端觉得"昵称跟着消息走很方便",但历史消息读取方需要的是"看到当前昵称",这两个诉求冲突时,必须服从读取方。这就是为什么后来我们把消息体、消息扩展、消息上下文拆成三层,每层独立演化,改动一个不影响另外两个。后面几节我会逐个拆解每个核心结构体。
2. 消息结构体:一条消息从诞生到被正确渲染,需要哪些字段支撑
消息结构体是整个 IM 系统里最重要的一个结构体,没有之一。好消息、坏消息、系统通知、红包消息、文件消息,本质上都逃不出消息结构体的框架。我的设计经验是围绕三个核心问题展开:这条消息是谁发的、什么时候发的、内容是什么,然后把答案固化成字段。
2.1 msgId 与 seq:两个"ID"各司其职,千万别混用
新手最容易掉的坑就是把 msgId 和 seq 混为一谈,或者干脆只用其中一个。这两者的职责是完全不同的:msgId 是消息的业务唯一标识,seq 是消息在会话内的排序标识。msgId 追求的是全局唯一、不可变、可追溯,通常由发送端生成,格式类似"设备ID-时间戳-随机数",保证任何客户端重试时携带同一个 msgId,服务端可以幂等去重;seq 追求的是单调递增、会话内有序,由服务端生成,客户端拿 seq 做本地排序、做增量同步的游标。
用一个场景说明两者的配合:用户 A 在弱网环境下点了一下发送,客户端自动重试了三次,如果服务端以 msgId 去重,最终只落了一条消息,这是正确行为;但如果客户端把 seq 也带上来重试,服务端拿 seq 去重就会出大问题,因为 seq 在服务端还没分配。所以我们的接口设计是:客户端请求只带 msgId,服务端回包带 seq,客户端本地显示用 msgId 做 key,排序用 seq,两者互不干扰。
2.2 payload 结构:内容字段的"三明治"设计
内容字段是最容易失控的。早期方案是 content string,所有类型消息都往里塞 JSON,服务端不解析、不校验,客户端拿到自己反序列化。这个方案表面上灵活,实际上隐患很大:服务端要做敏感词过滤时拿不到结构化内容,推送层要根据消息类型决定走什么通道时得先反序列化,不同客户端版本对同一个 JSON 字段的理解还会打架。
我的建议是 payload 拆成三层,像三明治一样:
- 第一层基础字段:content_type(文本、图片、语音、自定义)、content_encoding(plain、base64、加密)、content_text(纯文本摘要,用于搜索和通知栏展示)
- 第二层结构化字段:结构化的业务字段,比如图片的 url、宽高、大小,语音的时长、采样率,文件的名称、大小、md5
- 第三层扩展字段:一个 JSON 字符串,预留给客户端自定义协议和未来新消息类型
这样分层的好处是:服务端和推送层永远只处理第一层和第二层,不碰第三层;客户端各取所需;新增消息类型时,只需要扩展第三层,不用改表结构。第三层要约定一个硬性大小上限,我们定的是 4KB,超过这个值的扩展内容走附件上传,拿附件 ID 填充,防止有人把整个文件 base64 塞进去。
2.3 消息方向与状态流转:别让状态字段承担它不该承担的职责
消息结构体里还有几个让很多人纠结的字段:direction(上行/下行)、status(发送中/已送达/已读/失败)、sender_id、receiver_id。这里最容易出的问题是拿 status 去反推 direction,或者把"已读"状态直接挂在消息上。我见过一个案例,把已读状态挂在消息的 status 字段上,结果同一批消息在不同会话里已读状态互相覆盖,那个排查过程非常痛苦。
我的做法是:消息本身只有发送状态(发送中、成功、失败、被撤回),已读状态属于会话维度,不放进消息结构体,而是放进会话结构体的 read_seq 字段。会话的 read_seq 表示"这个会话我读到了哪条 seq",小于等于 read_seq 的消息都是已读的,大于 read_seq 的未读。这样设计后,消息状态和会话已读各司其职,一问"这条消息发出去没有"看 status,一问"这个会话还有几条没读"算 read_seq,不用再纠结。
3. 会话结构体与已读游标:理解这两个概念,IM 的复杂度能少一半
会话是 IM 里比消息更抽象也更难设计的东西。消息是流水,一条接一条来;会话是聚合,要回答"我和这个人聊到哪了""这个群我有多少条没读"。会话结构体的设计直接决定了历史记录加载、未读角标、多端同步这些核心体验的成败。
3.1 会话模型:为什么不能把会话直接等同于聊天对象
常见的错误设计是:一个会话就对应一个 peer(单聊的对方 ID 或群 ID),然后在这个会话上挂一堆字段。这个设计在小规模没问题,但一旦有系统号、客服号、公众号这些"一个人对多个人"的场景就崩了。比如一个客服号同时服务一千个用户,每个用户看到的会话顶栏都不一样,会话结构体如果只记 peer ID,根本存不下这一千份差异化信息。
我们的做法是引入session_key 概念,由 conversation_type、owner_id、peer_id 三个字段联合组成。conversation_type 区分单聊、群聊、系统通知、客服会话;owner_id 是会话的归属者;peer_id 是会话的对方(群 ID 或用户 ID)。客服场景下,owner_id 是每个用户,peer_id 是同一个客服号,但每个用户拥有独立的 session_key,顶栏信息挂在 owner_id 级别,互不干扰。单聊场景下,两条消息往来会形成两个方向上的两个 session_key,每个方向各存各的游标。
3.2 read_seq、clear_seq、delete_seq:三个游标字段撑起整个未读体系
会话结构体里最核心的三个字段是:read_seq(已读游标)、clear_seq(清空游标)、delete_seq(删除游标)。这三个字段解决了三个不同的问题:
- read_seq:我读到哪条消息了,小于等于 read_seq 的都视为已读
- clear_seq:我不想一条条标记已读,直接说"这个会话我全部读过了",效果等于把 read_seq 设为当前最新 seq
- delete_seq:我要删除本地会话记录,但服务端不能把消息物理删掉(对方还要看),所以只记一个"这个会话我从哪条 seq 开始不显示了"
这三个字段分离后,客户端逻辑非常清爽。举个例子:用户点了"清空聊天记录",客户端把 clear_seq 和服务端同步,本地清掉消息列表,但会话还留着,用户再发一条消息时,会话自动置顶,但历史记录从 delete_seq 之后重新拉取,不会被误删。这个设计还有一个好处是天然支持多端同步:手机端标记已读了,把 read_seq 同步到服务端,平板端拉取会话列表时用 read_seq 判断未读数,两边读数一致。
3.3 会话列表里的冗余字段:last_msg 的快照,一致性的来源
会话列表页要展示每个会话的最后一条消息摘要和发送时间,这个需求几乎是所有 IM 的标配。以前我们图省事,直接实时去消息表查每个会话的最新消息,结果会话一多,列表接口被拖垮了。后来改成在会话结构体里冗余 last_msg 快照,在消息落库时同步更新。这个冗余解决了性能问题,但带来了新的一致性问题:消息被撤回、被修改时,last_msg 快照也要跟着变,而且必须是事务性的。
我们最终的做法是在消息落库的同一个事务里更新 last_msg,事务里同时写消息表和会话表,一旦失败全部回滚。这块我后面会细说,这里先记住结论:会话结构体的冗余字段不能随便更新,必须和消息写入绑定在同一事务里,否则一定会出现会话列表和消息页数据对不上的问题。
4. 连接与在线状态结构体:心跳、连接和状态三方之间的备选详情
IM 是强实时系统,连接层是命脉。连接结构体和在线状态结构体看似简单(不就是记一下 TCP 连接和在线不在线吗),实际上涉及的心跳超时、多端互踢、状态透传,全是隐性的坑。
4.1 连接结构体:不只是 IP 和端口,还要记录"连接的生命周期"
接入层每建立一个长连接,都要有一个连接结构体来管理这个连接。最小可用版本大概长这样:
| 字段 | 用途 |
|---|---|
| conn_id | 全局唯一连接标识,日志排查的抓手 |
| user_id | 连接所属用户 |
| device_id | 设备唯一标识,多端管理靠它 |
| client_type | 客户端类型:iOS、Android、PC、Web |
| remote_addr | 客户端地址,用于安全风控和日志 |
| last_active_time | 最后一次收到客户端心跳的时间 |
| heartbeat_interval | 协商的心跳间隔,由服务端下发给客户端 |
| conn_status | 连接状态:未认证、已认证、半关闭、已关闭 |
这里要注意的是:连接结构体只负责"连接"本身的信息,不要往里塞业务数据。我见过有人把用户当前会话列表直接挂在连接结构体上,美其名曰"取用方便",结果每次连接状态切换都要深拷贝一遍,内存还特别容易炸。连接结构体应该保持轻量,业务数据和连接通过 user_id 关联,而不是把业务数据塞进连接结构体里。
4.2 心跳超时的窗口计算:为什么固定的 60 秒超时是个坑
心跳策略是连接层设计里最容易被忽视的部分。大多数初版实现都是:客户端每 30 秒发一个心跳,服务端 60 秒没收到就断开连接。这个方案在 Wi-Fi 环境下问题不大,但在移动网络下非常容易误杀。
移动网络的特性是:应用切后台后,系统可能会把应用的网络权限收走,或者让 TCP 连接静默一段时间,客户端心跳发不出去,服务端 60 秒没收到心跳就断开了。等用户切回前台,连接已经断了,客户端要重新建连,期间消息推送会全部走 APNs/FCM,体验直线下降。有个更隐蔽的坑是网络中间设备(比如运营商 NAT)的空闲超时,长时间没有数据流的 TCP 连接会被中间设备静默回收,客户端还以为连着,实际已经死了。这种连接叫"半开连接",服务端根本无法感知。
**我的处理方法是三层检测:**客户端主动心跳(间隔 T1=30 秒)、服务端空闲检测(间隔 T2=65 秒)、服务端保活探测(间隔 T3=70 秒,如果 65 秒没收到心跳,服务端主动发一个探测包,再等 15 秒没响应才断开)。简单说就是:不能只靠客户端心跳判断生死,服务端要有主动探测兜底。心跳间隔也不建议固定,服务端要根据客户端类型下发不同的心跳策略:Web 端可以短一点(20 秒),移动端可以长一点(45 秒),省电省流量。
这套策略上线后,我们统计过移动端的掉线率降低了差不多一半,最直观的感受是消息推送到达率明显提升了,因为在线状态判断准了之后,该走长连接的消息不会误走推送通道,也就少了那一层投递延迟。
4.3 在线状态结构体:pub/sub 模式下如何避免"状态风暴"
在线状态是给谁看的?给好友列表看的,给会话列表看的。所以在线状态结构体不应该只存一份"我在不在线",而是要考虑"谁能看到我、看到什么状态"。最早的实现是用户上下线都发一个事件广播给所有好友,结果一个头部用户上下线,几千个好友在线状态全刷新,服务端事件分发直接被打爆。
后来我们做了一版更保守的:状态事件不实时广播,而是定时聚合批量发送。每个用户的状态只存一份核心字段:online_status、last_online_time、current_device,需要展示在线状态的业务方(比如好友列表)拉取时按需获取,而不是被动接收广播。好友列表进入时拉一次,之后靠和用户的长连接心跳间接触发更新,这样每 5 分钟好友列表最多刷新一到两次,服务端压力直接降了一个量级。
这个路径里最核心的状态字段就三个,不需要设计一大堆,重点是语义要清晰。比如"今天 10:00 活跃"和"当前在线",后者是实时数据,存 Redis;前者是历史数据,存数据库,更新频率完全不同,不能混在一个结构体里。
5. 离线消息与推送结构体:APNs、FCM、厂商通道的"统一抽象层"
如果用户离线了,消息怎么触达?走 APNs/FCM/华为推送/小米推送。各家通道的消息结构体长得都不一样,如果每接一家厂商就写一套结构体,业务层会被厂商协议搞疯。我们的做法是做一个厂商无关的推送结构体,再在适配层转换成各家的格式。
5.1 推送结构体的"五元组"设计
一个通用的推送结构体,我建议至少包含五个维度:推送场景(push_scene)、推送目标(push_target)、推送内容(push_content)、推送策略(push_policy)、推送回执(push_feedback)。
- 场景:新消息、好友请求、系统通知、操作结果,场景决定了要不要弹窗、要不要响铃
- 目标:user_id + device_id + 厂商通道,解决多端推送路由问题
- 内容:标题、正文、透传参数、角标数,各家厂商的字段命名不同,但语义是相通的
- 策略:到达后的展示策略,比如是否折叠、是否静默、是否走通知栏
- 回执:推送结果、到达时间、点击时间,用于构建可靠的送达反馈
这个结构体的巧妙之处在于:业务层只需要构造五元组,适配层负责把五元组翻译成 APNs 或 FCM 的字典格式。以后新增一家厂商,只需要新增一个适配器,业务层的结构体纹丝不动。
5.2 离线消息存储:推不出去的坏消息,谁来兜底
推送只是触达手段,真正兜底的是离线消息。用户离线期间收到的所有新消息,要等用户下次上线时通过增量同步拉取。所以离线消息存储结构体的设计得围绕"用户上线后怎么高效取回"来展开。
我们的离线消息结构体核心字段是:owner_id(谁收到了这条离线消息)、msg_id(消息 ID)、seq(会话内排序)、conversation_id(属于哪个会话)、stored_time(存储时间)、message_payload(压缩后的完整消息体,直接存储从消息表里复制的那份)。
离线消息表以 owner_id + conversation_id 为联合索引,用户上线时的同步逻辑就是按会话维度,查每个会话的 seq 大于本地游标的消息。这个结构体千万别逐条存储消息的所有冗余字段,直接引用消息表的主键加上会话 ID 就够了。有一种优化是把同一会话的离线消息按会话聚合打包,减少拉取时的往返次数。比如离线期间群里来了 50 条消息,客户端只需要一次请求就能拉完,而不是 50 次。
5.3 推送配额与静默策略:结构体里的限流字段
推送通道是有成本的,APNs 和 FCM 都有速率限制,厂商通道在消息量太大时也会降级。所以推送结构体里要有配额相关的字段:push_priority、collapse_id、valid_until、throttle_group。
- collapse_id:iOS 的折叠 ID,同 ID 的消息只显示最后一条,适合群消息这种高频场景
- push_priority:高优先级走即时送达,低优先级可以延迟合并
- valid_until:消息的有效期,过期了就没必要再推了,减少无效推送
- throttle_group:限流组,同一个限流组内的推送会被服务端合并或排队
这几个字段看似不起眼,但在大群场景和营销消息场景下是保命的关键。一个万人群,一条全员 @ 消息如果不对推送做折叠和限流,通道会被瞬间打满,其他普通消息全部排队延迟。结构体设计时留好这些字段的坑位,后面做运营活动时你会回来感谢当时的设计。
6. 增量同步结构体:多端消息一致性的技术破局
IM 多端同步的本质是:每个端维护自己的同步游标,服务端按游标把增量数据吐回去。这个机制的实现结构体设计,决定了多端体验是否一致、同步是否高效。
6.1 同步游标的持久化结构:存什么、怎么存、什么时候更新
同步游标是一个集合,不是单一字段。每个端要维护多个维度的游标:
| 游标维度 | 含义 | 存储位置 |
|---|---|---|
| 消息游标 | 本端已同步到的全局消息序号 | 服务端 + 客户端本地 |
| 会话游标 | 每个会话已读到的 seq | 服务端 + 客户端本地 |
| 事件游标 | 已同步到的事件流序号(好友变更、群变更等) | 服务端 + 客户端本地 |
服务端存游标时要注意:游标的更新必须是事务性的,不能消息还没发出去游标先更新了,也不能游标更新了消息丢了。我们的做法是消息表和游标表在同一个存储事务里提交,消息落库成功、游标更新成功,两个都成功才算同步完成。
6.2 增量拉取的请求结构:batch_size 与 seq_range 的边界
增量拉取的请求结构体大概有这几个字段:sync_key(客户端上次的游标)、max_count(本次最多拉多少条)、conversation_id(可选,多会话全量拉取时传空)、last_batch_time(可选,用于服务端侧判断是否走增量还是全量)。
这里有个关键的设计细节:max_count 一定要由客户端控制,服务端只做上限校验。因为不同设备的性能不同,同一时间点,手机端可能需要一次拉 50 条,PC 端一次能拉 200 条。如果服务端写死批量大小,弱的端会卡顿,强的端又浪费带宽。我们定的上限是 500 条,超过 500 条的增量会按时间切片分成多批次返回,每个批次都携带 next_sync_key,客户端用 next_sync_key 继续拉。
6.3 全量同步与增量同步的分界:什么情况下客户端该走全量
增量同步再优雅,也有失效的时候。比如客户端本地数据库损坏,或者用户删了 App 重装,没有本地游标可用了。这时客户端要重建会话列表和消息记录,走全量同步。全量同步请求结构体里只需要带一个 full_sync flag,服务端就直接把该用户参与过的所有会话列表和每个会话最近 N 条消息一次性打包返回。
这里有一个很重要的经验:全量同步不要真的把用户所有历史消息全拉下来,代价太大了。我们只拉每个会话最近 30 条作为初始展示,用户往上翻页时再按需加载更早的消息。这个策略的平衡点是:冷启动速度够快,历史记录按需加载也有数据支撑。所以结构体里要设计好 unsigned 的上限标识和分页游标,让全量同步和增量同步无缝衔接:全量同步返回 latest_completed_seq,客户端把它视为新的本地游标存起来,后续的增量同步就从这个位置开始。
6.4 大字段消息的同步陷阱:拉取超时与存储膨胀
增量同步的最后一个坑,来自于大字段消息。早期我们把图片消息的缩略图 base64 直接存进消息表,一条缩略图几十 KB,一个群一晚上几千条消息,增量同步的数据量就变成几十 MB 甚至几百 MB,客户端同步一次要卡半天。
我后来定了一个规矩:所有的消息内容,凡是体积超过 1KB 的,一律走附件存储,消息表里只存附件 ID。同步结构体里附件字段走的是一套延迟加载机制:先拉消息骨架(msgId、seq、content_type、附件 ID),客户端渲染到消息列表时再按需加载附件内容。这样设计之后,增量同步的响应包体积急剧缩小,弱网环境下同步耗时降低了大约一半,拉取超时的报错几乎绝迹。这算是我在这几轮迭代里收获最大的一条经验。
7. 演进态:结构体设计的兼容性策略与最后的个人体会
做 IM 结构体设计这两年,我自己最大的感受是:不要追求一步到位,要追求留好演进的接口。一批结构体发布出去,线上客户端千千万,你永远无法要求用户升级到最新版才让你改结构体。所以字段的增删改必须考虑兼容性。
我们的兼容性策略是用 proto 方案管理所有核心结构体,字段编号一旦分配就永久占位,废弃的字段标记 reserved,不允许复用编号。新增字段时遵循"optional + 默认值"原则,老客户端解析新消息时不认识新字段也不会报错。版本发布时,服务端多版本同时兼容,通过灰度比例控制新结构体的放量,观测到异常立即回滚,整个过程结构体版本不做破坏性变更。
还有一个经验是:结构体设计文档必须和代码同步维护,每个字段旁边写清楚"为什么存在""谁在写""谁在读""兼容性约束",哪怕是一句话也好。这个文档的收益平时看不出来,当你离开这个项目三个月再回来看代码时,这份文档能帮你省下一个下午的时间。我自己就靠着这份文档,避免了至少三次因为忘记字段语义而写错逻辑的低级事故。
最后分享一个小技巧。当你觉得一个结构体"好像哪里不对,但说不出来"的时候,试着把它发给另一个同事看一眼,或者自己用一段话描述一遍每个字段的使用场景。大多数时候,说着说着你就会发现某个字段没有明确的使用者,或者某两个字段语义重叠了。这种"设计评审说出真知"的方法,比看任何架构书都管用。IM 系统的复杂度不在某个单点技术上而在这些细微的结构体取舍之间,把一个个小结构体打磨干净,系统的稳定性自然就上来了。