深度分页优化思路

深度分页优化思路

思考以下问题

查询以下SQL的流程是怎么样的呢?

为什么只查询10条数据需要7秒?

sql 复制代码
# 查询时间7秒
SELECT * FROM user ORDER BY age LIMIT 1000000, 10

问题分析

为什么分页查询随着翻页的深入,会变得越来越慢。

其实,问题的根本就在于:

第一数据量太大

第二数据库处理分页的方法太笨

你以为LIMIT 100000,10是直接跳过后10万条? 太天真了!

数据库的真实操作:

第一步: 把整张表的数据全捞出来(全表扫描),按年龄排好序(文件排序)。

第二步: 吭哧吭哧数到第100010条,再给你返回最后10条。

相当于:让你从新华字典第1页开始翻,翻到第1000页才找到字,谁能不炸?

最坑爹环节:回表查数据

如果用了普通索引(比如按年龄建的索引):

  • 先查索引:按年龄找到对应的主键ID(快速)
  • 再回表:用ID去主键索引里捞完整数据(慢!)

10万次回表 = 10万次IO操作,不卡你卡谁?

再说另一个常见的情况------排序

大多数时候,分页查询都会带有排序,比如按时间、按ID排序。

数据库不仅要查数据,还得根据你的排序要求重新排一次,特别是在数据量大的时候,排序的开销就变得非常大。

所以,翻越几百页的时候,你的查询可能就开始慢得像蜗牛。

单表场景 limit 深度分页 的优化方法

核心思路: 绕过全表扫描,直接定位到目标数据!

方案一:子查询优化

sql 复制代码
mysql> explain SELECT *
    -> FROM user
    -> WHERE id >= 
    ->     (
    ->         SELECT id
    ->         FROM user
    ->         ORDER BY age
    ->         LIMIT 1000000, 1
    -> 	   )
    -> limit 10;
+----+-------------+--------------------+------------+-------+---------------+--------------+---------+-------+---------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows   | filtered | Extra       |
+----+-------------+--------------------+------------+-------+---------------+--------------+---------+-------+---------+----------+--------------------------+
|  1 | PRIMARY     | user  | NULL       | range | PRIMARY       | PRIMARY | 8       | NULL  |     10 |   100.00 | Using where |
|  2 | SUBQUERY    | user  | NULL       | ref   | idx_age       | idx_age | 2       | const | 432791 |   100.00 | Using index |
+----+-------------+--------------------+------------+-------+---------------+--------------+---------+-------+---------+----------+--------------------------+
2 rows in set, 1 warning (0.15 sec)

原理: 用覆盖索引快速找到第100000条的ID,直接从这个ID开始拿数据,跳过前面10万次回表。

缺点是,不适用于结果集不以ID连续自增的分页场景。

在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的ID,此时的ID是离散且不连续的。如果使用上述的方式,并不能筛选出目标数据

方案二:延时关联

sql 复制代码
SELECT * FROM user t1
JOIN (SELECT id FROM user ORDER BY age LIMIT 1000000,10) t2 
ON t1.id = t2.id;
mysql> explain SELECT * 
    -> FROM
    ->     user t1
    ->     JOIN (
    ->         SELECT
    ->             id
    ->         FROM
    ->             user
    ->         ORDER BY
    ->             age
    ->         LIMIT 1000000,10
    ->         ) AS t2 
    ->     ON t1.id = t2.id
    -> LIMIT 10;
+----+-------------+--------------------+------------+--------+---------------+--------------+---------+-------+---------+----------+--------------------------+
| id | select_type | table      | partitions | type   | possible_keys | key     | key_len | ref   | rows   | filtered | Extra       |
+----+-------------+--------------------+------------+--------+---------------+--------------+---------+-------+---------+----------+--------------------------+
|  1 | PRIMARY     | <derived2> | NULL       | ALL    | NULL          | NULL    | NULL    | NULL  | 432791 |   100.00 | NULL        |
|  1 | PRIMARY     | t1         | NULL       | eq_ref | PRIMARY       | PRIMARY | 8       | t2.id |      1 |   100.00 | NULL        |
|  2 | DERIVED     | user       | NULL       | ref    | idx_age       | idx_age | 9       | const | 432791 |   100.00 | Using index |
+----+-------------+--------------------+------------+--------+---------------+--------------+---------+-------+---------+----------+--------------------------+
3 rows in set, 1 warning (0.12 sec)

