历史消息与漫游:分页、冷热分离与检索

历史消息(history message)是 IM 里访问模式最"歪"的数据:99% 的请求集中在最近一屏,剩下的 1% 可能翻到三年前。这决定了它不能只用一种存储、也不能用普通列表页的 offset 分页来交付。本章给出会话内 seq 游标分页、热温冷三层存储与检索索引的工程做法。

为什么 offset 分页在 IM 里是错的

  1. 漂移:翻页期间新消息不断插入,第 2 页会重复或漏掉几条,用户看到"同一句话出现两次";
  2. 深分页代价:LIMIT 10000, 20 要扫描并丢弃 1 万行,翻到"三年前"几乎必然超时;
  3. 没有稳定锚点:客户端断线重来,无法表达"从上次那条继续",只能用页码赌运气。

正确做法只有一条:按会话级 seq(conv_seq)游标分页。seq 单调递增且走主键索引,任何位置翻页都是 O(log n) 定位 + 顺序扫 20 行。

数据模型:正文与索引分离

-- 正文:一条消息只存一份,按 msg_id 分片
CREATE TABLE msg_content (
  msg_id     BIGINT UNSIGNED NOT NULL COMMENT '全局消息 ID(08 章)',
  conv_id    BIGINT UNSIGNED NOT NULL,
  sender_uid BIGINT UNSIGNED NOT NULL,
  msg_type   TINYINT UNSIGNED NOT NULL COMMENT '1=text 2=image 3=file ...',
  content    VARBINARY(4096) NOT NULL COMMENT '加密载荷,含 @ 与引用元信息',
  created_at DATETIME(3)     NOT NULL,
  PRIMARY KEY (msg_id),
  KEY idx_conv (conv_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 会话索引:会话内 seq 连续递增,是分页的唯一锚点
CREATE TABLE conv_msg (
  conv_id    BIGINT UNSIGNED NOT NULL COMMENT '分片键',
  seq        BIGINT UNSIGNED NOT NULL COMMENT '会话内单调递增序号(08 章)',
  msg_id     BIGINT UNSIGNED NOT NULL,
  sender_uid BIGINT UNSIGNED NOT NULL,
  msg_type   TINYINT UNSIGNED NOT NULL,
  created_at DATETIME(3)     NOT NULL,
  PRIMARY KEY (conv_id, seq),
  UNIQUE KEY uk_msg (msg_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

单聊的 conv_id 由两个 uid 派生(hash(min_uid, max_uid)),保证同一会话落在同一分片;群聊直接用群 ID。conv_msg 只存索引字段,列表页渲染时按 msg_id 批量回 msg_content 取正文(批量 IN 查询),避免把大字段带进翻页查询。

三种分页方式的取舍

方式SQL 形态优点致命问题
offset/limitLIMIT 10000, 20写法简单插入导致漂移;深翻页扫全表
时间戳游标WHERE created_at < ? ORDER BY created_at DESC直观、跨表通用同毫秒要二次排序;时间可被调整
seq 游标WHERE conv_id=? AND seq < ? ORDER BY seq DESC LIMIT 20稳定、走主键、无漂移依赖会话内 seq 单调(08 章)

统一用 seq 游标,三个方向各一条 SQL:

-- 向上翻(看更早的历史):首次 before_seq 传会话当前 max_seq + 1
SELECT seq, msg_id, sender_uid, msg_type, created_at
FROM conv_msg
WHERE conv_id = ? AND seq < ?
ORDER BY seq DESC
LIMIT 20;

-- 向下补(断线重连后追赶新消息)
SELECT seq, msg_id, sender_uid, msg_type, created_at
FROM conv_msg
WHERE conv_id = ? AND seq > ?
ORDER BY seq ASC
LIMIT 50;

-- 定位跳转("跳到某条消息"):先按 msg_id 找锚点,再向两端各取一页
SELECT seq, msg_id, created_at FROM conv_msg
WHERE conv_id = ? AND msg_id = ?;

响应统一带 has_more、min_seq、max_seq,客户端据此决定能否继续翻、以及是否需要检测断洞。

冷热分离

层数据范围存储单次查询目标说明
热最近 7 天或每会话最近 500 条Redis ZSet + MySQL 主表< 10ms打开会话首屏命中
温7 天 ~ 180 天MySQL 按天分区 / 归档表< 50ms向上翻页的主要区域
冷180 天以上对象存储(OSS/S3)+ 列式库(ClickHouse 等)100ms~2s按会话 + 日期定位文件后批量解压

热层 Redis 结构:

key类型说明TTL
im:hist:conv:{convID}ZSetscore=seq,member=msg_id:sender:type7 天
im:hist:maxseq:{convID}String会话当前最大 seq,用于未读与首屏定位无

查询路径固定为先热 → 命中即返回;未命中查温;温层返回空且 seq 超出温层下界,才去冷层。冷层查询必须异步化:接口超时 800ms,取到多少返回多少并带上 has_more,绝不因为对象存储的尾延迟把在线接口拖成 5xx。

时序

客户端                历史服务                  热(Redis)   温(MySQL)   冷(OSS)
  │ 拉首屏 ───────────▶│                          │           │          │
  │                    │─ ZREVRANGE 取最近 20 ───▶│           │          │
  │◀─ 20 条 + min_seq ─│                          │           │          │
  │ 向上翻(seq<min) ──▶│─ 热层 miss ──────────────┼──────────▶│          │
  │◀─ 20 条 + has_more ┤                          │           │          │
  │ 继续翻(超出温层) ─▶│─ 异步取归档文件 ─────────┼───────────┼─────────▶│
  │◀─ 分批返回(可能提示"加载中")────────────────┘           │          │

检索

历史检索不适合在 MySQL 上做 LIKE '%关键词%',需要独立倒排索引:

  • 索引文档按消息粒度:{msg_id, conv_id, sender_uid, created_at, msg_type, text},用 Elasticsearch/OpenSearch;
  • 权限过滤必须进查询条件:conv_id IN (...) 或 uid 由服务端从会话成员关系推导,不能只靠应用层过滤,更不能信任客户端传入的 conv_id 列表;
  • 检索只返回 msg_id 列表,回源取正文后再展示,保证被编辑/撤回后的状态一致;
  • 写入路径异步:消息落库后投递 MQ(15 章),由索引消费者写入,允许秒级延迟;
  • 撤回与删除同步更新索引(软删标记 + 查询过滤),不要指望索引删除立即可见。

若产品只要求"在会话内搜索",可先由 conv_id 定位会话再在会话范围内检索,索引分片键与 conv_id 对齐以避免跨分片聚合。

失败场景与兜底

场景表现兜底
翻页期间出现 seq 空洞少几条空洞来自未提交事务,重试同一区间;客户端不做连续性断言
归档任务延迟冷层查不到刚归档的数据归档"先写冷、再删温",温层删除延迟 7 天
冷层超时翻页卡住800ms 超时返回已取部分 + has_more,前端提示加载更多
检索索引滞后搜不到刚发的消息最近 5 分钟窗口回源 MySQL 补一次
大群历史被高频访问热层被穿透会话级热点探测 + 单机 LRU 缓存 100 条

工程注意点

  • 分页参数只用 seq:接口里不要同时暴露 offset 与 seq,两套并存必然被误用;
  • 首屏只查热层:打开会话的延迟决定 IM 的"手感",首屏绝不允许穿透到对象存储;
  • 冷层接口必须异步:任何"可能查冷层"的接口都要有超时与部分返回语义,不能同步等;
  • 归档与备份分开:归档是"给业务查的冷数据",备份是"给故障恢复用的副本",介质与生命周期不同,别混成一个任务;
  • 列表页批量取正文:一律"先取索引、再按 msg_id 批量 IN 查询",禁止 N+1。

小结:历史消息的做法是"正文一份、会话索引一份、以 conv_seq 游标分页"——向上翻 seq < before ORDER BY seq DESC、向下补 seq > after ORDER BY seq ASC、跳转先按 msg_id 找锚点再向两端取页,彻底放弃 offset;存储按热(Redis + MySQL 近期)、温(分区表 180 天)、冷(对象存储 + 列式库)分层,查询逐级穿透且冷层必须异步;检索交给独立倒排索引并强制带上服务端推导的会话权限过滤,结果按 msg_id 回源取正文。

笔记加载中…