ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IM消息存储子服务架构:分库分表与索引优化实践

IM消息存储子服务架构:分库分表与索引优化实践

1. 为什么要把消息存储单独拆成子服务

做即时通讯系统,很多人最开始的思路很简单:用户发一条消息,服务端转发给接收方,顺手存进数据库就算完事。小规模demo确实可以这么干,用户量一上来,这套逻辑立刻崩盘。真正让IM系统复杂起来的,不是消息转发那一下,而是“消息到底存哪儿、怎么存、怎么取”。

1.1 消息数据的特点决定了它不能和其他数据混在一起

先说结论:消息存储是一种非常特殊的数据访问模式,它和典型的业务数据在读写特征上有本质差别。

普通业务数据,比如用户资料、订单记录,是“写少读多”的典型模型,偶尔写一笔,反复去查。消息数据恰好反过来,是典型的“写多读集中”模型。聊天场景里,每秒写入的消息量可以轻松突破几千甚至几万条,而一条消息被写入之后,几乎不会再被修改。读虽然也频繁,但模式非常固定——用户打开会话,拉最近一页历史,往前翻页再拉一页。

这里有一个容易被忽略的关键点:消息数据几乎不存在“更新热操作”。消息撤回、已读回执,这些在业务层看是更新,但落到存储层,最优做法也不是直接UPDATE,而是追加一条状态变更或者单独建状态位。把这类数据和订单、库存那种强一致、频繁UPDATE的业务混在同一个库里,会有两个后果:

  • 写并发互相挤兑,业务库的锁竞争会把消息写入拖慢;
  • 消息表的膨胀速度远超普通业务表,几亿行数据会让日常的所有查询都变慢,连带拖垮别的业务。

所以把消息存储拆成独立子服务,不只是“架构上好看”,本质上是让不同数据模型各走各的存储底座,互不干扰。

1.2 子服务的职责边界

在我接手的这套微服务IM系统里,消息存储子服务的职责划得非常清晰:

  • 只负责消息数据的写入、读取、删除(逻辑删除)、归档;
  • 对外提供统一的消息存取接口,其他服务一律通过接口访问数据,不允许直连这个消息库;
  • 消息的格式校验、落库策略、分库分表路由,全部收敛在子服务内部实现。

从这个角度看,消息存储子服务本质上是整个IM系统里的一块“数据底座”。消息发送服务收到消息后,只负责把消息推给消息存储服务,存储成功后再往推送通道发通知。接收方不在线时,消息不需要实时推送,等他上线后再从存储服务拉取离线消息。

这套分工有两个明显的好处。一是故障隔离,如果消息存储服务出问题,至少用户登录、好友关系这些子系统还能正常工作;二是单独演进,存储层可以根据数据量单独扩容,不需要把整个IM系统都陪着一起扩。

2. 存储方案选型与技术决策

存储选型是消息存储子服务里最关键的决策,没有之一。很多团队在这里犹豫很久,MySQL、分库分表方案、云原生数据库、列式存储、消息中间件带存储功能,各路方案各有拥趸。我的建议很直接:没有绝对最好的方案,只有匹配业务规模和技术储备的方案。

2.1 关系型数据库分库分表路线

我最终选择的是“MySQL + 分库分表 + 业务路由”这一套经典组合。没有追新,原因是这套系统的数据模型本身就是结构化程度很高的二维表,关系型数据库的成熟事务、备份恢复体系可以少踩很多坑。

分库分表的具体做法是:按照消息所属会话ID做哈希路由,把数据打散到16个库,每个库64张表。分库键选择会话ID,而不是用户ID,这是有讲究的。

如果按用户ID分片,那一个会话的双方消息会落进两个分片,读取一个会话的历史消息时,需要跨多片聚合再merge,分页逻辑会非常痛苦。按会话ID分片之后,同一会话的所有消息都落在同一张表里,拉取会话消息时只需要查单表,天然支持按时间排序、翻页。

对比项按用户ID分片按会话ID分片
写入路由按发送者ID路由,简单按会话ID路由,稍复杂
会话消息拉取可能跨多分片,需merge单分片内完成,天然聚合
数据倾斜风险大V用户单点倾斜明显按会话分散,相对均衡
离线消息处理按接收者查,方便需要额外维护接收者索引

这里其实埋了一个前面提到的伏笔:按会话ID分片之后,“查某个人最近有哪些会话”会变成一次跨分片的聚合查询。解决方案我在后面第3章里详细说,这里先记住这个矛盾点。

2.2 消息ID生成的工程实践