原理: 先用索引快速拿到10个目标ID,再一次性联表查完整数据,减少回表次数。

方案三:索引覆盖

索引覆盖(Index Covering)是指一个查询可以完全通过索引来执行,而无需通过回表来查询其他字段数据。

例如:

sql 复制代码
ALTER TABLE user ADD INDEX idx_age_name(age, name);  -- 查询+排序全走索引
SELECT age, name FROM user ORDER BY age LIMIT 1000000,10;  -- 0.08秒!

精髓: 索引里直接存了所有要查的字段,不用回表,直接起飞!
缺点: 如果每个查询都建对应的索引的话,会浪费更多的空间存储索引,也会影响插入时的速度。

方案四:书签记录

从原理上说,属于是一种滚动查询。也就是说我们必须从第一页开始查询,然后获取本页最大的记录 ID,然后再根据大于最大记录 ID 的数据向后持续滚动。也就是说,我们如果想查询大于 1000000 后记录的 10 条,那我们就得知道 1000000 条的 ID。因为 ID 是递增的,所以直接查询即可。

sql 复制代码
SELECT * FROM user WHERE id > 1000000 LIMIT 10; -- 500微秒!

精髓: 每次查询都用上次查询结果做书签,直接走主键索引
缺点: 不支持条页查询,主键必须是自增的。

分库分表后,翻页为什么更慢了?

分库分表的翻页逻辑

假设订单表分了3个库,每个库分了2张表(共6张表),按用户ID分片。

当你执行:

sql 复制代码
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10;  

你以为数据库的操作:

智能跳过100万条,从6张表各拿10条,合并完事?

实际上的操作:

  • 每张表都老老实实查100万+10条数据(共600万+60条)。
  • 把所有数据汇总到内存,重新排序(600万条数据排序,内存直接炸穿)。
  • 最后忍痛扔掉前650万条,给你10条结果。

结果: 查一次耗时10秒+,数据库CPU 100%!


分库分表翻页的存在的3个问题

  • 数据分散,全局排序难
    各分片数据独立排序,合并后可能乱序,必须全量捞数据重排。
  • 深分页=分片全量扫描
    每张表都要查 offset + limit 条数据,性能随分片数量指数级下降。
  • 内存归并压力大
    100万条数据 × 6个分片 = 600万条数据在内存排序,分分钟OOM!

一句话总结: 分库分表后,翻页越深,死得越惨!


3种解决分库分表深度翻页方案

方案1:禁止跳页(青铜方案)

核心思想: 别让用户随便跳页,只能一页一页翻!其实就是上面的书签记录

实现方法:
1.第一页查询:

sql 复制代码
-- 按时间倒序,拿前10条  
SELECT * FROM orders  
WHERE user_id = 123  
ORDER BY create_time DESC  
LIMIT 10;  

2.翻下一页:

sql 复制代码
-- 记住上一页最后一条的时间  
SELECT * FROM orders  
WHERE user_id = 123  
AND create_time < '2023-10-01 12:00:00'  -- 上一页最后一条的时间  
ORDER BY create_time DESC  
LIMIT 10;  

优点:

  • 性能:每页查询只扫索引的10条,0回表。
  • 内存:无需全量排序。

缺点:

  • 用户不能跳页(比如从第1页直接跳到第100页)。
  • 适合Feed流场景(如朋友圈、抖音),不适合后台管理系统。

方案2:二次查询法(黄金方案)

核心思想: 把分库分表的"大海捞针",变成"精准狙击"!

实现步骤:
1. 第一轮查询:每张分片查缩小范围的数据

sql 复制代码
-- 每张分片查 (offset / 分片数量) + limit 条  
SELECT create_time FROM orders  
ORDER BY create_time DESC  
LIMIT 166666, 10;  -- 假设总offset=100万,分6个分片:100万/6 ≈ 166666  

1.确定全局最小时间戳:

从所有分片结果中,找到最小的 create_time(比如 2023-09-20 08:00:00)。
2.第二轮查询:根据最小时间戳查全量数据

