概述
MySQL 经过多年的发展已然成为最流行的数据库, 广泛用于互联网行业, 并逐步向各个传统行业渗透. 之所以流行, 一方面是其优秀的高并发事务处理的能力, 另一方面也得益于 MySQL 丰富的生态. MySQL 在处理 OLTP 场景下的短查询效果很好, 但对于复杂大查询则能力有限. 最直接一点就是, 对于一个 SQL 语句, MySQL 最多只能使用一个 CPU 核来处理, 在这种场景下无法发挥主机 CPU 多核的能力. MySQL 没有停滞不前, 一直在发展, 新推出的 8.0.14 版本第一次引入了并行查询特性, 使得 check table 和 select count(*) 类型的语句性能成倍提升. 虽然目前使用场景还比较有限, 但后续的发展值得期待.
使用方式
通过配置参数 innodb_parallel_read_threads 来设置并发线程数, 就能开始并行扫描功能, 默认这个值为 4. 我这里做一个简单的实验, 通过 sysbench 导入 2 亿条数据, 分别配置 innodb_parallel_read_threads 为 1,2,4,8,16,32,64, 测试并行执行的效果. 测试语句为 select count(*) from sbtest1;
横轴是配置并发线程数, 纵轴是语句执行时间. 从测试结果来看, 整个并行表现还是不错的, 扫描 2 亿条记录, 从单线程的 18s, 下降到 32 线程的 1s. 后面并发开再多, 由于数据量有限, 多线程的管理消耗超过了并发带来的性能提升, 不能再继续缩短 SQL 执行时间.
MySQL 并行执行
实际上目前 MySQL 的并行执行还处于非常初级阶段, 如下图所示, 左边是之前 MySQL 串行处理单个 SQL 形态; 中间的是目前 MySQL 版本提供的并行能力, InnoDB 引擎并行扫描的形态; 最右边的是未来 MySQL 要发展的形态, 优化器根据系统负载和 SQL 生成并行计划, 并将分区计划下发给执行器并行执行. 并行执行不仅仅是并行扫描, 还包括并行聚集, 并行连接, 并行分组, 以及并行排序等. 目前版本 MySQL 的上层的优化器以及执行器并没有配套的修改. 因此, 下文的讨论主要集中在 InnoDB 引擎如何实现并行扫描, 主要包括分区, 并行扫描, 预读以及与执行器交互的适配器类.
分区
并行扫描的一个核心步骤就是分区, 将扫描的数据划分成多份, 让多个线程并行扫描. InnoDB 引擎是索引组织表, 数据以 B+tree 的形式存储在磁盘上, 节点的单位是页面 (block/page), 同时缓冲池中会对热点页面进行缓存, 并通过 LRU 算法进行淘汰. 分区的逻辑就是, 从根节点页面出发, 逐层往下扫描, 当判断某一层的分支数超过了配置的线程数, 则停止拆分. 在实现时, 实际上总共会进行两次分区, 第一次是按根节点页的分支数划分分区, 每个分支的最左叶子节点的记录为左下界, 并将这个记录记为相邻上一个分支的右上界. 通过这种方式, 将 B+tree 划分成若干子树, 每个子树就是一个扫描分区. 经过第一次分区后, 可能出现分区数不能充分利用多核问题, 比如配置了并行扫描线程为 3, 第一次分区后, 产生了 4 个分区, 那么前 3 个分区并行做完后, 第 4 个分区至多只有一个线程扫描, 最终效果就是不能充分利用多核资源.
二次分区
为了解决这个问题, 8.0.17 版本引入了二次分区, 对于第 4 个分区, 继续下探拆分, 这样多个子分区又能并发扫描, InnoDB 引擎并发扫描的最小粒度是页面级别. 具体判断二次分区的逻辑是, 一次分区后, 若分区数大于线程数, 则编号大于线程数的分区, 需要继续进行二次分区; 若分区数小于线程数且 B+tree 层次很深, 则所有的分区都需要进行二次分区. 相关代码如下:
- split_point = 0;
- if (ranges.size()> max_threads()) {
- // 最后一批分区进行二次分区
- split_point = (ranges.size() / max_threads()) * max_threads();
- } else if (m_depth <SPLIT_THRESHOLD) {
- /* If the tree is not very deep then don't split. For smaller tables
- it is more expensive to split because we end up traversing more blocks*/
- split_point = max_threads();
- } else {
- // 如果 B+tree 的层次很深 (层数大于或等于 3, 数据量很大), 则所有分区都需要进行二次分区
- }
无论是一次分区, 还是二次分区, 分区边界的逻辑都一样, 以每个分区的最左叶子节点的记录为左下界, 并且将这个记录记为相邻上一个分支的右上界. 这样确保分区足够多, 粒度足够细, 充分并行. 下图展示了配置为 3 的并发线程, 扫描进行二次分区的情况.
相关代码如下:
create_ranges(size_t depth, size_t level)
一次分区:
- parallel_check_table
- add_scan
- partition(scan_range, level=0) /* start at root-page */
- create_ranges(scan_range, depth=0, level=0)
- create_contexts(range, index>= split_point)
二次分区:
- split()
- partition(scan_range, level=1)
- create_ranges(depth=0,level)
并行扫描
在一次分区后, 将每个分区扫描任务放入到一个 lock-free 队列中, 并行的 worker 线程从队列中获取任务, 执行扫描任务, 如果获取的任务带有 split 属性, 这个时候 worker 会将任务进行二次拆分, 并投入到队列中. 这个过程主要包括两个核心接口, 一个是工作线程接口, 另外一个是遍历记录接口, 前者从队列中获取任务并执行, 并维护统计计数; 后者根据可见性获取合适的记录, 并通过上层注入的回调函数处理, 比如计数等.
- Parallel_reader::worker(size_t thread_id)
- {
1. 从 ctx-queue 提取 ctx 任务
2. 根据 ctx 的 split 属性, 确定是否需要进一步拆分分区 (split())
3. 遍历分区所有记录 (traverse())
4. 一个分区任务结束后, 维护 m_n_completed 计数
5. 如果 m_n_compeleted 计数达到 ctx 数目, 唤醒所有 worker 线程结束
6. 根据 traverse 接口, 返回 err 信息.
- }
- Parallel_reader::Ctx::traverse()
- {
1. 根据 range 设置 pcursor
2. 找到 btree, 将游标定位到 range 的起始位置
3. 判断可见性 (check_visibility)
4. 如果可见, 根据回调函数计算 (比如统计)
5. 向后遍历, 若达到了页面的最后一条记录, 启动预读机制 (submit_read_ahead)
6. 超出范围后结束
}
同时在 8.0.17 版本还引入了预读机制, 避免因为 IO 瓶颈导致并行效果不佳的问题. 目前预读的线程数不能配置, 在代码中硬编码为 2 个线程. 每次预读的单位是一个簇 (InnoDB 文件通过段, 簇, 页三级结构管理, 一个簇是一组连续的页), 根据页面配置的大小, 可能为 1M 或者 2M. 对于常见的 16k 页面配置, 每次预读 1M, 也就是 64 个页面. worker 线程在进行扫描时, 会先判断相邻的下一个页面是否为簇的第一个页面, 如果是, 则发起预读任务. 预读任务同样通过 lock-free 队列缓存, worker 线程是生产者, read-ahead-worker 是消费者. 由于所有分区页面没有重叠, 因此预读任务也不会重复.
执行器交互 (适配器)
实际上, MySQL 已经封装了一个适配器类 Parallel_reader_adapter 来供上层使用, 为后续的更丰富的并行执行做准备. 首先这个类需要解决记录格式的问题, 将引擎层扫描的记录转换成 MySQL 格式, 这样做到上下层解耦, 执行器不用感知引擎层格式, 统一按 MySQL 格式处理. 整个过程是一个流水线, 通过一个 buffer 批量存储 MySQL 记录, worker 线程不停的将记录从引擎层上读上来, 同时有记录不停的被上层处理, 通过 buffer 可以平衡读取和处理速度的差异, 确保整个过程流动起来. 缓存大小默认是 2M, 根据表的记录行长来确定 buffer 可以缓存多少个 MySQL 记录. 核心流程主要在 process_rows 接口中, 流程如下
process_rows
{
1. 将引擎记录转换成 MySQL 记录
2. 获取本线程的 buffer 信息 (转换了多少 MySQL 记录, 发送了多少给上层)
3. 将 MySQL 记录填充进 buffer, 自增统计 m_n_read
4. 调用回调函数处理 (比如统计, 聚合, 排序等), 自增统计 m_n_send
}
对于调用者来说, 需要设置表的元信息, 以及注入处理记录回调函数, 比如处理聚集, 排序, 分组的工作. 回调函数通过设置 m_init_fn,m_load_fn 和 m_end_fn 来控制.
总结
MySQL8.0 引入了并行查询虽然还比较初级, 但已经让我们看到了 MySQL 并行查询的潜力, 从实验中我们也看到了开启并行执行后, SQL 语句执行充分发挥了多核能力, 响应时间急剧下降. 相信在不久的将来, 8.0 的会支持更多并行算子, 包括并行聚集, 并行连接, 并行分组以及并行排序等.
参考文档
- https://dev.mysql.com/worklog/task/?id=11720
- https://dev.mysql.com/worklog/task/?id=12978
- https://yq.aliyun.com/articles/691516?utm_content=g_1000045831
- http://mysql.taobao.org/monthly/2019/10/02/
- https://www.percona.com/blog/2019/01/17/using-parallel-query-with-amazon-aurora-for-mysql/
来源: https://www.cnblogs.com/cchust/p/12347166.html