CMU 15-445/645 — Fall 2024 Projects

参考 课程网站项目地址测试网站Discord 频道。尝试做一下新版的项目。

Project #0 - C++ Primer

Task #1 & Task #2

算法设计

HyperLogLog 主要用于近似计算 multiset 中的基数(不同元素的数量),相比精确计算需要的 \(O(n)\) 空间 \(n\) 为基数的大小),该算法只需要使用很少的内存。

基本想法是,使用哈希函数将多重集合的元素映射到均匀分布的随机数上,然后记录最左或者最右连续 \(0\) 的最大数量。利用哈希值的随机性,某个比特位为零的概率是 \(\frac{1}{2}\),如果最多 \(n\) 个连续的零,则估计多重集合的基数为 \(2^{n}\),因为该哈希值出现的概率是 \(\frac{1}{2^{n}}\)。

不过该算法方差较大,只要有一个哈希值包含很多零,就会严重高估基数。所以,可以将多重集合拆分为多个子集,通常是利用哈希值的前 \(k\) 个比特位确定元素拆分到的桶,然后每个桶内维护剩余比特位中连续零的最大值,取加权平均值 \(2^{\frac{1}{m}\sum_{i=1}^{m}{R_{i}}}\) 得到每个桶的平均基数(其中 \(m=2^{k}\)),然后再乘以 \(m\) 得到多重集合的基数估计值。

