深分页优化
业务逻辑
现有50万条订单数据,复合索引:(product_id, amount DESC)
。 按product_id
和amount
排序后,取第 9991-10000 条数据。
sql
CREATE TABLE `orders` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`product_id` int NOT NULL,
`amount` decimal(10,2) NOT NULL,
`order_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`address` text NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_orders_product_amount` (`product_id`,`amount` DESC)
) ENGINE=InnoDB AUTO_INCREMENT=500001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
原始查询
sql
select * from orders order by product_id,amount desc limit 9990,10;
优点:
- 写法最简单直观 :直接表达 "按
product_id
和amount
排序后,取第 9991-10000 条数据" 的业务逻辑,易于理解和维护。 - 无额外逻辑开销:没有子查询或关联操作,语法层面最简洁。
缺点:
- 性能极差(深分页场景) :
虽然表中存在idx_orders_product_amount
索引(与排序条件product_id, amount DESC
完全匹配),但SELECT *
需要回表获取所有字段(索引仅包含product_id
、amount
和主键id
)。
更关键的是,LIMIT 9990,10
需要扫描前9990+10=10000
条数据(通过索引定位后回表),再丢弃前 9990 条,仅保留 10 条。当偏移量9990
很大时,扫描和回表的成本极高(IO 密集型操作)。 - 无法避免全量扫描风险:若索引失效(如统计信息不准确导致优化器放弃索引),会触发全表扫描 + 文件排序,性能灾难级下降。
隐式连接子查询
sql
select t1.* FROM orders t1, (select id from orders order by product_id,amount desc limit 9990, 10) t2 where t1.id = t2.id;
优点:
- 减少回表开销 :
子查询select id ...
仅需获取id
,而idx_orders_product_amount
索引的叶子节点包含主键id
(InnoDB 二级索引特性),因此子查询可直接通过索引完成(覆盖索引扫描),无需回表。
主查询通过id
(主键索引)关联t1
,回表效率极高(主键查询是 O (1) 操作)。
相比原始查询,避免了 "扫描 10000 条数据时每条都回表取所有字段" 的开销,性能提升显著。
缺点:
- 仍依赖
offset
扫描 :子查询LIMIT 9990,10
仍需扫描前 10000 条索引记录(虽然是索引扫描,比全表扫描快,但偏移量越大,扫描量越多)。 - 隐式连接可读性差 :使用逗号分隔表的隐式连接(
t1, t2
)容易混淆关联条件,尤其在多表连接时可能引发逻辑错误(如遗漏关联条件导致笛卡尔积)。 - 排序依赖外部保证 :主查询未显式排序,虽然结果顺序通常与子查询一致(因
id
是按排序取的),但数据库不保证,极端情况下可能出现顺序错乱。
⭐显式内连接子查询
sql
SELECT o.*
FROM orders o
INNER JOIN (
-- 子查询:利用索引快速定位需要的记录ID
SELECT id
FROM orders
ORDER BY product_id, amount DESC
LIMIT 9990, 10
) AS sub ON o.id = sub.id
ORDER BY o.product_id, o.amount DESC;
优点:
- 继承第二个查询的性能优势 :子查询同样使用覆盖索引获取
id
,主查询通过主键回表,性能与第二个查询基本一致。 - 可读性和规范性更好 :使用
INNER JOIN
显式声明关联关系,关联条件清晰,符合现代 SQL 编码规范,降低维护成本。 - 排序明确 :主查询显式添加
ORDER BY
,确保结果顺序与业务预期一致(避免数据库优化导致的顺序错乱)。
缺点:
- 仍未解决
offset
扫描问题 :与第二个查询相同,子查询LIMIT 9990,10
仍需扫描前 10000 条索引记录,偏移量极大时(如offset=10万
)性能仍会下降。
范围查询优化
sql
SELECT *
FROM orders
WHERE
-- 处理product_id大于基准值的情况
(product_id > (SELECT product_id FROM orders ORDER BY product_id, amount DESC LIMIT 9990, 1))
OR
-- 处理product_id等于基准值但amount小于基准值的情况
(product_id = (SELECT product_id FROM orders ORDER BY product_id, amount DESC LIMIT 9990, 1)
AND amount <= (SELECT amount FROM orders ORDER BY product_id, amount DESC LIMIT 9990, 1))
ORDER BY product_id, amount DESC
LIMIT 10;
优点:
- 性能最优(深分页场景) :
核心是将 "偏移量扫描" 转化为 "范围查询":通过子查询获取第 9990 条记录的product_id
和amount
,再用(product_id > ...) OR (product_id = ... AND amount <= ...)
直接定位起始位置,只需扫描 10 条数据即可。
无论偏移量多大(如offset=100万
),扫描量始终是固定的(子查询扫描 1 条 + 主查询扫描 10 条),性能几乎不受偏移量影响。 - 索引利用率最大化 :
WHERE
条件与ORDER BY
均匹配idx_orders_product_amount
索引,可通过索引快速定位范围,避免全表扫描。
缺点:
- 写法复杂,易出错 :
需要处理product_id
相同的边界情况(product_id = ... AND amount <= ...
),逻辑繁琐;若amount
存在重复且未结合id
(唯一键),可能出现漏数据或重复数据(例如两条记录product_id
和amount
完全相同,范围查询可能误判)。 - 子查询冗余 :三次子查询
LIMIT 9990,1
(实际执行时数据库可能优化为一次,但逻辑上冗余),增加理解成本。 - 不支持跳页灵活性 :若业务需要 "随机跳页"(如直接从第 1 页跳到第 100 页),需要先查询目标页的起始
product_id
和amount
,额外增加一次查询开销。
总结对比
维度 | 原始查询 | 隐式连接子查询 | ⭐显式内连接子查询 | 范围查询优化 |
---|---|---|---|---|
性能(深分页) | 最差(全量扫描 + 回表) | 较好(索引扫描 + 主键回表) | 较好(同左,更稳定) | 最优(范围查询,固定扫描量) |
可读性 | 最好(直观) | 较差(隐式连接歧义) | 好(显式连接,清晰) | 最差(逻辑复杂) |
维护成本 | 低 | 中(易混淆关联条件) | 低(规范写法) | 高(边界条件多) |
适用场景 | 偏移量极小(如前 10 页) | 需跳页,偏移量中等 | 需跳页,偏移量中等(推荐) | 偏移量极大(如 1000 页 +),顺序翻页 |
补充:游标优化
游标(Cursor)是维护一个 "查询位置指针",每次查询都从 "上一次的结束位置" 开始,而非从开头计算偏移量。本质是 "流式分页",不支持跳页,但适合 "加载更多"(如滚动到底部加载下一页)的场景,如APP首页瀑布流。
以orders
表为例,用主键id
作为游标:
第一次查询(第一页):
sql
-- 首次查询:获取前10条,记录最后一条的id作为游标
SELECT id, order_no, create_time
FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 假设返回的最后一条id是10000,记录游标值:last_id = 10000
第二次查询(第二页):
sql
-- 基于上一页的游标查询下一页
SELECT id, order_no, create_time
FROM orders
WHERE create_time <= (SELECT create_time FROM orders WHERE id = 10000)
AND id < 10000 -- 用id避免create_time重复导致的漏数据
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 更新游标:last_id = 本次返回的最后一条id
以此类推,每次查询都依赖上一次的last_id
,无需计算offset
。