首页 / 资讯中心 / 文章详情

微服务IM消息存储子服务重构实践:分表、幂等与压测全解析

微服务IM消息存储子服务重构实践:分表、幂等与压测全解析 ★ FEATURED ARTICLE
1. 项目背景与核心需求拆解前阵子我把一套微服务即时通讯系统里的消息存储子服务完整重构了一遍顺带把功能测试和性能压测也重新理了一轮。这个子服务看着不起眼但在整个IM架构里属于那种“平时没人夸、出故障全组遭殃”的基础设施。这篇文章就把我当时踩过的坑、做过的选型权衡、落库链路的实现细节以及测试方案的完整思路整理出来。正在做IM系统、或者打算把消息模块从单体里拆出去的同学可以参考一下能少走不少弯路。先说清楚这个子服务要解决什么问题。即时通讯系统里用户最关心的就两件事消息能不能秒达、历史记录能不能随时翻。消息发送和推送靠长连接网关和实时消息服务但“历史记录”这一步必须落到持久化存储里。如果让消息发送服务直接写数据库短期内能用一旦消息量上来会出现几个绕不开的问题存储压力跟着在线人数线性上涨、历史消息查询和实时写入互相争抢数据库资源、单库单表在亿级消息下索引和写入性能迅速恶化。消息存储子服务就是把“消息读写”从核心业务链路中隔离出来独立部署、独立扩容、独立优化让即时通讯系统的其他模块不需要关心消息到底存在哪、怎么存、多久清理只需要调用存储接口。说得直白一点消息存储子服务就是一个高度定制化的消息持久化网关。消息先经过它落库确认写入成功后再返回给业务层后续历史消息查询也统一走它的读取接口。它的核心职责有三块写入可靠、读取高效、数据可管理。写入可靠意味着消息不能因为磁盘故障、进程重启、缓存淘汰等意外静默丢失读取高效意味着翻历史聊天记录不能越翻越慢、越翻越卡数据可管理意味着要对消息的保存周期、存储成本、冷热生命周期有明确策略。这个子服务的适用场景也不止是IM。直播间弹幕回放、客服工单对话记录、社区评论归档本质都是“海量小消息 频繁读历史 要求不丢不重”。做完这个项目之后我最大的感受是存储服务的技术难点不在“写进去”这一步而在“长时间稳定地写进去、还能很快查出来”以及“量上来之后依然扛得住”。1.1 消息存储子服务在IM架构中的位置在微服务即时通讯系统里消息路径通常是这样的客户端A发消息 → 网关服务做连接与协议解析 → 消息服务做业务逻辑校验、敏感词、会话判断 → 消息存储服务落库 → 消息服务推给在线端/离线消息中转 → 客户端B拉取新消息。我在设计的时候把消息存储服务放在消息服务下游二者通过内部RPC接口通信不直接暴露到公网。这样拆有两个直接好处。一是消息服务可以专注处理实时性逻辑不用关心SQL拼接、分库分表、慢查询优化这些事二是存储服务的部署规模可以和消息服务解耦比如大促或运营活动期间在线人数暴涨只扩容网关和消息服务存储服务CPU和连接池压力不会跟着全线拉爆。当时我们线上有一次因为活动导致消息量翻了近三倍就是因为存储服务独立后端数据库除了连接数升高整体负载还在可控范围内。1.2 存储子服务的三大核心问题我梳理需求时把问题收敛成三个维度后面所有技术选型都围绕这三个维度展开。第一个是数据可靠性。消息发出去了用户看到“已发送”状态这时候消息是绝不能丢的。数据库节点故障、网络分区、进程OOM这些故障在长时间运行的服务里一定会发生只是时间问题。所以从写入路径设计上就要保证业务确认成功之前消息必须已经落在可靠存储介质里。第二个是读写性能。IM消息不像电商订单那样有明显的读写比例它是典型的写多读少、但读有突发性。用户点进一个群聊一口气可能翻几百条历史消息但平时挂机在线时几乎不产生读请求。消息存储子服务要能扛住写入洪峰同时拉历史消息时第一屏要快翻页也不能有明显的阶梯式延迟。第三个是数据管理成本。消息攒着不删再大的存储也不够用。但用户哪天忽然想起半年前的一句话又必须能找回来。所以要有分级存储和过期清理机制在成本和体验之间找平衡点。这些需求不是说简单建个表就完事而是需要在表结构、索引、分片、归档策略上做一套组合设计。2. 存储模型与核心实现设计先交代一下我当时做选型的背景。团队已有基础设施是 MySQL 集群加 Redis消息中间件最早用的 RabbitMQ后来逐步在向 Kafka 迁移。考虑到运维成本和团队技术惯性我们没有因为“微服务”三个字就盲目引入 TiDB、Cassandra 这类分布式存储而是先把 MySQL 的水平扩展能力用够。事实证明在消息量到一定规模之前MySQL 合理分片完全顶得住而且排障和备份都要简单得多。2.1 消息表模型设计从单表到分表消息表的设计是整个存储子服务的地基。第一版我按“最常规”的做法建了一张单表字段包括消息ID、会话ID、发送者ID、消息类型、消息内容、客户端消息ID、创建时间、修改时间等。这个表在数据量到几百万条的时候一切正常但到了千万级之后索引体积增大、写入页分裂加剧最明显的是翻页查询开始出现明显的延迟尖刺。后面改成按会话维度分表。核心思路是用会话ID做哈希取模将消息数据分散到多张物理表中。当时我们按 64 张表来分会话ID哈希后对 64 取模。这样同一个会话的消息一定落在同一张表里查询单会话历史消息时只需要在一个分片上操作避免跨库跨表聚合。分表之后单表数据量被限制在一个合理范围索引维护成本也降下来了。表结构大概是这样实际字段比这多一些但核心就这些CREATE TABLE message_0 ( id BIGINT NOT NULL COMMENT 全局消息ID, session_id BIGINT NOT NULL COMMENT 会话ID, sender_id BIGINT NOT NULL COMMENT 发送者ID, msg_type TINYINT NOT NULL COMMENT 1文本 2图片 3语音 4文件, content MEDIUMBLOB NOT NULL COMMENT 消息内容加密后, client_msg_id VARCHAR(64) NOT NULL COMMENT 客户端生成的消息ID用于幂等, create_time DATETIME(3) NOT NULL COMMENT 消息产生时间, PRIMARY KEY (id), UNIQUE KEY uk_session_msg (session_id, client_msg_id), KEY idx_session_create (session_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个细节容易踩坑。一是content字段类型根据实际内容灵活选择纯文本场景用TEXT够用但IM系统往往要兼容图片URL、语音文件地址、自定义消息体统一用MEDIUMBLOB或者独立的message_content扩展表更稳妥。二是client_msg_id必须有唯一索引这是消息幂等的关键保障后面展开讲。2.2 消息ID生成与顺序性设计消息ID是存储服务的核心数据之一不能随便用数据库自增ID——分库分表场景下自增ID根本没法全局唯一而且消息ID还承担着排序功能。我当时采用的方案是全局发号服务统一发放趋势递增的分布式ID底层用类似雪花算法的实现64位整数中包含时间戳、机器ID、序列号三部分。为什么要趋势递增因为IM历史消息的查询几乎总是按时间顺序来展示如果ID递增趋势与时间保持一致那么“按ID排序”就等价于“按消息产生时间排序”可以减少一次额外的排序开销。同时建索引时ID作为主键顺序写入对B树友好避免随机写导致的页分裂。这里说一个容易忽略的点消息ID的时间有序性并不需要做到严格全局时间序只需要做到“同一会话内”基本有序即可。因为用户感知的消息顺序是会话维度的不同会话之间的先后关系没有那么敏感。所以发号器在跨机器存在轻微时钟漂移时只要漂移控制在合理范围例如几十毫秒内对业务是无感知的。2.3 数据分片策略与选型依据分片有两种常见方式按会话ID哈希和按时间分片。我当时选了按会话ID哈希原因是IM历史消息查询几乎全是“某个会话拉取一段”按会话维度切分能让一次查询只落到一个分片。如果按时间分片比如按月分表单会话的历史消息会横跨很多表翻历史记录就需要跨表合并再排序查询复杂度会明显上升。但会话哈希分片也有代价最典型的两个。一是热点会话问题某些活跃群聊的消息量远超普通会话哈希会让这些热点消息集中在特定几个分片上形成局部热点。二是会话数量少的时候分片利用率不均匀。针对热点我当时做了两级处理先把热点会话单独识别出来路由到独立的高性能存储分片同时对单表容量设置阈值当某个分片接近水位线时对该分片内的会话做冷热迁移。这里不展开细说想强调的是分片策略没有“绝对最佳”只有“适不适合你自己业务”。提示做会话哈希分片时分片数量尽量选2的幂次或者素数取模结果更均匀。另外分片数量要留出未来2-3年的增长余量否则后期扩容要迁移数据成本很高。3. 写入链路消息如何安全落库落库链路是整个消息存储子服务最核心的部分。我先描述一个完整消息从业务层到磁盘的路径然后重点讲三个环节异步削峰、幂等去重、可靠性兜底。3.1 消息写入的完整调用链业务方消息服务调用存储服务时我设计的是同步接口确认加异步批量落库的组合方式。调用方先把消息数据提交到存储服务存储服务将消息写入本地的内存队列并立刻返回“已接收”后台批量线程再攒批写入数据库。很多人会担心既然落库是异步的那如果写库前服务崩溃了消息不就丢了吗这里要区分“已接收”和“已确认”两个概念。我实际做的时候并没有让业务层把“已接收”当成最终成功。完整链路是这样的消息服务接收入站消息后调用存储服务接口传入消息内容。存储服务先将消息写入本地WAL日志基于磁盘的顺序追加写保证进程崩溃后可以重放。写入WAL成功后将消息推入内存队列同时应答存储服务调用方“写日志成功”。后台消费者从内存队列批量取出消息攒批写入MySQL。MySQL写入成功后更新WAL中该消息的状态为已完成并向消息服务发送异步确认。这个设计的关键在于消息服务不会因为收到“成功”应答就立刻向用户展示“已发送”。它要等存储子服务异步确认“MySQL落库完成”之后才更新消息状态。所以对业务层来说最终成功是以MySQL落库为准WAL只解决“进程崩溃后不丢”的中间状态问题。我用生活化的类比解释一下这就好比你去银行存钱柜台先把你的单据扫描进系统WAL告诉你“业务已受理”但你会听到“存款成功”的语音提示其实是在核心系统记账完成后才发生的。中间那段时间系统就算断电也能凭单据重新入账。3.2 消息幂等与去重设计网络超时重试、消息服务重复投递、客户端断线重连后重发消息这些场景在IM里太常见了。如果没有幂等机制同一条消息重复落库用户就会看到两遍内容几乎是最容易遭投诉的BUG类型。我们的做法是双保险。第一层是客户端生成全局唯一的client_msg_id客户端在构造消息时生成重试同一消息时保持不变。第二层是存储服务写入时利用uk_session_msg (session_id, client_msg_id)唯一索引做约束插入时使用INSERT ... ON DUPLICATE KEY UPDATE或者先查后插。在并发写入场景下唯一索引直接由数据库保证比应用层加锁要可靠得多。这里有一个细节值得说如果消息被重复投递但客户端同一时刻只发了一次为什么不能只在客户端做去重原因在于服务端不确定客户端是否会重试。客户端A发送消息后网络闪断用户以为没发出去又点了一次但第一次的消息实际已经到了服务端只是ACK没回到客户端。这种场景只有服务端靠消息ID做最终去重才能保证用户端和存储端一致。所以客户端ID去重不是可选项而是必选项。3.3 可靠性与一致性权衡消息存储服务不是数据库本身它比数据库更靠近业务因此还要考虑一个远端问题MySQL主从切换、网络分区、存储节点不可用时存储服务怎么表现我当时给存储服务设定的行为是故障期间拒绝写入而不是降级为只写缓存。原因很简单缓存写成功但数据库没写成功会给业务层造成“消息已存”的错觉一旦缓存因淘汰或重启丢失消息就永久丢了。所以宁可短暂不可用也不能在可靠性上打折扣。这个取舍很重要我建议所有做IM存储的同学都记下这句话消息系统里“不丢”永远优先于“可用”。写入MySQL时我也做了分级策略。普通消息用异步批量写入每条消息延迟在百毫秒级吞吐量高而针对一些需要立即生效的消息比如撤回消息、重要系统通知则走同步写路径确保写入数据库成功后才返回。两条路径并存避免一刀切让所有消息都变慢。4. 读取链路会话消息拉取与分页优化写入做扎实了读取就是用户体验的胜负手。IM历史记录最典型的行为是进会话先拉最近一页然后往上翻更早的消息。这个行为和分页密切相关选错分页方案前几十页没事越翻到后面越卡。4.1 分页为什么必须用游标如果看过一些后端开发者的老代码会发现常规后台管理列表都爱用LIMIT offset, size做分页。这种方案在消息存储服务里必须避雷。为什么因为LIMIT offset是先查出来前面所有数据再丢掉消息量越大翻得越深数据库扫描的行数越多。当用户翻到第几百页的时候一次分页查询可能扫描几百万行延迟直接秒级。就算加了索引MySQL也没法直接“跳过”offset行。我采用游标分页方案。所谓游标分页就是每次查询传入上一页最后一条消息的IDSQL 写成WHERE session_id ? AND id ? ORDER BY id DESC LIMIT 20。由于主键ID的有序性数据库可以直接定位到游标位置继续向后扫描每页查询消耗的资源和页数无关。这里有个功夫在诗外的点游标分页依赖ID有序所以消息ID的“趋势递增”设计在读取侧又体现了一次价值。ID有序 主键聚簇索引查询天然高效。如果当初ID用随机字符串或者UUID存储层的分页优化就很难做。4.2 会话维度读取与缓存设计消息存储服务不是只查数据库就完了。对IM来说用户反复查看的往往是最新消息和最活跃的几个会话。如果每次都穿透到数据库那是浪费。我当时引入了一层本地缓存加一层Redis缓存组成两级缓存。第一级进程内本地缓存存储热点会话最近的前200条消息读请求先查本地命中直接返回。第二级Redis缓存存储最近N天活跃会话的消息摘要和最近页数据用于多实例间的共享缓存。未命中前两级才走数据库查询并回填缓存。缓存最怕的是缓存穿透、击穿、雪崩。对消息场景穿透多发生在恶意构造不存在的会话ID解决办法是缓存空结果并且设置较短过期时间击穿容易发生在冷门会话第一次被大量拉取解决办法是加互斥锁控制回源查询并发雪崩则通过给缓存过期时间增加随机抖动来规避。4.3 历史消息冷热分离很多团队做IM存储最容易忽略的就是数据量无限增长带来的存储成本和查询性能下降。消息不能无限期存着但也不该粗暴地按天数一刀切删除。我当时设计了一个简单的三区架构热区最近30天的消息存储在 MySQL 主集群读写都走这里。温区31天到180天的消息保留在 MySQL 但从主查询链路中剔除非必要路径访问。冷区超过180天的消息异步归档到廉价的冷存储系统比如对象存储服务用户翻历史时通过异步任务从冷区拉取。这个方案的关键是“归档”动作要平滑进行。当时我们用了一个定时任务扫描过期分片把数据分批迁移到冷存储并在原表中删除已归档数据。这里要特别提醒删除数据时一定要用小批量多次删除的方式每次删除几百条、sleep一下千万不要跑一个大范围DELETE否则会给主库带来很大的主从延迟。注意设置消息保存周期之前一定要和产品确认好合规和客服审计场景的需求。有些行业消息需要保留更久不能省存储就把合规给省了。5. 测试方案与性能验证实录代码写完只是开始真正让我觉得这活干完的是测试环节。我按三层来搭单元测试、集成测试、性能压测。每一层解决的问题不一样缺一个都不行。5.1 功能测试单元与集成测试怎么搭单元测试主要覆盖存储服务内部的纯逻辑比如消息ID解析、分片路由计算、消息体的序列化与反序列化、幂等判断逻辑。这些不依赖外部组件用JUnit或同类框架直接跑核心目的就是快速回归。集成测试则是把存储服务和真实的MySQL、Redis、MQ连起来模拟完整链路。我测试用例里重点覆盖如下场景正常发送文本消息验证落库成功、返回确认。同一client_msg_id重复投递验证只存一条。并发发送100条消息验证消息顺序在同一会话内不乱。模拟MySQL主库故障验证写入被拒且明确报错。模拟进程重启验证WAL重放能恢复未确认消息。集成测试有个很容易犯的错测试环境连的是同一个数据库导致测试用例之间的数据互相污染。我的做法是每个测试用例跑完后强制清理测试数据并且测试库和预发库物理隔离。这套流程跑熟了之后后面改表结构、调索引回归成本低很多。5.2 性能压测设计与参数估算压测前先要做容量预估不然压测数据规模拍脑袋没有意义。我们当时的场景是预估在线用户10万人人均每天发送消息200条那么日均消息量就是 2000万条峰值一般按日均的5倍来算也就是每秒约1157条消息写请求。考虑到消息服务会调用重试和补偿存储服务的实际峰值写入预计要达到每秒1500到2000条。读请求按写请求的3倍估算大概每秒4500到6000次读。基于这个预估我把压测目标定为写入TPS 2000P99延迟小于200ms读取TPS 6000P99延迟小于100ms。压测工具我用了开源的压测框架模拟RPC调用脚本里构造了单聊、群聊两种消息模型并加入了随机热点分布。压测时主要监控四个指标TPS吞吐量、P99/P99.9延迟、数据库连接池使用率、MySQL慢查询数量。5.3 压测挖出的三个典型问题第一次压测结果其实不算理想问题主要集中在三处第一是数据库连接池耗尽。写入量一上来存储服务每个线程都在等数据库连接连接池默认配置只有50个积压请求一多直接拒绝新任务。解决办法是连接池调到200个并设置合理的超时时间同时把批量写入的批量大小从50条调到200条减少数据库交互次数。调整后同样压力下TPS提升了一倍多。第二是慢查询集中在深分页接口。压测脚本里我故意留了一版用LIMIT offset的查询接口结果用户翻到几百页时这条查询的P99延迟直接超过3秒。这个结果也验证了我们做游标分页的必要性。如果当时没发现这个问题上线后被用户翻历史记录翻到卡顿那就真被动了。第三是热点会话导致单个分片CPU飙高。压测中模拟了一个万人活跃大群哈希路由后所有消息都落在同一个分片上该分片对应数据库的CPU直接打满。后来对热点会话做了单独路由和缓存优先策略才让整体负载均衡下来。6. 常见问题排查与运维心得最后聊一点上线后的真实体会。消息存储子服务一旦跑起来基本上就是24小时全天候运转排查问题的思路和普通业务系统很不一样。6.1 常见故障与排查思路我把这段时间遇到的典型问题整理成了一张表方便直接参考现象可能原因排查思路与解法消息发送后长时间显示“发送中”存储子服务拒绝写入或WAL写入卡顿先看存储服务的连接池和磁盘IO确认MySQL是否发生主从切换部分会话翻历史很慢命中深分页或缓存失效检查查询SQL是否用了游标分页检查该会话是否处于冷区消息重复显示客户端重复生成ID或幂等键失效查client_msg_id是否唯一检查客户端重试是否复用ID消息延迟高但不丢批量写入批大小过大或磁盘IO异常调小批量大小检查WAL落盘模式存储服务内存飙升本地队列积压过多看消费者线程是否数过小检查下游MySQL是否有慢查询6.2 长期运维的实用建议第一个建议是监控绝对不能省。消息量、存储服务队列积压量、MySQL主从延迟、慢查询数、连接池水位这五项我建议至少按1分钟粒度采集并告警。很多故障不是突然发生的都是指标一路走高后爆发的提前盯住就能避免大事故。第二个建议是消息清理任务一定要有兜底机制。归档任务跑挂了数据全堆在热区过段时间MySQL容量就会报警。我当时给归档任务加了“进度记录”机制每次处理完一批都记录游标下次启动从上次位置继续避免重复扫描和重复迁移。这一点在数据量大时尤其重要。第三个建议是发版和变更要慢一点。消息存储子服务最怕的不是功能BUG而是表结构变更或索引调整引发的锁表、主从延迟。大表加索引不能直接在线执行要用在线DDL工具并且变更前后都要观察主从延迟。我们有一次因为直接在千万级表上加字段导致主从延迟飙到十几分钟差点把读链路拖垮那次之后所有变更都强制走流程审查。这个项目做完之后我的体会是消息存储子服务不像网关那样“每天都有新功能可以做”它更像是在修一条高速公路——路修好了大家跑得顺畅没人注意但路上任何一个小坑都会让所有业务方立刻感觉到颠簸。把基础链路做扎实、把测试做完善比频繁加新功能重要得多。如果以后有人问我怎么评估这类底层服务做得好不好我会说看它在故障时期的反应速度和处理结果比看它平时的功能列表更有说服力。
阅读完成 · 觉得有帮助?
咨询建站