"I got a bad idea..",如果在代码评审或技术讨论里听到这句话,多数人第一反应是希望对方趁早收手,别把不可控的复杂度带进项目。但这句话如果换一个场景,恰恰可能是某个优化方案“即将改变现状”的开始。
本文想从一个真实常见的工程问题讲起:数据库深分页。它看起来模块简单、逻辑清晰,就是一个LIMIT加OFFSET,却会在数据量增长后,把接口拖慢到让人怀疑人生。更麻烦的是,针对它的几种优化方案,从表面看都像“坏主意”:延迟关联改写复杂、游标分页会改变翻页行为、加索引还可能触发更多坑。这让很多团队宁可继续忍耐慢查询,也不愿意碰这个看起来很傻的问题。
越是这样,越值得认真拆解。这篇文章会从“坏主意”这个判断出发,讲清楚为什么深分页会慢,什么情况下适合用延迟关联,什么情况下适合用游标分页,并给出可复制的 SQL、MyBatis 示例和验证方法。读完以后,你可以独立完成一次查询优化改造,也能在下次代码评审里,用更清晰的逻辑判断一个方案是不是真的“bad idea”。
1. 为什么“坏主意”值得被认真对待
1.1 “坏主意”往往是被低估的信号
在工程语境里,一个方案被称做“坏主意”,通常有三种原因:
- 它打破了团队已经习惯的编码方式。
- 它引入了额外复杂度,短期看不到收益。
- 它可能改变产品上一个看起来“正常”的行为。
这三种原因都和“技术正确性”没有直接关系。换句话说,当一个方案被叫做坏主意时,往往只是因为它的成本被高估了,或者它的收益没有被完整表达。
很多最终被证明有效的架构调整,最初都顶着“坏主意”的标签。比如在单体应用里先抽出独立的缓存服务、在前端引入服务端渲染、在定时任务里改为消息驱动,这些方案在初期都被质疑过:价值不明显、改动面大、维护成本高。但真正让它们被接受的,不是“看着合理”,而是可验证的效果。
所以,当你说“I got a bad idea”时,正确的下一步不是立刻否定,而是把它转译成一个可以验证的问题:它解决了什么痛点?它改变了哪个环节?它会不会引入新的故障点?
1.2 判断的标准不是观感,而是可验证的影响
一个方案是否值得做,可以建立一组朴素标准:
- 是否针对真实的性能或稳定性问题。
- 是否在可控范围内改变现状。
- 是否有可观测的指标来证明它变好了。
- 出现问题之后,能否回滚或降级。
如果满足以上四点,即便方案看起来不常规,也值得投入小成本验证。如果只是“听说这样更优雅”或者“感觉以后用得上”,那确实应该趁早放弃。
数据库深分页的改造,正好满足前三点:它是真实问题,改的是查询路径,可以通过执行计划和响应时间验证。唯一需要谨慎的是第四点,即回滚方案。所以这篇文章也会重点讲怎么验证、怎么控制风险。
2. 深分页:一个看起来很傻的问题
2.1 传统分页的代价
先看一段最常见的分页 SQL:
SELECT id, order_no, user_id, amount, created_at FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;它的作用是跳过前面 10 万行,取出第 100001 到第 100020 行。数据库执行时,需要先扫描到第 10 万行,再丢弃前 10 万行,最后返回 20 行。前面的 10 万行其实都没有返回给客户端,但因为要排序和定位,数据库必须把它们全部读出来。
数据量小的时候,这个代价完全无感。但订单表、日志表、行为记录表一旦增长到百万级、千万级,越往后的页面延迟会明显增加。这就是深分页问题的本质:
- 扫描量大:
OFFSET越大,扫描的行数越多。 - 排序代价高:
ORDER BY会先对扫描结果排序,即使最终只返回少部分行。 - 索引利用率差:如果索引无法完全覆盖排序和过滤条件,数据库还要回表读取更多数据。
2.2 延迟关联与游标分页:初看像“坏主意”
针对深分页,常见方案有两类。
第一类是延迟关联。它先通过覆盖索引快速定位目标行的主键,再回表获取完整数据。SQL 会被改写成类似这样:
SELECT t.id, t.order_no, t.user_id, t.amount, t.created_at FROM orders t INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000 ) tmp ON t.id = tmp.id ORDER BY t.created_at DESC;表面上看,这条 SQL 比原来复杂了,子查询也增加了阅读难度。但它让内层 SELECT 可以只扫描索引页,大幅减少回表次数。对数据库来说,扫描 10 万行索引页,比扫描 10 万行“索引 + 数据页”的成本低很多。
第二类是游标分页。它不再使用OFFSET,而是记住上一页最后一条记录的排序值,下一页通过WHERE条件继续取:
SELECT id, order_no, user_id, amount, created_at FROM orders WHERE created_at < '2024-11-01 12:00:00' ORDER BY created_at DESC LIMIT 20;这个方案在翻页场景下高效稳定,但会改变分页行为:用户不能直接从第 1 页跳到第 100 页,所有页面必须按顺序翻。这在很多产品里是不可接受的。所以它常常被引用为“坏主意”的典型。
然而,如果我们把场景限定在“个人中心订单列表”“移动端消息列表”“App 内下拉加载更多”等场景,游标分页反而比传统分页更合理。因为用户很少需要精确跳页,而数据库却节省了大量无谓扫描。
下表可以直观对比两种方案:
| 方案 | 核心思路 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 传统 LIMIT/OFFSET | 跳过前 N 行 | 实现简单、支持任意跳页 | 深分页时扫描量大、延迟高 | 数据量小、后台管理列表 |
| 延迟关联 | 先查主键再回表 | 大幅减少回表,兼容原有跳页 | SQL 相对复杂、需要覆盖索引支撑 | 大数据量但必须支持跳页 |
| 游标分页 | 基于排序键翻页 | 扫描量稳定、性能可预期 | 不支持精确跳页、依赖稳定排序键 | App 列表、消息流、滚动加载 |
3. 环境准备与前置条件
本文后续示例,以 MySQL 和 Spring Boot 项目为基础。具体版本不用刻意追求最新,只要满足下面的条件即可:
- 操作系统:Linux、macOS 或 Windows 均可。
- 数据库:MySQL 5.7 或 8.x。
- JDK:Java 8 及以上。
- 框架:Spring Boot 2.x / 3.x 均可,示例以 MyBatis 为主。
- 构建工具:Maven 或 Gradle。
如果团队目前没有现成的 Spring Boot 项目,也可以通过 JDBC 或数据库客户端直接执行 SQL 来理解核心逻辑。实际改造时再迁移到项目代码里。
以下环境参数建议提前确认:
# 数据库连接示例,具体以本地环境为准 spring.datasource.url=jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=your_password需要说明的是,本文不会写死某个 MySQL 小版本,因为核心原理在 5.7 和 8.x 上都成立。真正影响实验效果的,是你的测试数据量和表结构设计。
4. 核心流程拆解
4.1 复现深分页问题
任何优化都从复现问题开始。先准备一张订单表,写入一定量测试数据,然后模拟深分页查询。
建议分三步走:
- 创建简单的订单表。
- 批量插入测试数据,几百条不够,至少要几十万条才能看到差异。
- 用不同
OFFSET执行查询,观察耗时和执行计划。
复现时最容易犯的错误是数据量太少。如果你的表只有几千行,OFFSET拉到 10000,数据库扫描成本依然很低,优化前后几乎看不出区别。这会导致你误以为方案无效。所以在本地实验时,建议用存储过程或脚本插入 50 万行以上的数据。
4.2 方案一:延迟关联优化
延迟关联的核心思想,是把“回表”动作尽量延后。
原始查询是这样的:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;当OFFSET很大时,数据库可能要读取大量数据页,然后丢弃。延迟关联的改法是:
SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000 ) tmp ON t.id = tmp.id ORDER BY t.created_at DESC;这里的关键在于:内层查询只需要返回主键id。如果表上存在(created_at, id)的复合索引,数据库可以直接在索引上完成排序和分页,不需要查询完整行记录。外层再通过主键回表,只取 20 行的完整数据。
从数据库执行流程看,它把原来的“扫 10 万行数据页”变成了“扫 10 万行索引页 + 取 20 行数据页”。索引页的大小通常远小于数据页,单位时间内能读取的索引记录数更多,因此整体 I/O 有明显下降。
4.3 方案二:游标分页
如果说延迟关联是对原有 SQL 的“补丁”,游标分页则是对分页交互的重新设计。
传统分页的搜索条件是“第几页”。游标分页的搜索条件是“从哪个位置继续”。比如第一页查询:
SELECT id, order_no, user_id, amount, created_at FROM orders ORDER BY created_at DESC LIMIT 20;拿到结果后,记录最后一条数据的created_at,作为下一页的启动位置:
SELECT id, order_no, user_id, amount, created_at FROM orders WHERE created_at < '2024-11-01 12:00:00' ORDER BY created_at DESC LIMIT 20;这个方案的优势很明显:无论你翻到第 100 页还是第 10000 页,数据库都只扫描LIMIT指定数量的记录,性能非常稳定。
但坏处也很明显:它无法支持任意跳页。产品如果有一个“用户可以直接跳到第 200 页”的需求,游标分页就做不到了。
所以,游标分页适合“瀑布流加载”和“加载更多”这类场景,而不适合传统的后台管理系统分页表格。
5. 完整示例与代码实现
5.1 建表与测试数据
先创建订单表:
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, KEY idx_created_id (created_at, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里建议加上idx_created_id (created_at, id)复合索引,它是延迟关联生效的关键。如果没有这个索引,内层查询排序时可能要使用文件排序,优化效果会大打折扣。
批量写入测试数据,可以使用存储过程,也可以使用 Java 程序循环插入。为了在数据库客户端快速验证,推荐用脚本方式:
DROP PROCEDURE IF EXISTS insert_test_orders; DELIMITER $$ CREATE PROCEDURE insert_test_orders(IN total INT) BEGIN DECLARE i INT DEFAULT 1; START TRANSACTION; WHILE i <= total DO INSERT INTO orders (order_no, user_id, amount, status, created_at) VALUES ( CONCAT('NO', LPAD(i, 8, '0')), FLOOR(RAND() * 10000) + 1, ROUND(RAND() * 1000, 2), 0, DATE_SUB(NOW(), INTERVAL i SECOND) ); SET i = i + 1; IF i % 1000 = 0 THEN COMMIT; START TRANSACTION; END IF; END WHILE; COMMIT; END $$ DELIMITER ; CALL insert_test_orders(500000);注意,存储过程插入 50 万行可能需要几十秒甚至更久,不同机器差异较大。如果不想等太久,可以先插入 10 万行做验证,后面再按需加量。
5.2 MyBatis Mapper 实现延迟关联
先给一个原始的 Mapper 接口:
// 文件路径:src/main/java/com/example/demo/mapper/OrderMapper.java public interface OrderMapper { List<Order> selectPage(PageParam param); }对应的 XML:
<!-- 文件路径:src/main/resources/mapper/OrderMapper.xml --> <select id="selectPage" resultType="com.example.demo.entity.Order"> SELECT id, order_no, user_id, amount, status, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT #{offset}, #{limit} </select>这是最常规的分页写法。改造为延迟关联后:
<!-- 文件路径:src/main/resources/mapper/OrderMapper.xml --> <select id="selectPageByLazyJoin" resultType="com.example.demo.entity.Order"> SELECT t.id, t.order_no, t.user_id, t.amount, t.status, t.created_at FROM orders t INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC, id DESC LIMIT #{offset}, #{limit} ) tmp ON t.id = tmp.id ORDER BY t.created_at DESC, t.id DESC </select>这里的PageParam至少包含offset和limit两个字段:
// 文件路径:src/main/java/com/example/demo/param/PageParam.java public class PageParam { private Integer offset; private Integer limit; public Integer getOffset() { return offset; } public void setOffset(Integer offset) { this.offset = offset; } public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } }如果你用的是 PageHelper,也可以在业务层先查出一页主键,再用主键查询完整记录,效果类似。但这里的 XML 写法更直观,适合理解原理。
5.3 游标分页的 Java 层实现
游标分页的参数不再是offset,而是上一页最后一条记录的位置。定义如下:
// 文件路径:src/main/java/com/example/demo/param/PageCursorParam.java public class PageCursorParam { private String lastCreatedAt; private Long lastId; private Integer limit; // getter/setter 略 }Mapper 接口:
// 文件路径:src/main/java/com/example/demo/mapper/OrderMapper.java List<Order> selectPageByCursor(PageCursorParam param);XML 实现:
<!-- 文件路径:src/main/resources/mapper/OrderMapper.xml --> <select id="selectPageByCursor" resultType="com.example.demo.entity.Order"> SELECT id, order_no, user_id, amount, status, created_at FROM orders <where> <if test="lastCreatedAt != null and lastCreatedAt != ''"> created_at < #{lastCreatedAt} OR (created_at = #{lastCreatedAt} AND id < #{lastId}) </if> </where> ORDER BY created_at DESC, id DESC LIMIT #{limit} </select>注意,条件里同时带上created_at和id,是为了处理排序字段重复的情况。如果只按created_at排序,而同一秒内有多条记录,翻页时可能丢数据或重复数据。再加上id后,排序和过滤条件都是唯一且确定的。
在 XML 中,<需要使用<,这是 MyBatis 的常见坑,很多新手第一次写游标分页都会在这里报错。
业务层调用时,需要把上一页最后一条记录的created_at和id取出来,封装到参数里:
// 文件路径:src/main/java/com/example/demo/service/OrderService.java public List<Order> pageByCursor(Order lastOrder, int limit) { PageCursorParam param = new PageCursorParam(); if (lastOrder != null) { param.setLastCreatedAt(lastOrder.getCreatedAt()); param.setLastId(lastOrder.getId()); } param.setLimit(limit); return orderMapper.selectPageByCursor(param); }这个示例展示的是最简单的同步分页。在真实项目里,返回结果还会附带游标信息,方便前端传回后端。
5.4 如何运行与验证
运行前提:
- 数据库表结构创建成功。
- 测试数据已经插入。
- Spring Boot 项目可以正常启动。
如果只想快速验证延迟关联效果,可以不启动项目,直接在 MySQL 客户端执行两条 SQL,对比执行计划:
EXPLAIN SELECT id, order_no, user_id, amount, status, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 100000; EXPLAIN SELECT t.id, t.order_no, t.user_id, t.amount, t.status, t.created_at FROM orders t INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 100000 ) tmp ON t.id = tmp.id ORDER BY t.created_at DESC, t.id DESC;游标分页则可以这样验证:
EXPLAIN SELECT id, order_no, user_id, amount, status, created_at FROM orders WHERE created_at < '2024-11-01 12:00:00' ORDER BY created_at DESC, id DESC LIMIT 20;查看执行计划时,重点看rows字段:传统分页在深分页时,扫描行数会随着OFFSET增大而明显增长;延迟关联和游标分页的扫描行数则更接近目标行数。
6. 运行结果与效果验证
6.1 用 EXPLAIN 验证执行计划
以 50 万行数据为例,传统深分页的执行计划通常会在rows字段显示很大的估算值。如果表结构没有正确索引,还会出现Using filesort。
延迟关联的 inner 查询会走idx_created_id索引,执行计划一般能看到:
type:indexkey:idx_created_idExtra:Using index
这意味着查询完全在索引上完成,不需要回表。外层通过主键id回表时,只读取LIMIT对应的少量行,所以耗时远小于原始方案。
游标分页在WHERE created_at < ?条件下,如果索引生效,执行计划会显示type为range,扫描范围控制在两个连续值之间,性能稳定。
6.2 页面耗时与扫描行数对比
优化的验证,不能只看 SQL 是否多读了索引。建议做两组对比:
第一组:固定 OFFSET,对比原始 SQL 和延迟关联 SQL。
- 在服务层分别调用两个查询。
- 用日志打印耗时。
- 记录
OFFSET = 1000、10000、50000、100000时的耗时变化。
结果是:在数据量较小时,两者差异不明显;随着OFFSET增大,原始 SQL 耗时增长更快,延迟关联的耗时相对平缓。这也说明一个原则:没有深分页场景的表,不需要为了优化而优化。
第二组:游标分页和传统分页对比。
模拟连续翻页,例如连续翻 100 页,记录每页的平均耗时。传统分页在第 1 页和第 100 页的耗时可能相差很大;游标分页则每一页耗时都接近。
具体判断标准可以这样定:
- 如果
OFFSET超过 1 万行时,接口 P99 延迟明显超过 500ms,说明问题真实存在。 - 如果优化后,同样
OFFSET下耗时下降 50% 以上,方案有效。 - 如果扫描行数显著下降,且没有新增慢查询,说明可以进入灰度阶段。
这里不要纠结于“到底降了多少毫秒”,因为不同机器的磁盘、内存、并发压力差异很大。更重要的是看趋势:优化方案是否在大偏移量下保持低延迟。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 延迟关联后反而更慢 | 表数据量太小,优化优势不明显;或索引缺失 | 查看 EXPLAIN,确认是否使用覆盖索引 | 确保(created_at, id)索引存在;扩大到足够数据量测试 |
| 游标分页出现重复数据 | 只按created_at排序,时间字段重复 | 检查上页最后一条记录的时间值 | 排序条件补上id,并让过滤条件同时包含时间和 id |
| MyBatis 执行报 SQL 语法错误 | XML 中的<被当成标签开始符 | 查看日志中完整 SQL | 将<改为< |
| ORDER BY 和 WHERE 条件不一致 | 翻页时排序键顺序变化 | 对比两条 SQL 的排序字段 | 统一排序条件为created_at DESC, id DESC |
| 游标分页无法跳页 | 产品交互不支持基于游标跳页 | 和产品确认需求 | 保留传统分页接口,或改用延迟关联 |
| 深分页时索引失效 | 查询条件里使用了函数、隐式转换 | 使用EXPLAIN查看type字段 | 避免在索引列上使用函数,保持数据类型一致 |
| 插入大量测试数据很慢 | 单条 INSERT 频繁提交 | 使用存储过程或批量插入脚本 | 分批事务提交,每批 1000 条左右 |
这些问题是实际改造时比较常见的坑,尤其是 MyBatis 中<转义和排序字段重复,很容易出现。
8. 最佳实践与工程建议
8.1 改造前先确认场景
不是所有分页都需要从传统方案迁移到延迟关联或游标分页。建议先做一次盘点:
- 列表接口的分页深度分布如何?
- 用户主要翻前几页还是深翻?
- 数据量增长趋势是否会导致问题恶化?
- 后台管理系统是否需要精确跳页?
如果查询基本上只翻前几页,OFFSET不超过几十,优化就没有太大意义。相反,如果存在“导出全量数据”“爬虫高频翻页”“用户大批量浏览”的情况,深分页优化会带来明显收益。
8.2 让“坏主意”进入评审闭环
一个方案被提出时,最容易出现的两种极端是:直接否定,或者无脑接受。更合理的流程是把它拉进一个轻量评审闭环:
- 写清楚现状指标:当前接口耗时的 P95、P99,扫描行数。
- 明确方案改动点:SQL、索引、接口参数、前端交互。
- 设计小范围验证:用测试环境或影子流量做对比。
- 制定回滚方案:先改接口,再切换前端;或者先灰度 10% 流量。
- 回归性能指标:对比优化前后趋势,而不是单次数据。
“I got a bad idea”这句话,最理想的后续是:“但我们可以先花半小时验证一下”。
8.3 生产环境的安全意识
延迟关联和游标分页改造,涉及的是数据库查询路径,操作前要注意以下几点:
- 索引变更属于 DDL,线上执行会锁表或占用资源,务必在低峰期进行,并有备份和回滚方案。
- 不要直接在核心库上跑大规模测试数据,优先在测试环境验证。
- 生产环境灰度发布时,先让少量流量命中新 SQL,观察数据库慢查询数量、CPU 和连接池占用情况。
- 如果游标分页参数由前端传入,必须校验参数长度和格式,避免恶意构造超长条件。
- 任何 SQL 改动都要结合查询日志和监控平台回看,不要只看一次手工查询结果。
这些意识比具体代码更重要。毕竟查询优化这件事,改错了不会立刻报警,但可能在业务高峰期集中爆发。
9. 总结
回到标题那句话:“I got a bad idea..”。它可能是一句自嘲,也可能是一个被低估的优化契机。真正的工程判断,不取决于方案听起来是否常规,而取决于它是否解决了真实问题、能否被验证、是否可回滚。
这篇文章通过深分页这个具体问题,讲解了延迟关联和游标分页两种方案。前者适合必须支持跳页的业务,后者适合“加载更多”类场景。两者的核心目标都是减少数据库无谓的扫描和回表,从而稳定接口延迟。
你可能还没有遇到过千万级订单表,也可能你的系统在未来半年都不需要这种优化。但下一次听到团队成员说“我有一个坏主意”时,不妨先问一句:你打算怎么证明它有效?
如果能用 30 分钟跑一个最小实验,用执行计划和耗时代替主观判断,很多“坏主意”反而会成为项目里最有价值的优化项。