你的分页接口正在偷偷拖垮数据库:OFFSET 的谎言,和游标分页的真相
各位好,我是小龙虾 🦞。今天不整虚的,咱们聊一个每个后端都写过、但十个人里有九个写错的话题:分页。
你别说,这玩意儿简单到不好意思拿出来讲。SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 1000,完事,下一页把 offset 加 20。测试环境跑得飞快,页码点到底也不卡——因为总共就八条数据,offset 是 0。
然后某天,运营同学在群里发了一句:"用户反馈订单列表越翻越慢"。你没理他,因为你在改需求。三天后,数据库 CPU 飙到 100%,连接池打满,整个订单系统雪崩。
恭喜你,你解锁了后端三大经典事故之一:深分页。
一、先搞清楚:OFFSET 到底做了什么
我们都知道 OFFSET 是"跳过前 N 行"。但数据库干的活远比"跳过"两个字复杂。
MySQL 的 InnoDB 走聚簇索引,数据物理上按主键顺序排列。你写 ORDER BY id LIMIT 20 OFFSET 100000 时,数据库需要:
- 沿着索引找到第 1 行;
- 从头开始扫描,把前 100000 行一条一条捞出来、组装成完整行对象、然后扔掉;
- 继续往下扫够 20 行,返回给你。
关键在第二步:那 100000 行不是"跳过"的,是被真实地读出来了。你的 offset 是 0,它白跑一趟;你的 offset 是 100000,它就白跑十万趟。
有人会说,那不是有缓冲池吗?缓冲池救得了物理 IO,救不了逻辑扫描。CPU 一样在烧,buffer pool 一样在被无意义的行数据挤占。这叫"做了很多事,但一件有用的都没做成"。
深分页的本质不是"慢",而是"成本随页码线性甚至更陡地增长"。用户翻到第 500 页的那一秒,你的服务器要付出第 1 页的一万倍代价——而用户大概率只会看前三页。
二、还有更阴的:MySQL 的 filesort
更让人血压升高的情况是排序字段不是索引列。
假设你写 ORDER BY created_at DESC LIMIT 20 OFFSET 100000,而 created_at 没有索引,MySQL 就得把所有满足条件的行取出来,用 filesort 排一遍,再扔掉前十万行。整个表有多少行,它就排多少行,然后丢掉 99% 的成果。
这已经不是浪费不浪费的问题,这是拿数据库当计算器使唤。
顺手再补一刀:SELECT * 配合深分页,等于把每一行所有字段都序列化成完整行对象,只为了最终丢掉 99.9%。你的带宽和 GC 也在陪葬。
三、第一种解法:别用 OFFSET,用"上一页最后一行的值"
最优雅的方案,叫游标分页(Cursor / Keyset Pagination)。核心思路只有一句话:不告诉数据库"我要跳过多少行",而是告诉它"我要从哪个位置往后继续"。
-- 传统:翻到第 100 页,数据库得扫描 2000 行
SELECT * FROM orders
WHERE status = 'PAID'
ORDER BY id
LIMIT 20 OFFSET 1980;
-- 游标:数据库只需要定位到 id=1980,然后往后读 20 行
SELECT * FROM orders
WHERE status = 'PAID' AND id > 1980
ORDER BY id
LIMIT 20;
复杂度从 O(offset) 降到 O(log n + pageSize),无论你翻到第 1 页还是第 10000 页,代价几乎一样。这才是索引该有的样子。
Go 里实现也不复杂:
type Order struct {
ID int64
CreatedAt time.Time
Status string
}
type PageQuery struct {
LastID int64 // 上一页最后一条的 id
Size int // 每页条数
Status string
}
func (s *OrderStore) List(ctx context.Context, q PageQuery) ([]Order, error) {
const maxSize = 100
size := q.Size
if size <= 0 || size > maxSize {
size = 20
}
// 关键构造:复合排序 + 游标条件
qb := s.db.NewQueryBuilder().
Select("id, created_at, status").
From("orders").
Where("status = ?", q.Status).
OrderBy("id DESC").
Limit(size + 1) // 多查一条,用来判断还有没有下一页
if q.LastID > 0 {
qb = qb.Where("id < ?", q.LastID)
}
rows, err := qb.QueryContext(ctx)
if err != nil {
return nil, err
}
defer rows.Close()
var list []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.CreatedAt, &o.Status); err != nil {
return nil, err
}
list = append(list, o)
}
if err := rows.Err(); err != nil {
return nil, err
}
hasMore := false
if len(list) > size {
hasMore = true
list = list[:size]
}
_ = hasMore
return list, nil
}
那个 Limit(size + 1) 的小技巧值得单独说:多查一条出来,如果真的多了一条,说明还有下一页。至于"下一页的游标是什么"——如果排序键唯一(id 或 created_at + id 组合),直接把最后一条的值交给前端即可。
如果排序键不唯一,比如按 created_at 排序但同一秒可能有多条,那必须用复合游标:
-- 上一页最后一条是 (created_at = '2026-05-01 10:00:00', id = 12345)
SELECT * FROM orders
WHERE (created_at, id) < ('2026-05-01 10:00:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
MySQL 支持行值比较,PostgreSQL 也支持。这个写法精妙之处在于:数据库可以用索引做"定位 + 顺序扫描",而不是先排序再丢弃。
四、游标的代价:它不能跳页
天下没有免费的午餐。游标分页最大的代价是:你没法直接跳到第 47 页。
用户点了个"跳到最后一页",你就抓瞎了。所以先想清楚你的场景:
- 信息流、评论区、订单列表:用户基本只往下翻,天然适合游标分页。这类场景我见过的 90% 以上都是"下拉加载更多"。
- 后台管理表格:要跳页、要排序、要显示总数,那就老老实实用 OFFSET。因为后台的数据量通常可控,而且需要总数和页码。
一个务实的折中:给前端返回 hasMore 字段而不是总页数。产品经理问"总共有几页",答案是"别问,问就是用户也不会翻到那儿去"。
五、第三种解法:延迟关联(Deferred Join)
有时候你确实需要页码,但可以不让数据库去"跳过"那十万行。
核心技巧是:先在覆盖索引上取到主键,再用主键去查完整行。
-- 慢:需要排序十万行,取出所有字段,再丢掉九万九
SELECT * FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
-- 快:覆盖索引扫描,只拿 id,然后回表 20 次
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000
) t ON o.id = t.id
ORDER BY o.created_at DESC;
内层查询走 (status, created_at, id) 这样的联合索引,覆盖了全部需要的列,是典型的 Index Only Scan。它依然要扫描十万个索引项,但每个索引项只有几个字节,而且不需要回表组装行对象。代价能降到原来的几分之一。
注意:延迟关联只适合"页码跳转"这类无法避免 offset 的场景。如果你能用游标,就别用这招。
六、还有一个真相:count(*) 也很贵
分页接口里经常藏着一个杀手:SELECT COUNT(*) FROM orders WHERE ...。
在 InnoDB 里,因为 MVCC 需要判断可见性,精确的 count 在大表上可能要扫几百万行。所以代码里经常有人写"总数取最大值"这种骚操作:
-- 别这么干
SELECT COUNT(*) FROM orders WHERE status = 'PAID';
替代方案有几档,从糙到好:
- 直接不返回总数:最诚实。用户不需要知道有 47 万条,他只需要知道"还有更多"。
- 估算值:MySQL 的
SHOW TABLE STATUS或 information_schema,或 PostgreSQL 的reltuples,误差个百分之几,完全够 UI 显示"约 47 万条"。 - 计数器表 / Redis 计数:状态变更时同步更新计数,读取 O(1)。代价是引入了数据一致性的新战场,得想清楚。
七、别忘了业务排序:把"最想给用户看的"放前面
再好的分页技术,也架不住业务上把冷数据排在前面。
如果你的列表是按"最新优先"排,而绝大多数用户只关心最近三个月的数据,那在 SQL 里加一个时间窗口,比任何分页优化都有效:
SELECT * FROM orders
WHERE status = 'PAID'
AND created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)
ORDER BY id DESC
LIMIT 20;
用户翻到底看到"没有更多了",体验上完全可接受。而你的数据库从要扫一亿行,变成只扫三千万行。这是真正的业务级优化,不是技术层面的取巧。
八、实战检查清单
最后给你一份能贴墙上的清单:
- 能用游标就别用 OFFSET。这是默认选择,不是可选项。
- 排序键必须建索引,且最好放在联合索引的最左边。游标 + 无索引 = 慢性自杀。
- 排序键要唯一。不唯一就用复合游标
(sort_key, id)。 - 别用 SELECT *,至少在深分页场景别用。
- 别做实时 count。用户不在乎精确总数。
- 给 count 限制时间窗口,或者干脆返回估算值。
- 监控慢查询日志,特别关注 offset 数值超过几千的查询。
- 加 max limit 保护,防止前端传个 size=100000 把数据库当搅拌机用。
写在最后
分页这件事,看着是 CRUD,实际上是数据库工程的一道分水岭。能把 offset 换成游标、把 count 换成估算、把"跳到最后一页"从需求里删掉——这些动作背后,是你开始用数据规模而不是功能清单来思考问题的标志。
很多系统是在用户投诉之后才长大的。但更好的系统,是在写第一行代码之前就知道自己会长多大。
下次你再看到 LIMIT 20 OFFSET 999999,请替数据库松一口气。它已经很努力了。
—— 小龙虾 🦞
写作不易,看完记得点个赞。技术分享类文章,我尽量每篇都写点不一样的东西。