深分页优化
业务逻辑
现有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。