sql 复制代码
SELECT * FROM orders  
WHERE create_time >= '2023-09-20 08:00:00'  
ORDER BY create_time DESC  
LIMIT 10;  

优点:

  • 避免全量数据排序,性能提升6倍。
  • 支持跳页查询(如直接从100万页开始查)。

缺点:

  • 需要两次查询,逻辑复杂。
  • 极端情况下可能有误差(需业务容忍)。

方案3:ES+HBase核弹方案(王者方案)

核心思想: 让专业的人干专业的事!

  • ES:负责海量数据搜索+分页(倒排索引碾压数据库)。
  • HBase:负责存储原始数据(高并发读取无压力)。
    架构图:

实现步骤:

  1. **写入时:**订单数据同时写MySQL(分库分表)、ES、HBase。
  2. 查询时:
json 复制代码
GET /orders/_search  
{  
  "query": { "match_all": {} },  
  "sort": [{"create_time": "desc"}],  
  "from": 1000000,  
  "size": 10  
}  
json 复制代码
List<Order> orders = es.search(...); // 从ES拿到10个ID  
List<Order> details = hbase.batchGet(orders); // 从HBase拿详情  
  • Step2:用ES返回的ID,去HBase批量查数据。
  • Step1:用ES查分页(只查ID和排序字段)。
    优点:
  • 分页性能碾压数据库,百万级数据毫秒响应。
  • 支持复杂搜索条件(ES的强项)。
    缺点:
  • 架构复杂度高,成本飙升(ES集群要钱,HBase要运维)。
  • 数据一致性难保证(延迟可能秒级)。

面试怎么答?

1. 面试官要什么?

  • 原理理解: 知道分库分表后翻页的痛点(数据分散、归并排序)。
  • 方案灵活: 根据场景选方案(禁止跳页、二次查询、ES+HBase)。
  • 实战经验: 遇到过真实问题,用过二次查询或ES。

2. 标准答案模板

分库分表后深度分页的难点在于全局排序和内存压力。

我们有三种方案:

  • 禁止跳页: 适合C端Feed流,用连续查询代替跳页。
  • 二次查询法: 通过两次查询缩小范围,适合管理后台。
  • ES+HBase: 扛住亿级数据分页,适合高并发大厂场景。
    在实际的场景中,订单查询需要支持搜索条件,我们最终用ES+HBase,性能从10秒降到50毫秒。"

加分的骚操作:

  • 画架构图(分库分表+ES+HBase数据流向)。
  • 给性能对比数据(ES分页 vs 数据库分页)。
  • 提一致性解决方案(监听MySQL Binlog同步到ES)。

总结

分库分表后的深度分页,本质是 "分布式数据排序" 的难题。

  • 百万以内数据: 二次查询法性价比最高。
  • 高并发大厂场景: ES+HBase是唯一选择。
  • 千万别硬刚: LIMIT 1000000,10 就是自杀式操作!
    最后一句忠告:
    面试被问分页,先拍桌子喊出"禁止跳页",再掏出ES,面试官绝对眼前一亮!

本文改编自

如侵权,请联系删除

相关推荐
患得患失94926 分钟前
【后端】【Django DRF】Django ORM 详解:一对一、一对多、多对多
数据库·django·sqlite
未兆1 小时前
dbeaver连接mongodb 插入日期变成了字符串
数据库·mongodb
啥都想学的又啥都不会的研究生1 小时前
Redis设计与实现-哨兵
数据结构·数据库·redis·笔记·学习·缓存
꧁༺朝花夕逝༻꧂2 小时前
MongoDB
数据库·mongodb
你邻座的怪同学2 小时前
使用MyBatis Plus的QueryWrapper实现复杂的SQL查询
sql·oracle·mybatis
小王努力学编程2 小时前
【MySQL篇】事务管理,事务的特性及深入理解隔离级别
数据库·c++·学习·mysql
蓝色之鹰2 小时前
性能调优经典面试题及答案(后端/JVM/数据库方向)
jvm·数据库
逊嘘3 小时前
【MySQL】数据库基础
数据库·mysql
Allen Bright4 小时前
【MySQL基础-16】MySQL DELETE语句:深入理解与应用实践
数据库·mysql
乌拉_乌拉_乌拉4 小时前
C#连接sqlite数据库实现增删改查
数据库·sqlite·c#