【华为云MySQL技术专栏】MySQL InnoDB 并行查询特性原理和实现
1、引言
MySQL凭借其卓越的高并发事务处理能力和丰富的生态系统,不仅在互联网行业广泛应用,也持续向传统行业渗透。
然而在OLAP场景下,MySQL面临显著挑战:其单线程执行模型在处理复杂大查询时存在天然瓶颈,单个SQL语句最多只能利用一个CPU核心,无法充分发挥现代多核服务器的硬件潜力。随着数据量增长,这一问题日益凸显——全表扫描类查询耗时随数据规模线性增加。
为突破这一限制,MySQL社区在8.0.14版本里程碑式地引入了InnoDB并行查询特性。该技术通过innodb_parallel_read_threads参数控制并行线程数,首次实现了对聚簇索引的并行扫描。虽然初期仅支持 CHECK TABLE 和SELECT COUNT(*) 等特定操作,但实测性能已获得倍数级提升。这一演进不仅解决了历史性瓶颈,更为未来支持更复杂的并行执行计划奠定了坚实基础。
本文将以8.0.22代码为基准,对SELECT COUNT(*)操作在InnoDB层的并行查询过程的基本原理和代码进行解析。
2、并行查询原理
InnoDB并行查询的基本思想是:将一个聚簇索引上的范围扫描切分成多个更小的区间扫描,且多线程并行执行。主要步骤有:
1. 对整个扫描范围进行切分,分成很多小的区间数据;
2. 启动若干个worker线程,每个worker线程去fetch数据区间并进行扫描;
3. 计算逻辑以回调函数的方式在区间扫描的过程中执行。
2.1 切分区间数据
MySQL并行查询的关键在于将待扫描的数据区间进行分片,以实现多线程并行处理。整个分片过程分为两个阶段:粗粒度的“首次分片”用于初步划分数据范围,细粒度的“二次分片”则进一步精细拆分以提升并发效率。
2.1.1 首次分片
用户线程扫描B+树,完成首次分片,过程如下图所示:
图1 首次分片示意图
该B+树一共三层:页面0为root page,包含了33条记录,对应了33个子页面。33个二级节点页面,每个页面包含了10条记录,对应10个子节点(也就是叶子节点)页面。每个叶子节点的页面包含了10条记录。
首次分片的步骤如下:
步骤0:索引上加S锁
步骤1:root page(页面0)加S锁
步骤2~3:访问页面0的第一条记录,根据该记录定位到叶子节点(页面34)的记录,并沿途路径上的页面加S锁(页面1/34)
步骤4:根据叶子结点记录创建一个分片,起始位置为叶子结点记录(1),终点为上个分片的起始位置(无上个分片,因此为null),添加到Range队列。
步骤5:释放路径持有的S锁(页面1/34)
步骤 6~n+1:按照步骤2~5相同处理方式,遍历页面0的剩余的32条记录,完成32个Range的创建
步骤 n+2: 释放页面0和索引树上的S锁
在上述处理过程中,共生成了33个分片:{[1, 101), [101, 201), [201, 301), …, [3201, null)}。假设系统配置了4个worker线程,分片数量显然无法均匀分配,必然存在某个线程需额外处理一个Range。而位于root page下的子树通常对应较大的 Range,扫描耗时较长,导致整体查询时间被拉高。
为缓解这一负载不均问题,MySQL会对剩余分片进行细粒度的二次分片(用户线程为本例中的[3201, null)打上split标记)。由于B+树本身具备“矮胖”结构,经过两轮分片后,已能将大表有效拆分为足够数量的子树,从而显著提升并行线程的利用率。因此,MySQL 通常仅执行两次分片,无需进一步细化。
2.1.2 二次分片
二次分片由worker线程完成。用户线程在完成首次分片后,就会启动worker线程。worker线程会到分片队列中去fetch分片。如果当前的分片已打上split标记,则开启二次分片过程。以案例中的分片[3201, null)做二次分片为例,其过程如下:
图2 二次分片示意图
二次分片的处理流程与基于root page的首次分片处理逻辑基本一致:
1.首先根据Range的起始位置(如3201)定位到子树的根节点页面(例如页面 33)中的对应记录
2.然后从该记录进一步定位到叶子节点,并据此生成第一个分片边界(3201)
3.接着继续处理页面33中的下一条记录,重复上述步骤2,直到遍历完Range区间 [3201, null)
最终生成的分片如下:{[3201, 3211), [3211, 3221), [3221, 3231), [3231, 3241), [3241, 3251), [3251, 3261), [3261, 3271), [3271, 3281), [3281, 3291), [3291, null)}
针对二次分片的处理过程,常见的两个疑问如下:
1.为何不在用户线程做二次分片,而选择在 worker 节点处理?
主要原因是分片操作本身较为耗时,尤其是二次分片涉及复杂的边界计算与数据处理,且待二次分片的分片个数可能是多个。将该任务下放至 worker 节点,能够充分发挥多核 CPU 的并行能力,从而提升整体执行效率。
2.若在首次与二次分片之间,索引结构发生变更(如页面分裂、合并或记录变动),是否会影响分片结果?
不会。因为分片边界的定位是基于索引键这一逻辑位置,而非依赖物理页面结构。即使索引发生结构性变化,系统仍能准确定位到更新后的叶子节点,从而确保分片结果的准确性。
2.2 分片扫描过程
2.2.1 存储和恢复位置
在做完成分片后,索引树的结构有可能已经发生变化,分片中记录的分界点的物理位置(cursor)在扫描时需要基于最新的索引结构映射到适当的位置。
InnoDB封装了基础数据结构和接口,进行索引树结构变化前后的位置转化。
// 索引范围扫描的边界点
struct Iter {
~Iter();
mem_heap_t *m_heap{};
const ulint *m_offsets{}; // 行记录的列偏移数组
const rec_t *m_rec{}; // 物理行记录,指向实际行数据
const dtuple_t *m_tuple{}; // 物理行的逻辑元组表示,用于范围比较
btr_pcur_t *m_pcur{}; // 持久化B+树游标,行记录的定位信息
};
// 分区 [begin, end).
using Range = std::pair<std::shared_ptr<Iter>, std::shared_ptr<Iter>>;
struct btr_pcur_t {
...
// 位置恢复
bool restore_position(ulint latch_mode, mtr_t *mtr, const char *file,
ulint line);
// 位置储存
void store_position(mtr_t *mtr);
...
};
持久化游标(btr_pcur_t)是实现位置转化的核心数据结构,用于在B+树中保存一个位置,以便在后续的扫描操作中可以快速恢复到这个位置。
持久化游标保存和恢复位置的过程如下:
store_position: 将当前游标的位置存储到游标结构中。它会记录当前页的页号、记录在页中的堆号(heap_no)以及当前页的修改日志序列号(LSN)。同时,它还会复制一个搜索元组(dtuple_t),这个元组是当前记录的主键(或者唯一键索引)的元组。存储位置后,游标进入“已存储位置”状态。
restore_position: 根据之前存储的位置信息,尝试恢复游标的位置。恢复过程如下:
1)首先尝试通过之前存储的页号和堆号直接定位。如果该页仍然在缓冲池中,且页的LSN没有变化(即页没有发生分裂或合并等),则可以直接定位到记录。
2)如果通过页号和堆号定位失败(例如页不在缓冲池中,或者页的LSN已经改变,说明页可能被修改了),则使用存储的搜索元组(dtuple_t)重新进行B+树搜索,以找到当前记录。
2.2.2 分片扫描
worker线程从分片队列中获取任务后,若当前分片区间 [start, end) 未标记为split,则执行如下扫描流程:
1)恢复start位置的持久化游标,定位对应叶子页,并加S锁;
2)扫描页内记录,进行MVCC可见性判断并构造旧版本;
3)到达页尾后,获取并加锁next page;
4)释放当前页锁,继续扫描下一页,直至覆盖到end位置。
2.3 总体流程
InnoDB并行查询总体流程如下图所示:
图3 并行查询总体流程图
用户线程(单个):负责执行首次分片操作,将初步划分的数据区间推入共享队列,并启动多个worker线程。待所有worker完成计算后,用户线程负责结果汇总并将最终结果返回给客户端。
Worker线程(多个):从共享队列中获取分片任务,执行二次分片或直接扫描分片内的所有记录。对每条记录执行回调函数,例如在SELECT COUNT(*)场景下,回调函数的逻辑即为对行数进行累加。
3、应用场景和限制
3.1 SQL示例
支持并行查询的SELECT COUNT语句:
# COUNT(*) 走并行 mysql> explain analyze select count(*) from expense_re_other_exp; +-----------------------------------------------------------------------------------------+ | EXPLAIN | +-----------------------------------------------------------------------------------------+ | -> Count rows in expense_re_other_exp (actual time=3451.422..3451.422 rows=1 loops=1) | +-----------------------------------------------------------------------------------------+ 1 row in set (3.45 sec) mysql> # COUNT(主键列) 走并行 mysql> explain analyze select count(id) from expense_re_other_exp; +-----------------------------------------------------------------------------------------+ | EXPLAIN | +-----------------------------------------------------------------------------------------+ | -> Count rows in expense_re_other_exp (actual time=3358.603..3358.604 rows=1 loops=1) | +-----------------------------------------------------------------------------------------+ 1 row in set (3.36 sec)
3.2 配置参数
mysql> show variables like '%innodb_parallel_read_threads%'; +------------------------------+-------+ | Variable_name | Value | +------------------------------+-------+ | innodb_parallel_read_threads | 4 | +------------------------------+-------+ 1 row in set (0.00 sec)
该参数用于设置并行扫描主键索引的线程数(当前不支持并行扫描二级索引)。当值大于 1 时启用并行扫描,最大可设为 256。
3.3 注意事项
执行 SELECT COUNT(*) FROM table时,并非所有场景下并行执行的性能都优于串行。由于并行模式通常会强制使用聚簇索引进行扫描,在聚簇索引数据量较大而二级索引相对较小的情况下,串行执行通过二级索引可能反而更高效。
4、源码解析
4.1关键数据结构
关键数据结构示意如下:
图4 关键数据结构类图
Iter类:对应的是一个分界点,两个Iter对象组成一个Range,也就是一个分片。
Ctx类:对应的是一个范围扫描任务,其包含一个分片,也就是其扫描的范围。
Thread_ctx类:worker线程执行上下文。一个woker线程可能需要执行多个Ctx。
Scan_range类:对应的是一个逻辑上的(也就是索引键值范围)扫描范围。
Config类:对应的是一个Scan_ctx的配置信息,包含索引、逻辑扫描范围。
Scan_ctx类:每个Scan_ctx对象代表一次分拆的索引区间的一个扫描器实例,负责分割和分配B+树区间给worker线程。也就是说一个Scan_ctx对象可能会生成多个Ctx,待woker线程执行。
Parllel_reader类:是一次并行扫描的调度器和全局生命周期管理框架,管理任务的分片、线程调度、资源归还、错误归并和全局状态,包含上面所有的类。
4.2 流程解析
4.2.1 用户线程执行
用户线程在并行执行SELECT COUNT(*)时,InnoDB的处理过程如下:
|--> ha_innobase::records
|--> thd_parallel_read_threads // 获取并行线程数
|--> row_scan_index_for_mysql // 并行扫描clustered index
|--> row_mysql_parallel_select_count_star
|--> Parallel_reader::add_scan
|--> 创建Scan_ctx对象
|--> scan_ctx->index_s_lock // 索引上加S锁
|--> Scan_ctx::partition // 预分片
|--> 开启mtr
|--> Scan_ctx::create_ranges
|--> block_get_s_latched // 获取root page的s-latch
|--> 移动游标到页面第一个有效记录 // Skip the infimum record
|--> 循环遍历该页面下所有记录
|--> btr_node_ptr_get_child_page_no //获取当前记录对应的子节点页面
|--> Scan_ctx::start_range // 从当前页面定位到叶子节点记录,沿途页面加s-latch,并基于叶子节点记录创建持续化游标,作为分界点
|--> Scan_ctx::create_range // 基于分界点创建分区[begin, end)
|--> 释放从当前页面定位到叶子节点记录,沿途页面所加的s-latch
|--> 释放root page的s-latch
|--> 提交mtr
|--> scan_ctx->create_contexts // 基于分片创建Ctx对象,如果分片数不能被线程数整除得话,多余的Ctx打上split标识,并添加到共享Ctx队列
|--> scan_ctx->index_s_unlock // 释放索引S锁
|--> Parallel_reader::run // 启动worker线程并等待所有woker执行结束
|--> 汇总各woker结果,并返回
4.2.2 worker线程执行
worker线程由用户线程启动,其处理流程如下:
|--> Parallel_reader::worker
|--> 循环从共享Ctx队列获取扫描任务,直至队列为空
|--> 如果当前Ctx已标记为split,则Ctx::split
|--> scan_ctx->index_s_lock // 索引上加S锁
|--> Scan_ctx::partition // 二次分片
|--> 开启mtr
|--> Scan_ctx::create_ranges // 初始调用入参页面为root page
|--> block_get_s_latched // 获取page的s-latch
|--> 基于区间[start, end)的start(索引键值)定位到当前页面记录游标
|--> 从start位置开始循环遍历该页面下所有记录,直到end位置结束
|--> btr_node_ptr_get_child_page_no //获取当前记录对应的子节点页面
|--> 如果当前页面节点层级 > split页面节点层级 // root节点最大,叶子节点为0
|--> 递归调用Scan_ctx::create_ranges,直至到达split页面节点
|--> ... // 后续流程与预分片基本一致
|--> 提交mtr
|--> scan_ctx->create_contexts // 基于分片创建Ctx对象,二次分片后,不再分片
|--> scan_ctx->index_s_unlock // 释放索引S锁
|--> 否则Ctx::traverse
|--> 开启mtr
|--> PCursor::restore_position // 基于[start, end)的start位置的持久性游标恢复位置
|--> Ctx::traverse_recs
|--> 从start位置开始循环扫描Range区间的所有记录
|--> 如果当前游标指示到页面尾,则移动到下一个页面
|--> Scan_ctx::check_visibility // MVCC可见性判断和构建旧版本
|--> 如果已到达end位置,则退出扫描
|--> 记录可见,则回调count计数处理函数
|--> 移到下一条记录
|--> 提交mtr
5、总结
本文介绍了MySQL在InnoDB层提供的并行查询的基本用法、实现原理及当前的使用限制。现阶段,MySQL对并行查询的支持仍较为有限,仅适用于如聚簇索引的并行扫描、CHECK TABLE和SELECT COUNT(*) 等特定场景。尽管并行查询涵盖扫描、聚合、连接、分组、排序等多个维度,但MySQL的优化器与执行器尚未具备生成并行执行计划并自动调度的能力。
理想状态下,优化器应能根据系统负载和SQL特征生成并行计划,并将任务分发至执行器高效处理。我们期待社区在未来持续完善并行查询能力,拓展其适用范围,进一步提升查询性能。
最后补充下,华为云TaurusDB已在并行查询方面实现更广泛的支持(详情可点击https://support.huaweicloud.com/intl/zh-cn/kerneldesc-taurusdb/taurusdb_20_0005.html查看),涵盖全表扫描、索引扫描、JOIN操作、子查询、排序、分组与过滤等多种场景,并支持多种 JOIN算法与子查询类型。欢迎大家深入了解并体验TaurusDB的高性能并行查询能力。
- 点赞
- 收藏
- 关注作者
评论(0)