MySQL分页查询踩坑记:从物理存储到结果排序的思考

2025-01-22T23:07:11+08:00 | 4分钟阅读 | 更新于 2025-01-22T23:07:11+08:00

bestzy
MySQL分页查询踩坑记:从物理存储到结果排序的思考

最近在处理一个分页查询时,遇到了结果集顺序不确定的问题,这引发了我对 MySQL 查询执行模型的思考。

引言

在 MySQL 数据库的日常使用中,我们经常会遇到一些看似简单但实际上涉及复杂内部机制的问题。

最近在处理一个分页查询时,遇到了结果集顺序不确定的问题,这引发了我对 MySQL 查询执行模型的思考。本文将从物理存储、查询优化、执行计划等多个维度,深入分析 MySQL 的工作原理。

问题引出

假设存在表tb_foo_bar,其见表语句如下:

CREATE TABLE tb_foo_bar (
    id INT AUTO_INCREMENT PRIMARY KEY,
    company_id INT NOT NULL,
    subject_type INT NOT NULL,
    subject_id INT NOT NULL,
    INDEX idx_company_id (company_id,subject_type,subject_id)
);

考虑以下查询:

SELECT *
FROM tb_foo_bar
WHERE company_id = 4
	AND subject_type = 1
LIMIT 10 OFFSET 40;

这个查询在不同的 OFFSET 值下可能出现重复的结果数据集。如 OFFSET 为40时结果如下:

idcompany_idsubject_idsubject_type
1245301
1645311
1345331
1445371
34611
4846181
4746191
4946201
5046211
5146221

而当OFFSET为50时,结果可能如下:

idcompany_idsubject_idsubject_type
1245301
1645311
1345331
5546261
5646271
5746281
5846291
5946301
6146311
6046321

原因很简单,即查询语句中没有指定 ORDER BY 子句。 当 MySQL 执行这个查询时:

  1. 可能使用索引idx_company_id
  2. 可能进行全表扫描;
  3. 不同的 OFFSET 值可能导致优化器选择不同的执行计划。

但这个现象引出了几个深层次的问题:

  1. 为什么 InnoDB 的物理存储是有序的,但查询结果可能无序?
  2. 查询优化器在不同 OFFSET 值下为什么会选择不同的执行计划?
  3. 数据的访问路径如何影响最终的结果顺序?

一、MySQL的物理存储模型

1.1 InnoDB的聚簇索引结构

InnoDB 使用聚簇索引组织数据,这意味着:

[数据页结构]
Page Header
├── 记录1 (主键=1)
├── 记录2 (主键=2)
├── 记录3 (主键=3)
└── ...

[页间关系]
Page 1 ──► Page 2 ──► Page 3 ──► ...
(1-100)    (101-200)   (201-300)

1.2 数据页的加载机制

Buffer Pool
├── LRU List
│   ├── Young Page List
│   └── Old Page List
└── Free List

这种结构导致:

  1. 数据物理上是有序的
  2. 但读取顺序受 Buffer Pool 状态影响
  3. 多线程并行读取时顺序更不确定

二、查询优化器的决策模型

2.1 成本估算

MySQL 查询优化器采用基于成本的优化策略。在处理查询时,优化器会评估多个可能的执行计划,并选择总成本最低的方案。查询优化器使用复杂的成本模型来选择执行计划:

总成本 = 数据页读取成本 + 行检查成本 + 排序成本

其中:

  1. 数据页读取成本

    • 内存读取成本 = 页数 × 1.0
    • 磁盘读取成本 = 页数 × 4.0
  2. 行检查成本

    • 索引检查成本 = 记录数 × 0.2
    • 全表检查成本 = 记录数 × 0.1
  3. 排序成本(如果需要)

    • 内存排序 = 记录数 × log2(记录数) × 0.1
    • 磁盘排序 = 记录数 × log2(记录数) × 0.2

对于不同的 OFFSET:

  • 小偏移:索引扫描成本 < 全表扫描成本
  • 大偏移:索引扫描成本 > 全表扫描成本(需要扫描大量索引项)

2.2 执行计划的动态选择

MySQL 优化器会根据统计信息和成本估算动态选择执行计划。

让我们通过实际的 EXPLAIN 分析来理解这个过程:

-- 案例分析
EXPLAIN FORMAT=TREE
SELECT * 
FROM tb_foo_bar 
WHERE company_id = 4 
  AND subject_type = 1 
LIMIT 10 OFFSET 1000;

/*
可能的执行计划1(小偏移):
└── Index Scan (idx_company_subject)
    └── Filter: company_id=4 AND subject_type=1
        └── Limit/Offset

可能的执行计划2(大偏移):
└── Table Scan
    └── Filter: company_id=4 AND subject_type=1
        └── Limit/Offset
*/

三、深入分析:为什么会出现重复结果?

3.1 执行路径分析

当 MySQL 执行分页查询时,根据优化器选择的执行计划,数据访问路径可能完全不同:

路径1(使用索引):
└── 索引扫描
    ├── 1. 定位索引起始位置 (company_id=4 AND subject_type=1)
    ├── 2. 顺序扫描索引记录
    ├── 3. 通过主键回表获取完整记录
    └── 4. 收集直到满足 OFFSET+LIMIT 条记录

路径2(全表扫描):
└── 表扫描
    ├── 1. 从第一个数据页开始顺序扫描
    ├── 2. 对每条记录应用 WHERE 条件过滤
    ├── 3. 符合条件的记录进入结果集
    └── 4. 直到满足 OFFSET+LIMIT 条记录

3.2 结果集的构建过程

MySQL 在构建最终结果集时,经过多个阶段的处理:

1. 数据获取阶段
   ├── 索引扫描路径
   │   ├── 按索引顺序读取(company_id, subject_type, subject_id 顺序)
   │   ├── 回表获取完整记录
   │   └── 结果顺序受索引结构影响
   └── 全表扫描路径
       ├── 按数据页顺序读取
       ├── 应用过滤条件
       └── 结果顺序受数据页存储顺序影响

2. 结果集处理阶段
   ├── 内存临时表处理(sort_buffer_size 范围内)
   │   ├── 存储中间结果
   │   └── 可能发生内存中排序
   └── 磁盘临时表处理(超出 sort_buffer_size)
       ├── 创建磁盘临时表
       ├── 分批处理数据
       └── 可能发生文件排序

3. LIMIT/OFFSET 处理阶段
   ├── 跳过 OFFSET 条记录
   ├── 保留 LIMIT 条记录
   └── 返回最终结果集

这种复杂的处理流程,再加上没有明确的 ORDER BY 子句,导致:

  1. 不同的执行路径会产生不同的数据顺序
  2. 相同的查询在不同时刻可能走不同的执行计划
  3. 最终导致分页结果出现重复或遗漏的记录

因此,在没有明确排序条件的情况下,MySQL 无法保证分页结果的一致性和完整性。这就是为什么我们会看到不同页码之间出现重复数据的根本原因。

总结

通过深入分析 MySQL 的工作原理,我们可以看到:

  1. 数据的物理存储顺序与查询结果顺序是两个独立的概念
  2. 查询优化器的决策直接影响结果的顺序和性能
  3. 正确的索引设计和查询方式是解决问题的关键

在实际应用中,我们应该:

  1. 始终显式指定排序条件
  2. 根据实际需求选择合适的分页方式

© 2025 Bestzy's Blog

🌱 Powered by Hugo with theme Dream.

About Me

👋 Hi, This is Zheng Yi.