消息ID是消息存储子服务里最容易被轻视、实际最容易出问题的点。

有人用数据库自增ID,这在单库里能用,分库分表后直接报废——多个库各自按自己的自增值走,必然生成重复ID。还有人用UUID,这个倒是全局唯一了,但ID是字符串,无序,而且比较长。做分页查询时如果用UUID做排序键,性能灾难。

我采用业界用得最多的方案:雪花算法(Snowflake)生成趋势递增的64位整型ID。结构上分四段:

  • 1位符号位,固定为0;
  • 41位时间戳,精确到毫秒,可以用大概69年;
  • 10位机器标识;
  • 12位毫秒内自增序列。

用雪花算法有几个实际收益。生成过程不依赖数据库和网络,各服务实例本地就能算出来,单机单毫秒可生成4096个ID,并发场景完全足够。ID是长整型,在数据库里做索引、做分页查询都高效。带时间信息这一点很爽,做归档策略时直接用ID排序就约等于按时间排序,不需要额外时间字段排序导致的文件排序损耗。

补充一个实操细节:机器标识位不要写死在配置里,规范化做法是从注册中心动态申请,否则实例重启后分配关系一变,可能会生成重复ID。

2.3 读写分离的取舍

读写分离是一种很诱人的优化手段,但消息存储场景下我最终没有启用数据库层面的主从读写分离。原因很容易被忽略:MySQL主从复制是有延迟的,正常情况下几百毫秒,高峰期可能到秒级。

想象一下这个场景:用户A发消息给用户B,B刚好在线,消息被实时推送过去了。B想往上翻历史记录,发出拉取请求。如果读请求打到从库,而主从延迟还没结束,B会看到自己刚刚收到的那条消息不见了——刚被推送的消息,在历史记录里却消失了。这种“消息闪回”在IM产品里是不能接受的体验问题。

折中方案是:所有消息的写入和读取都走主库,只有做后台报表分析、数据导出这种对一致性不敏感的场景,才走从库。这套取舍牺牲了一点主库压力,换来了数据一致性的确定性,实测下来是值得的。

3. 表结构设计与核心模型

表结构设计是整个消息存储子服务里信息量最大的部分。这里不堆一堆理论,直接把我在系统里用到的核心设计拆开讲,字段、索引、规则都给出来。

3.1 消息内容表

消息内容表存储消息本体,是所有消息数据的唯一事实来源。DDL结构大致长这样:

