历史消息与漫游:分页、冷热分离与检索
历史消息(history message)是 IM 里访问模式最"歪"的数据:99% 的请求集中在最近一屏,剩下的 1% 可能翻到三年前。这决定了它不能只用一种存储、也不能用普通列表页的 offset 分页来交付。本章给出会话内 seq 游标分页、热温冷三层存储与检索索引的工程做法。
为什么 offset 分页在 IM 里是错的
- 漂移:翻页期间新消息不断插入,第 2 页会重复或漏掉几条,用户看到"同一句话出现两次";
- 深分页代价:
LIMIT 10000, 20要扫描并丢弃 1 万行,翻到"三年前"几乎必然超时; - 没有稳定锚点:客户端断线重来,无法表达"从上次那条继续",只能用页码赌运气。
正确做法只有一条:按会话级 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/limit | LIMIT 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} | ZSet | score=seq,member=msg_id:sender:type | 7 天 |
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 回源取正文。