【华为云MySQL技术专栏】MySQL InnoDB 并行查询特性原理和实现

举报
GaussDB 数据库 发表于 2025/11/13 17:58:43 2025/11/13
【摘要】 1、引言MySQL凭借其卓越的高并发事务处理能力和丰富的生态系统,不仅在互联网行业广泛应用,也持续向传统行业渗透。然而在OLAP场景下,MySQL面临显著挑战:其单线程执行模型在处理复杂大查询时存在天然瓶颈,单个SQL语句最多只能利用一个CPU核心,无法充分发挥现代多核服务器的硬件潜力。随着数据量增长,这一问题日益凸显——全表扫描类查询耗时随数据规模线性增加。为突破这一限制,MySQL社区在...

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.png图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.png图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.png图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.png图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的高性能并行查询能力。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。