MySQL 分页方案 全网共识
并行做几个不同角度的搜索,覆盖中英文权威资料。
读几篇最权威的深度内容,并行获取。
读取核心章节。
基于权威资料(PlanetScale、Vlad Mihalcea、HackMySQL、JavaGuide、阿里巴巴《Java 开发手册》)综合得出结论。
一句话结论
没有”最好”,只有”最合适”。但权威结论高度一致:
- 小数据量 / 后台管理 / 必须支持跳页 → 保持
OFFSET/LIMIT,足够。 - 大表深分页 / 性能敏感 / 仅前后翻 → Keyset(游标)分页性能最好,是业界公认的”正解”。
- 必须支持跳页又怕深分页打挂数据库 → Deferred Join(延迟关联),阿里 Java 开发手册和 PlanetScale 都推荐,OFFSET 语义不变,提速 3-10 倍。
为什么 OFFSET 深了就慢(根因)
MySQL 执行 LIMIT 1000000, 10 时并不会跳跃:
- 从头扫描
offset + n= 1,000,010 行 - 如果走二级索引,还要对前 100 万条做回表(随机 IO)
- 最后丢弃前 100 万行,只返回 10 行
“LIMIT 应用在最终结果集上,所以
LIMIT offset, n必然要先读 offset 个完整行再丢弃。” —— HackMySQL
这就是 OFFSET 越深越慢、深翻页打挂数据库的根本原因。
四种主流方案对比
| 方案 | 跳页 | 性能 | 排序灵活性 | 实现复杂度 | 数据一致性 |
|---|---|---|---|---|---|
| OFFSET/LIMIT(当前 Caelor 用法) | ✅ | 浅页快、深页崩 | 高 | 极简 | 翻页中并发增删可能漏/重 |
| Keyset / Seek(游标) | ❌ 仅前后翻 | 任意页都快(O(log N)) | 中(需稳定排序键 + 主键 tie-breaker) | 中 | 比 OFFSET 更稳 |
| 子查询 + WHERE id ≥ | ✅ | 快 | 高 | 中 | 同 OFFSET |
| Deferred Join(延迟关联) | ✅ | 接近 Keyset(PlanetScale 实测 3-10×) | 高 | 中 | 同 OFFSET |
各方案核心 SQL
1. Keyset(游标)—— 性能王者
-- 单一排序键SELECT * FROM t_orderWHERE id < :last_idORDER BY id DESCLIMIT 10;
-- 复合排序键(如按 created_at 排序)必须带主键 tie-breakerSELECT * FROM postWHERE (created_on, id) < ('2024-10-02 21:00:00', 4951)ORDER BY created_on DESC, id DESCLIMIT 50;Vlad Mihalcea 实测:100 万行表,最后一页 OFFSET 要 1+ 秒,Keyset 仅 0.279 ms。
限制:无法直接跳到第 N 页、必须有稳定排序键。适合”加载更多”、信息流、消息列表。
2. Deferred Join(延迟关联)—— 最务实的折中
阿里《Java 开发手册》强制要求的优化方式:
-- 不行:OFFSET 大时极慢SELECT * FROM people ORDER BY birthday, id LIMIT 20 OFFSET 450000;-- 600-700 ms
-- 优化:先在覆盖索引上 OFFSET,再回表 20 次SELECT * FROM peopleINNER JOIN ( SELECT id FROM people ORDER BY birthday, id LIMIT 20 OFFSET 450000) AS p2 USING (id)ORDER BY birthday, id;-- 200 ms(再加 birthday 索引可降到 60 ms)核心原理:让 MySQL 在只含主键的覆盖索引上完成 OFFSET 跳过,避免对 100 万行做回表。最终只回表 20 次。
3. 子查询定位起点
SELECT * FROM t_orderWHERE id >= ( SELECT id FROM t_order ORDER BY id LIMIT 1000000, 1 )ORDER BY id LIMIT 10;效果接近延迟关联,但复杂过滤场景容易产生临时表,生产建议优先选 INNER JOIN 延迟关联。
4. 覆盖索引(加速器,常和上面组合)
让查询所需字段都包含在索引里,二级索引直接出结果,完全不回表。是上面所有方案的共同加速手段。
针对 Caelor 项目的具体建议
当前现状(参考上一轮分析):所有列表服务都是 OFFSET + LIMIT + ORDER BY id DESC。
不用动的(保持 OFFSET 即可)
数据量天然小、后台必须支持跳页:
@e:/go_project/caelor/server/service/user/user.go用户列表@e:/go_project/caelor/server/service/apisvc/api.goAPI 列表@e:/go_project/caelor/server/service/dict/dict.go字典- 角色、菜单、部门、配置类
重点关注的(建议 Deferred Join 或 Keyset)
会持续增长、容易堆到几十万到上千万行:
@e:/go_project/caelor/server/service/logsvc/log.go——sys_operation_log/sys_login_log,最危险,日志类天生大表,深翻页 OFFSET 必崩@e:/go_project/caelor/server/service/cmdb/host.go—— 如果接入 agent 心跳/监控历史会很大@e:/go_project/caelor/server/service/ai/—— AI 对话消息流(按对话内分页消息),天然适合 Keyset(“加载更多历史消息”是单向的)
落地优先级建议
P0(高优先级,性价比最高):日志列表改为延迟关联
-- 当前(@logsvc/log.go:60)SELECT * FROM sys_operation_log WHERE ... ORDER BY id DESC LIMIT 10 OFFSET 1000000;
-- 改成SELECT t.* FROM sys_operation_log tINNER JOIN ( SELECT id FROM sys_operation_log WHERE ... -- 同样的过滤 ORDER BY id DESC LIMIT 10 OFFSET 1000000) k ON t.id = k.idORDER BY t.id DESC;API 完全不变,只动 service 层 SQL。需要为常用过滤字段加联合索引(如 (username, id)、(status, id))让子查询走覆盖索引。
P1:AI 消息历史改 Keyset,因为加载历史本来就是单向滚动:“WHERE conversation_id = ? AND id < :last_id ORDER BY id DESC LIMIT 20”,无需 COUNT(*),性能拉满。
P2:通用基础设施层面,可在 @e:/go_project/caelor/server/model/request/base_request.go 之外新增 KeysetRequest{ LastID, PageSize },作为可选模板(不替换 PageRequest),让新业务自行选用。
监控建议(不改代码也要做)
JavaGuide 给的生产建议:
- 慢查询日志监控含
LIMIT且offset > 10000的 SQL - 接口层加防御:
max_offset上限(例如page * page_size > 50000直接拒绝),避免恶意/失误请求拖垮 DB - 列表页 UI 上不让用户随便跳到 999 页,强制只能前后翻或搜索过滤
一句话推荐
Caelor 当前最佳做法:
- 通用列表保持
OFFSET/LIMIT不变 - 日志/AI 消息/未来的监控数据等大表 → 延迟关联(保持 API 兼容)或 Keyset(彻底解决)
- 接口层加
max_offset防御,慢查询日志加监控
不需要、也不应该一次性把全项目分页都改造——这是用户规则里 “Scope Discipline” 明确反对的。等具体某张表真出现性能瓶颈,再针对性切到延迟关联即可。
这个划分是业界主流共识,背后逻辑比单纯”性能”更深。我的理解如下。
核心判断:是 UX 选了算法,不是性能选了算法
表格(管理后台) → page/pageSize → 必须有 total,必须能跳页信息流(移动端) → cursor → 没有 total,单向流动两种交互天生需要两种分页契约,不是哪个更好的妥协,而是问题本身不一样。
为什么表格天然适合 page/pageSize
- 用户预期能跳页:「跳到第 5 页」「显示 100-110 行」「最后一页是多少」
- 必须显示总数和总页数:
Showing 41-50 of 8,237 - 数据量通常有上限:B 端管理后台的字典、用户、菜单、API、配置,撑死几万行
- 用户会回看:搜索过滤后回去看上一页很常见
- 列表是稀疏修改的:今天加 1 条,不会破坏已经看过的页码语义
OFFSET/LIMIT 的”深分页慢”在这种场景几乎不会触发——因为没人会在管理后台手动翻到第 5000 页。
为什么信息流天然适合 cursor
- 用户只往一个方向滑:往下加载历史 / 往上拉新消息
- 没人关心总数:没人会问「我一共有多少条朋友圈」
- 数据量天然无界:消息、事件、日志、订单历史,亿级是常态
- 数据是频繁追加的:刷新瞬间就有新数据
如果用 page/pageSize 处理这种场景,会出两个致命问题:
1. 翻页错位(cursor 真正解决的痛点)
用户在第 1 页(最新 20 条)→ 此时来了 5 条新消息 →用户滑动加载第 2 页(OFFSET 20 LIMIT 20)→新插入的 5 条把原第 1 页末尾的 5 条挤到了第 21-25 位→ 用户在第 2 页又看到了第 1 页末尾那 5 条(重复!)→ 而真正的"第 21-25 条"被挤到 26-30,被跳过(漏读!)这个问题对表格不致命(用户能容忍管理后台偶尔重复),对滚动信息流是灾难(IM 重复消息、订单列表丢单)。
cursor 用 WHERE id < last_id 锚定绝对位置,新数据插在哪都不影响”已加载过的内容”,天然免疫翻页错位。
2. 性能(次要,但叠加问题)
亿级数据下 OFFSET 1000000 会把数据库打挂,cursor 是 O(log N) 索引 seek。
业界案例佐证
| 公司/产品 | 场景 | 选用 |
|---|---|---|
| Stripe API | 交易/客户列表 | cursor (starting_after/ending_before) |
| GitHub GraphQL | 全部资源 | cursor (after/before) |
| Twitter/X API | 推文流 | cursor (since_id/max_id) |
| Slack API | 消息历史 | cursor (cursor) |
| Ant Design Pro Table | 后台表格 | page/pageSize |
| Element Plus / Naive UI Table | 后台表格 | page/pageSize |
| 微信朋友圈/抖音 | 信息流 | cursor (max_id) |
模式高度一致:B 端管理后台 = 表格 + page;C 端流式 = 滚动 + cursor。
一个常被忽略的洞察
“表格用 page、滚动用 cursor” 的本质,不是 cursor 性能更好,而是 cursor 的语义和滚动 UX 的语义同构。
- page 给的是”位置”——用户也想要位置(第几页)
- cursor 给的是”接着上次那条往后”——用户也想要这个(接着刷)
强行用错了,性能是次要问题,用户体验出 bug 才是核心。
一句话总结
判断标准不是”表多大”,而是”用户是怎么看这个列表的”。
- 用户会跳页/看总数 → 表格 →
page/pageSize - 用户会一路往下滑 → 流式 →
cursor - 用户两种都要(罕见,例如订单列表后台 + App) → 同一个 API 同时支持两种参数
性能差异是结果,不是原因。先选对契约,再谈优化。