聊天系统中的历史消息分页查询设计:基于游标分页方案
在即时通讯系统中,一个常见需求是:
当用户第一次打开聊天窗口时,需要加载之前的聊天记录。
例如:
用户 A 打开和用户 B 的聊天页面:
用户A
|
| 打开聊天窗口
|
↓
请求历史消息
↓
服务器返回最近聊天记录
↓
用户继续向上滑动
↓
加载更早的消息
看似简单,但当聊天数据量非常大时,如何高效查询历史消息成为一个重要问题。
本文将介绍聊天系统中 历史消息分页查询接口的设计,并重点分析为什么采用 游标分页(Cursor Pagination),而不是传统分页。
1. 业务场景分析
假设我们的聊天系统每天产生大量消息:
chat_message 表
+----+---------+---------+----------------+
| id | user_id | room_id | content |
+----+---------+---------+----------------+
| 1 | 1001 | 2001 | hello |
| 2 | 1002 | 2001 | hi |
| 3 | 1001 | 2001 | how are you |
|... | ... | ... | ... |
+----+---------+---------+----------------+
当用户进入聊天室:
第一次:
加载最近20条消息
继续向上滑:
加载更早20条消息
例如:
第一次请求:
GET /message/history?roomId=1001
返回:
消息10000
消息9999
...
消息9981
第二次:
GET /message/history?
roomId=1001
cursor=9981
返回:
消息9980
消息9979
...
消息9961
这种模式就是游标分页。
2. 为什么不用传统分页?
很多系统第一反应是:
page=1
size=20
例如:
SELECT *
FROM chat_message
WHERE room_id=1001
ORDER BY id DESC
LIMIT 20 OFFSET 0;
第二页:
SELECT *
FROM chat_message
WHERE room_id=1001
ORDER BY id DESC
LIMIT 20 OFFSET 20;
看起来没有问题。
但是随着数据量增加,会出现 深度分页问题。
3. 深度分页问题
假设聊天室已经积累:
1亿条消息
用户查询:
第500万页
SQL:
LIMIT 20 OFFSET 100000000
数据库执行过程:
扫描前100000000条数据
↓
丢弃这些数据
↓
返回最后20条
也就是说:
用户只需要20条数据。
数据库却需要处理:
100000020条数据
随着 OFFSET 增大:
- 查询越来越慢
- CPU 消耗增加
- IO 增加
- 数据库压力变大
这就是:
深度分页问题
4. 游标分页思想
游标分页的核心:
不告诉数据库跳过多少条,而是告诉数据库从哪里继续查询。
例如:
第一次:
查询最新20条
结果:
id
10000
9999
9998
...
9981
记录最后一条:
cursor = 9981
下一次:
告诉数据库:
从 id < 9981 的地方继续查
SQL:
SELECT *
FROM chat_message
WHERE room_id = 1001
AND id < 9981
ORDER BY id DESC
LIMIT 20;
数据库执行:
定位 id=9981
↓
向前扫描20条
↓
返回结果
时间复杂度基本稳定。
5. 历史消息接口设计
接口定义
查询历史消息
GET /api/chat/history
请求参数:
{
"roomId":1001,
"cursor":9981,
"limit":20
}
参数说明:
| 参数 | 说明 |
|---|---|
| roomId | 聊天室ID |
| cursor | 游标位置 |
| limit | 查询数量 |
第一次请求:
没有 cursor:
{
"roomId":1001,
"limit":20
}
表示:
查询最新20条消息
6. 返回结构设计
响应:
{
"code":200,
"data":{
"list":[
{
"id":9980,
"senderId":1001,
"content":"hello",
"createTime":"2026-01-12 10:00:00"
}
],
"nextCursor":9961,
"hasMore":true
}
}
字段说明:
list
当前消息列表:
20条聊天记录
nextCursor
下一次查询游标:
例如:
9961
下一次:
id < 9961
hasMore
是否还有更多数据:
true:
还有历史消息
false:
已经到底
7. 数据库设计优化
聊天消息表:
CREATE TABLE chat_message
(
id BIGINT PRIMARY KEY,
room_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
content TEXT,
create_time DATETIME,
INDEX idx_room_id_id(room_id,id)
);
关键索引:
(room_id,id)
为什么?
查询:
WHERE room_id=1001
AND id < 9981
ORDER BY id DESC
LIMIT 20
数据库可以直接利用索引:
room_id
|
|
+---- id倒序扫描
9980
9979
9978
...
避免全表扫描。
8. 为什么聊天系统特别适合游标分页?
聊天消息有几个特点:
① 数据只增加,不修改
消息通常:
insert
insert
insert
很少:
update
delete
非常适合使用 ID 作为游标。
② 查询方向固定
用户查看历史:
最新
↓
更早
↓
更早
天然符合:
id < cursor
③ 数据量巨大
大型 IM 系统:
每天可能产生:
亿级消息
传统分页无法支撑。
9. 关于消息顺序问题
通常消息 ID 使用:
方案1:数据库自增 ID
例如:
10001
10002
10003
优点:
- 简单
- 索引效率高
缺点:
- 分布式环境困难
方案2:雪花算法 ID
例如:
174324234234234
特点:
时间递增:
旧消息
↓
新消息
适合:
- 分布式 IM
- 微服务架构
10. 游标分页完整流程
用户打开聊天:
客户端
GET history
↓
服务器
查询最新20条
↓
返回:
messages
cursor
↓
客户端展示
用户继续上滑:
携带cursor
↓
服务器:
WHERE id < cursor
↓
返回更早消息
循环:
最新消息
↓
上一页
↓
上一页
↓
直到结束
11. 游标分页与普通分页对比
| 对比 | OFFSET分页 | 游标分页 |
|---|---|---|
| 实现难度 | 简单 | 稍复杂 |
| 查询性能 | 后期下降明显 | 稳定 |
| 深分页 | 慢 | 快 |
| 数据变化影响 | 大 | 小 |
| 适合数据量 | 小数据 | 海量数据 |
| 聊天记录 | ❌ | ✅ |
12. 实际 IM 系统架构
一个完整聊天系统通常:
客户端
|
|
HTTP
|
历史消息服务
|
MySQL
|
chat_message表
实时消息:
WebSocket
|
消息网关
|
消息服务
其中:
HTTP:
负责:
历史消息查询
登录
用户信息
WebSocket:
负责:
实时聊天
消息推送
在线状态
两者结合:
HTTP + WebSocket
=
完整IM系统
总结
在聊天系统中,历史消息查询是一个高频业务。
当数据量较小时:
page + size
也可以满足需求。
但随着聊天记录增长:
千万级
亿级
传统分页会出现严重的深度分页问题。
因此大型 IM 系统通常采用:
基于游标(Cursor)的分页方案
核心思想:
不要告诉数据库跳过多少条数据
而是告诉数据库
从哪个位置继续查询
最终实现:
- 查询性能稳定
- 支持海量消息
- 用户滑动加载流畅
这也是微信、QQ、Slack 等即时通讯系统加载历史消息时常见的设计方案。