Skip to content

MySQL千万级数据翻页优化方案

当MySQL单表数据量达到千万级时,翻页操作(特别是深度翻页)变慢是一个典型问题。其核心原因在于数据库底层处理LIMIT offset, size的方式。

🔍 翻页变慢的底层原因

简单来说,当你使用LIMIT 1000000, 20时,数据库需要费力地先找到这100万行数据,然后才能把你要的20行给你。这个过程主要慢在以下几点:

  • 巨大的无效I/O开销:这是最核心的原因。LIMIT 1000000, 20意味着MySQL需要从数据库中读取1000020条完整的记录,然后将前面的100万条丢弃,只返回最后的20条。随着偏移量(offset)的增大,需要扫描和丢弃的数据量线性增长,消耗大量的磁盘I/O和CPU资源。
  • 昂贵的“回表”操作:如果你的SELECT *查询使用了二级索引(比如对user_id建的索引),但索引并未覆盖所有查询字段,就会发生回表。即数据库先通过二级索引找到满足条件的主键ID,然后再用这些ID回到主键索引(聚簇索引)中去查找完整的行数据。在深度分页时,这个操作可能发生上万甚至上百万次,带来大量的随机I/O,极大地拖慢了查询速度。
  • 排序操作的负担:如果ORDER BY的字段没有合适的索引,MySQL就需要在内存中或磁盘上创建一个临时表,对所有数据进行排序(称为Using filesort)。对于千万级数据,这个排序过程会非常缓慢并且消耗大量资源。

💡 高效的解决方案

理解了原因,解决方案就更有针对性。下面的表格汇总了几种核心的优化方法,你可以根据具体场景选择。

解决方案核心原理适用场景注意事项
✅ 游标分页 (Cursor-based Pagination)记录上一页最后一条记录的ID,下一页查询时直接使用WHERE id > last_id来定位。完全跳过了OFFSET连续翻页(如无限滚动、“上一页/下一页”)。性能最优。不支持随机跳页(如直接跳到第100页)。需要前端配合传递游标。
✅ 延迟关联 (Deferred Join)先通过索引获取主键,再关联回表查数据。子查询利用覆盖索引快速定位到需要的主键ID,大幅减少回表次数。需要兼容随机跳页的大偏移量查询依赖于合适的索引。
✅ 覆盖索引 (Covering Index)创建包含所有查询字段的联合索引,使得索引本身就能提供查询所需的所有数据,避免回表查询字段较少,可以构建覆盖索引的场景。索引会占用更多磁盘空间,可能影响写入性能。
🛠️ 其他架构级方案- 缓存高频页:如将首页、热门页结果存入Redis。
- 使用搜索引擎:如将数据同步到Elasticsearch处理复杂查询和分页。
- 分区表:按时间范围等将大表物理拆分,查询时只扫描相关分区。
数据更新不频繁的热点数据。复杂的搜索和过滤场景。数据有明显的时间或范围特征。牺牲数据实时性。引入技术栈复杂性。管理复杂度高,对简单查询可能不友好。

方案示例与代码

  1. 游标分页 假设你按自增主键id排序。

    sql
    -- 第一页
    SELECT * FROM your_table ORDER BY id DESC LIMIT 20;

    假设返回的最后一条记录的id是1000,那么查询第二页时:

    sql
    -- 第二页及后续页面
    SELECT * FROM your_table WHERE id < 1000 ORDER BY id DESC LIMIT 20;
  2. 延迟关联

    sql
    SELECT t1.*
    FROM your_table t1
    INNER JOIN (
        SELECT id -- 先通过索引快速定位到需要的主键
        FROM your_table
        WHERE ... -- 你的查询条件
        ORDER BY ...
        LIMIT 1000000, 20
    ) t2 ON t1.id = t2.id; -- 再通过主键高效地关联回表获取完整数据

💎 实践建议与总结

  • 索引是根本:确保你的ORDER BYWHERE条件中的字段有合适的索引,并遵循最左前缀原则
  • 避免 SELECT *:只查询业务需要的字段,减少数据传输量和回表可能性。
  • 业务折衷:从产品层面考虑,是否可以限制最大的翻页深度,或者只提供“上一页/下一页”功能,这能从根本上避免深度分页问题。

总而言之,解决千万级数据翻页慢的关键在于让数据库扫描尽可能少的数据。游标分页是体验最好的方案,如果业务必须支持随机跳页,那么延迟关联是性价比最高的技术手段。

好的,我们来深入探讨这两个关键技术概念。

📄 深度翻页 (Deep Pagination)

定义

深度翻页指的是在数据分页查询中,当偏移量(offset)非常大时的查询场景。比如从第100万条开始取20条数据:

sql
SELECT * FROM table ORDER BY id LIMIT 1000000, 20;