CREATE TABLE `msg_content_xx` ( `msg_id` BIGINT NOT NULL COMMENT '雪花算法生成的全局消息ID', `session_id` BIGINT NOT NULL COMMENT '会话ID,分表键', `sender_id` BIGINT NOT NULL COMMENT '发送者用户ID', `receiver_type` TINYINT NOT NULL DEFAULT 0 COMMENT '接收方类型:0单聊,1群聊', `receiver_id` BIGINT NOT NULL COMMENT '接收方ID,群聊时为群ID', `msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '消息类型:文本/图片/语音/文件/系统消息', `content` MEDIUMBLOB NOT NULL COMMENT '消息内容,序列化后的二进制数据', `client_msg_id` VARCHAR(64) NOT NULL COMMENT '客户端生成的消息唯一ID,用于幂等', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '消息状态,0正常,1撤回,2删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '服务端落库时间', PRIMARY KEY (`msg_id`), UNIQUE KEY `uk_session_client_msg` (`session_id`, `client_msg_id`), KEY `idx_session_time` (`session_id`, `msg_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息内容表';

几个容易看走眼但很重要的设计点:

  • content字段用MEDIUMBLOB保存序列化后的消息体,而不是直接用通用字符串或JSON字段。搜一下中文为什么还有很多团队踩坑:一旦你是面向移动端、微信小程序、H5多端推同一套消息结构,消息内容里包含类型、缩略图信息、扩展字段、引用消息等等,结构会一直在变。把统一的消息传输对象序列化成二进制放进去,服务端不解析、不关心、不修改,只在拉取时把它原样还给客户端。存和取都只做搬运工,这套模式在消息内容演进频繁的业务里特别稳。

  • client_msg_id这个字段是为了解决“消息重发导致重复落库”的问题。移动端网络不稳定,客户端可能需要重试发送同一条消息,如果服务端不处理,用户会看到一条消息变成两条。这个字段和会话ID一起建唯一索引,同样的客户端消息ID如果在同会话里已经存在,子服务直接返回原有消息ID,不重复入库。

  • idx_session_time索引里我故意没建“时间”字段索引,而是用msg_id代替。原因前面说过,雪花ID本身就是趋势递增的,按msg_id排序约等于按时间排序。这样分页查询时,天然依托主键索引就能按序扫描,效率远高于额外的时间索引。

3.2 会话索引表

上面那个表留了一个坑:按会话ID分片后,用户想知道“自己有哪些会话,每个会话最新一条消息是什么”,没法直接查消息表,因为不知道自己的会话ID列表。

会话索引表就是用来干这个的。它记录每个用户的会话维度元数据,包括会话ID、会话类型、最后一条消息ID、最后一条消息摘要、未读数快照等。这张表的数量级和消息表不是一个量级的,每个用户只冗余了一份“最新消息预览”数据。

关键点在于:这张索引表的更新时机不是消息落库时,而是发送方真正写入消息成功后,在同一个事务/流程里进行更新。这里我用了一个很实用的工程方案——发送消息成功后,再异步更新发送方和接收方的会话预览。所谓异步,代价是会话列表预览可能有一个瞬时不一致窗口,但要注意,用户点进具体会话之后,历史消息走的是消息内容表,绝对是一致的,不受影响。

这样设计之后,查询路径很清楚:

  • 首页会话列表:读会话索引表,秒回;
  • 点进聊天窗口:读消息内容表,按session_id+msg_id分页拉数据;
  • 两边数据交互:靠last_msg_id对齐。

3.3 索引与归档策略

数据量一旦上来,你会发现一个残酷的事实:消息表里的“读”都是近期的,“老数据”几乎永远没人访问。所以索引设计上,需要配合冷热分离使用。

热点消息默认保存在在线存储里,超过30天的消息自动搬入冷存储。冷存储我选的是列式存储引擎。这里需要特别说明,消息数据在冷存储里不再承担频繁实时的写入压力,只做归档、回看、审计这类低频操作。归档时按天切片存储,配合消息ID天然按时间递增的特性,归档的批量导出也无需额外全表扫描,只需要从当天ID最小值顺序扫描到当天ID最大值。

冷数据如果需要回看,用户会触发“加载更早的消息”,存储子服务识别到请求的消息ID区间已经被归档后,再从冷存储找回。这一层对上层业务完全透明,调用方只知道在拉历史消息,并不知道数据在热库还是冷库。

热数据表的保留周期、归档阈值这些,都要做成配置,并且随时可调。

4. 读写链路与核心接口实现

前面把存储模型讲透了,接下来看一条消息从发出到被读到,实际经过的完整链路。

4.1 消息写入链路

一次消息写入,完整流程是这样:

  1. 消息生产方(移动端/服务端业务逻辑)调用子服务的消息发送接口,携带sessionId、senderId、content、clientMsgId等字段;
  2. 子服务对消息体做格式校验、大小校验,超过限制直接拒绝;
  3. 根据sessionId做哈希取模,计算出所属的分库分表路由;
  4. 生成雪花ID作为msgId;
  5. 执行消息内容表的INSERT,这里用唯一索引兜底幂等判断;
  6. 写入成功后,更新会话索引表里双方的会话预览信息;
  7. 返回带msgId的完整消息实体。

这段流程看着简单,实际性能瓶颈很容易出在第六步上。群聊场景里,一个500人的群发一条消息,如果给每个成员都更新会话索引,那一次发送会产生500次索引更新,数据库直接被压垮。

这里采用的方案是:会话索引表有“活跃成员标记”和“批量模式”。群聊场景下,接收方的会话列表预览并不需要精确到发送后毫秒级一致,可以把同一群的多条消息累积起来,批量刷新群成员的会话索引。批处理窗口控制在1秒以内,对用户体验无感,但对数据库写入量是数量级的削减。

// 分库分表路由示意 public final class ShardingRouter { // 16库 × 64表 private static final int DB_COUNT = 16; private static final int TABLE_COUNT = 64; public static RouteResult route(long sessionId) { // 对会话ID取哈希,均匀打到不同分片上 long hash = Hashing.consistentHash(sessionId, DB_COUNT * TABLE_COUNT); int dbIndex = (int) (hash / TABLE_COUNT); int tableIndex = (int) (hash % TABLE_COUNT); return new RouteResult(dbIndex, tableIndex); } }

一致性哈希这个细节我多提一句,它和普通哈希取模不同,在做分片扩容时只需要迁移部分数据,不用全量重hash,这是后面水平扩容能不能顺利做的前提。

4.2 消息拉取接口设计

拉取历史消息的接口,核心参数是sessionId、anchorMsgId、direction和limit,而不是页数。

// 拉取消息请求参数 { "sessionId": 10086, "anchorMsgId": 7394817023954128896, "direction": "backward", "limit": 20 }

这里的anchorMsgId是消息锚点,direction=backward表示向上翻历史,即拉取比锚点更早的消息;forward则表示向下拉更新消息,用于从某个历史位置同步到最新。

不用页数的原因:IM的会话数据是持续增长的,翻页的时候如果有新消息插入,页与页之间可能错过或重复。基于锚点的方式稳定得多:客户端一直持有自己当前看到的最后一条消息ID,上滑时把这条消息ID作为锚点传上来,子服务直接查session_id = ? AND msg_id < anchor ORDER BY msg_id DESC LIMIT 20,走idx_session_time索引,扫表深度固定,性能稳定。

4.3 离线消息处理

离线消息的读模型与历史消息拉取略有差别。用户上线时,客户端需要得到的是“我离开这段时间,别人发给我的消息”。

直接做法是查所有会话的最新消息,但这样既慢又会产生大量重复推送。我采用的方案是维护“每个会话接收者的游标”:为每个用户保存每个会话的“已读消息游标”,每次拉离线消息时,只需要找到用户所属的所有会话,对这些会话分别查msg_id > read_cursor的消息,汇总返回。

这个方案里有个需要特别注意的点:用户没有点进会话前,游标是不前移的,离线消息就一直在“未读区”。用户点进会话拉取消息后,游标才会推进到已读位置。

5. 测试方案设计与性能验证

这个子服务开发完,测试工作比开发工作更费精力。消息存储这类基础组件,如果测试没做到位,后面出了问题就是线上事故级别的。测试方案我从功能、一致性、性能三个层面来拆。

5.1 功能测试要点

功能测试里最容易漏掉的是幂等测试。我专门构造了这样的测试场景:模拟客户端断网重发,同一条消息以相同clientMsgId重发三次,断言返回的msgId一致,消息表里最终只有一条记录。

还有两类边界测试必须做:

  • 会话不存在时写入消息,是否返回明确错误码;
  • 拉取消息时锚点消息ID已经不存在(比如该消息在保留策略里被归档了),是否还能正常返回更早的消息而不是直接报错。

第二类边界很容易在现场翻车。如果用“只查小于锚点ID的消息”的逻辑去实现,锚点消息不存在也没有关系,查询条件只要写对,结果反而返回正确。怕的是实现里直接先查锚点消息,查不到就报错,这就要命了。

5.2 并发写入与数据一致性验证

消息存储是典型的并发写入场景。我在测试环境搭了一个集群,模拟了200个用户会话同时高频发消息的环境,设计了一个验证脚本:

  1. 随机生成一批消息,每个消息分配唯一clientMsgId;
  2. 所有客户端同时发送消息,其中一部分故意重复发送;
  3. 结束后,把所有消息按clientMsgId去重,核对数据库里每条消息ID是否唯一、是否归类到正确的分库分表;
  4. 随机抽选几个会话,按顺序拉取历史消息,比对消息顺序是否与预期的发送顺序一致。

一致性验证里最值得关注的是“消息顺序”。IM系统不要求全局严格有序,但单会话内必须保证发送顺序和数据顺序一致。雪花ID能保证全局趋势递增,但如果写入时出现乱序落库(比如网络重试导致先发的消息后落库),就会破坏这个保证。针对这一点,我在写入链路里加了一个约束:同一个会话的消息写入走同一条数据库连接池通道,串行化写入,这样可以保证消息在存储层按照到达顺序落库。

并发场景预期结果实测结果
200个会话并发发消息全部写入成功,消息ID全局唯一通过
同一条消息重复发送只落库一次,返回相同msgId通过
单会话内乱序提交落库顺序与提交顺序一致通过
群发消息批量刷新索引索引最终一致,消息总数正确通过

5.3 压测结果与容量预估

压测工具我用的常规JMeter,脚本压了一晚上,拿到了几组关键数据。

写路径压测:单实例4核8G配置,满负荷写入,稳定在4000 TPS左右,P99延迟28ms。读路径压测:会话历史消息翻页读取,P99延迟稳定在15ms以内。

这个数据代表着什么容量水平呢?按单实例4000 TPS算,3个实例部署,就能支撑每秒1.2万条消息的写入。对绝大多数中小型IM产品来说,这个量级至少撑到百万日活没有问题。真到数据量再大时,优先扩分库分表的数量,一致性哈希的迁移成本是可控的。

6. 常见故障与排查实录

这个子服务上线运行后,我记录了一些实际遇到的坑和排查思路。

6.1 慢查询与索引失效

上线初期遇到过一条慢SQL,查询条件是WHERE session_id = ? ORDER BY msg_id DESC LIMIT 20,走了idx_session_time索引,但某几个会话的查询耗时达到300ms以上。

排查后定位到的问题不在SQL本身,而在这几个会话的消息密度太高。热门大群一天几十万条消息,LIMIT 20的查询本身不慢,慢的是那些要“翻几十页”的深度分页场景——用户从几千条消息往前面翻,msg_id < anchor的范围过大,索引扫描深度大。

优化方案是做“消息深翻页”的跨层缓存:每50条消息抽一条建立稀疏索引,翻页时先查稀疏索引定位到附近位置,再精确扫描。效果非常明显,最深翻页场景的耗时降到了40ms以内。

6.2 数据倾斜问题

按会话ID分片,理论上数据均匀分布,但实际业务会打破这个假设。比如一个超大群长期高频聊天,这个会话所在的表数据量就会是其他表的几十倍。

这种倾斜单靠分库分表解决不了,需要针对超大群单独设计方案。一个实用方案是给超大群拆“子通道”:把大群按照消息时间自动拆成多个子表分片,读取时并行查多子表再merge,写入时轮询落到子表。这个方案逻辑上会复杂一些,但对于超大群场景很有效。

6.3 磁盘与备份

消息数据的增长速度,永远比你预估的快。系统上线前我估算半年才用掉一半磁盘,实际上三个月就报警了。归档策略提前开启后,磁盘水位才稳定下来。

备份这块要特别强调。常规的全量备份策略在消息存储场景下不够用。消息数据量大、备份窗口长,全量备份期间会拖慢主库的写入。我现在用“主库全量备份(凌晨低峰)+ 归档冷存储增量备份 + 中转日志补录”的组合方案,既控制备份窗口,又能保证恢复时数据不丢。这个方案在实测恢复演练里,确实能恢复到分钟级粒度。

还有一件事经常被忘记:消息数据通常是IM系统里唯一不可再生的数据。用户资料丢了可以从第三方找补,好友关系丢了可以重建,唯独聊天记录是双方交互产生的,丢了就没有了。所以消息存储子服务的备份必须定期做恢复演练,而不只是“每天备份了就行”。我每个季度做一次完整的恢复演练,这件事建议做这个子服务的团队务必制度化。

7. 几个值得单独提的优化细节

最后分享几个零散但实战中很管用的优化细节,不成体系,但都是踩坑换来的。

压缩消息体再落库。文本消息压缩率不高,但图片消息的缩略图、语音消息转写文本、文件的元数据JSON,这类内容压缩率非常可观。我在消息体序列化之后、落库之前,对内容做了一层压缩。实测整体存储成本降低了40%左右,CPU开销几乎可以忽略。

连接池参数按写入模型调优。默认的数据库连接池参数在消息写入场景下不是最优的。我调大了写入专用连接池的maxActive,同时把maxWait调小,宁可让极端情况下的请求快速失败进入重试,也不要让大量线程阻塞等待连接。IM系统本身就有客户端重试机制,服务端快速失败比无限排队体验更好。

“找人拉会话列表”这个高频请求。会话索引表在高并发时也会成为热点。优化方式是给会话索引表增加本地缓存层,客户端拉取会话列表时,大部分请求直接命中内存缓存,只有缓存失效时才真正查库。这里还是要把缓存失效打散的细节做好,避免大量缓存同时过期导致数据库瞬间被打满。采用的做法是分布式环境下开启“过期时间随机抖动”,避免同秒大量集中失效。

消息里的二进制数据和文本数据的存储拆分。大字段(图片原图、视频、文件)走独立的对象存储,消息表里只存访问地址和缩略图信息。这个分离不仅让数据库表变小,更重要的是拉取历史消息时,不会被大量无效大字段拖慢IO。

写到这里,这个子服务做得深的部分、踩过的坑基本都是这些了。消息存储作为IM系统里最低调但最命脉的模块,值得投入的精力远不止“找个地方把消息存下来”。一开始我也没有意识到它会有这么多门道,是几次线上告警和教育了我。如果你也在做类似的即时通讯项目,希望这篇实现与测试的实录能帮你少走几段弯路。最后再分享一个我自己的操作习惯:做完分库分表那一层路由后,第一时间把各个分片上的数据量分布写成监控指标,任何一个分片的数据倾斜超过阈值就立刻报警。这个监控项曾经帮我提前两周发现了一个濒临出问题的热点会话。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进