MySQL 分页方案全网共识

2330 字
12 分钟
MySQL 分页方案全网共识

MySQL 分页方案 全网共识#

并行做几个不同角度的搜索,覆盖中英文权威资料。

读几篇最权威的深度内容,并行获取。

读取核心章节。

基于权威资料(PlanetScale、Vlad Mihalcea、HackMySQL、JavaGuide、阿里巴巴《Java 开发手册》)综合得出结论。

一句话结论#

没有”最好”,只有”最合适”。但权威结论高度一致:

  • 小数据量 / 后台管理 / 必须支持跳页 → 保持 OFFSET/LIMIT,足够。
  • 大表深分页 / 性能敏感 / 仅前后翻Keyset(游标)分页性能最好,是业界公认的”正解”。
  • 必须支持跳页又怕深分页打挂数据库Deferred Join(延迟关联),阿里 Java 开发手册和 PlanetScale 都推荐,OFFSET 语义不变,提速 3-10 倍。

为什么 OFFSET 深了就慢(根因)#

MySQL 执行 LIMIT 1000000, 10并不会跳跃

  1. 从头扫描 offset + n = 1,000,010 行
  2. 如果走二级索引,还要对前 100 万条做回表(随机 IO)
  3. 最后丢弃前 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_order
WHERE id < :last_id
ORDER BY id DESC
LIMIT 10;
-- 复合排序键(如按 created_at 排序)必须带主键 tie-breaker
SELECT * FROM post
WHERE (created_on, id) < ('2024-10-02 21:00:00', 4951)
ORDER BY created_on DESC, id DESC
LIMIT 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 people
INNER 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_order
WHERE 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.go API 列表
  • @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 t
INNER JOIN (
SELECT id FROM sys_operation_log
WHERE ... -- 同样的过滤
ORDER BY id DESC
LIMIT 10 OFFSET 1000000
) k ON t.id = k.id
ORDER 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 给的生产建议:

  • 慢查询日志监控含 LIMIToffset > 10000 的 SQL
  • 接口层加防御:max_offset 上限(例如 page * page_size > 50000 直接拒绝),避免恶意/失误请求拖垮 DB
  • 列表页 UI 上不让用户随便跳到 999 页,强制只能前后翻或搜索过滤

一句话推荐#

Caelor 当前最佳做法

  1. 通用列表保持 OFFSET/LIMIT 不变
  2. 日志/AI 消息/未来的监控数据等大表 → 延迟关联(保持 API 兼容)或 Keyset(彻底解决)
  3. 接口层加 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 同时支持两种参数

性能差异是结果,不是原因。先选对契约,再谈优化。

文章目录