即时通讯项目中怎么实现历史消息的下拉分页加载?
- 标签:后端、系统设计、场景题
🏛 业务架构师
历史消息分页加载,我会先点出核心矛盾:聊天记录是持续追加的,传统的 OFFSET 分页在数据动态插入时会出现翻页漂移和重复——用户往上翻历史消息时正好来了新消息,OFFSET 基准变了,体验就是消息跳变,这在 IM 产品里是明确的功能缺陷。
正确方案:游标(Cursor)分页
以消息 ID 或消息时间戳为游标:
sql
SELECT * FROM msg
WHERE conv_id = ? AND id < #{lastMsgId}
ORDER BY id DESC LIMIT 20;- 每页返回末尾消息 ID 作为下一页游标,天然抗插入、性能恒定(走
(conv_id, id)联合索引),深翻页也不会像 OFFSET 那样越翻越慢。 - 消息 ID 用会话内单调递增的序列(雪花 ID 或服务端分配),同时充当排序与去重依据。
按业务量级分档的存储选型
- 中小 IM(日活几十万内):MySQL 按
(会话ID, 消息ID)索引 + 最近的会话进 Redis 缓存,完全够用,别上重存储。 - 大规模 IM:按会话做哈希分片,HBase/Cassandra 这类按 RowKey 范围扫描的宽表存储,写扩散/读扩散的选择取决于群聊规模。
业务侧我还会主动补两点
- 首屏体验:进入会话先用本地 DB(客户端缓存)渲染最近消息,再后台拉服务端增量(增量同步按 seq 区间拉),把"翻页加载"变成"断线补偿",这是 IM 可用性感知的关键。
- 多端同步:游标设计要同时服务多端消息漫游,seq 连续性检查还能顺带发现丢消息。
面试一句话收尾:IM 分页的本质不是分页算法,是为"持续写入 + 多端同步"设计的有序不可变日志读取模型。
🏗 系统架构师
IM 历史消息分页,架构上要解决三件事:消息 ID 的有序性、分页方式、存储模型。
消息 ID 设计(一切的根基) 必须保证单会话内单调递增。全局雪花 ID 在单会话内不保证严格有序(多端时钟偏差),我会用"会话级 sequence":每个会话维护一个发号器(Redis INCR 或 DB 自增),消息 ID = 会话ID + seq。这样分页才有稳定的游标语义。
分页方式:游标,坚决不用 OFFSETWHERE conv_id=? AND seq < last_seq ORDER BY seq DESC LIMIT 20,索引 (conv_id, seq) 上这是深度分页 O(log n),而 OFFSET 深翻页是 O(n)。下拉加载"上一页"就是拿当前页最小 seq 继续往前查,天然支持跳到某个时间点(按消息定位 seq)。
存储模型取舍
- 写扩散(每人收件箱一份):读快,写放大,适合单聊;大群必须改读扩散(消息只存会话 timeline 一份,进入会话时拉取),否则一个 10 万人群一条消息要写 10 万份。
- 我的一般选择:单聊写扩散、大群读扩散混合,timeline 表按会话 ID 分表(5000W 会话 × 平均消息量估算分表数)。
接口细节 返回消息列表 + has_more + 下页游标;本地已读位(conversation 的 read_seq)也随接口返回,客户端据此渲染已读/未读。历史消息属于允许最终一致的数据,多端同步靠 seq 做增量同步(WHERE seq > client_max_seq),比全量翻页更省。
💻 全栈开发专家
IM 历史消息分页的核心就一条:用游标(cursor)分页,坚决不用 offset。
为什么不能用 LIMIT OFFSET 会话消息是持续新增的。用 LIMIT 20 OFFSET 10000 时,用户翻页期间来了新消息,整个窗口后移,会漏消息或重复消息;而且 offset 越大 MySQL 扫得越久,深翻页直接拖垮 DB。
正确做法:消息序号游标 每个会话维护单调递增的 seq(发消息时 Redis INCR 或号段模式分配),按会话 + seq 建联合索引:
sql
SELECT msg_id, seq, content, created_at
FROM message
WHERE conversation_id = ? AND seq < ?
ORDER BY seq DESC
LIMIT 20;- 首屏:seq 传 0(或不带条件)取最新 20 条。
- 上翻加载更早:前端把当前最早一条的 seq 回传,永远基于「锚点消息」往前取,新消息进来也不影响。
- 返回体带
hasMore和下一页游标,前端触顶(onScrollTop)再拉。
细节与坑
- seq 必须严格有序且唯一,不要直接用时间戳(同毫秒冲突)。我用
Redis INCR conv:{id}:seq,简单可靠。 - 消息删除/撤回:查询用
status IN (0,2)过滤已撤回的,或撤回只改状态不物理删,避免 seq 空洞干扰分页。 - 客户端本地缓存最近若干条,进入会话先渲染本地、再静默增量拉取(
seq > 本地最大往新方向拉),体验更顺。 - 量大了按会话做分表或归档冷数据到 HBase/Cassandra,热表只留最近 N 个月。
这套「锚点 + 单调 seq」的方案,本质和 feeds 流翻页是一个思路。