技术层面的问题根源

1. 数据扫描机制

MySQL执行LIMIT offset, size时的工作流程:

1. 通过索引或全表扫描定位到第一条符合条件的数据
2. 顺序扫描offset + size条记录
3. 丢弃前offset条记录
4. 返回剩余的size条记录

对于LIMIT 1000000, 20

  • 需要实际扫描1,000,020条记录
  • 最终丢弃1,000,000条记录
  • 只返回20条有效记录

2. 性能衰减曲线

python
# 扫描数据量 vs 偏移量的关系
扫描数据量 = offset + size
有效数据比例 = size / (offset + size)

# 示例:
LIMIT 100, 20     → 扫描120条,有效率16.7%
LIMIT 10000, 20   → 扫描10020条,有效率0.2%  
LIMIT 1000000, 20 → 扫描1000020条,有效率0.002%

3. 执行计划分析

使用EXPLAIN可以看到深度翻页查询的代价:

sql
EXPLAIN SELECT * FROM users ORDER BY id LIMIT 1000000, 20;

结果会显示:

  • type: ALL(可能全表扫描)
  • rows: 1000020(预估扫描行数)
  • Extra: Using filesort(如果排序字段无索引)

🔄 回表 (Back to Table / Bookmark Lookup)

定义

回表是指:当使用二级索引进行查询时,如果所需字段不在索引中,需要根据索引找到的主键ID,回到主键索引(聚簇索引)中查找完整行数据的过程。

回表产生的技术条件

1. MySQL索引结构回顾

  • 聚簇索引(主键索引):叶子节点存储完整的行数据
  • 二级索引(非主键索引):叶子节点存储索引字段 + 主键值

2. 回表产生场景

假设表结构:

sql
CREATE TABLE users (
    id INT PRIMARY KEY,          -- 主键
    name VARCHAR(100),           -- 姓名
    age INT,                     -- 年龄
    city VARCHAR(100),           -- 城市
    created_time DATETIME,       -- 创建时间
    INDEX idx_age_city (age, city)  -- 联合索引
);

场景1:查询字段不全部在索引中

sql
-- 产生回表
SELECT * FROM users WHERE age > 25 AND city = 'Beijing';

执行过程:

  1. 使用idx_age_city索引找到所有age > 25 AND city = 'Beijing'的记录,获得对应的主键id
  2. 用这些id回到聚簇索引中查找完整的行数据(包括name, created_time等字段)

场景2:SELECT * 查询

sql
-- 几乎总是产生回表
SELECT * FROM users ORDER BY age LIMIT 1000000, 20;

回表的性能影响

1. I/O模式差异

  • 二级索引扫描:通常是顺序I/O(索引叶子节点是链表结构)
  • 回表操作:通常是随机I/O(主键值分散在不同数据页)

2. 性能对比示例

sql
-- 案例1:无回表(覆盖索引)
SELECT id, age, city FROM users 
WHERE age BETWEEN 20 AND 30 
ORDER BY age LIMIT 1000000, 20;
-- 性能:较好,仅扫描索引

-- 案例2:有回表  
SELECT * FROM users 
WHERE age BETWEEN 20 AND 30 
ORDER BY age LIMIT 1000000, 20;
-- 性能:较差,扫描索引 + 百万次回表

3. 执行计划识别回表

sql
EXPLAIN SELECT * FROM users WHERE age > 25;

结果分析:

  • type: range(使用了索引范围扫描)
  • key: idx_age_city(使用了哪个索引)
  • Extra: Using index condition(可能表示需要回表)

关键指标:如果Extra中没有出现"Using index",就很可能发生了回表。

深度翻页 + 回表 = 性能灾难

当深度翻页遇到回表时,问题会指数级放大:

sql
SELECT * FROM users ORDER BY age LIMIT 1000000, 20;

执行过程:

  1. 扫描二级索引:顺序扫描idx_age_city索引的1,000,020条记录
  2. 获取主键ID:从索引中提取1,000,020个主键值
  3. 回表操作:执行1,000,020次随机I/O到聚簇索引查找完整数据
  4. 数据丢弃:丢弃前1,000,000条完整记录
  5. 结果返回:返回最后20条记录

总成本 = 二级索引顺序扫描(1,000,020) + 聚簇索引随机访问(1,000,020)

💡 技术解决方案回顾

避免深度翻页

  • 游标分页WHERE id > last_id LIMIT 20
  • 业务限制:限制最大翻页深度

避免回表

  • 覆盖索引CREATE INDEX idx_covering ON users(age, city, name, created_time)
  • 延迟关联:先查主键,再关联回表
  • **减少SELECT ***:只查询需要的字段

学而不思则罔,思而不学则殆。