Query Execution & Scheduling
对于数据库系统,交互模式大致如上。
前端有多个用户,也就是多个应用程序来访问后端。后端则发送逻辑查询给DBMS,DBMS得到SQL语句或者其他方式的逻辑查询之后,将之转化为物理查询,得到数据后再返回给后端。
查询计划
一个查询计划是由多个操作组成的。比如,SELECTION,JOIN等等。
一个操作对应到具体的物理查询操作,往往又会有多个操作实例,比如,我要扫描一个表进行选择,那么我可能会有5个操作实例,每个扫描一部分数据。
一个任务则是由一段操作实例构成的,比如,使用管道技术来执行操作实例,那么,这个管道里的操作实例就是一个任务。
进程模型
DBMS的进程模型定义了系统应该如何架构,从而支持来自多用户应用程序的并发请求。
worker是DBMS组件,负责代表客户端执行任务并返回结果。
PROCESS PER WORKER
每个worker就是一个单独的OS进程。
- 依靠OS调度程序,DBMS无法控制worker的规模,包括I/O和socket数量等等。
- 为全局数据结构使用共享内存。
- 每个worker都是一个独立的个体,如果没有共享内存,那么就会存储过多的重复数据副本。
- 进程崩溃不会使整个系统崩溃。
- 例子:IBM DB2,Postgres,Oracle
后端发送请求给DBMS,DBMS通过调度线程进行权限验证、分配worker、转移连接等操作。
后端发送的连接被转移到分配的worker上,之后的所有数据交互就不用通过调度线程,而是直接与worker进行交互。
调度线程对所有worker的信息都非常了解,哪些是可用的,哪些是不可用的。但是这一切都是依赖于OS自身的调度。
PROCESS POOL
worker能够使用进程池中任何空闲的进程
- 仍然依靠OS调度程序和共享内存。
- CPU缓存局部性不好。
- 因为对于cache而言,有可能同时有一个worker的多个线程在读写同一个数据
- 示例:IBM DB2,Postgres(2015)
同时所有的连接不再是和worker进行连接,而是与调度线程进行交互和数据传输。
THREAD PER WORKER
单个进程与多个工作线程。
- DBMS必须管理自己的调度。
- scheduling对数据库系统效率影响极大,所以根据自己需要的情况设计调度算法,比依赖于OS本身的调度更有效率。
- 可能或不可以使用调度程序线程。
- 线程崩溃(可能)杀死整个系统。
- 示例:IBM DB2,MSSQL,MySQL,Oracle(2014)
使用多线程体系结构有几个优点:
- 每次上下文切换开销更少。
- 不必管理共享内存。
每个woker模型的线程并不意味着你有intra-query并行性。
就老师而言,他不知道最近7-8年内设计的新DBMS有不使用线程的。
SCHEDULING
对于每个查询计划,DBMS都必须决定何时,何时以及如何执行。
- 它应该使用多少个任务?
- 它应该使用多少个CPU核心?
- 任务执行的核心是什么?
- 任务存储在哪里?
DBMS总是比操作系统知道更多
查询并行性
INTER-QUERY PARALLELISM
允许多个查询同时执行,从而提高整体性能。
- 通过并发控制方案提供隔离。
实施并发控制方案的难度不会明显地受到DBMS线程模型的影响。
INTRA-QUERY PARALLELISM
通过并行执行其运算符来提高单个查询的性能。
方法1:内部运算符(水平)
- 运算符被分解为独立的实例,在不同的数据子集上执行相同的功能。
- 似乎不太常用,这块我没太听清是不是说方法1不太常用
方法二:worker间(垂直)
- 为了将数据从一个阶段传输到下一个阶段而不实现,操作重叠。
- 类似流水线操作
对于线性扫描A表,我们可以将它分成三份,由三个CPU核来扫描,扫描结果存入各自的hash表中。
Exchange操作源于火山模型,只有当它的子节点数据全部可用的时候,它才能给进行操作。
将两边的hash表进行JOIN操作。如果没有exchange来合并hash表,仅仅有一部分hash表,那么结果必然是错的。
对于JOIN操作,我们也可以将之划分成很多分,同时进行。
对于JOIN之后的SELECTION操作,它可以使用类似streaming或者管道的方式。将每一个产生的JOIN结果直接传入到SELECTION操作,而不是等到所有的数据都JOIN完毕,再进行SELECTION操作。
这种方式类似于CPU或者其他硬件的流水线操作,也非常适合与data streaming系统。
WORKER分配模型
根据CPU内核的数量,数据的大小以及worker的功能,提供合适数量的worker来完成查询计划。
方法1:每个core只有一个worker
- 每个core都分配有一个固定在该内核中的线程。
- 请参阅sched_setaffinity
方法#2:每个core多个工人
- 每个core(或每个socket)使用一组worker。
- 允许CPU core充分利用,以防core中的一个worker正在等待I/O或者其他什么的,导致进程堵塞。
任务分配模型
方法#1:push
- 有一个中央调度线程,它将工作分配给worker并监视其进度,它能够了解每一个worker的信息。
- 当worker通知调度线程完成时,调度线程会给予它新的任务。
方法2:pull
- 没有中央调度线程
- worker从队列中拉出下一个任务,处理它,然后转到下一个任务。
内存访问
不管DBMS使用什么样的worker分配或任务分配策略,worker能够使用存在本地数据,这是很重要。
大家可以类比分布式系统,我们不希望一台服务器存数据,另一台服务器通过网络来访问它。
DBMS的调度程序必须知道它的底层硬件的内存布局。
UNIFORM MEMORY ACCESS
用总线的方式做去维护内存。
每个CPU内核又有自己的local cache,也要维护cache,进行valdidation。
前10~15年,大部分的数据库系统就是这么设计的。
系统的优点是每个CPU核访问存储数据的时间比较稳定。
系统的缺点是每次只能有一个CPU核去访问存储的数据,因为总线会被占用。横向扩展能力较差。
NON-UNIFORM MEMORY ACCESS
这样设计会使得访问自己的数据非常快,访问别人的数据特别慢。
分布式系统可以解决UNIFORM MEMORY ACCESS的I/O瓶颈,从而达到水平扩展提升性能的效果。
数据存储位置
正如上面说的一样,我们不希望访问别的机器的数据。因此,DBMS需要将数据库分成一块一块的,即分区存储,并将每个分区分配给CPU。
通过控制和跟踪分区的位置,它可以安排worker在最近的CPU核心上执行工作。
请参阅Linux的move_pages。
MEMORY ALLOCATION
当DBMS调用malloc时会发生什么?
- 假设内存池没有可以发出的内存块。
那么,就会像外部扩展,向操作系统要。
操作系统分配器将扩展进程的数据段,但是仅仅是返回了一个地址而已,这个新的虚拟内存不会立即被物理内存支持。
只有当CPU去访问这块地址,并发生了page fault之后,操作系统再真正地分配物理内存。
MEMORY ALLOCATION LOCATION
page fault之后,操作系统在NUMA(NON-UNIFORM MEMORY ACCESS)系统中分配物理内存的位置在哪里?
方法1:Interleaving
- 在CPU之间均匀分配分配的内存。
方法2:First-Touch
- 访问导致page fault的内存位置的线程的CPU。
默认的是方法一,可以改成方法二,似乎方法二效率更高。
PARTITIONING VS. PLACEMENT
Partitioning方案用于分割数据库。
- 循环
- 属性范围
- 哈希
- 部分/完全复制
Placement方案告诉DBMS把这些分区放在哪里。
- 循环
- 穿过核心交错
将逻辑查询转化为任务
我们已经有了进程模型、worker分配模型、任务分配模型、数据存储策略。
那么我们应该如何将一个逻辑查询计划转化为一个或者一系列查询任务呢?
- 对于OLTP数据库更简单
- 对于OLAP数据库更难
静态策略
DBMS在生成计划时决定使用多少个线程来执行查询,那么执行查询时就不会更改。
- 最简单的方法是使用与核心数量相同的任务数量。
MORSEL驱动的调度
在跨核心分布的称为“morsel”的水平分区上,运行的任务的动态调度。
- 每个核一个worker
- 使用pull方式进行任务分配
- 循环数据放置
支持并行的,支持NUMA的操作符实现。
混合结构
没有单独的调度程序线程。线程为每个查询计划执行协作调度。
- 每个worker都有一个任务队列,由socket进行控制。它将在本地执行。
- 它从全局工作队列中提取下一个任务。
将数据进行分割,并分配给不同的核。
将操作结束产生的数据写回本地数据库。
假设前两个核先结束了,那么morsels知道,如果B的数据没有处理完是不能进行JOIN的,所以就从全局的任务队列中将B的处理了。
假设第一个提前结束,也是不可能进行JOIN操作的,那么就把B3分配给它。
在执行的时候,还需要去访问3的结果。虽然这样的访问比较慢,但是对于全局而言,执行该任务的最终时间将会缩短,或者对于每一个core而言,执行该任务的最终时间会是一样的。
因为每个core只有一个worker,所以他们不得不使用work stealing策略,否则某些线程可能会闲置等待。
使用无锁哈希表来维护全局工作队列。
- 我们将讨论下一课的散列表。
SAP HANA – NUMA-AWARE SCHEDULER
带有多个工作线程的基于pull的调度,这些工作线程被组织成组(池)。
→每个CPU可以有多个组。
→每个组都有一个软硬优先级队列。
- 软队列:别的组可以访问该队列,即,可以work stealing。
- 硬队列:别的组不可以访问该队列,即,不可以work stealing。
使用单独的“看门狗”线程来检查组是否饱和,并可以动态地重新分配任务。
每个线程四个不同的线程池:
→工作:积极执行任务。
→非活动:由于锁存而在内核中被阻塞。
→空闲:睡一会儿,醒来看是否有新的任务执行。
→停放:像空闲状态,但不自行醒来。
可以根据任务是CPU瓶颈(计算太慢)还是I/O瓶颈(读写太慢)绑定来动态调整线程锁定。
发现work stealing对于socket数量较多的系统来说并不是那么有利。
使用线程组可以让core执行其他任务,而不仅仅是查询。
参考文献
课件
What is the difference between SMP and NUMA architectures?