额外的优化是使用 constant=0.79402 和调和平均来提升准确率,使用稀疏布局和密集布局来平衡内存开销和准确率,最后计算公式如下。(参考 HyperLogLog in Presto: A significantly faster way to handle cardinality estimationA problem so hard even Google relies on Random ChanceHyperLogLog

$$ CARDINALITY_{HLL}=constant\cdot m\cdot \frac{m}{\sum_{i=1}^{m}2^{-R_{i}}} $$

注意事项

n_bits 的范围可以确定下限是 0,上限不确定,如果很大会内存溢出。② 很多地方使用的是无符号整数,在使用字面量时需要声明后缀(例如 1UL),以及做算术运算时需要避免溢出(例如取负值时需要转为有符号数)。③ 做移位操作 1 << x 时需要注意 x 的大小,如果结果超出表示范围则行为未定义。经过测试不同环境 1 << 32 会得到 0 或者 1。④ 可以使用 ./test/hyperloglog_test --gtest_filter=HyperLogLogTest.EdgeTest1 执行指定的测试。

Project #1 - Buffer Pool Manager

Task #1 - LRU-K Replacement Policy

设计思路

简单的实现,使用单个哈希表维护 frame_idLRUKNode 的映射关系,LRUKNode 负责维护该页面的最后 k 次访问时间戳,在淘汰时通过扫描整个哈希表来确定需要淘汰哪个 frame_id,淘汰的时间复杂度为 \(O(n)\)。如果使用两个链表分别维护 <k>=k 的 Frame 访问顺序,如果不考虑 Pin,那么可以 \(O(1)\) 时间实现淘汰。但是由于不知道 Frame 是否被 Pin,所以仍然需要遍历链表查找第一个未被 Pin 的 Frame。也可以使用类似 TreeMap 的结构,直接存储未被 Pin 的所有 Frame,按照次数、时间戳排序,淘汰的时间复杂度是 \(O(\log{n})\),不过每次访问都需要重新删除插入来保证排序。(实际上都差不多,因为 BPM 的主要瓶颈在磁盘 I/O)

Task #2 - Disk Scheduler

设计思路

比较有意思的设计就是毒丸(poison pill),队列中存储的是 std::optional<bustub::DiskRequest> 类型的元素,向队列中添加 std::nullopt 元素,表示终止工作线程的执行。

Task #3 - Buffer Pool Manager

设计思路

缓冲池的基本功能就是获取 Frame,可以从空闲列表或者淘汰 Frame 来获取。如果是通过淘汰获取,则需要删除 Frame 和 Page 的映射关系、将脏页刷盘以及重置 FrameHeader。然后可以将 Frame 和 Page 关联,需要设置 Frame 和 Page 的映射关系、设置 FrameHeader 的成员变量、从磁盘读取数据、调用 LRUK 相关函数。

最后可以将该 Frame 和 PageGuard 关联,由于 PageGuard 会获取 Frame 的读写锁,所以要在创建 PageGuard 之前解锁 BPM 独占锁,从而避免 BPM 阻塞在 Frame 的锁上。例如,典型的情况是有两个线程同时对相同的 Page 获取 WritePageGuard。

线上测试就只有 BufferPoolManagerTest.SchedulerTest (0/0) 没有过,花几个小时找 BUG 都没找到,最后还是简单暴力打印日志解决问题。只能说别想太多,问题出在 is_dirty_ 的设置位置不对,我在 GetDataMut 方法中设置,而即使不调用该方法,NewPage 得到的空白页面也算是脏页,所以要在构造函数中设置。而且,调用 FlushPage/FlushAllPages 之后不应该将脏页标志重置,因为只要 WritePageGuard 还在那么页面依然可能变脏。(如果在刷盘的过程中修改页面,或许会产生问题,应该在刷盘之前获取 Frame 的读写锁,但是要避免和 BPM 独占锁产生死锁,这个点暂时不做)

Leaderboard Task (Optional)

方案一(×)

简单的想法是在磁盘 I/O 时不持有 BPM 锁,而是直接解锁然后在磁盘 I/O 结束之后加锁。但是在如下交错下,相同的页面会占用多个 Frame(Problem #1)。解决方式也有,就是在解锁 BPM 之前更新 Page 和 Frame 之间的映射关系。但是会有新的并发问题,也就是之后的线程判断 Page 在缓冲池中,然后直接读写该 Frame,而此时之前的脏页还没有刷盘,Page 数据也没有读取到 Frame 中(Problem #2)。

1
2
3
Problem #1
thread A -> fetch page 1 -> bpm lock -> evict -> bpm unlock (t1) -> dirty flush
thread B -> fetch page 1 -> bpm lock (t2) -> evict (error)
1
2
3
Problem #2
... -> bpm lock -> evict -> update map -> bpm unlock (t1) -> dirty flush
... -> bpm lock (t2) -> exist -> bpm unlock -> frame lock -> read/write page (error)

所以需要在解锁 BPM 之前给 Frame 加独占锁,但是这样做需要调整代码结构来避免死锁,也就是把 BPM 级别的共享变量的更新都放在解锁 BPM 之前,从而避免之后再加锁 BPM。这里给 Frame 加锁不会像之前一样阻塞 BPM,因为此时的 Frame 不被任何 PageGuard 持有。如果 Page 在缓冲池中,设置 Replacer 相关的数据一定要持有 BPM 锁,因为淘汰时只会加 BPM 锁,要避免页面在淘汰之后立即被访问的情况。(特殊情况可以不加,但是要注意如何设置值)

1
2
3
Problem #3
... -> frame lock -> bpm unlock (t1) -> dirty flush -> bpm lock (deadlock)
... -> bpm lock (t2) -> exist -> bpm unlock -> frame lock (deadlock) -> read/write page
1
2
3
Problem #4
... -> bpm lock -> exist page 1 -> frame lock -> bpm unlock (t1) -> access page 1 (t4)
... -> bpm lock (t2) -> evict page 1 (t3) -> ...

这样还是有问题,就是淘汰的 Frame 在包含脏页刷盘时会解锁 BPM,而此时如果有线程获取该 Page,则会从磁盘读取到旧页面或者此时磁盘中没有该页面(如果该页面是 NewPage)。此时需要额外维护哈希表存储相关信息,使用额外的锁来保护,该锁需要在解锁 BPM 之前持有,否则依然会发生上述错误。如果当前读取的 Page 还没有完成刷盘,则直接从旧 Frame 复制到当前 Frame 中。可以发现使用这种解锁 BPM 的方案会有各种问题,这都是我上次实现时遇到过的,最后 QPS 从 3700+ 反向优化到 1000+。

1
2
3
Problem #4
... -> bpm lock -> evict page 1 -> bpm unlock (t1) -> dirty flush
... -> fetch page 1 -> bpm lock (t2) -> disk read

方案二(×)

主要瓶颈在单个 BPM 独占锁,可以使用类似 Java 的 ConcurrentHashMap 的思路,将 BPM 锁拆分成多个锁。由于不能使用外部现有的并发库,那么可以使用哈希表数组,根据 Page 的编号映射到不同哈希表中。如果需要同时锁定多个分区,则需要按照顺序加锁来避免死锁,可以使用 std::scoped_lock。还有很多坑点和方案一类似,最后 QPS 从 3700+ 反向优化到 900+。

1
2
3
Problem #1
thread A -> bpm lock 1 -> bpm lock 2 (deadlock)
thread B -> bpm lock 2 -> bpm lock 1 (deadlock)

方案三(√)

可以不使用额外的锁,而是设置标志位表示该页面正在刷盘或者读取中,例如在 Page 到 Frame 的哈希表中记录无效的 Frame 编号,然后其他获取线程使用条件变量等待执行完成。需要注意等待的条件是哈希表中不存在该 Page 映射或者对应 Frame 编号有效,因为如果空闲列表为空且无法淘汰其他 Frame,则需要手动删除该临时键值对而不会设置有效的 Frame 编号。此时可以在磁盘 I/O 时解锁 BPM,之后加锁修改标志位,然后唤醒线程。

如果磁盘调度器不使用线程池,则 QPS 反向优化到 900+。如果使用单队列 + 8 线程,则 QPS 优化到 5300+。实际上不使用磁盘调度器的后台线程,而是直接调用 Schedule 方法,QPS 可以到 14700+。使用单队列 + 16 线程,则 QPS 优化到 14000+,因为工作线程总共有 16 个。

如果为每个线程分配一个队列,任务循环放置到每个队列,则 QPS 只有 11000+。有延迟场景性能更低可能是因为没有任务窃取机制,某个线程没被调度导致相应队列的任务积压。但是无延迟场景的 QPS 更接近不使用后台线程的情况,因为各个线程不必竞争相同队列的锁。(该优化会有问题,如果存在对同一页面的多个磁盘 I/O 请求,可以想到的情况是主动 Flush 脏页,会存在多个对相同页面的写请求,无法保证顺序)

由于测试使用 8 个 Scan 线程顺序读,8 个 Get 线程随机写(使用 Zipfian 分布)。由于第三个测试结果的权值最大,主要优化第三个场景,也就是顺序读每 0.1ms 执行一次,随机写每 1ms 执行一次。理论上最大 QPS 是 88000,目前看来 Scan 操作的 QPS 优化空间较大。

1
scan_qps_large / 1000 + get_qps_large / 1000 + scan_qps_small / 1000 + get_qps_small / 1000 + scan_qps_1ms + get_qps_1ms

经过测试,Scan 操作的缓存命中率都是 0%,使用优先淘汰策略(不论优先淘汰什么类型)的 Scan 操作 QPS 可以提升到 30000+。使用默认的 LRUK 淘汰策略、优先淘汰只被 Scan 访问的 Frame、优先淘汰只被 Get 访问的 Frame 的 Get 操作命中率和 QPS 分别是 7% 5800+、11% 4300+ 和 3% 6500+。

虽然按理说优先淘汰只被 Scan 访问的 Frame 比较正常,因为 Scan 线程是顺序读本身就无法利用缓存,提前淘汰不会影响性能。基于单队列 + 16 线程的代码,可以将 QPS 从 14000+ 优化到 42000+。但是实际上优先淘汰任意访问类型的 Frame 都可以得到差不多的 QPS,因为只要提升 Evict 的速度,就可以大幅提升 Scan 操作的 QPS。另外,命中率更高的 Get 操作 QPS 反而更低,真不知道什么原因。

排名

Rank Submission Name scan_qps_large get_qps_large scan_qps_small get_qps_small scan_qps_1ms get_qps_1ms QPS
98 ALEX 41513 4156 48125 643 3433 248 3776
24 ALEX 19407 19844 17793 21289 2491 2788 5358
16 ALEX 107110 104764 99692 102739 8370 5930 14715
18 ALEX 13775 13869 14001 16822 8148 5829 14035
22 ALEX 70177 70478 65910 73478 5775 5049 11105
4 ALEX 19866 10945 20708 13994 37939 4325 42330

CUM 15-445 课程总结

幻灯片和笔记其他同学整理的笔记Discord 讨论dbdb.io

本来想做个课程总结和项目总结的,但是有点没心情做,排行榜优化也暂时搁置吧。:(

更新:还是做一下总结,课程的内容不只下面这些,有很多内容对我来说可能用不到,所以没有记录。


Advanced SQL

查询满足某个条件的记录数量:

1
2
SELECT COUNT(*) FROM t WHERE xx;
SELECT SUM(IF(xx, 1, 0)) FROM t;

查询满足某个条件的记录百分率:

1
SELECT ROUND(AVG(IFNULL(xx, 0)), 2) FROM t;

窗口函数(文档:12.20 Window Functions):

1
2
SELECT ROW_NUMBER() OVER(PARTITION BY xx ORDER BY xx) FROM t;
SELECT AVG(xx) OVER (PARTITION BY xx ORDER BY xx ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)

日期和时间函数(文档:12.7 Date and Time Functions):

1
2
SELECT DATE_FORMAT(xx, xx);
SELECT DATEDIFF(xx, xx);

Database Storage

课程主要介绍面向磁盘的 DBMS,数据库以文件的形式存储在磁盘上,文件以特定于 DBMS 的方式编码,它被表示为页面的集合,每个页面都会有一个唯一标识符(页面 ID),大多数 DBMS 使用哈希表将页面 ID 映射为文件路径和文件内的偏移量。注意区分硬件页面(通常为 4 KB)、操作系统页面(4 KB)和数据库页面(1-16 KB)。存储设备可以保证硬件页面的原子写入,如果数据库页面大于硬件页面,则 DBMS 需要采取额外的措施来保证原子性。

每个页面被分为两部分:页面头部和页面内容。页面头部用于记录有关页面内容的元数据,包括页面大小、校验和、DBMS 版本、事务可见性和自包含(Self-containment)等。页面内容有两种主要的数据布局方式:slotted-pages 和 log-structured。

Storage Models & Compression

三种工作负载:OLTP(在线事务处理),OLAP(在线分析处理),HTAP(混合事务和分析处理)。

两种存储模型:行式存储和列式存储。

Memory Management

缓冲池实际上是一个页面数组,用于缓存磁盘中的页面,为了区分缓冲池页面和磁盘页面,我们将缓冲池页面称为帧(frame)。当 DBMS 请求一个页面时,存储管理器会首先搜索缓冲池,如果页面不在缓冲池中,就将该页面从磁盘复制到空闲的帧中。(有关缓冲池的详细信息可以参考 Project #1)

缓冲池优化方式:

  • 多缓冲池:可以为不同数据库或不同页面类型提供不同的缓冲池,这样可以根据其中的数据定制优化策略。
  • 预取:可以根据查询计划预取页面,通常在顺序访问页面时进行该优化。
  • 扫描共享(同步扫描):当多个查询扫描的数据存储重叠时,重叠部分可以只进行一次扫描。
  • 缓冲池绕过:顺序扫描或临时数据处理不会将页面存储在缓冲池中,而是为其单独开一块内存,以避免缓冲池污染,因为这些页面通常不会再被访问。

大部分数据库使用直接 I/O 绕过操作系统缓存,以避免多余的页面缓存(Postgres 除外)。

Hash Tables

哈希表的实现由两部分组成:

  • 哈希函数:需要在计算速度和冲突率之间进行权衡。
  • 哈希模式:发生冲突时如何处理。(静态哈希和动态哈希)

Trees Indexes

索引的数量越多,查询的速度就越快,但是索引占用的存储空间以及维护成本也会随之提高。

B+ 树是一种平衡查找树,它保持数据有序,并且支持 \(O(\log{n})\) 的查询、插入和删除操作。B+ 树平衡的关键在于,它要求所有节点都至少是半满的。B 树在所有节点中存储值,而 B+ 树只在叶子节点中存储值。B+ 树相比哈希表的优势在于可以进行范围查询以及模糊查询。(有关 B+ 树的详细信息可以参考 Project #2)

Index Concurrency Control

区分 Lock 和 Latch:

  • Lock:高级原语,作用是保护数据库的内容(元组、表和数据库),在事务中使用。
  • Latch:低级原语,作用是保护数据库的内部数据结构,在临界区中使用。

实现 Latch 的底层原语是比较并交换(CAS)原子指令。有多种不同类型的 Latch 可供 DBMS 使用:

  • Blocking OS Mutex:使用操作系统内置的互斥锁基础设施,Linux 提供 futex(fast user-space mutex),它由用户空间的自旋锁和操作系统级别的互斥锁组成。示例 std::mutex,优点是使用简单,缺点是加锁/解锁的时间成本较高并且不可扩展(如果发生竞争,则当前线程会被操作系统阻塞)。
  • Test-and-Set Spin Latch(TAS):自旋锁具有更高的灵活性,DBMS 可以控制当存在竞争时应该执行什么操作。示例 std::atomic<T>,优点是加锁/解锁更快,缺点是在竞争比较激烈时,会浪费很多 CPU 资源。
  • Reader-Writer Latches:示例 std::shared_mutex,优点是可以并发读取,缺点是需要额外的空间存储读/写队列。

B+ 树使用蟹行协议(Latch Crabbing Protocol)来保证自上而下的加锁顺序,当线程的访问模式不包含叶节点扫描时,该实现方式可以避免死锁。因为索引锁不支持死锁检测或避免(疑问:这里的描述和幻灯片第 23 页的内容不太一样),所以叶节点扫描在获取同级锁时遵循无等待模式,即如果获取同级锁失败就立即重启操作。

Sorting & Aggregations Algorithms

排序可能被用在 ORDER BY、GROUP BY、JOIN 和 DISTINCT 操作中。如果数据能够放入内存,则可以使用快速排序(当查询包含 LIMIT 和 ORDER BY 时,可以使用堆排序),否则使用外部归并排序。

外部归并排序由两部分组成:

  • 排序:将数据分为多个可以放入内存的数据块,分别进行排序,然后将排序后的数据写回磁盘。
  • 合并:将排序后的数据块合并。

外部归并排序的 I/O 成本分析:

  • 假设 \(N\) 为数据页面的个数,\(B\) 为可以使用的缓冲区页面的个数。
  • 在排序阶段,每次可以读取 \(B\) 个数据页面到缓冲区,进行排序之后写回磁盘,总共执行 \(\lceil \frac{N}{B}\rceil\) 次排序,I/O 成本为 \(2N\)(读入缓冲区和写回磁盘各一次)。
  • 在合并阶段,可以使用 \(B-1\) 个缓冲区页面存储 \(B-1\) 个数据块的第一个页面,剩下一个缓冲区页面存储合并的结果并根据需要写回磁盘,如果将合并的过程看作多叉树,则树的高度为 \(\lceil\log_{B-1}{\lceil \frac{N}{B}\rceil}\rceil\),每层合并都会将所有数据读写一次,I/O 成本为 \(2N\times\lceil\log_{B-1}{\lceil \frac{N}{B}\rceil}\rceil\)。
  • 最后,总 I/O 成本为 \(2N\times(1+\lceil\log_{B-1}{\lceil \frac{N}{B}\rceil}\rceil)\)。

外部归并排序可以使用双缓冲区优化,前台缓冲区进行计算的同时,后台缓冲区预取数据。如果数据在排序键上存在 B+ 树聚集索引,那么可以直接遍历索引得到有序数据。因为,如果是聚集索引,数据访问将是顺序 I/O,成本为 \(N\);如果是非聚集索引,数据访问将是随机 I/O,成本为 \(N\times M\),其中 \(M\) 为每页中的记录个数(缓冲区大小有限,随机 I/O 会导致页面抖动)。

聚合操作有两种实现方式:

  • 排序:首先将数据按照聚合键进行排序,然后对有序数据执行顺序扫描来计算聚合值。
  • 哈希:除非数据已经有序,否则哈希总是比排序更高效。由于内存可能容纳不下整个哈希表,为了避免随机 I/O,肯定不能将哈希表直接溢出到磁盘。我们可以首先进行一次哈希,将数据分区,然后对每个分区单独进行聚合操作,这样每个分区的哈希表大小应该会足够小,最好情况是能放入内存中。

Joins Algorithms

连接操作有两种输出模式:

  • 提前物化(early materialization):将外表和内表的所有属性都放入临时表。
  • 延迟物化(late materialization):只将连接键以及外表和内表的记录 ID 放入临时表。

连接算法的 I/O 成本分析(假设连接是等值连接):

  • 假设外表 \(R\) 有 \(M\) 页,总共包含 \(m\) 个元组;内表 \(S\) 有 \(N\) 页,总共包含 \(n\) 个元组。
  • Nested Loop Join:
    • 该算法由两个嵌套的 for 循环组成,外层循环遍历外表,内层循环遍历内表,如果两个元组满足连接谓词,则将它们连接并输出。注意,我们总是应该使用较小的表作为外表,因为。
    • Naive Nested Loop Join:对于外表的每个元组,将其和内表中的每个元组进行比较,I/O 成本为 \(M+(m\times N)\)。
    • Block Nested Loop Join:对于外表的每个块,将其和内表中的每个元组进行比较。假设有 \(B\) 个可用的缓冲区,该算法可以使用 \(B-2\) 个缓冲区扫描外表,\(1\) 个缓冲区扫描内表,\(1\) 个缓冲区存储连接结果,I/O 成本为 \(M+(\lceil \frac{M}{B-2}\rceil\times N)\)。
    • Index Nested Loop Join:如果内表在连接键上建有索引(或者临时建立索引),那么可以直接使用索引搜索到满足条件的元组,I/O 成本为 \(M+(m\times C)\),其中 \(C\) 为单次索引搜索的成本。
  • Sort-Merge Join:
    • 首先对外表和内表进行排序,然后使用双指针分别遍历外表和内表,来进行连接谓词判断。如果内表的连接键有重复值,那么内表指针在匹配时可能需要回退。当外表或内表已经有序,或者输出结果要求按照连接键排序时,可以选择使用该算法。
    • 排序成本:外表为 \(2M\times(1+\lceil\log_{B-1}{\lceil \frac{M}{B}\rceil}\rceil)\),内表为 \(2N\times(1+\lceil\log_{B-1}{\lceil \frac{N}{B}\rceil}\rceil)\)。
    • 合并成本:最坏情况下,两个表中的所有元组的连接键都相等,合并的成本为 \(MN\)。一般情况下,连接键大多是唯一的,合并成本为 \(M+N\)。
  • Hash Join:
    • Basic Hash Join:首先将外表的连接键作为 key 构建哈希表,将外表的元组或者元组 ID 作为 value。然后对于内表中的每个元组,可以直接通过哈希表获取匹配的元组。由于可能存在哈希冲突,即使元组被哈希到某个桶,在桶内肯定还需要进行比较来判断元组是否真的匹配,这里我们可以额外使用布隆过滤器来过滤元组,以减少磁盘 I/O。
    • Grace Hash Join / Partitioned Hash Join:当哈希表无法放入内存时,Basic Hash Join 存在页面抖动问题,解决该问题的方法是进行分区。首先分别对外表和内表构建哈希表,并根据需要写入磁盘,如果单个桶都无法放入内存,则递归的进行分区(前提是桶内的键存在不同,否则会导致无限递归),I/O 成本为 \(2\times(M+N)\)。然后将外表和内表对应的桶进行嵌套循环连接,此时页面都可以放入内存,I/O 成本为 \(M+N\)。

假设 \(M=10^{3}\),\(m=10^{5}\),\(N=5\times 10^{2}\),\(n=4\times 10^{4}\),\(B=100\),每页的 I/O 花费 \(0.1\) 毫秒,各个算法花费的时间如下:

Algorithm I/O Cost Example
Naive Nested Loop Join \(M+(m\times N)\) 1.4 hours
Block Nested Loop Join \(M+(M\times N)\) 50 seconds
Index Nested Loop Join \(M+(m\times C)\) Varies
Sort-Merge Join \(M+N+(sort cost)\) 0.75 seconds
Hash Join \(3\times(M+N)\) 0.45 seconds

该表是课程笔记上的,但是有点疑问,Block Nested Loop Join 的 I/O 成本计算公式有问题吧,如果按照之前说的公式计算,I/O 花费的时间是 \(0.65\) 秒。

Query Execution

DBMS 将 SQL 语句转化为查询计划,查询计划由操作符构成的树表示,数据从叶子节点流向根节点,根节点的输出就是查询的结果。处理模型(processing model)定义系统如何执行查询计划,下面介绍三种处理模型:

  • 迭代器模型/火山模型(Iterator Model):每个操作符都会实现 Next 函数,该函数由其父节点调用,以获取子节点的输出元组。因为每次调用只会返回单个元组,所以 Next 函数的调用一般放在循环中。调用从根节点传递到叶子节点,数据从叶子节点通过层层处理返回至根节点(实际上就是一个递归的过程,有点像树的后序遍历)。该模型允许以流水线的方式处理元组,有些操作符需要其子节点传递所有元组才能进行计算,包括哈希连接、子查询和排序等,这些操作符被称为流水线破坏者(pipeline breakers)。
  • 物化模型(Materialization Model):每个操作符都会实现 Output 函数,该函数会返回所有元组。该模型相比迭代器模型可以减少函数的调用次数,适合 OLTP 工作负载,因为其单词查询访问的数据量不大,而 OLAP 工作负载会查询大量数据,操作符的返回结果将会溢出到磁盘,从而增加 I/O 成本。
  • 向量模型(Vectorization Model):类似迭代器模型,区别在于每次调用 Next 会返回一批元组(即向量)。该模型适合访问大量数据的 OLAP 工作负载,相比迭代器模型,它可以减少函数调用的次数,还可以允许使用 SIMD 指令成批的处理元组。

PS:这些模型实际上是很简单的东西,无非就是返回的数据量不同,写这么多是不是有点浪费笔墨,额。这让我想起之前的一个感想,有些术语看上去很难懂,但是它们的本质其实非常简单,所以有些东西真的是增加学习难度。如无必要,勿增实体,不知道放在这合不合适。

Query Planning & Optimization

应用程序连接到数据库并发送 SQL 查询,该查询可以被重写为不同的形式,然后查询被解析为抽象语法树,绑定器通过查看系统目录,将语法树中的命名对象替换为内部标识,并生成逻辑计划,该逻辑计划同样可以被重写,之后优化器使用成本模型进行估计,将逻辑计划转化为物理计划。

逻辑计划和物理计划的区别:逻辑计划只描述了抽象的关系代数表达式,物理计划将抽象的表达式对应到某个具体实现,例如连接运算符有多种不同实现。逻辑计划和物理计划并不总是一一对应的。

查询优化有启发式优化(heuristics)和基于成本的搜索(cost-based search)两种策略:

  • 启发式方法将查询的各个部分与已知的模式进行匹配,以将其转换为更有效的模式。
  • 基于成本的搜索枚举等价的查询计划,然后选择成本最低的那个。

基于成本的搜索,如何估计谓词的选择性(就是谓词选择的数据占总数据的比率):

  • 关系 \(R\) 中的元组数量 \(N_{R}\),属性 \(A\) 的不同值的数量 \(V(A,R)\)。使用这两个信息就可以计算出每个属性值的平均记录数 \(\frac{N_{R}}{V(A,R)}\),称作选择基数(selection cardinality)。DBMS 可以根据选择基数估计谓词的选择性。
  • 由于数据并不是均匀分布的,各个谓词之间也不是相互独立的,所以通过选择基数估计谓词的选择性偏差较大。DBMS 还可以维护等宽/等深直方图,或者对原表进行抽样得到类似原表分布的副本表,然后通过遍历副本表来计算选择性。

Concurrency Control Theory

关键问题:如何避免竞态条件(race condition),以及实现崩溃恢复。

事务的 ACID 原则:

  • 原子性:事务中的操作要么全部执行,要么都不执行。有两种实现方式:日志(主流实现),写时复制。
  • 一致性:数据在逻辑上是正确的,遵循完整性约束。
  • 隔离性:并发执行的事务相互隔离,就像在串行执行一样,通过使用并发控制协议(concurrency control protocol)实现。并发事务之间存在三种冲突:读写冲突(不可重复读),写读冲突(脏读),写写冲突(丢失修改)。
  • 持久性:已提交的事务所做的修改将会持久化到磁盘上。

Two-Phase Locking Concurrency Control

两阶段锁(2PL)是一种悲观的并发控制协议,将事务执行过程分为两个阶段:加锁阶段(Growing)和解锁阶段(Shrinking)。两阶段锁存在脏读和死锁等问题,它有多个变体:

  • Conservative Two-Phase Locking(C2PL):在事务开始时获取需要的所有锁,此协议可以避免死锁。
  • Strict Two-Phase Locking(S2PL):事务结束时释放写锁,读锁可以在解锁阶段逐步释放,此协议可以避免脏读。
  • Strong Strict Two-Phase Locking(SS2PL):事务结束时释放读/写锁,此协议在 S2PL 的基础上保证了事务的提交顺序(Commitment Ordering,CO)。

死锁是事务之间发生循环等待的现象,2PL 中有两种处理死锁的方法:

  • 死锁检测:DBMS 定期构建等待图,如果图中存在环,则通过中止环中的某个事务,来打破循环。
  • 死锁预防:当一个事务试图获取另一个事务持有的锁时,DBMS 会中止两个事务中的某个事务,从而避免死锁。

锁兼容矩阵图:

锁升级矩阵图:

持有锁 目标锁
IS S,X,IX,SIX
S X,SIX
IX X,SIX
SIX X

存在的异常:脏读、不可重复读和幻读。隔离级别:读未提交、读已提交、可重复读和可串行化。(基于 2PL 实现的隔离级别、死锁检测可以参考 Project #4)

Timestamp Ordering Concurrency Control

Timestamp Ordering(T/O)和 Optimistic Concurrency Control(OCC) 都是乐观的并发控制协议,这些协议假设事务之间很少发生冲突,并且使用时间戳而不是锁来控制事务的执行顺序。

Basic Timestamp Ordering(BASIC T/O):每个事务都会被分配唯一的时间戳,每个数据库对象都会记录最后一次被读/写的时间戳。每当事务读/写数据库对象时,都会将事务时间戳和对象时间戳做比较,以此确定操作是否能够执行。事务还需要保留对象的本地副本,以确保可重复读。

Optimistic Concurrency Control(OCC):该协议将数据库对象复制到本地进行更改(写时复制),当事务想要提交时,进行冲突检测(比较事务的时间戳),如果通过则将事务的本地更改应用到数据库。

PS:这节课有点没太明白,特别是 OCC 的验证阶段看不懂。

Multi-Version Concurrency Control

多版本并发控制(MVCC):DBMS 维护数据库中单个逻辑对象的多个物理版本,当事务开始时,DBMS 会创建数据库快照(通过复制事务状态表),然后根据快照来确定事务可见的对象版本。

MCC 有四个重要的设计决策:使用什么并发控制协议(2PL、T/O、OCC),多版本的存储方式,垃圾收集(回收对所有事务都不可见的版本),索引管理(辅助索引存储对象的逻辑指针还是物理指针,主键索引总是存储对象的物理指针)。

Database Logging

崩溃恢复算法的两个关键原语:

  • UNDO:回滚不完整的更改。
  • REDO:如果已提交事务的更改没有写入磁盘,则重做这些更改。

两个崩溃恢复算法:

  • 写时复制:字面意思。该算法的 UNDO 操作就是删除所有页面副本,没有 REDO 操作,因为事务提交的同时会原子的修改数据库根节点的指针,即已提交的事务必定会将更改落实到数据库中。
  • 预写日志(Write-Ahead Logging,WAL):日志首先存放在日志缓冲区中,DBMS 必须先将日志写入磁盘(顺序 I/O),然后才能将脏页写入磁盘(不需要立即执行,可以使用后台线程进行写入操作)。只有当日志写入磁盘,事务才能被视为已提交。DBMS 可以通过批量提交事务,来避免频繁的日志 I/O 操作。每个日志记录都包含:事务 ID,对象 ID,之前的值(用于 UNDO),之后的值(用于 REDO)。(这里应该是指物理日志)

日志的模式(Logging Schemes):

  • 物理日志(Physical Logging):记录数据的字节级更改。
  • 逻辑日志(Logical Logging):记录 INSERT、DELETE 和 UPDATE 语句。
  • 混合日志(Physiological Logging):混合方法,以逻辑地址(页面中的槽号)的方式记录物理日志。

Database Recovery

Algorithms for Recovery and Isolation Exploiting Semantics(ARIES) 是由 IBM 在 1990 年代开发出的恢复算法,该算法包含三个关键概念:

  • Write Ahead Logging:先将日志写入磁盘,才能将脏页写入磁盘。
  • Repeating History During Redo:数据库重启时,重做日志将数据库恢复到崩溃之前的状态。
  • Logging Changes During Undo:将回滚操作记录到日志中,以确保再次崩溃时不会重复回滚。

该算法为每个日志记录分配全局唯一的日志序列号(log sequence number,LSN),系统中的各个组件会跟踪与其相关的 LSN。每个数据页面会包含最近一次更新对应的 LSN(\(pageLSN\)),缓冲池会包含已经刷到磁盘的最大 LSN(\(flushedLSN\)),DBMS 在将第 \(i\) 页刷新到磁盘之前,必须保证 \(pageLSN_{i}\leq flushedLSN\),即预写日志。

  • 事务提交:DBMS 首先将 COMMIT 日志记录写入日志缓冲区,然后将日志刷新到磁盘(顺序 I/O)。当日志已经成功刷新到磁盘之后,DBMS 就会向应用程序返回事务提交成功的信息。在之后的某个时刻,DBMS 将 TXN-END 日志记录写入日志缓冲区,表明该事务已经完成。(额,课程中没有说明在这段时间内,事务会执行什么操作)
  • 事务中止:每个日志记录会包含 \(prevLSN\) 字段,表示该 LSN 在事务中对应的上一个 LSN 是多少。DBMS 使用 \(prevLSN\) 维护事务的日志链表,以方便进行 UNDO 操作。这里引入一个新类型的日志 compensation log record(CLR),表示所执行的 UNDO 操作,该类型的日志不会被 UNDO。事务中止时,DBMS 首先将 ABORT 日志记录写入日志缓冲区,然后根据 ABORT 日志的 \(prevLSN\) 回滚事务的更新,当回滚完成时 DBMS 将 TXN-END 日志记录写入日志缓冲区。

为了避免数据库崩溃之后重做整个日志,DBMS 会定期设置检查点(Checkpoint,其实就是存档),在设置检查点时会将缓冲池中的所有脏页都刷新到磁盘中,Checkpoint 有下面几种实现方式:

  • Blocking Checkpoints:首先停止开始新事物,等待活动事务完成,然后将日志和脏页刷新到磁盘,最后将 CHECKPOINT 日志记录写入缓冲区并刷新到磁盘。之所以要停止并等待事务,是为了避免丢失更新。
  • Slightly Better Blocking Checkpoints:在开始设置检查点时,会记录内部系统的状态,从而不必等待活动事务完成,取而代之的是暂停活动事务。内部系统状态包括:Active Transaction Table(ATT)和 Dirty Page Table(DPT)。
  • Fuzzy Checkpoints:通过使用额外的日志记录(CHECKPOINT-BEGINCHECKPOINT-END)跟踪检查点的边界,从而不需要暂停活动事务。

ARIES 算法在 DBMS 崩溃重启之后执行,分为三个阶段:

  • 分析(Analysis):从 \(MasterRecord\) 对应检查点开始扫描日志,以构建 ATT 和 DPT,它们包含崩溃时缓冲池中存在的脏页以及活动的事务信息。
  • 重做(Redo):从 DPT 的所有脏页中最小的 \(recLSN\) 开始重做,即所有脏页中最旧的修改日志记录。
  • 回滚(Undo):从崩溃时所有活动事务中最旧的日志记录开始,撤销崩溃时活动事务所做的修改。

Introduction to Distributed Databases

并行数据库和分布式数据库的区别:

并行数据库 分布式数据库
节点之间距离较近 节点之间距离较远
节点之间使用高速局域网连接 节点之间使用公共网络连接
通信的成本很小且可靠 通信的成本很高且不可靠

DBMS 的系统架构指定 CPU 可以访问哪些共享资源,有如下四种架构方式:

一致性哈希:

  • 优势:假设有 \(n\) 个键,\(m\) 个节点,则一致性哈希平均只需要对 \(\frac{n}{m}\) 个键进行再散列。
  • 原理:使用哈希函数将键和节点映射到圆上,每个键都会被分配给在顺时针方向上的下一个节点。每当添加一个节点时,只需要对其顺时针方向的下一个节点上的键进行再散列;每当删除一个节点时,只需要将当前节点的键移动到顺时针方向的下一个节点。(相当于多个节点将圆划分为多个圆弧,每个节点只包含映射到对应圆弧上的键)

Distributed OLTP Database Systems

如果某个事务需要访问多个节点上的数据(由于数据分区),则其是分布式事务。提交分布式事务时,根据协议的不同,可能需要得到所有或大多数节点的同意。原子提交协议(Atomic Commit Protocols)有:Two-Phase Commit,Three-Phase Commit,Paxos,Raft,ZAB,Viewstamped Replication。如果节点不可信,则需要使用拜占庭容错(byzantine fault tolerant)协议。

PS:虽然该课程将以上算法统称为原子提交协议,但是 2PC/3PC 和其他共识算法有一个显著的区别,就是 2PC/3PC 通常用于分布式事务,而其他共识算法通常用于数据复制。前者涉及多个节点上的不同数据,且协调器存在单点故障。后者涉及多个节点上的相同数据,且基于多数原则,不存在单点故障。可以将 2PC/3PC 和其他共识算法结合使用,从而消除单点故障(例如 Spanner 分布式数据库所做的)。

CAP 定理(Consistency,Availability,Partition tolerance):指在发生网络分区故障时,要么选择一致性(指的是可线性化),要么选择可用性(允许读写网络分区节点)。

Project #4 - Concurrency Control

项目准备

项目地址:Project #4 - Concurrency Control

准备工作:阅读 Chapter 18 19,学习 Lecture #15 #16 #17 #18 #19 #20,以及阅读课堂笔记。

项目结构

项目的注释中有锁升级的矩阵图,但是没有兼容性的矩阵图,这里贴一下。

Task #1 - Lock Manager

实现

① 比较关键的一个问题是,LockRequestQueue 里面存什么。我之前漏掉 granted_ 成员,导致整个项目理解都有问题。一个请求会经历未获取锁、已获取锁,已释放锁三个过程,LockRequestQueue 存储所有没有被释放的锁请求,即前两个过程。因为之后能否获取锁,需要和之前已经获取的锁做兼容性判断。

② 加锁阶段:

  • 代码组织:我们可以根据请求的锁模式来分类讨论,也可以根据事务的锁模式来分类讨论,也可以根据事务是否有锁进行分类讨论。我最后是选择最后一种方式,这样写起来真的简洁。如果当前事务没有该资源的锁,则将请求入队,并且根据该资源是否被其他事务上锁,从而直接拿锁或者进行等待;否则,判断能否进行锁升级。
  • 锁升级:
    • 根据提示,首先判断请求的锁模式是否和事务的锁模式是否相同,如果相同则直接返回 true。我在这里有个比较疑惑的点,如果请求锁模式的级别低于当前持有的锁模式,应该也可以直接返回 true,但是注释中并没有提及,并且线上测试结果告诉我不行,必须抛出异常。似乎是设计的问题,讨论在此,而且这个讨论似乎说的也不完整。
    • 然后判断是否可以锁升级,如果可以,我们需要释放之前的锁,并等待获取升级的锁。这两个步骤可以通过修改队列中的 LockRequest 实现,将锁模式修改为新的锁模式,将 granted_ 修改为 false,然后 cv_.wait() 即可。关键是条件变量的获取锁的条件如何编写。注意,一定要在等待之前从当前事务的锁集中移除原来的锁,因为线上测试会在等待时检查锁集。
  • 获取锁:如何以 FIFO 的方式获取锁,并且使兼容的锁可以同时获取,以及使锁升级的优先级最高。遍历请求队列,如果当前事务是锁升级请求,则只需判断当前请求是否和已 granted_ 的请求兼容。如果当前事务不是锁升级请求,并且存在其他事务的锁升级请求,则直接返回 false,否则不仅需要判断当前请求是否和已 granted_ 的请求兼容,还需要判断当前请求是否和在该请求之前的未 granted_ 的请求兼容。

③ 解锁阶段:按照注释模拟,需要注意从队列中移除完成的锁请求,并在最后执行 cv_.notify_all()

④ 事务的 ABORTED 状态:如果事务被中止,那么应该取消该事务所做的操作,事务中止之后会自动调用 TransactionManager::Abort 函数来进行解锁和还原所有写操作。但是如果事务在等待锁的过程中被中止,那么就需要我们手动重置,因为 Abort 函数不会清除未获取锁的请求。步骤如下:在使用条件变量时,额外判断当前事务的状态是否是 ABORTED,如果是则直接退出等待,并从队列中移除该请求,如果是锁升级还要记得重置 upgrading_,最后调用 cv_.notify_all() 并返回 false

补充

① 一个细节问题,在获取 map 中的 LockRequestQueue 时,我依赖 C++ 在使用 [] 访问会自动创建对象的特性,没有注意到 map 中存的是智能指针,这样默认是创建空指针,结果就会报各种奇怪的错误。

② 表解锁同样需要改变事务的状态,一开始我天真的以为只需要在行解锁的时候改变就行,因为我以为加表锁必定会加行锁,但是不是这样的,可以只加表锁(或许全表扫描就是只加表锁而不加行锁)。

③ 线上测试遇到神奇的错误,pthread_mutex_lock.c:94: _pthread_mutex_lock: Assertion ‘mutex->data.__owner == 0’ failed,而且不是每次测试都会发生。经过排查,发现又是自动补全的锅,导致重复执行 unlock() 操作,有关该错误的讨论在此

④ 目前似乎不需要使用事务锁,单个事务加锁/解锁是单线程的。

Task #2 - Deadlock Detection

① 构建等待图,使用二重循环遍历 table_lock_map_row_lock_map_ 来向 waits_for_ 添加从 granted_ == false 请求到 granted_ == true 请求的边。其实这样单纯的加边是比较简单的,但是可能存在锁兼容的情况,这样构成的环是不会造成死锁的,导致误杀事务,不过测试能过就不改代码了。记得加锁。

② 因为可以存在很多环,如果检测顺序不一样,中止的事务可能完全不同,所以 NOTES 中要求我们从最小的事务开始做 DFS,按照从小到大的顺序遍历相邻节点,如果找到环,则中止环中最大的事务。如果事务被中止,则应该从图中删掉连接该事务的边,或者也可以打标记。有坑!!!HasCycle 应该包含什么代码,之前我是把最小事务编号作为参数传递,然后从该事务开始做 DFS 来检测环。但是线上 GraphTest 测试会调用 HasCycle,按照线上测试代码的逻辑,HashCycle 应该包含整个环检测代码,包括排序 waits_for_,排序 GetEdgeList 得到的边集,以及 DFS。特别注意,不要在 HashCycle 中调用 txn_manager_ 的任何方法,因为 GraphTest 测试根本就没创建事务!!!我是调试半天找不到错,才反应过来,非常无语。

③ 最后,从 HasCycle 返回时,删除中止事务的边,然后调用 TransactionManager::Abort 函数中止事务。在消除所有环之后清空 waits_for_

Task #3 - Concurrent Query Execution

① 非常非常无语!!!就是我在 Task#1 中提到的,高级锁可以包含低级锁的需求,不应该抛出异常,结果测试不给过,Task#3 又需要我兼容这种情况,那么只能在 Executor 代码中特判了。

② 根据提示,should not acquire S/X table lock, grab IS/IX instead,只为表加 IS/IX 锁。

③ 细节问题:行加锁之后再判断行是否删除,这个错误找很长时间才发现;死锁检测在调用 Abort 函数之前,先将事务状态设置为 ABORTED,否则当前事务可能会在之后的解锁过程中被唤醒,触发 LOCK_ON_SHRINKING 异常;实现 Abort 函数时,将恢复阶段放在解锁阶段之前,不然可能会有并发问题。

Leaderboard Task (Optional)

① 初次提交。

Rank Submission Name Update QPS Count QPS Weighted QPS
59 ALEX 14 14 14

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
make lock_manager_test -j$(nproc)
make deadlock_detection_test -j`nproc`
make txn_integration_test -j`nproc`
make -j`nproc` terrier-bench
./test/lock_manager_test
./test/deadlock_detection_test
./test/txn_integration_test
./bin/bustub-terrier-bench --duration 30000
make format
make check-lint
make check-clang-tidy-p4
make submit-p4

项目小结

难点就在项目理解以及代码细节上,Task#1 和 Task#2 被队列和 HashCycle 的理解整晕了,然后要使代码能够在多线程情况下正常运行,一定要注意代码中逻辑的先后顺序!!!实现过程部分参考做个数据库:2022 CMU15-445 Project4 Concurrency Control,Task#1 的解释帮助很大。