
最近在处理一个分页查询时,遇到了结果集顺序不确定的问题,这引发了我对 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时结果如下:
| id | company_id | subject_id | subject_type |
|---|---|---|---|
| 12 | 4 | 530 | 1 |
| 16 | 4 | 531 | 1 |
| 13 | 4 | 533 | 1 |
| 14 | 4 | 537 | 1 |
| 3 | 4 | 61 | 1 |
| 48 | 4 | 618 | 1 |
| 47 | 4 | 619 | 1 |
| 49 | 4 | 620 | 1 |
| 50 | 4 | 621 | 1 |
| 51 | 4 | 622 | 1 |
而当OFFSET为50时,结果可能如下:
| id | company_id | subject_id | subject_type |
|---|---|---|---|
| 12 | 4 | 530 | 1 |
| 16 | 4 | 531 | 1 |
| 13 | 4 | 533 | 1 |
| 55 | 4 | 626 | 1 |
| 56 | 4 | 627 | 1 |
| 57 | 4 | 628 | 1 |
| 58 | 4 | 629 | 1 |
| 59 | 4 | 630 | 1 |
| 61 | 4 | 631 | 1 |
| 60 | 4 | 632 | 1 |
原因很简单,即查询语句中没有指定 ORDER BY 子句。 当 MySQL 执行这个查询时:
- 可能使用索引
idx_company_id; - 可能进行全表扫描;
- 不同的 OFFSET 值可能导致优化器选择不同的执行计划。
但这个现象引出了几个深层次的问题:
- 为什么 InnoDB 的物理存储是有序的,但查询结果可能无序?
- 查询优化器在不同 OFFSET 值下为什么会选择不同的执行计划?
- 数据的访问路径如何影响最终的结果顺序?
一、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
这种结构导致:
- 数据物理上是有序的
- 但读取顺序受 Buffer Pool 状态影响
- 多线程并行读取时顺序更不确定
二、查询优化器的决策模型
2.1 成本估算
MySQL 查询优化器采用基于成本的优化策略。在处理查询时,优化器会评估多个可能的执行计划,并选择总成本最低的方案。查询优化器使用复杂的成本模型来选择执行计划:
总成本 = 数据页读取成本 + 行检查成本 + 排序成本
其中:
数据页读取成本
- 内存读取成本 = 页数 × 1.0
- 磁盘读取成本 = 页数 × 4.0
行检查成本
- 索引检查成本 = 记录数 × 0.2
- 全表检查成本 = 记录数 × 0.1
排序成本(如果需要)
- 内存排序 = 记录数 × 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 子句,导致:
- 不同的执行路径会产生不同的数据顺序
- 相同的查询在不同时刻可能走不同的执行计划
- 最终导致分页结果出现重复或遗漏的记录
因此,在没有明确排序条件的情况下,MySQL 无法保证分页结果的一致性和完整性。这就是为什么我们会看到不同页码之间出现重复数据的根本原因。
总结
通过深入分析 MySQL 的工作原理,我们可以看到:
- 数据的物理存储顺序与查询结果顺序是两个独立的概念
- 查询优化器的决策直接影响结果的顺序和性能
- 正确的索引设计和查询方式是解决问题的关键
在实际应用中,我们应该:
- 始终显式指定排序条件
- 根据实际需求选择合适的分页方式