CMU15445数据库系统笔记.18221501
本篇文章是CMU 15445数据库系统的学习笔记,持续更新...
[课程视频 Fall 2021] | [课程主页]
LEC03. 数据库存储结构(上)
分层设计概述
设计任何大型系统时的一个常用手段是分层,数据库系统也可以被分成若干层,每一层处理自己的事情,向上提供简单的API隐藏细节。
举个例子,比如最底层的磁盘管理器负责处理如何划分、组织磁盘页,而由于磁盘上的数据只有在内存中才能被CPU处理,所以再上一层的Buffer Pool管理器便负责在需要时将这些页从磁盘fetch到内存中并保持一段时间。它可能做了很多工作,比如当fetch一个新页,但内存不够需要踢出一个旧页时,如何做出正确且最好的选择。而对于上层来说,它只跟Buffer Pool管理器提供的API交流,它们甚至可以认为我们有一个无限大的内存,数据全部存在内存中(是不是有点像操作系统课程中学过的虚拟内存)。
虽然数据库底层的DiskManager只负责划分和组织磁盘页,但LEC03的内容不止于此,涉及到:
- 将页面在文件中布局的方式(如何通过页号找到页面,如何管理空闲空间)
- 将tuple在物理页中布局的方式
- 将数据在tuple中布局的方式
顶层视角
下面我们来从磁盘管理器和Buffer Pool管理器的顶层视角来看一下它们是如何工作的
- 首先我们注意到,页一定是需要通过某些方式编址的,这个地址就叫页号
- 其次,我们要有一个页号到物理页位置的映射,一般来说会在一个公知的磁盘页上保存这些映射,这个磁盘页就叫页目录
- 外界通过页号来访问Buffer Pool管理器,Buffer Pool管理器会查看当前该页是否被加载到内存中,是否过期等,如果需要的话,就访问磁盘把对应的页fetch出来
- fetch任何页之前,肯定要先fetch页目录,页目录基本是要pin在Buffer Pool中的
如何在文件中组织页面
在本节中讨论如何在文件中组织数据库页面,涉及页面的新增、获取、写入和删除操作,并且我们的组织方式必须支持所有页面的遍历(考虑全表扫描场景)。我们需要有一些元数据来跟踪我们有哪些页面以及哪些页面具有空闲空间。
常见的组织方式有三种:
- 堆文件组织
- 顺序文件组织
- Hash文件组织
这里主要介绍堆文件组织
堆文件是一个页面的无序集合,有两种方式实现堆文件:
- 链表
- 页目录
堆文件:链表实现
使用一个公知位置的page作为header page,其中只存储两个指针,这两个指针构成两个链表,一个是具有空闲空间的页面组成的列表,一个是已经完全满了的页面组成的列表。
当向一个页面写入时,我们需要遍历Free PageList,找到第一个能够满足我们写入请求的页面。
在寻找一个页面时,我们没有任何元数据,所以也只能遍历寻找。
堆文件:页目录实现
使用一个公知位置的page作为页目录,它维护指定页号的页面在文件中的位置。
页目录同样保存了每一个页面具有多大空间的元数据。
DBMS必须确保在页发生变化时,页目录同步更新。在使用链表时,页面的元数据是在页内部保存的,只要页能原子写入就不存在元数据和页实际情况不符的时候,但页目录必须处理这种情况。
这里只介绍了高层次的思路,但没有讨论细节。实际上很多东西可以讨论,比如一个页面做页目录如果不够了咋办?如何能减少物理页的数量(提高页大小)?如何组织页目录让它对CPU缓存友好?
页结构
页头
页面有一个头部来描述页面的一些元数据,比如页面大小、checksum、用于兼容版本升级的version号、事务可见性信息、压缩信息等等。一些高级的数据库系统要求页面是自包含(self-contained)的,以提供更强的容灾,此时可能还要在头中存储一切自包含信息(比如表的schema)。
数据
数据库系统可能有很多不同种类的页,比如有的会存数据、有的存索引、有的存日志......我们这里只讨论存储数据的数据页。实际上在很多系统中虽然有很多种页面,但它们都被像数据页一样的方式处理,即将要存储的内容也作为tuple。
一个简单的存储tuple的方式就是保存页内的元组数量,然后只是一个接一个的存储:
这种方式有几个问题:
- 若每个tuple长度不一致(变长列),删除tuple时可能产生碎片
- 如果你将tuple在页内的绝对偏移返回给上层用于定位,那你就不能进行碎片清理
- 如果你将tuple的与位置无关的编号返回给上层,则查找时需要在页内对所有tuple进行遍历
一种更常见的,被商用数据库使用的方式是槽页:
- 在页面顶部存储一些固定长度的槽
- tuple则被存在页面底部
- 一旦有了一个tuple,就会有一个槽指向它
- 返回给上层的是槽号,这让我们可以随意的对页面进行碎片清理,只需要更新槽中指针就行,槽对上层屏蔽了这些动作(就像页目录向上层屏蔽了页的物理位置)
数据库系统的上层使用
pageid + slot/offset
作为元组的唯一记录id(record identifier)。具体场景:查询名字叫andy的教授,我们会查name列的索引,索引中包含对应记录的(pageid, slot)
,可以拿它去表文件中找。
PostgreSQL中的CTID、Sqlite和Oracle中的ROWID都是这样做的,InnoDB的索引组织表好像使用了完全不同的方案,这种场景下需要两次索引查询。
LEC04. 数据库存储结构(下)
日志文件组织
顺序的向页面中追加每一个数据库操作
这种组织方式的写入很快,因为都是顺序IO,但在读取时则需要通过向上扫描日志来重建我们需要的元组。比如有如下日志:
INSERT id=1, col1=a, col2=b
// a lots of log entries
UPDATE id=1, col1=z
我们必须从最后一个UPDATE读到最前面才知道id=1
的这条记录的所有字段内容。
加速读取:
- 建立索引,以可以直接找到对应id的一批记录(但顺序写入的优势就降低了)
- 定期压缩,日志文件组织随着时间推移肯定会有很多无用的内容,需要定期压缩以减少向上扫描时的浪费
使用日志文件组织的数据库:HBASE、cassandra、levelDB、RocksDB
固定精度数字带来的问题
由于浮点数的精度问题,数据库系统不得不为用户实现固定精度的小数——NUMRIC
、DECIMAL
。
用户可以指定固定精度小数的整数位数和小数位数,但由于CPU中并没有一个像浮点数那样的电路来执行固定精度小数之间的计算,所以这些计算都是在应用层用大量代码来完成的,它的性能肯定不如浮点类型。
溢出页
一般数据库不会允许一个元组或者说一个元组中的列大于整个页面的,数据库一般会在列个数和列长度之间做出限制。
但若还是存在一个元组或一个列可能超过一个页大小的情况(BLOB、TEXT),对应的内容就会被存在溢出页中,而元组中的列则保存一个到溢出页的指针。
一个值可能占用多个溢出页,溢出页之间可以组织成一个链表或以什么其它方式组织。
InnoDB的压缩行模式中貌似对于VARCHAR等数据,超过一定长度就会存到溢出页中,这样有好处也有坏处。好处就是一个数据页容纳的数据量更多了,坏处就是每次取这个溢出列的值都是一次随机IO。
一些DBMS允许你将非常大的值存储在外部文件中,而不是页面内,比如Oracle的BFILE
类型。
System Catalogs
System Catalogs保存数据库中的一些元数据信息,包含:
- 表、列、索引、视图的元数据
- 用户、权限的元数据
- 内部统计数据
大多数系统会将这些元数据信息包装成和数据表一样的关系、元组形式供用户使用,如MySQL的information_schema
(这好象是一个ANSI标准),同时有特殊的代码来驱动这些特殊的库表。
数据库工作负载
数据库可能用于处理不同的工作负载,这对如何构造实际的存储系统有巨大的影响,数据库领域常见的工作负载如下:
- 在线事务处理(OLTP):每次只读/更新少量数据的快速操作,重点在于处理大量这样事务的并发。
- 在线分析处理(OLAP):读取大量数据并计算聚合的复杂查询
- 混合事务分析处理(HTAP):OLTP + OLAP在一个数据库实例中
常见的公司的做法是:
- 部署OLTP数据桶 + OLAP数据仓
- 所有事务在OLTP侧发生,通过ETL(提取、转换、加载)过程被周期性推送到OLAP数据仓(比如一天一次)
- OLAP进行分析计算,得出一些分析结果
- 如果需要的话,也有可能将分析结果推送回OLTP侧
面向行的NSM(OLTP)
N-ARY存储模型(NSM)是指DBMS在一个页面中连续地存储一个单一元组的所有属性,非常适用于OLTP这种只对独立实体操作且重写入的工作负载。
考虑下面的查询:
SELECT * FROM t_user
WHERE username = 'xxx';
这是OLTP应用的常见查询,DBMS首先通过在username
列上的索引找到对应记录所在的页面(也许是pageid, slot
),然后只再需要一次读取就可以一次性读取该数据的全部内容了,因为所有属性都被连续的存在了一起。
考虑下面的查询:
SELECT COUNT(U.lastLogin),
EXTRACT(month FROM U.lastLogin) as month
FROM t_user AS U
WHERE U.hostname LIKE '%.gov'
GROUP BY EXTRACT(month FROM U.lastLogin)
该查询找出所有hostname以.gov
结尾的用户,将它们根据最后登录的月份分组,最终导出每一个月份有多少官方人员最后一次登录的统计。
在类似这样的统计查询中,用户通常只关心某些列而不是全部列,如果使用NSM,一个页面中大部分数据可能是你不关心的,这样一次IO读取的收益就很低了。
NSM优势:
- 插入、更新以及删除都很快,数据都在一个地方,找到了写就完事了
- 很适合OLTP这种大部分查询需要返回整个元组的
劣势:
- 不适合扫描表中大量数据
- 不适合只读取元组的子属性集
面向列的DSM(OLAP)
分解存储模型(DSM)是指DBMS将元组的所有单一属性连续地存储在一个页面中,适合OLAP这种写入少且需要在表的子属性集上做大量扫描的工作负载。
再次考虑刚刚的查询:
SELECT COUNT(U.lastLogin),
EXTRACT(month FROM U.lastLogin) as month
FROM t_user AS U
WHERE U.hostname LIKE '%.gov'
GROUP BY EXTRACT(month FROM U.lastLogin)
这次,DBMS只需要找到保存hostname
和lastLogin
列的页面,读到的都是它所需要读的数据。
将列单独存储的额外代价是:必须有一个策略识别每一列的每一个值来自于哪个原始行,否则,像上面的SQL,你利用hostname
列过滤出了最终需要统计的用户,但你怎么知道过滤出的是哪些用户?如何和lastLogin
列关联?
元组识别:固定长度offset
对于一些列,比如它的类型是TINYINT
或INT
这种定长列,可以直接通过 第几条记录 * 字段长度,得到某一行的某一列在对应列的页面中的偏移量。
对于变长列,你必须使用注入填充列到最大长度等类似手段,所以感觉不太适合变长列。
元组识别:内嵌ID
在存储列时并非只存储列值,对于每一个列值还需要存储其对应的行id以便追溯。
这种方式虽然保存了一些冗余数据,但对变长列友好,而且由于和列长度、位置无关,我们可以对列执行任何的排序、压缩、优化等处理,以减少全部数据占用的空间,并提升系统的查询性能(尤其是对于OLAP分析负载)。
相比于NSM,DSM好像针对每一个列建立了独立的索引(所以它写入不行),并且不保存原始表数据
优势:
- 允许DBMS只读它需要的部分,降低I/O浪费
- 对于查询处理以及数据压缩更有利
- 比如列被排序存储,以在查询分析时使用二分法
- 比如相同的列可以只保存一份
劣势:
- 单点查询、插入、更新和删除慢,因为元组被分割了
LEC05. 缓冲池
之前的两次Lecture讨论了DBMS如何将数据库以文件形式保存在磁盘上,但磁盘上的数据最终必须要进入内存才能被DBMS软件所使用,而BufferPool,便可以看作是磁盘页在内存中的管理器,负责处理页面在磁盘和内存中来回的移动。
为什么重造轮子?
如果学过OS,应该知道操作系统中是有一个buffer cache在做我们相同的事。但一般的DBMS不会依赖OS的buffer cache,它在打开文件时会使用O_DRIECT
选项来告知OS绕过其提供的缓冲池,直接将磁盘页读到应用的内存中。
这样做的原因是,在通用OS设计的任何设计决策上,其考虑的必然是通用性,而通用代表它一定不会专精于某一个方向。
对于DBMS管理的对象——数据库,OS肯定没有DBMS对其理解更加深刻,比如:
- 哪个页面现在换出可能对性能造成巨大冲击
- 在做索引扫描时我应该预取哪些页面会带来更优的性能
OS没有这些知识来替它做决策,DBMS总能做的更好。
同时,andy教授在早几个lecture中也提到
mmap
这个系统调用。它的观点是这个系统调用可能在初期看来能让DBMS实现起来非常方便,但很多现实的商业系统(如MongoDB)的实际案例都说明,开发DBMS的内存管理子系统最好还是不要依赖这个东西。OS给你提供了一套API来让它尽量按照你的预想来操作,但它们往往会把事情搞得更加复杂。
缓冲池优化
多缓冲池
多缓冲池是指在DBMS中有多个独立服务的缓冲池实例,一般来说可以通过如下方式来区分:
- 每个数据库一个缓冲池
- 每种页类型一个缓冲池
多个缓冲池允许独立的更多并发流,可以提升系统性能,并且可以针对不同缓冲池不同的负载做单独的优化。
预取
假设下面是一个数据库文件:
假设现在正在对该库中的某个表进行全表扫描,该表的所有页面都要被读取,我们假设page0
到page5
都是该表的页面,在这种情况下,相比于老实的每次取一页到缓冲池中,DBMS可以提前预取一些页面,毕竟那些页面稍后肯定会被读到。比如在读page0时将page1和2也读取进来。
DBMS可以执行一次预取,然后会对这些取到的页面做计算,此时IO线程可以继续去取后面的页面,这样就不会有浪费的IO stall。
当然,缓冲池当前的情况也是预取时的一个权衡因素,如果数据库此时的负载很高,缓冲池近乎被占满,此时预取页面并pin在内存中可能会导致其它事务的延迟,需要权衡。
考虑索引扫描负载,假设我们要查询索引列>100的那些行,我们应该走的路径是page0
、page1
、page3
、page5
、page6
。这里的重点是BufferPool在预取时应该避开那些不需要被读到的页面,比如page2
、page4
。
索引预取行为也是一个为什么OS的buffer cache不如DBMS自己实现Buffer Pool的一个案例,因为OS不知道索引的结构。
我认为从纵向的层次结构上来说这里的内容已经不是Buffer Pool要实现的了,或许是利用Buffer Pool的上层要实现的内容
预取并非无成本的,计算出要预取的页面、以及预取页提前占用的资源都是成本,这些内容超出了本Lecture的范围,商业系统的BufferPool非常复杂,而且为了满足各种负载,通常有复杂的参数进行调优。
扫描共享
扫描共享的基本思想是,当有多个查询需要执行相同的页面扫描,可以让这多个查询共享这次扫描,多个查询附着在一个表扫描的游标上,而不是进行多次独立的IO读取。
举例,考虑两个查询:
- 查询班级里有多少年龄为20岁的学生
- 查询班级里有多少名字为李开头的学生
如果没有任何索引,这两个查询都将扫描表的每一个页面,我们假设第一个查询先开始执行,它扫描到了第2页,此时第二个查询到达,此时:
- 它附着在前一个查询的游标上
- 每读取一个页面后,这两个查询都需要读取页面的内容进行计算,直到第一个查询完结
- 由于第二个查询是中间插入的,还有没扫描到的页面(页面0和1),它需要继续扫描
扫描共享节约了磁盘IO的读取次数,但每次读取后都要等待所有附着的查询处理完成,这可能给其它查询带来一些额外的延迟,也可能让页面占用缓冲池的时间变长,设计DBMS时需要权衡考虑。
考虑极端情况下,如果你有持续的,很多的全表扫描负载,此时可以让一个后台线程从头到尾进行扫描,然后让进来的查询附着在这个扫描的游标上,没赶上的就等下一轮。
缓冲池旁通
最后一种缓冲池的优化,即——不使用缓冲池。
该优化适合于全表扫描之类的负载,因为全表扫描会带来大量页面,可能远远超过缓冲池大小,结果就是可能将缓冲池中的全部页面冲掉(如果你的页面淘汰策略没那么智能的话)
MySQL的InnoDB并没有使用缓冲池旁通来解决这一问题,而是使用中间插入的LRU淘汰算法,即页面刚进入Buffer Pool时进入的是LRU链表的中间部分,只有它们稍后真的成为热点,才会被挪到前面(page made young),越前面的页越不容易被替换。
替换策略
替换策略是:当缓冲池满了,有一个新页面要被加载到内存中,该用哪个已经在内存中的页面应该被扔掉
目标:
- 正确性:不能扔掉不该扔掉的页,导致数据库数据不一致,比如我们尚未写出的脏页
- 准确:我们希望尽量扔掉最该扔的页面
- 速度:(思考应该扔掉哪个页面的)算法应该很快,至少它的运行时间和它带来的收益比应该很高
- 元数据过载:算法运行所需要的元数据不应该占用过多的空间
LRU
老生常谈,略
时钟算法
老生常谈,略
LRU-K
考虑现在有三个查询Q1、Q2、Q3依次到达
- Q1向缓冲池中加载了page1,然后休眠了
- Q2顺序扫描到page3时,缓冲池满了,需要替换出timestamp最小的页面,也就是page1
- Q3到来,page1又需要加载进缓冲池
总结来说,Q2是一个大型的扫描,它加载很多页面到缓冲池并且只用一次就走了,它会污染缓冲池,Q1和Q3对page1的读取会发生抖动。
问题在于只是这样应用简单的LRU算法,使用最后访问的时间作为唯一评判标准太过武断。
LRU-K记录了数据最后K次被访问的时间戳,这样你会得到一系列时间间隔,此时便可以一定程度上断定该数据的访问模式,这样我们的评判指标中就有了一个该数据未来多久可能会被访问。
本地化策略
事务/查询只选择它自己加载的页面来替换,这最小化了每一个查询对buffer pool的污染。
需要跟踪查询访问的页面
优先级提示
DBMS的上层比缓冲池层对数据的存储模式更为了解,比如当访问一个B+树索引时,上层更知道未来将会访问哪些页面,上层可以将这些知识下推给缓冲池层,以让它做出更好的决策。
比如在执行上面的id递增的插入时,数据库知道所有要加载的页面肯定是B+树右侧的路径,它就可以将这些知识推给buffer pool,让它尽量将这些页面pin住。
脏页
对于缓冲池的替换策略:
- 非脏页:剔除非脏页时,你不用做任何额外的磁盘写,直接扔就行,所以很快
- 脏页:剔除脏页时需要考虑将该脏页写入磁盘,所以较慢
替换策略总要在剔除更快的非脏页 以及 剔除较慢但未来很久不会被访问的脏页中做抉择。
一个折中的办法是:DBMS可以使用后台线程对脏页进行定时的写回,以释放buffer pool空间;而替换策略如非必要都不会剔除脏页。InnoDB中貌似是这样处理的。
其他内存池
- 排序 + Join Buffer
- 查询缓存
- Maintenance Buffer
- 日志Buffer
- 目录缓存
MySQL中除了BufferPool也有很多其它的内存池,比如查询缓存、日志Buffer
LEC06. 哈希表
- 在为DBMS设计数据结构时,数据结构可能存储在磁盘页上
- 在为DBMS设计数据结构时,并发往往是一个重要的考虑因素
- 在DBMS要处理的数据量级上,算法时间复杂度的常数因子很重要
- DBMS中的hash表往往要实现动态扩缩容,因为表的数据是可以任意增长的
Hash函数
一些常见的Hash函数:
这些哈希函数的benchmark,值得注意的是一些hash算法在key长度正好与缓存大小成倍数关系时(缓存对齐)性能有急剧的攀升:
这个Lecture没啥好记的
LEC07. B+树
- 重复key存储:正常存储两个key / 溢出叶子节点(不推荐)
- 聚簇索引:整个表存储为一个以主键作为key的索引,叶子节点中存储tuple的全部列。基于主键的顺序扫描快。
- 非聚簇索引:根据指定列做key进行索引,叶子节点中存储recordId或其它能够定位到原tuple的标识符。这种索引的顺序扫描,每一个索引节点指向的页面是随机的,通常的优化方式是先扫描,然后根据页号排序,再去查询(InnoDB中貌似称作MRR优化?)
- 索引合并:当节点过于空的时候(由于删除操作)需要对索引进行合并,以减少空间利用率并提升查询性能。一般系统不会使用激进的节点不到半满立即合并方式,都是会延迟合并,避免刚刚合并好的节点又因为插入操作而分裂开
- 可变长key,如何更好的管理空间
- 指针 / 可变长度节点(直接存) / padding / key映射(类似数据页中的槽数组)
- In-Node查询:除了要考虑在磁盘上的索引页面定位,当索引页被加载进内存,如何进行页内索引数据的检索也是很重要的
- 线性扫描
- 二分查找(需要在页内进行某种数据结构的布局,InnoDB采用了类似的手段)
- 插值(按照已知的key分布来预估预期的位置)
- 前缀压缩:在相同叶子节点中的key很容易具有相同的前缀,此时可以做一些前缀压缩优化
- 去冗余:如果索引是非unique的,会有很多key值重复的keyvalue对在leafnode中,此时可以只存储一个key
- 创建新索引时的批量插入:先对key排序,然后自底向上构建(避免插入时每次都从树根查找,并且能尽量保证构建好的B+树叶子节点完全相邻)
书籍推荐:Modern B-Tree Technique
LEC08. 索引并发控制
为什么需要并发
在一个查询中,DBMS经常要IO阻塞,它的计算和IO操作比例是很低的,若不并发,DBMS大部分时间都其实什么都没干,只是等待IO。
近一段时间的内存数据库系统则采用完全不同的思路。有研究表明DBMS的相当可观的一部分时空成本用于并发控制了,而基于内存的数据库没有IO阻塞,所以对于它们更加常见的做法是将数据库划分成多个独立的子区域来去除并发控制。极端的例子是Redis。
Locks vs. Latches
数据库中这是两个截然不同的概念
- Locks:作用于数据库向用户提供的逻辑对象上,被事务持有,在事务之间提供保护,需要考虑回滚变更
- Latches:用于对数据库内部数据结构进行并发保护,不需要考虑回滚变更
如何实现锁
- OS Lock:利用mutex等OS提供的锁机制
- 不可扩展
- 重,即使有futex支持也可能进入内核(即使单个加解锁操作很快但会累积)
- TAS自旋锁:
- 完全用户空间,非常高效
- 同样不可扩展
- 缓存不友好
- OS不友好
- 并发情况下CPU重负载
- 读写锁
- 允许并发读(读可扩展)
- 需要管理饥饿问题(考虑有10个线程持有读锁,现在同时来了一个读一个写请求,给谁锁)
- 可以以多种方式实现(spin、sleep)
Hash表锁
- 页面级锁:每个页面有一个读写latch
- 槽级锁:每个槽有自己的latch,由于粒度已经非常细了,可以不使用读写锁,只使用普通的锁以避免元数据膨胀
B+树锁
B+树的保护可能没有那么容易,因为进行一次操作可能要涉及若干个节点的变动,比如下图中删除44:
- 真的删除44
- 从H中将41移过来保证平衡
- 修改父节点D
B+树的仅仅一个操作修改了3个节点
考虑并发线程:
- T1:删除44
- T2:查找41
经历如下时序:
T1 T2
从I中删除44
查找D节点,发现自己要找H
从H中移走41
修改D节点
在H节点中没找到41,以为未命中
一种允许多线程访问B+树的锁协议是——Latch Carbbing/Coupling
,其核心思想如下:
- 获取parent的锁
- 获取child的锁
- 如果“安全”,释放parent的锁
“安全”意味着在更新时不会拆分或合并节点,比如插入时节点未满或删除时没超过半满
例子,删除38:
- 获取A的写锁
- 获取B的写锁,由于不知道下面会发生什么,暂时不能释放A的锁
- 获取D的写锁
- 发现删掉一个38不会使D和旁边的C合并,所以释放祖先的锁是安全的
- 获取H的写锁,释放D的锁
- 删除38
- 释放H的锁
在一次性释放所有祖先的锁时,顺序不会对正确性带来影响,但是B+树靠上的节点被访问的多,root则是整棵树的终极热点,所以为了性能,通常更早的释放上面的节点
这种锁存算法一定程度上允许B+树并发,但是对于任何更新,第一件事都是获取根节点的写锁,这仍然可能成为性能瓶颈。
一种更好的算法基于这样的假设:大部分的B+树修改都不需要分裂和合并,尤其是对于更加顶部的节点。基于这个假设,可以采用乐观策略,即假设不会发生分裂,仅使用读锁来保护我们遍历树的路径,当发现我们猜错了,改用悲观策略重新遍历一次。
LEC09. 排序和聚合
何时需要排序
首先,关系模型/SQL是不对数据的存储顺序做任何保证的,获得这种保证的唯一途径就是使用ORDER BY
字句来进行查询,可以指定DBMS将返回结果按照一个或一组属性进行排序,这是我们要求的显式排序。
其次,DBMS也可能做隐式排序,比如在执行DISTINCT
和GROUP BY
字句时,DBMS会隐式的对对应的列应用排序。
外部归并排序
- 基本思路:将待排序数据分割成独立的runs,将每一个runs分别排序,然后将有序的runs合并成一个更大的有序runs,最终达到整体有序
- 一个run是一个键值对集合,key就是排序属性,value可以是整个元组(early materialization)或者是record id(late materialization)
- value存储整个tuple,会导致排序的中间产物会很大,如果runs大小是固定的,这意味着我们需要更多的runs,也就是更多的排序-合并次数,意味着更多的IO读写
- value只存record id,会导致排序后我们还要使用record id进行实际元组的获取,这是大量的随机IO。若record id是类似page id + slot num,可以将所有record id进行排序后顺序读取
完整的待排序数据占用\(N\)个页面,DBMS具有有限的\(B\)个buffer pool页面,我们每次将\(M\)个runs排序并合成一个更大的runs
完成排序最少需要多少内存页(B的数量)
与每次要合并的runs数量有关,假设每次合并两个runs到一个更大的runs,则最少需要3页。
- 初始时,runs大小为页面大小,假设\(N=4\),页号分别为1234
- 加载page1到内存,排序,写回到page1'
- 加载page2到内存,排序,写回到page2'
- 加载page1和page2到内存,合并,写回到M1(2页大小)。由于合并,这个操作需要3个内存页
- 对page3和4执行同样的操作,得到M2
- 读取M1前半部分,和M2前半部分,合并,过程中有可能M1或M2的前半部分读完了,读后半部分继续归并,最终得到结果R
可以看出,在每次合并两个(2-way-merge)时,最多需要3个内存页,在每次合并k个时(k-way-merge),最多需要k+1个内存页,因为总是需要额外的一个进行归并,归并页满了就可以写回磁盘一次然后清空继续归并
k-way merge扩展
- 第0趟:
- 使用\(B\)个内存页面,产生\(\lceil N/B\rceil\)个已排序的runs,每个runs大小是\(B\)个内存页
- 后续趟:
- 每次合并\(B-1\)个runs(k-way merge需要k+1个内存页)
- 趟数:\(1+\lceil \log_{B-1}\lceil N/B\rceil\rceil\)
- 总I/O次数:\(2N \times (总趟数)\)(每一趟都要对N个页面进行读写)
示例:
并行归并
不难发现在进行归并时可以并发,因为不同的runs之间归并是不互相影响的,但是越往下合并线程数量越少,因为可归并的runs越少。另外,每多一个并发线程,需要的内存页就增加一倍。
双缓冲优化
在内存中执行排序时,如果有更多的buffer pool空间,可以使用后台线程读取并存储下一个runs,以让DBMS在处理完当前runs时无需等待磁盘读取IO,直接继续工作。
同样的思路,我们也可以准备多个归并runs,当一个被合并操作填满,我们立即转向下一个,然后由后台线程将该runs写入,这样就无需等待磁盘写入IO。
发现数据库系统的一个设计思路就是利用当前计算的时间利用多核能力偷偷执行后续计算依赖的IO读取,以让计算尽量不产生IO阻塞
聚合操作
如group by、distinct等聚合操作可以利用排序完成
- 若整个查询中涉及ORDER BY排序,可以利用外部归并排序完成
- 否则,可以用外部hash聚合来完成
外部hash聚合
- 分区:对于每一个元组,利用Hash函数\(H_1\)将其输出到指定的分区中
- 可以用\(B\)个内存页来做这件事,我们就有了\(B\)个分区,当某个分区的内存页满了,可以写出磁盘,清空内存页,继续进行
- 我们还必须有1个额外的内存页来读取表
- 最终我们得到了\(B\)个具有若干页的分区,所有经过\(H_1\)计算,hash值相同的key都在其中,而且相同的key必然在一个分区内
- 重hash:对于每一个分区内的所有key,使用Hash函数\(H_2\),进行内存聚合计算
在最坏情况下,假设\(B=4\),有4个分区,每个分区的大小很大,且内部的key几乎没有重复,此时可能无法在内存中进行重hash。课程中只是假设了每一个partition都能加载进内存,不过貌似我们可以针对第一步后得到的每一个分区继续应用二次分区,直到最终得到的每一个分区都能加载进内存(那如果再极端情况,几亿条数据的聚合key都相同呢)
例子:
阶段一,分区:
阶段二,重hash:
阶段二中,左侧的三个页面来自同一个分区(在阶段一的图中没画出来),这里利用\(H_2\)建立内存hash表,并进行聚合操作,得到最终结果。如果要计算
COUNT
、SUM
等聚合操作,可以利用hash表的value来保存对应计算内容。
建立分区之后,相同的key肯定在同一个分区中,好处就是你在计算聚合操作时可以只关注当前分区
这种两阶段的hash消除了在磁盘上构建巨大hash表所造成的大量随机IO,它首先将大hash表分区成若干不相交的较小hash表,然后再在内存中进行hash
LEC10. JOIN
算法
假设参加Join的两个关系:
- \(R\),具有\(M\)个页面,\(m\)个元组
- \(S\),具有\(N\)个页面,\(n\)个元组
现在要计算\(R \times S\)
循环Join
基于元组
foreach tuple r in R:
foreach tuple s in S:
emit, if r and s match
这种方式非常愚蠢
- 平方级别的复杂度
- 对于每一个外层元组\(r\),都要对\(S\)进行一次完整的遍历,相当于读取\(S\)的每一页,这是大量的IO
IO次数:\(M+(m\times N)\)
如果DBMS具有\(M,m,N,n\)这些信息,那么它可以利用这些知识来选择哪个关系在循环的外部可以带来最小的IO次数
基于块:
foreach block Br in R:
foreach block Bs in S:
foreach tuple r in Br:
foreach tuple s in Bs:
emit, if r and s match
这种方式比上一个强点:
- 平方级别的复杂度
- 对于每一个外层块\(Br\),都要对\(S\)进行一次完整的遍历。这比上一个算法强多了。
IO次数:\(M + (M\times N)\)
后面的乘法是可交换的,但是前面的\(M\)越小总体IO次数越小,虽然\(M\)和后面的项比起来不起绝对性支配作用,但总的来说DBMS还是可以通过选择内外层的方式来节省一些IO次数。页面少的表应该在外面。
利用更多页:
在上面Join算法中,我们要有两个输入页面,一个用于保存来自于\(R\)的数据,一个用于保存来自于\(S\)的数据,还要有一个输出页面来输出匹配的tuple。
假设我们有\(B\)个可利用的缓冲池页面,如果我们使用更多的页面,比如一次读两个外层页,这样就是对于外层表的两个页,读取内层表所有页面一次,IO次数就可以少几乎一倍。
当有\(B\)个缓冲池页面时,最多可以一次读取\(B-2\)个外层表页面
IO次数:\(M + (\lceil \frac{M}{B-2} \rceil \times N)\)
上面三种算法,CMU给出了一个例子:
- \(R: M=1000, m=100000\)
- \(S: N=500, n=40000\)
- \(IO time: 0.1ms/IO\)
基于元组循环:1.3h
基于块循环:50s
B-2块循环(外层完全能够装载进内存的情况):0.15s
索引循环Join:
假设某一个表上Join的匹配键有索引,可以把它放在内层,对于外层的每一个匹配键都去内层表的索引中搜索。
假设索引探测的时间是常数\(C\):
IO次数:\(M + (m \times C)\)
这种算法不管数字上看起来是好是坏,首先它全是随机IO。我们在刚刚的讨论中忽略了顺序IO和随机IO。实际上这个算法的成本是很难预估的。
总结:
- 使用更小的表作为外层
- 尽可能多的缓冲外层表的页面
排序归并Join
排序归并Join分为两步:
- 排序:将两个表分别根据Join Key排序
- 归并:遍历排好序的两个表,发射匹配的元组(可能需要回溯)
sort R,S on join keys
cursor_r <- R_sorted, cursor_s <- S_sorted
while cursor_r and cursor_s:
if cursor_r > cursor_s:
increment cursor_s
if cursor_r < cursor_s:
increment cursor_r
elif cursor_r and cursor_s match:
emit
increment cursor_s
IO次数:\(总排序IO次数 + (M + N)\)
使用外部归并排序,排序IO次数(R):\(2M \times (1 + \lceil \log_{B-1}\lceil M/B \rceil \rceil)\)
使用外部归并排序,排序IO次数(S):\(2N \times (1 + \lceil \log_{B-1}\lceil N/B \rceil \rceil)\)
排序IO次数与实际使用的排序算法有关,最少要M + N(当M和N完全能够被装载进内存进行排序)。若使用外部归并排序,则IO次数前面要乘一个关于总页面数量的对数函数。不论如何,这种算法相较于嵌套循环的平方级别的复杂度已经更加优秀了。
还是基于之前的例子:
- \(R: M=1000, m=100000\)
- \(S: N=500, n=40000\)
- \(IO time: 0.1ms/IO\)
总耗时:0.75s
排序归并Join的时间复杂度看起来相对较小,但它需要额外的空间,以及大量的磁盘写去写入中间内容。如果你有一个或两个表已经在特定的joinkey上排序了(比如存在索引或用户指定必须在该key上排序)那么该算法的成本就很低了。
Hash Join
简单Hash Join
build hash table HT_r for R
foreach tuple s in S:
emit, if hash(s) in HT_r
布隆过滤器优化:
build hash table HT_r
bloom filter bf_r for R
foreach tuple s in S:
h = hash(s)
if h in bf_r:
// 由于布隆过滤器存在假阳性,所以还需要判断h是否在hash表中
emit, if h in HT_r
这种方式没人会想要用的,随机IO次数不可控...
Grace Hash Join
循环嵌套算法的一个缺点就是,对于外层循环R中的一个页,我们不知道S中的哪些页中存在和它匹配的元组,所以我们在内层中每次都要扫描全部的页面,带来平方级别的IO次数。
Hash Join基于这样的原理:
- 如果元组\(r \in R\),元组\(s \in S\)匹配,则它们在join属性上的值肯定相同
- 如果这个值经过某个hash函数分配到分区\(i\),那么\(R\)中的那个元组肯定在\(r_i\)中,\(S\)中的那个元组肯定在\(s_i\)中
- 只需要比较所有对应的分区即可,即对于每一个\(i\),比较\(r_i\)和\(s_i\)
build hash table H_r for R
build hash table H_s for S
for i in partition_numbers: // 遍历每一个分区
for b_ri in r_i: // 遍历r_i中的每一个页
for b_si in s_i: // 遍历s_i中的每一个页
for t_ri in b_ri: // 遍历b_ri中的每一个元组
for t_si in b_si: // 遍历b_si中的每一个元组
emit, if t_ri and t_si are match
Hash Join的IO次数和键的分布有很大关系,考虑极端情况下所有键都相同,则退化成最基本的嵌套循环。不过DBMS的统计数据应该能支持它在合适的情况下选择HashJoin。
IO次数(AVG,假设分区正常,最终每一个分区都能被载入内存):\(3 \times (M+N) = 分区IO + 探测IO\)
还是基于之前的例子:
- \(R: M=1000, m=100000\)
- \(S: N=500, n=40000\)
- \(IO time: 0.1ms/IO\)
总耗时:0.45s
总结
LEC11. 查询执行(上)
前面几乎介绍了DBMS要实现的每一种操作符,本节和下一节的内容是如何将它们串在一起组成查询计划,本节讨论基础,下一节加上并发。
迭代器模型(火山模型)
每一个操作符实现一个Next
方法,操作符之间组成一个树,每一个操作符调用子节点的Next
获取元组,并执行自己的逻辑,向上层发射元组。如下图:
- 顶层的投影操作符从它的子节点中获取每一个元组,应用投影,然后发射给上层。最顶层发射的东西最终就是查询结果
- 第二层的Join操作符使用了Hash Join,它首先调用左侧的子节点的
Next
来构造Hash表。然后,调用右侧子节点的Next
来获取来自右侧的元组,并进行Hash探测,若JoinKey存在于Hash表中,便向上层发射Join后的元组 - 最左侧的操作符是一个读取关系操作符,它将读取关系R中的每一个操作符并向上层发射
- Join右侧的子节点上的过滤运算符会调用子节点的
Next
,应用过滤条件,并在满足条件时向上发射 - 最右侧的操作符和最左侧的一样,不过是读取S中的关系
迭代器模型的好处:
- 给操作符实现者隐藏了很多细节,暴露一致清晰的接口
- 可以流水线化工作,即底层的工作并不一定非要做完,只要emit了,上层便可以开始处理(有些操作符比如第二层的HashJoin需要等待一侧完全完成)
- 整个处理可以更好的利用数据的局部性,比如获取一个页面后,它可以从最底层流水线到顶层,而不是等一个操作符完全处理完,上层再处理,这样可能需要重复读取页面
- 貌似很容易并发,比如底层使用后台线程加载页面,上层感知不到底层带来的IO停顿
- 迭代器调用是从顶层出发的,除了类似Join这种操作,如果顶层不再调用
Next
,整个操作就停止了,这对于limit
等操作符来说很方便
疑问:
- 数据库系统中的很多优化经常是要上下层联动才能搞起来的,这种模型下上下层怎么交互
- 之前的课程中,很多操作符的实现实际上和块非常相关,这种基于元组的模型实现起来会不会有诸多限制(比如嵌套外部归并排序)
使用该方式的系统:
物化模型
每一个操作符一次性处理它的输入,并且一次性执行输出
- 操作符将它的输出物化成一个单一结果
- DBMS可以下推提示(如LIMIT)以避免扫描太多元组
- 可以发送整个物化行或者单一列、全部列的子集
在OLTP负载中,由于每次只访问少量元组,使用物化模型更优:
- 更低的执行 / 协作负载
- 更少的函数调用
对于OLAP这种具有大量中间结果的查询不友好
使用物化模型的系统:
VOLTDB正是本门课程的教授Andy的研究成果的商业化
矢量化模型
和迭代器模型差不多,但是每次Next
调用可以返回一批元组:
若操作全部是基于内存的,迭代器方式中每一个元组上的函数调用开销就很可观了,此时矢量化模型非常有优势。但若操作基于磁盘,可能这些开销就微不足道了。
矢量化模型适合OLAP负载,并且可以将函数调用成本分摊在大量的元组上。基本不会用于OLTP负载,因为缓冲会带来一些延迟等待。
以下是使用矢量化模型的系统:
访问方法
访问方法是指DBMS访问存储在表中的数据的方式,有三种基本方法:
- 顺序扫描
- 索引扫描
- 多索引 / 位图扫描
顺序扫描
顺序扫描即遍历表中每一页到内存中,再对每一页中的每个元组进行操作:
for page in table.pages:
for t in page.tuples:
if evalPred(t):
// do something...
顺序扫描是我们最想要避免的,尤其是在OLTP应用中,我们一定不想扫描整个表的所有页面只为了定位一条数据,但是若开发人员没有建立适当的索引导致必须全表扫描时,DBMS也可以有一些办法来进行优化:
- 预取
- buffer pool旁通
- 并行化
- 堆聚集
- 区域映射
- 延迟物化
区域映射(Zone Maps)
为每一个页面预计算一些属性值的聚合,DBMS在顺序扫描时可以检查这些聚合值以决定是否要读取该页面。
区域映射通常存储在某种单独的映射页面中,一个页面中可以存储大量页面的聚合信息。
延迟物化(对于DSM系统)
列存储(DSM)的DBMS可以推迟将列组合成为元组的时间,直到上层需要才组合:
如上图这个查询,上层只需要foo.c
,DBMS要做的就是:
- 读取
foo.a
列,执行过滤 - 读取过滤结果之后的那些元组的
foo.b
,以及bar.b
,进行join - 得出最终在结果中的
foo
(比如record id),读取foo.c
,计算AVG
索引扫描
常规索引扫描就不说了,DBMS会基于查询条件、索引是否覆盖、统计信息等选出最具选择性的索引来加速查询。
下面来说一个索引扫描的优化,即多索引扫描,如果对于一个查询DBMS有多个索引可以利用:
- 使用每个索引过滤出最终匹配的record id
- 根据查询条件组合这些record id(取交集或并集)
- 根据最终剩下的record id来获取记录
多索引扫描可以并行完成
我觉得这个优化在应用时需要谨慎的权衡,比如参与扫描的索引都要具有极高的选择性,否则可能阻塞其它写入操作并且对查询没什么好处
表达式计算
对于查询中的表达式可以构造成一个表达式树,但是对于每一个元组都遍历、解释执行这个表达式树可能过于浪费CPU,有一些优化的方式:
- 表达式优化,如
1=1
这种条件去掉、常数运算提前计算出来、简化表达式等 - JIT优化:直接生成表达式树对应的代码,而不用解释执行表达式树