写这篇的起因是前两天帮朋友看了一个慢查询,他的接口跑了 8秒,最后发现罪魁祸首是分页写错了——对,就是那种全宇宙程序员都在用的 LIMIT page*size, size。
你以为这是个低级错误?但我跟至少十个工程师聊过这个问题,发现大家都在用,但没几个能说清楚为什么不对。今天我们就来把这个事情掰开揉碎聊透。
先说个冷知识:你以为的分页,其实不是分页
大多数 CRUD 工程师(包括以前的我)写分页是这样的:
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20 OFFSET 100
漂亮,简洁,看起来完全没问题。
但数据库的内心 OS 是这样的:
哥,你是要第5页,每页20条对吧?没问题,我先把你前面100条都查出来扔掉,然后给你留第5页的20条。
是的,OFFSET 100 的意思是「跳过前100条」,不是「从第100条开始读」。数据库必须真的去读那100条,然后扔掉。这是物理层面的浪费,不是算法优化能解决的。
当你 offset 到了 LIMIT 1000000, 20 的时候,数据库已经累死累活读了一百万行,就为了给你看最后20条。这就是为什么越往后翻越慢——不是因为你的代码烂,是因为这个算法本身就是 O(n) 的。
真实案例:一个订单列表差点让我被祭天
那年我负责一个电商后端,有个大客户反应「我的订单列表加载要 15秒」。
查了一圈,发现这个客户的账号下有 80万条订单。翻到第100页的时候:
LIMIT 20 OFFSET 2000
数据库吭哧吭哧扫描了两千行。翻到第5000页:
LIMIT 20 OFFSET 100000
十万行,扔掉,给你20条。用户翻页翻到崩溃。
当时我司 DBA 说了一句话让我记到现在:「OFFSET 是给人类用的,不是给机器用的」。
Cursor-based Pagination:真正的分页
cursor 分页的核心思想是:不再说「跳过多少条」,而是「从这条之后继续」。
-- 第一次请求,拿最新20条
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20
-- 第二次请求,传入最后一条的 created_at
SELECT * FROM orders
WHERE user_id = 123
AND created_at < "2024-03-15 10:30:00"
ORDER BY created_at DESC
LIMIT 20
这次数据库只需要扫描「符合条件的后20条」,不管你翻到第几页,性能永远稳定。因为你不再说「跳过」,而是说「从这开始拿」。
这是 O(1) 的复杂度,而 offset 是 O(n)。数据量越大,差距越离谱。
有人要问了:那我怎么跳到指定页?
这是 cursor 分页最让人吐槽的地方:它不支持跳页。
你不能像以前一样说「我要跳到第 5888 页」。因为 cursor 的逻辑是「下一页」,不是「第N页」。
解决方案有几个:
- 如果业务真的需要跳页(比如用户点了第5页但没点过前面的),那就给前端加个「请从头开始翻」的提示,或者用混合方案——前100页用 offset,100页之后提示用户「数据太多,请使用搜索」
- 如果能用搜索/筛选代替翻页,尽量用筛选条件替代分页,用户搜「2024年3月的订单」比翻到地老天荒要靠谱得多
- 如果必须支持跳页且数据量大,考虑用 Elasticsearch,把分页逻辑扔给 ES,MySQL 只做存储
MySQL 还有一个大坑:深度分页的锁问题
你以为 cursor 就完美了?too young。
在 MySQL 的 RR(可重复读)隔离级别下,如果你这样写:
SELECT * FROM orders
WHERE id > #{last_id}
ORDER BY id ASC
LIMIT 20
当你的数据在频繁写入时,可能会出现「幻读」——有些记录在翻页过程中被插入或删除,导致你可能会:
- 看到重复的记录(被删除的记录被跳过)
- 漏掉某些记录(新插入的记录)
解决方案是用游标快照:
BEGIN;
-- 先记录当前最大 ID
SELECT @last_id = MAX(id) FROM orders WHERE ...;
-- 再用这个固定的值作为游标
SELECT * FROM orders
WHERE id > #{cursor_id}
ORDER BY id ASC
LIMIT 20;
COMMIT;
但说实话,如果不是金融级场景,这个坑你可以先忽略。普通业务系统里丢几条订单不算天塌了。
说点政治不正确的:什么时候可以继续用 OFFSET
我知道有些杠精要说「我用 offset 也没见出问题啊」。对,在某些场景下 offset 确实能用:
- 数据量小(万级别以下),翻到第100页也才扫1000行
- 不需要翻很深,用户最多翻前10页
- 查询条件很精确,能走索引,OFFSET 的数据不需要全表扫描
但问题是——谁的需求会永远不变?
今天是小客户,明天有大客户。今天是测试数据,明天是生产数据。今天你说「用户不会翻那么深」,明天产品就加了「历史订单」功能。
我的建议是:从一开始就选择正确的分页方式。用 cursor 分页不会让你多写几行代码,但能让你的系统在数据量涨100倍之后还活得好好的。
最后给个实战模板
Node.js + MySQL 下的完整 cursor 分页实现:
// 获取订单列表
async function getOrders(userId, cursor, limit = 20) {
let query = 'SELECT * FROM orders WHERE user_id = ?';
const params = [userId];
// 如果有 cursor,就加上游标条件
if (cursor) {
query += ' AND created_at < ?';
params.push(cursor);
}
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(limit + 1); // 多拿一条判断有没有下一页
const rows = await db.query(query, params);
// 如果结果比请求的多,说明还有下一页
const hasNextPage = rows.length > limit;
const data = hasNextPage ? rows.slice(0, limit) : rows;
const nextCursor = hasNextPage ? data[data.length - 1].created_at : null;
return {
data,
hasNextPage,
nextCursor
};
}
返回给前端的样子:
{
"orders": [...],
"pagination": {
"hasNextPage": true,
"nextCursor": "2024-03-15T10:30:00Z"
}
}
前端下次请求的时候带上 nextCursor,服务端继续往后拿。完美。
总结一下
分页这个问题,说大不大说小不小。但我见过太多系统因为分页写错,在数据量涨上来之后突然暴毙,然后紧急重构。
记住两句话:
- OFFSET 是给人类用的,不是给数据库用的
- 用 cursor 代替 offset,用「从这开始」代替「跳过多少」
就这么简单。但简单的东西,往往最容易被忽视。
下次写分页接口的时候,多想一下你的数据库在做什么。共勉。