背景
即时通讯(IM)系统通常由接入层、消息服务、消息同步、消息存储、消息检索等模块组成。其中,历史消息查询直接影响用户打开会话、上拉聊天记录、跨端消息漫游等体验。IM消息系统需要同时满足消息同步、消息存储和消息检索能力;在数据规模持续增长后,存储层还需支撑海量数据、低成本存储、生命周期管理、高写入吞吐和低延迟读取。
部分实现中,聊天记录会按照“单条消息一个Key”的方式存储。以下示例以会话ID c10086 为例;在多租户场景下,可将 tenantId 与 conversationId 组合作为会话的唯一标识。 例如:
im:msg:{tenantId}:{conversationId}:{msgId} -> messageBody 查询某个会话的历史消息时,业务通过 SCAN MATCH 命令按Key前缀查找消息Key,再批量读取消息内容。
- 按会话前缀扫描消息Key的命令如下:
SCAN 0 MATCH im:msg:t001:c10086:* COUNT 1000
- 根据扫描得到的Key批量读取消息内容:
MGET im:msg:t001:c10086:m001 im:msg:t001:c10086:m002 im:msg:t001:c10086:m003
该方案实现简单,适合在消息规模较小、查询频率较低的阶段使用。但随着会话数量、消息数量和数据保留周期的增加,线上历史消息查询的耗时会逐步升高,部分场景下可能从几十毫秒攀升至秒级,并伴随P95、P99的抖动。
GeminiDB Redis 基于云原生分布式架构,兼容 Redis 协议,支持 String、Hash、ZSet、Stream 等主流数据结构及 SCAN 等常用命令。对于 IM 系统而言,这意味着业务侧无需改造即可沿用成熟的 Redis 生态,同时获得具备长期数据承载能力的分布式存储底座。
本文以此类“通过 SCAN MATCH 按会话前缀查找聊天记录”的场景为例,介绍在使用 GeminiDB Redis 后的推荐数据模型和查询方式,并说明如何通过分区并行 SCAN 来提升后台任务的处理效率。
使用场景
- IM 历史消息查询通过 SCAN MATCH 指令基于会话前缀匹配 Key。
- Redis 中消息 Key 总量庞大,查询性能受全局 Key 数量显著影响,导致检索耗时增加。
问题分析
SCAN 命令机制与限制
SCAN 是一种基于游标(Cursor)的增量迭代命令。遍历过程从 cursor=0 开始,直至服务端再次返回 0 结束。COUNT 表示本次迭代的工作量提示,不保证每次返回固定数量元素。SCAN 也可能返回重复元素,客户端需要具备幂等处理能力。
IM 消息查询场景分析
在IM聊天记录查询场景中,业务通常需要读取某个会话下的一段有序消息。例如:
conversationId = c10086
lastSeq = 123456
pageSize = 20
若使用 SCAN MATCH im:msg:t001:c10086:* 时,服务端需要在Key空间中增量遍历,再根据 MATCH 条件返回匹配 Key。
性能瓶颈
随着 Redis 全局 Key 数量激增,单个会话 Key 在总空间中的占比急剧下降。这意味着服务端需要遍历更多无关 Key 才能命中目标数据,导致查询耗时与全局 Key 规模呈正相关,严重拖慢响应速度。
图1 改造前聊天记录查询链路
该链路的主要耗时来自多轮 SCAN 网络往返、Key 空间遍历、MATCH 过滤、消息体读取,以及客户端排序和去重。对于高频历史消息查询,这类扫描型访问路径会放大实例 Key 规模对接口时延的影响。
方案介绍
本方案采用“消息体与索引分离”的双层存储架构,旨在平衡数据完整性与查询性能:
消息体数据层:持久化存储完整的消息内容,涵盖发送人、时间戳、消息类型、正文及扩展字段等元数据。
会话索引层:仅维护会话内的消息顺序映射(如消息序号或 Key),作为快速定位分页数据的“导航图”。
在线业务查询:采用“先索引后数据”的两步法。首先通过会话索引快速计算出目标页码对应的消息序号或 Key,随后批量读取对应的消息体数据,显著降低 I/O 开销。
利用 GeminiDB Redis 的分区并行 SCAN 能力,高效执行存量数据回填、索引一致性修复及归档巡检等后台任务,确保系统在高并发下的稳定性与数据一致性。
图2 改造后聊天记录查询链路
数据模型设计
以下示例以会话 c10086 为例。为便于同一会话相关Key在集群场景下聚合,示例中使用 {c10086} 作为hash tag。
- 消息体数据
消息体保存完整的业务消息内容,推荐使用 Hash 或 String 保存。使用Hash时,字段可独立读取;使用String时,通常将消息序列化为JSON或二进制内容。
消息体Key示例:
im:msg:{c10086}:000000000123 其中,c10086 表示 conversationId,000000000123 表示会话内消息序号 seq。
下面的命令创建了一条新的消息体Key,用于保存 seq=123 这条消息的完整内容:
HSET im:msg:{c10086}:000000000123
tenantId t001
conversationId c10086
msgId m123
senderId u1001
seq 123
sendTime 1715136000123
msgType text
content "hello"
ext "{}"
status normal 后续历史消息接口返回给用户的发送人、时间、消息类型、正文和状态等字段,主要来自这个消息体Key。
- 会话索引数据
会话索引使用 ZSET 保存。ZSET中保留排序和定位所需信息:score 使用会话内递增 seq,member 使用固定宽度 seq 或消息定位值。完整消息内容保存在消息体Key中。
会话索引Key示例:
im:idx:{c10086}:seq:000000 该Key表示会话 c10086 的第 000000 个 seq 索引分桶。下面的命令把 seq=123 这条消息写入该分桶:
ZADD im:idx:{c10086}:seq:000000 123 000000000123 这条索引记录的含义是:排序分数为 123,member 为 000000000123。读取历史消息时,业务根据member拼接出消息体Key im:msg:{c10086}:000000000123,然后读取完整消息内容。
- 会话元信息
会话元信息用于记录当前会话的最新消息序号、当前索引分桶、分桶大小等信息。历史消息读取最近一页时,可以先读取该Key定位当前分桶。
会话元信息Key示例:
im:conv:{c10086}:meta 下面的命令更新会话 c10086 的最新状态:
HSET im:conv:{c10086}:meta
latestSeq 123
bucketSize 5000
currentBucket 0
updateTime 1715136000123 这表示当前会话最新消息序号为 123,索引分桶大小为 5000,当前写入第 0 个分桶。
- 分桶规格
分桶规格是避免会话索引形成大Key的重要设计项。建议将单个ZSET分桶的成员数控制在一个明确上限内,例如以 5000 条消息作为初始分桶规格,并结合会话类型、读写频率和压测结果调整。
普通单聊可以优先使用月分桶或 seq 分桶;活跃群聊建议使用日分桶或 seq 分桶;超活跃群聊和长期保留历史消息的场景建议优先使用 seq 分桶。无论采用时间分桶还是 seq 分桶,都需要设置成员数上限,当单个分桶接近上限时切换到新的索引Key。
读取跨越多个分桶时,History API 从当前分桶倒序读取;当前分桶不足一页时,再读取前一个分桶,直到凑满 pageSize 或没有更多历史消息。
写入流程
发送一条消息时,消息服务为会话生成递增 seq,然后依次写入消息体、会话索引和会话元信息。
图3 消息写入流程
- 创建新的消息体Key im:msg:{c10086}:000000000123,并写入 seq=123 这条消息的完整字段:
HSET im:msg:{c10086}:000000000123
tenantId t001
conversationId c10086
msgId m123
senderId u1001
seq 123
sendTime 1715136000123
msgType text
content "hello"
status normal - 将同一条消息加入会话索引分桶。后续历史消息读取时,会先从该ZSET中获取member,再根据member读取消息体:
ZADD im:idx:{c10086}:seq:000000 123 000000000123 - 更新会话最新状态,记录最新 seq、当前分桶和更新时间,便于读取最近消息时快速定位起始位置:
HSET im:conv:{c10086}:meta
latestSeq 123
currentBucket 0
updateTime 1715136000123 实际写入时,可以通过Pipeline减少网络往返。消息体Key、索引member和会话元信息均以 seq 为核心字段,重复执行同一条消息写入时仍能保持幂等。
读取流程
读取最近20条消息时,业务先读取会话元信息,确定最新 seq、分桶大小和当前分桶;随后从会话ZSET中按 seq 倒序读取member;最后根据member批量读取消息体。
图4 最近消息读取流程
- 读取会话元信息,确认当前最新消息序号和当前分桶:
HGETALL im:conv:{c10086}:meta 假设返回 latestSeq=5123、bucketSize=5000、currentBucket=1,读取最近20条消息时,业务从第 000001 个分桶开始倒序读取。下面的命令表示:从 seq<=5123 开始,倒序取最多20个member:
ZREVRANGEBYSCORE im:idx:{c10086}:seq:000001 5123 -inf LIMIT 0 20 该命令返回的是消息定位值,例如:
000000005123
000000005122
000000005121
...
- 业务根据这些member拼接消息体Key。对于Hash消息体,通常将20个 HMGET 命令放入一个Pipeline批次中发送,减少网络往返。下面示例读取每条消息的核心字段:
# Pipeline batch
HMGET im:msg:{c10086}:000000005123 senderId sendTime msgType content status
HMGET im:msg:{c10086}:000000005122 senderId sendTime msgType content status
HMGET im:msg:{c10086}:000000005121 senderId sendTime msgType content status
... 如果消息体使用String保存序列化内容,则可以根据member拼接出20个消息体Key后使用 MGET 批量读取:
MGET im:msg:{c10086}:000000005123 im:msg:{c10086}:000000005122 ... 当当前分桶返回数量不足一页时,继续读取前一个分桶。例如第 000001 个分桶只返回3条,还需要17条,则继续读取第 000000 个分桶:
ZREVRANGEBYSCORE im:idx:{c10086}:seq:000000 +inf -inf LIMIT 0 17 上拉历史消息时,上一页返回的最小 seq 可作为下一页游标。假设上一页最小 seq 为 5123,下一页从 seq<=5122 开始读取:
ZREVRANGEBYSCORE im:idx:{c10086}:seq:000001 5122 -inf LIMIT 0 20 返回结果组装完成后,将本页最小 seq 作为 nextCursor 返回给客户端,客户端下一次上拉时继续使用该游标。
后台动作
后台动作与在线写入、在线读取并列存在,主要用于数据补齐、数据修复等数据管理。这些动作由业务侧后台Worker执行,GeminiDB Redis提供所需的数据结构、命令能力和分区扫描能力。
GeminiDB Redis的分区扫描能力适合这类后台任务。业务可以为不同分区启动独立Worker,每个Worker使用 SCAN ... PARTITION 扫描指定分区并维护自己的cursor。多个分区并行扫描时,可以显著提升大规模Key遍历效率。
存量回填
存量回填主要发生在迁移或数据模型升级时。已有历史消息中如果缺少会话索引,可以由后台Worker扫描历史消息Key,解析消息中的 conversationId、seq、sendTime 等字段,再补写消息体和会话索引。
图5 存量回填流程
索引修复
索引修复用于处理消息体和会话索引不一致的情况。例如消息体存在但索引缺失,或者索引中存在member但对应消息体已经被清理。该动作可以低频执行,也可以在发现异常会话后定向执行。
图6 索引修复流程
存量回填主要服务于迁移或模型升级阶段;索引修复属于后台治理能力,可按业务需要定期执行、低频执行或定向执行。在线历史消息查询稳定使用“消息体 + 会话索引”模型后,SCAN主要用于后台任务。
总结
本实践介绍了由传统SCAN MATCH前缀扫描到会话索引查询的IM历史消息优化实践。方案采用“消息体 + 会话ZSET索引”的数据模型:消息体保存完整消息内容,会话ZSET以seq作为排序字段、以消息定位值作为成员,历史消息接口通过conversationId + seq/cursor读取索引,再批量获取对应消息体,使查询范围收敛到单会话、单分桶索引。
GeminiDB Redis为该方案提供了更适合IM海量消息场景的基础能力。
- 其Redis兼容能力可以承接现有业务的数据结构和命令使用习惯,便于业务平滑改造;
- 其分布式架构适合承载大规模消息数据和高并发读写访问;
- 其超大容量存储、冷热数据管理和成本优化能力,有助于支撑IM历史消息长期保存和冷热分层;
- 其分区扫描能力可以将后台扫描任务拆分到多个数据分区并行处理,提升存量回填、索引修复、归档巡检等任务的执行效率。
整体来看,GeminiDB Redis在该方案中既承担在线消息数据的高性能存储角色,也承担后台数据治理的高效处理底座。在线链路通过会话索引提升查询效率,后台链路通过分区SCAN提升处理效率,两者配合后,可以将IM聊天记录查询从受全局Key扫描影响的秒级抖动,优化为更稳定的毫秒级访问体验,同时兼顾海量存储、冷热分层和长期成本控制。