消息存储子服务,在整个即时通讯微服务架构里属于那种"平时没人夸、一出事全找它"的模块。我当年接手这套系统的时候,线上消息量已经过亿,单库单表扛到了极限,每次大促或者晚高峰,消息拉取接口的耗时就像过山车。后来我们把消息存储拆成独立子服务,重构了数据模型和写入链路,才把问题压下去。这篇文章就把我做这个子服务的完整思路、踩坑过程和最终的落地方案整理出来,给准备做或者正在做IM微服务拆分的同学一个参考。
整个系列会分成多篇来讲,这篇聚焦在消息存储子服务1,也就是整体定位、数据模型、写入链路和查询链路的第一次设计迭代。后面还会聊到分库分表、多端同步位点、冷热数据分离,以及消息回溯和审计相关的进阶内容。
1. 为什么消息存储要单独拆成一个子服务
很多人一开始做IM系统,习惯性把消息存储直接塞进业务服务里,聊天接口里顺手写一条数据库记录就完了。这套逻辑在用户量小、消息量可控的时候没什么问题,但一旦聊天的频率上去了,第一个扛不住的就是数据库连接池和锁竞争。消息写入的高峰往往是突发的,比如一个万人群同时刷屏,或者直播间的公聊,瞬间的写QPS能冲到几千甚至上万,如果这个写压力直接打在业务服务的主库上,聊天接口和用户登录、资料修改这些核心接口就会互相拖垮。
把消息存储拆成独立子服务,核心收益有三个:
第一个是故障隔离。业务服务还在线,但消息存储服务因为磁盘故障或者慢查询挂了,用户可以继续收发信令,只是消息记录暂时查不到,等存储恢复后再补同步。这比整个聊天功能全部雪崩要好得多。
第二个是水平扩展。存储服务可以独立增加副本和分片,根据消息量级别单独做容量规划。你不用为了应付消息量的增长,给整个业务集群全部扩容,成本控制更灵活。
第三个是查询和写入的解耦。存储子服务可以同时服务多个上游业务线,比如App聊天、Web客服、系统通知,这些业务共享一套消息存取能力,各自的逻辑只需要封装成一两句调用就行。
我见过有些团队偷懒,把消息存储做成业务服务的一个模块,用Feign或RPC接口对外暴露,但底层还是同一个数据库。这不叫拆分,只是把接口换了个皮。真正的独立子服务,从数据表、连接池、存储介质到部署单元都应该是独立的,至少要做到数据库层面的物理隔离。否则核心消息库出现慢查询或锁等待,一样会把整个业务集群拖下水。当时我们设计的时候,直接给消息存储服务配了独立的一组MySQL实例,以及一套单独的Redis集群,内存不跟业务服务混用,从根上避免互相干扰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 消息存储子服务的边界划分与功能清单
拆服务不是拆完就完了,最重要的先想清楚这个服务"管什么、不管什么"。边界不清晰,后面协作的时候必然扯皮,接口设计也会反复改。
我们这个消息存储子服务最终划定的边界是这样的。它只负责消息数据本身的持久化、读取、归档与删除,不负责任何消息推送逻辑、不持有用户的长连接,也不做消息内容的全文检索。全文检索我们独立交给了ES集群,消息存储只服务ES的数据回源。这样可以避免存储服务承担太多职责,导致迭代时互相影响。
功能清单大概列一下:
- 消息落库:各类消息的异步写入,包括单聊、群聊、系统通知,以及消息内容的版本历史。
- 消息拉取:按会话维度拉取历史记录,支持分页、游标、按时间段过滤。
- 会话维度管理:获取一个会话最新的N条消息、未读数相关的消息位置信息。
- 多端同步位点:记录每个用户每个设备的同步游标,配合增量拉取。
- 消息撤回与删除:标记删除消息,支持撤回后的留痕与占位展示。
- 归档与转储:冷数据定期迁移到归档存储,保证热库查询性能。
有些团队会把"消息已读回执"也放进存储服务,但我们在做架构评审的时候否决了。已读回执本质上是状态机,更新频率比消息本身高得多,写入量更大,和消息append-only的特性完全不一样。把它硬塞进存储子服务,会导致一张消息表既有追加写又有高频更新,锁竞争翻倍。所以我们把已读回执拆给了独立的在线状态服务,消息存储只维护这条消息是否被撤回、是否被删除这些静态状态。
这个边界划分在实际协作中效果很好。客户端团队只需要对接消息拉取和发送时调用存储接口,不需要关心消息是怎么存进去的;推送服务需要消息内容做推送文案时,也是通过存储服务拿数据,但存储服务不反向调用推送。依赖方向是单向的,整个链路很清爽。
3. 数据表设计:核心表结构、索引策略与扩展预留
边界定清楚以后,开始设计表结构。消息存储子服务的表结构比很多人想象中要简单,核心就三张表,但每张表的字段和索引都需要反复推敲,因为这张表一旦上线运行,后面改起来成本极高。
先看核心的消息表。结构大体如下:
sql复制CREATE TABLE `im_message_0` (
`msg_id` bigint(20) NOT NULL COMMENT '全局消息ID,雪花算法生成',
`conversation_id` bigint(20) NOT NULL COMMENT '会话ID,单聊双人会话和群聊会话统一编码',
`sender_id` bigint(20) NOT NULL COMMENT '发送者用户ID',
`msg_type` tinyint(4) NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5文件 6系统 7撤回占位',
`content` text NOT NULL COMMENT '消息内容,文本或媒体文件的URL/对象信息',
`client_msg_id` varchar(64) NOT NULL COMMENT '客户端生成的消息唯一ID,用于幂等去重',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0正常 1撤回 2删除',
`create_time` datetime(3) NOT NULL COMMENT '消息产生时间',
`update_time` datetime(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY (`msg_id`),
KEY `idx_conversation_msg` (`conversation_id`, `msg_id`),
KEY `idx_sender_time` (`sender_id`, `create_time`),
UNIQUE KEY `uk_client_msg` (`client_msg_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='消息主表';
这个表设计有几个关键点,很多人会忽略。
第一个是msg_id用雪花算法生成,而不是数据库自增。原因很简单,消息是要在多个分片里路由的,如果依赖自增主键,分片规划就会非常痛苦,而且未来做冷热分离时迁移数据也有问题。雪花ID本身包含时间戳,天然带单调递增的特性,配合游标分页非常顺滑。
第二个是conversation_id的编码。我们把单聊和群聊统一成一个会话ID,单聊会话也是有一个固定ID的,不搞成根据两个用户ID算哈希的方式。这样所有会话的查询逻辑都是同一套,不用判断类型后在业务层做二次拼装。单聊会话的元数据(两个成员是谁)单独存一张会话映射表,存储服务只认conversation_id。
第三个是idx_conversation_msg索引。这是消息拉取最核心的索引,按照会话查历史消息时,用WHERE conversation_id = ? AND msg_id < ? ORDER BY msg_id DESC LIMIT 20,这个索引能直接命中最新的N条记录。特别注意,这个联合索引的顺序必须把conversation_id放前面,msg_id放后面,如果反过来,查询计划就会走全索引扫描,性能差一个量级。
然后是会话维度表。这张表存储每个会话的元信息和最新消息摘要,用于会话列表展示。
sql复制CREATE TABLE `im_conversation` (
`conversation_id` bigint(20) NOT NULL,
`conversation_type` tinyint(4) NOT NULL COMMENT '1单聊 2群聊',
`member_count` int(11) NOT NULL DEFAULT '0' COMMENT '成员数,单聊固定2',
`last_msg_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '最后一条消息ID',
`last_msg_content` text COMMENT '最后一条消息内容摘要,用于会话列表展示',
`last_msg_time` datetime(3) DEFAULT NULL,
`create_time` datetime(3) NOT NULL,
`update_time` datetime(3) NOT NULL,
PRIMARY KEY (`conversation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='会话信息表';
这张表的主要作用是避免会话列表页每次都去消息表里算"最后一条消息",那会引发很恐怖的全表扫描。在写入消息的时候顺带更新一次会话表的last_msg_*字段,会话列表拉取就变成简单的按主键查询。
最后是同步位点表,用于多端增量同步。每个用户每个设备在每个会话上记录一个"已同步到的消息ID",下次增量拉取时从这个位置往后扫。
sql复制CREATE TABLE `im_msg_sync_cursor` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`device_id` varchar(64) NOT NULL,
`conversation_id` bigint(20) NOT NULL,
`last_msg_id` bigint(20) NOT NULL COMMENT '该设备已同步到的消息ID',
`update_time` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_device_conv` (`user_id`, `device_id`, `conversation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='多端同步位点表';
这张表的uk_user_device_conv唯一约束很重要,它保证了一个用户在一个设备上对于一个会话只有一个进度。更新时用INSERT ... ON DUPLICATE KEY UPDATE last_msg_id = IF(last_msg_id < VALUES(last_msg_id), VALUES(last_msg_id), last_msg_id),避免并发时位点回退。
表结构设计完以后,我强烈建议大家做一个"变更预演",把未来三个月可能出现的需求变更在脑内过一遍。比如消息引用回复时要存被回复消息的ID,这个字段在表结构里就要预留,或者用扩展字段的方式存储在content里。我们当时因为没预留,后面加字段做了一轮在线DDL,虽然借助工具没锁表,但压力还是很大。
4. 写入链路:同步接住、异步落库、幂等去重三层设计
消息存储的写入链路是整个系统里最容易写崩的一环。如果把客户端消息直接同步写入数据库,发送接口的RT会变得很不稳定,一旦数据库抖动,客户端直接超时,很容易造成消息重试风暴。
我们的方案分三层:同步接住、异步落库、幂等去重。
先看同步接住阶段。客户端发送消息时,业务服务调用消息存储服务的发送接口,核心逻辑如下:
java复制// 消息存储服务发送接口(伪代码)
public SendResult sendMessage(SendMessageRequest request) {
// 1. 生成全局消息ID(雪花)
long msgId = idGenerator.nextId();
// 2. 校验参数:会话是否存在、发送者是否有权限等
validate(request, msgId);
// 3. 写入Redis消息队列,返回给上游"已受理"
MessageEntity entity = buildMessageEntity(request, msgId);
messageQueueProducer.send(entity);
return SendResult.success(msgId, entity.getCreateTime());
}
这里最关键的是把写数据库的逻辑从同步流程里剥离出来,客户端拿到的只是"消息已受理"的确认,真正落库的操作交给消息队列异步执行。这样发送接口的QPS上限完全取决于Redis的生产能力,而不是数据库的写入能力,整个接口的RT也稳定在个位数毫秒级。
然后是异步落库阶段。我们用的是RabbitMQ作为消息队列,消费者监听消息队列,批量拉取消息后写入数据库。批量插入比逐条插入性能高很多,我们实测同样的消息量,批量插入100条一批比逐条插入快3到5倍。
java复制// 异步落库消费者(伪代码)
public void onMessage(List<MessageEntity> messages) {
// 1. 批量幂等过滤
List<MessageEntity> filtered = deduplicate(messages);
if (filtered.isEmpty()) {
return;
}
// 2. 事务内批量插入
transactionTemplate.execute(status -> {
messageMapper.batchInsert(filtered);
// 3. 更新会话摘要
updateConversationSummary(filtered);
return null;
});
// 4. 清空Redis中的幂等标记
deduplicateCache.delete(filtered.stream().map(MessageEntity::getClientMsgId).collect(Collectors.toList()));
}
异步落库的时候,最容易忽略的是顺序性问题。同一会话的消息,如果消费者并行处理,可能出现msg_id大的消息先落库、msg_id小的后落库的情况。虽然消息表本身没问题,但会话摘要里last_msg_id就会暂时指向一个较早的消息,导致会话列表排序异常。我们用的方案是消息队列的sharding key指定为conversation_id,同一个会话的消息进入同一个队列分区,由同一个消费者串行处理,这样既保证了单会话的落库顺序,又避免了并发带来的锁竞争。
第三层是幂等去重。这是消息存储系统中一个隐形的坑,但踩中就会很疼。客户端在网络抖动时很容易重复发送同一条消息,如果服务端不区分客户端重试和业务上的两条相同消息,库里就会出现重复记录。我们用client_msg_id作为全局唯一键,Redis里放一个短TTL的标记,数据库表上加唯一索引,双保险。就算Redis的标记过期了,数据库的唯一索引也能拦截重复插入,保证同一条客户端消息在库里只有一条记录。
写到这里我想特别提醒一下:异步落库的积压监控必须做。如果消息队列消费速度跟不上生产速度,积压越来越多,客户端发出去的消息在聊天窗口里迟迟不出现,用户体验会非常糟。我们的告警阈值是队列积压超过1万条就触发告警,并自动对消费服务扩容。另外,消费者拉取批量消息时,如果消息量过大,务必限制批量大小,我见过因为一次性拉取几万条消息把数据库连接池打满的案例。
5. 查询链路:游标分页的前世今生
查询历史消息是消息存储子服务的核心读接口,也是最容易出性能问题的接口。很多同学第一版实现历史消息拉取时,会直接用LIMIT offset, size做分页,刚开始用户量小、数据量少的时候看似没问题,但数据量一上去,后端排序就会非常痛苦。
我直接说我们遇到的真实问题。某天下午,线上突然有用户反馈,在一个几千条消息的大群里往上翻聊天记录,翻到第几百条的时候,接口要好几秒才返回,有时候直接超时。马上查监控,发现消息拉取接口的p99延迟从正常的50毫秒飙到了4秒。
快速分析了一下慢查询日志,发现罪魁祸首是一条LIMIT 100000, 20的SQL。MySQL深分页的原理很多人知道但没当回事:LIMIT offset, size需要先扫描并丢弃前面的offset行,offset越大,扫描的行数越多,性能断崖式下跌。在千万级消息表上用LIMIT 500000, 20,执行计划显示扫描了50多万行,能不慢吗?
深分页在消息记录这种持续增长的海量数据场景下是必然遇见的,只是一个时间问题。所以我们的查询链路一开始就应该用游标分页,而不是传统的页数分页。
游标分页的基本思想是:不记录"第几页",而是记录"上一次拉到的最后一条消息ID"。下次拉取时,直接从这个消息ID往后/往前取。SQL长这样:
sql复制-- 上滑加载更多(拉取更早的消息)
SELECT msg_id, sender_id, msg_type, content, create_time
FROM im_message_0
WHERE conversation_id = #{conversationId}
AND msg_id < #{lastMsgId} -- 游标,上一次拉到的最早一条
ORDER BY msg_id DESC
LIMIT #{pageSize};
因为msg_id是雪花ID,天然带时间语义,所以msg_id < lastMsgId既能按时间倒序,又能走索引,不需要额外排序,性能非常稳定,不管翻到多早的数据,查询时间都稳定在几十毫秒。
我们实现的远程拉取接口,参数大概是这样的:
code复制POST /api/msg/pull
{
"conversationId": 123456,
"lastMsgId": 987654321, // 0表示从头拉最新
"limit": 20,
"direction": "up" // up上滑加载更早,down下拉刷新更晚
}
这里有个容易踩坑的点,就是"下拉刷新"和"上滑加载更多"的游标方向是反的。上滑加载更多需要msg_id < lastMsgId倒序;而下拉刷新,如果有新消息,需要msg_id > lastMsgId正序。我们在实际代码里把这两个逻辑拆成了两个独立的方法,避免方向混在一起造成混乱。
另外一个细节是,游标分页偶数和首屏。用户进入一个会话,我们需要先拉取最近的历史消息,这时候lastMsgId传0,SQL变成WHERE conversation_id = ? AND msg_id > 0 ORDER BY msg_id DESC LIMIT 20,拿到的就是这个会话最新的20条消息。如果用户需要看更早的,再传当前最旧的那条msg_id继续往上翻。
深分页的坑我们算是给后续做分库分表提前扫了雷,因为如果当时图省事用了页数分页,后面数据量翻倍再分表,迁移和兼容绝对是一场灾难。
6. 分库分表的切分策略和实操细节
消息存储子服务发展到现在,单表几百万数据已经是不错的水平。如果单表到了几千万,任何索引的效率都会开始下降。分库分表不是可选项,是必然选项。我们做了基于conversation_id的哈希分片,把数据打散到多个表里。
先明确一个原则:消息表的切分维度要以最核心的查询条件为准。对我们这个服务来说,最核心的查询永远是"按会话拉历史消息",所以conversation_id就是天然的sharding key。按conversation_id哈希分片,同一个会话的所有消息都落在同一个分片里,查一个会话的消息只需要访问一个分片,不需要跨库聚合,效率最高。
我们的分片算法:
java复制// 分片算法:对conversation_id做哈希取模
public int shardByConversationId(long conversationId, int shardCount) {
// 用hash值而不是直接取模,避免哈希冲突导致的倾斜
int bucket = Hashing.consistentHash(conversationId, shardCount);
return bucket;
}
这里提个醒,直接conversationId % shardCount效果很差,因为会话ID往往不是均匀分布的,有人说话多的会话ID连续,就会导致一个分片的数据量远大于其他分片。用一致性哈希可以把数据尽量打散。
分片之后,很多逻辑都要跟着调整:
写入:插入前需要先算出分片号,再插入对应表。
java复制public MessageEntity insertMessage(MessageEntity message) {
int shard = shardByConversationId(message.getConversationId(), SHARD_COUNT);
messageMapperByShard[shard].insert(message);
return message;
}
查询历史消息:先算conversationId对应的分片,然后只查那个分片。
sql复制SELECT msg_id, sender_id, msg_type, content, create_time
FROM im_message_${shard}
WHERE conversation_id = #{conversationId}
AND msg_id < #{lastMsgId}
ORDER BY msg_id DESC
LIMIT #{pageSize};
拼表名的安全性:分片号是我们自己算出来的整型,不是用户传入的字符串,拼接表名不会有SQL注入风险,但建议还是做个白名单校验,防止shard参数异常。
分库分表后,跨分片操作想都不要想,特别是"按用户查TA参与的所有会话消息"这类需求,天然就是跨分片的,我们在存储服务里根本没有实现这种查询,而是通过"会话列表"这张表先找到用户参与的所有会话ID,再逐个会话按分片查。架构上就要避免跨分片聚合,如果业务上确实需要聚合,应该考虑用另一套索引或数据副本,而不是主链路硬闯。
分库分表的数量也不是拍脑袋定的,要预留一到两年的增长空间。我们当时估算消息量年增长在50%左右,最终规划了32个物理分表挂在4个物理库上,每个库8张表。这样未来单库压力大了,可以按库粒度迁移,不用停服整表迁移。
7. 多端同步位点的实现细节与并发控制
前面说了位点表的设计,这一步是客户端多端同步的核心。用户可能在手机、PC、平板同时登录,每端都要拿到自己没见过的消息。位点表的逻辑就是记录每个端在每个会话上"看到哪了"。
客户端上滑加载历史消息并不会更新位点,只有打开会话并拉到最新时,才会更新该会话的位点。位点更新逻辑大概是这样:
java复制public SyncResult syncMessages(long userId, String deviceId,
long conversationId, long lastMsgId) {
// 1. 从位点表读取该设备在该会话上的游标
Long cursor = syncCursorMapper.getCursor(userId, deviceId, conversationId);
if (cursor == null) {
cursor = 0L;
}
// 2. 从游标开始拉取增量消息
List<MessageEntity> messages = messageMapper.queryByCursor(
conversationId, cursor, SYNC_BATCH_SIZE);
// 3. 如果有新数据,自动推进游标
if (!messages.isEmpty()) {
long newCursor = messages.get(messages.size() - 1).getMsgId();
syncCursorMapper.updateCursor(userId, deviceId, conversationId, newCursor);
}
return SyncResult.success(messages);
}
这段代码里有一个隐含的坑:updateCursor如果直接用last_msg_id = newCursor,在高并发情况下,两条同步请求并发更新同一位点,旧值可能覆盖新值,导致位点回退,客户端会重复拉取同一批消息。所以更新语句里加了条件:
sql复制UPDATE im_msg_sync_cursor
SET last_msg_id = #{newCursor}, update_time = NOW(3)
WHERE user_id = #{userId} AND device_id = #{deviceId} AND conversation_id = #{conversationId}
AND last_msg_id < #{newCursor};
只有当新位点比旧位点还大的时候才更新,这就避免了位点回退。
另外还需要考虑用户全网统一同步的场景。比如用户在PC上读了所有未读消息,然后打开手机,手机要能知道"消息已经读过了"。这个需求就必须要一个全网的同步位点,记录的是用户在拉取全站消息的进度,而不只是单个会话。我们在设计时加了一个全局位点表,用户每端都存一个全局游标,只要有会话更新,全局游标就跟着动。客户端拉取时,先用全局游标判断整体有没有新消息,有的话再逐个会话拉取详细内容。
多端同步还有一个细节是离线期间的补偿。用户离线时间长了,某会话积累了几百条新消息,如果直接拉取全部,响应体太大。所以我们的同步接口支持两级拉取:第一级只拉取最近N条摘要,第二级根据用户滚动选择再拉完整内容。这样既保证了同步的及时性,又不会因为一次拉太多拖垮客户端渲染。
8. 监控告警与容量规划:存储子服务的运维底线
存储子服务上线后,想要睡得安稳,监控告警和容量规划必须提前做好。很多团队把服务上线当终点,其实监控做不好,服务迟早出大问题。
先看核心监控指标。我整理了一份我们日常监控的清单:
| 指标 | 告警阈值 | 说明 |
|---|---|---|
| 写入QPS | 超过预估容量的80% | 防止写入热点导致积压 |
| 消费积压数 | 超过1万条 | 队列积压过多会影响消息实时性 |
| 消息拉取P99 | 超过200ms | 超过300ms用户能明显感觉到卡顿 |
| 数据库连接池占用率 | 超过70% | 连接池耗尽前必须预警 |
| 慢查询数量 | 每分钟超过5条 | 慢SQL是性能恶化的前兆 |
| 主从复制延迟 | 超过5秒 | 延迟过大会导致同步位点异常 |
| 消息表容量 | 单表超过500万 | 考虑扩容或分表 |
这些阈值不是一锤定音的,需要根据线上实际情况动态调整。我记得有段时间我们的拉取P99经常飙到300ms以上,后来查出来是慢查询在高峰期偶发,调整了索引和游标分页后,P99稳定到了50ms以下,阈值也相应收紧了。
容量规划这块,我建议每个季度做一次,根据消息量增速推算未来几个月的存储容量和QPS峰值。比如一条文本消息包括索引和冗余,平均占用约1KB到2KB;一张图片消息因为content里要存URL和缩略图信息,大概2KB到3KB。假设日增消息量1000万条,平均单条2KB,一天就要新增大约20GB数据,一个月就是600GB,一年7TB左右。这个量级下,分片和归档的节奏就要提前按照这个预估去推进。
冷数据我们用的是90天为一档,超过90天的消息迁移到冷存储。热库只保留90天内的消息,冷库用低频存储保存全量历史。迁移任务跑在凌晨低峰期,用了分批扫描的方式,每批处理一万条,避免迁移过程拖垮在线业务。用户查询超90天的历史消息时,走冷存储接口,响应时间会比热库慢一些,但可接受。
冷热分离上线后,热库单表的数据量从几千万降到了几百万,查询性能稳定多了。存储成本也因为冷库的低单价降了60%以上。
最后再提醒一点:存储子服务作为所有消息的最终归宿,任何变更都要比普通服务更谨慎。我们内部流程是:表结构变更必须提前三个工作日评估,DDL必须用在线变更工具,变更完成后必须进行数据一致性校验。线上业务一旦跑起来,这个模块经不起太多的试错。
