资讯详情

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

📅 2026/10/22 11:07:08 | 华诺云谱 👁 阅读
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位符号位固定为041位时间戳精确到毫秒可以用大概69年10位机器标识12位毫秒内自增序列。用雪花算法有几个实际收益。生成过程不依赖数据库和网络各服务实例本地就能算出来单机单毫秒可生成4096个ID并发场景完全足够。ID是长整型在数据库里做索引、做分页查询都高效。带时间信息这一点很爽做归档策略时直接用ID排序就约等于按时间排序不需要额外时间字段排序导致的文件排序损耗。补充一个实操细节机器标识位不要写死在配置里规范化做法是从注册中心动态申请否则实例重启后分配关系一变可能会生成重复ID。2.3 读写分离的取舍读写分离是一种很诱人的优化手段但消息存储场景下我最终没有启用数据库层面的主从读写分离。原因很容易被忽略MySQL主从复制是有延迟的正常情况下几百毫秒高峰期可能到秒级。想象一下这个场景用户A发消息给用户BB刚好在线消息被实时推送过去了。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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_idmsg_id分页拉数据两边数据交互靠last_msg_id对齐。3.3 索引与归档策略数据量一旦上来你会发现一个残酷的事实消息表里的“读”都是近期的“老数据”几乎永远没人访问。所以索引设计上需要配合冷热分离使用。热点消息默认保存在在线存储里超过30天的消息自动搬入冷存储。冷存储我选的是列式存储引擎。这里需要特别说明消息数据在冷存储里不再承担频繁实时的写入压力只做归档、回看、审计这类低频操作。归档时按天切片存储配合消息ID天然按时间递增的特性归档的批量导出也无需额外全表扫描只需要从当天ID最小值顺序扫描到当天ID最大值。冷数据如果需要回看用户会触发“加载更早的消息”存储子服务识别到请求的消息ID区间已经被归档后再从冷存储找回。这一层对上层业务完全透明调用方只知道在拉历史消息并不知道数据在热库还是冷库。热数据表的保留周期、归档阈值这些都要做成配置并且随时可调。4. 读写链路与核心接口实现前面把存储模型讲透了接下来看一条消息从发出到被读到实际经过的完整链路。4.1 消息写入链路一次消息写入完整流程是这样消息生产方移动端/服务端业务逻辑调用子服务的消息发送接口携带sessionId、senderId、content、clientMsgId等字段子服务对消息体做格式校验、大小校验超过限制直接拒绝根据sessionId做哈希取模计算出所属的分库分表路由生成雪花ID作为msgId执行消息内容表的INSERT这里用唯一索引兜底幂等判断写入成功后更新会话索引表里双方的会话预览信息返回带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是消息锚点directionbackward表示向上翻历史即拉取比锚点更早的消息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个用户会话同时高频发消息的环境设计了一个验证脚本随机生成一批消息每个消息分配唯一clientMsgId所有客户端同时发送消息其中一部分故意重复发送结束后把所有消息按clientMsgId去重核对数据库里每条消息ID是否唯一、是否归类到正确的分库分表随机抽选几个会话按顺序拉取历史消息比对消息顺序是否与预期的发送顺序一致。一致性验证里最值得关注的是“消息顺序”。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系统里最低调但最命脉的模块值得投入的精力远不止“找个地方把消息存下来”。一开始我也没有意识到它会有这么多门道是几次线上告警和教育了我。如果你也在做类似的即时通讯项目希望这篇实现与测试的实录能帮你少走几段弯路。最后再分享一个我自己的操作习惯做完分库分表那一层路由后第一时间把各个分片上的数据量分布写成监控指标任何一个分片的数据倾斜超过阈值就立刻报警。这个监控项曾经帮我提前两周发现了一个濒临出问题的热点会话。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