CMU15-445数据库bustub Project #2:B+ Tree(下)
Posted Altair_Alpha_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CMU15-445数据库bustub Project #2:B+ Tree(下)相关的知识,希望对你有一定的参考价值。
Project 2 最后一篇,讲解 B+ 树并发控制的实现。说实话一开始博主以为这块内容不会很难(毕竟有 Project 1 一把大锁摆烂秒过的历史x),但实现起来才发现不用一把大锁真的极其痛苦,折腾了一周多才弄完。
本文分基础版算法和改进版算法两部分,基础版算法部分我就只讲实现的一些要素,改进版算法再放重要代码,避免两个版本的代码引起混乱。由于加了并发控制后代码改变的位置比较多,我这里贴的截图不能覆盖到所有,如果需要源码可以评论区或私信联系。
开始之前先推荐知乎上的两篇文章,写得都非常好,而且有带图的例子方便理解。
CMU 15445-2022 P2 B+Tree Concurrent Control
做个数据库:2022 CMU15-445 Project2 B+Tree Index
理论基础
基础版算法
首先,请把 Lecture #09: Index Concurrency Control 这个课件第 36 页开始的内容仔细看一遍,实验文档中关于并发控制的描述很简略,而这个课件中详细讲解了并发控制的基本算法和改进算法,还带了很多具体例子方便理解。这里我就简要摘抄一下核心内容。
定义 “Safe Node 安全节点”:
- 如果操作为插入,则没有满的节点为安全节点;
- 如果操作为删除,则超过半满的节点为安全节点;
- 如果操作为读取,则所有节点均为安全节点。
一句话概括,就是操作时不会使树的结构发生改变的节点(插入的分裂,删除的合并)。从课件前面的例子也能看出,B+ 树实现并发访问的一个主要问题就是树的结构的改变会和读取或另一个结构改变发生冲突,造成读取无效位置或树的逻辑结构错误。而安全节点就是那些我们不用担心出现这个问题的节点。
于是,可以得出一个基础版的并发控制算法:
对于读操作,在子节点获取读锁然后把父节点的读锁释放掉;对于插入/删除操作,在子节点获取写锁,判断子节点是否安全:如果安全,将祖先节点的所有写锁释放,否则继续持有。这里也不难理解:如果子节点安全,那么对其下面的节点做插入/删除操作引起的树结构变化最多会传递到该层,而不会影响上层节点的结构,所以可以放掉祖先节点的锁允许其它操作访问。
算法的名称也非常形象:Latch Crabbing / Coupling,因为获取锁的方式就像螃蟹前进一样,先把一条腿迈到下一个位置(子节点),然后把另一条腿从上一个位置(父节点)收回来。
改进版算法
以上算法在插入/删除时第一步总是要先获得根节点的写锁(独占性),这是树形结构本身决定的,但这在性能上很容易成为瓶颈。而另一个观察是,大多数的插入/删除并不会使节点发生分裂或合并(只要节点的 max size 不设置得太小),所以实际上获取根节点(或者说那些较为靠上节点)的写锁大部分情况是不必要的。因此,课件给出了一个改进版的算法:
其思路是先 “乐观” 地假设插入/删除不会发生分裂或合并,于是只需沿路径像读取一样获取和释放读锁,然后检查叶节点:如果叶节点安全,那么假设正确,对其更新只需要叶节点的写锁;而如果不安全,说明假设错误,重新像基础版算法一样跑一遍。显然,如果上面的观察成立,那么这种算法带来的收益是会超过其代价的。
扫描(迭代器)问题
上一节我们还实现了 B+ 树的一种操作:迭代器,也就是顺序访问所有叶节点的元素。但到目前为止我们讨论的都是从根到叶路线的并发控制,那么能否直接从叶跳到下一个叶呢?答案是不可以,课件中也给出了死锁的例子。最终给出的方案是:从一个叶节点跳到下一个叶节点时,只有立刻能获取到其读锁,才可以继续,否则直接报告错误,也就是不要等待。这部分实验讲义中说明了不在评测范围之内。
基础版算法实现
回顾上一节的代码,我们已经写了一个函数 GetLeafPage()
负责从根节点搜索到值可能所在的叶节点。并发控制的主体代码应该添加到该函数中。首先定义操作的枚举类型:
Transaction
原理讲解中提到插入/删除涉及所有祖先节点的释放,这就要用到我们前面一直忽视的参数:transaction
,它携带了一个数据库事务的所有信息,不过这里我们只需要关注其两个成员 std::shared_ptr<std::deque<Page *>> page_set_
和 std::shared_ptr<std::unordered_set<page_id_t>> deleted_page_set_
,分别可以记录 B+ 树查找过程中访问的页面集合和删除的页面集合。注意前者是用双端队列记录的,能维持插入的顺序,这样释放锁时也能按照从根向下的顺序,且元素为 Page *
,因为我们要用 Page
类的解锁函数。删除集合当然无所谓顺序了,而且只需记录 page_id
,因为最后是用 buffer_pool_manager_->DeletePage(page_id)
删除(这个与并发控制无关,单纯是上节中忘做了x)。
根节点保护
讲义中 Common Pitfalls 部分提到了课件中没讲的一个细节:根节点的保护。这里说的根节点保护不是对根节点那个 Page 的保护,而是对获取哪个 Page 是根节点,也就是 root_page_id
访问的保护(这正是 GetLeafPage()
中搜索的第一步)。于是在 BPlusTree
类中添加一个成员 ReaderWriterLatch root_latch_
,每次访问前第一步先上这个锁,再进入 latch crabbing。但这个独立定义的锁如何放入 transaction 的 page_set 集合呢?我们可以规定:在 page_set 中放入一个 nullptr
,表示锁定 root_latch_
,在访问 page_set 解锁时只需做一个判断即可。
Unlock
和 Unpin
的顺序问题
讲义 Common Pitfalls 提到的另一个需要思考的问题,正确操作是先 Unlock
后 Unpin
,因为 Unpin
后这个 Page 的 pin count 可能降为 0,buffer_pool_manager 可能会将该 Page 指针的内容改写为另一个 Page 的,导致 Unlock
错误(如果不清楚可以回看 FetchPgImp
的实现)。
安全节点判断
这里没有什么难点,按照定义写即可。唯一需要注意的是删除时对于根节点的判断,如果是叶节点只需有 1 个或以上元素,而内部节点需要有 2 个或以上,因为根据上节代码如果只有 1 个元素则应该将子节点变为新的根节点,树的高度减一。
2023/03/23 更新:感谢 @weixin_45297876 的指正,这里 Remove 的判断条件应该分别为大于 1 和 2,因为
IsPageSafe()
是在执行删除前被调用的,删除后达到 0 或 1 是不安全的。
祖先节点锁释放
读锁都是获取下一层后立刻释放上一层,所以不需要用到 transaction 的 page_set 记录,只有写锁才会需要记录多个祖先节点然后一次性释放。于是可以写一个函数进行所有写锁的释放:
修改 GetLeafPage()
函数
- 刚开始应该给
root_latch_
上读锁或写锁,但因为在调用GetLeafPage
之前,读/插入/删除操作都需要先判断空树,所以给root_latch_
上锁的任务放在那些函数开头进行。 - 因为读操作时要释放上一个节点的锁,所以添加一个
prev_page
指针,初始化为nullptr
。对于读操作,先给root_latch_
上读锁。每一轮中,先page->RLatch()
,然后判断prev_page
,如果是nullptr
,则将root_latch_
RUnlock 掉;否则,将prev_page
RUnlatch 并 Unpin 掉。 - 对于插入/删除操作,先给
root_latch_
上写锁。每一轮中,先page->WLatch()
,然后判断当前页是否安全,如果安全,将祖先节点的写锁释放。将当前页加入到 transaction 的 page_set 中。
修改 查询/插入/删除 函数
- 第一步,对于读,给
root_latch_
上读锁;对于插入/删除,给root_latch_
上写锁,同时向 transaction 的 page_set 中添加一个nullptr
。 - 需要想清楚的问题是:调用
GetLeafPage()
后,有哪些资源被占用(锁定),需要释放?对于读,只有叶节点被上读锁,所以最后要进行的清理是page->RUnlatch(); buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
。而对于插入和删除,答案是 transaction 的 page_set 中存储的页,而前面我们已经写好了ReleaseWLatches
函数释放这些资源,应该在所有情况 return 之前调用。 - 对于插入,之前是每轮循环中以及最后一个循环后都把原有的页(
old_tree_page
)和新建的分裂页(new_tree_page
)Unpin 掉,而现在可能发生分裂的节点,即那些old_tree_page
都已记录在 transaction 的 page_set 中,它们会在最后调用ReleaseWLatches
时被 Unpin。所以,现在只需 Unpin 所有new_tree_page
。 - 对于删除,访问兄弟节点时要上写锁,这个是没有放在 transaction 中的。正好我们上节写过一个释放兄弟的函数
ReleaseSiblings()
,所以可以把解锁放在该函数的 Unpin 之前。 - 对于删除,在
MergePage()
中把删掉的页(即right_page
)记录到 transaction 的 deleted_page_set 中,最后记得对这个集合中的页用 buffer_pool_manager 的DeletePage()
清理掉。
迭代器
获取迭代器前搜索位置加读锁就可以,迭代器构建时确保已获取 Page 的读锁,析构函数中解锁。在迭代器内部,需要考虑的是 operator++()
跳页时的处理。这部分讲义说了不做要求,如果想实现课件中所述 “立刻获取下一页的锁,否则不等待立刻返回失败”,需要阅读源码:Page
的锁就是一个 ReaderWriterLatch
,而 ReaderWriterLatch
本质是 std::shared_mutex
,只是用 RLock()
,WLock()
替换了 lock_shared()
和 lock()
的名称。而 std::shared_mutex
还有一对函数:try_lock_shared()
和 try_lock()
,效果正是 “尝试获取锁,立刻返回成功或失败的 bool 值”。所以,只需在 ReaderWriterLatch
和 Page
上相应添加一对 TryRLock()
,TryWLock()
的包装函数即可实现。
测试
并发控制有两个可用的本地测试 b_plus_tree_concurrent_test
和 b_plus_tree_contention_test
,后者会评估 B+ 树使用全局锁和你的并发控制实现的耗时比(所以如果你的 B+ 树也只用了一把大锁,这个比值应该接近 1)。正确的实现应该在
[
2.5
,
3.5
]
[2.5, 3.5]
[2.5,3.5] 这个区间内。下面是我测试我的基础版实现的结果:
数字比较正常,但并不高。提交至 GradeScope,Leaderboard 用时为 8.74s,这个成绩emm…不太行
第一步优化
到这里博主还没确定要不要做改进版算法,就按习惯先用 gprof 跑了一下程序运行耗时统计。为了生成 gprof 统计信息,编译时要添加 -pg
参数,用 CMake 编译的添加方法为,在 bustub 目录下的 CMakeLists.txt 中,添加:
SET(CMAKE_CXX_FLAGS "$CMAKE_CXX_FLAGS -pg")
SET(CMAKE_EXE_LINKER_FLAGS "$CMAKE_EXE_LINKER_FLAGS -pg")
SET(CMAKE_SHARED_LINKER_FLAGS "$CMAKE_SHARED_LINKER_FLAGS -pg")
然后编译 b_plus_tree_contention_test,运行:
cd test
./b_plus_tree_contention_test
此时 test 目录中应该会生成一个 gmon.out 文件,运行:
gprof b_plus_tree_contention_test gmon.out > prof.txt
打开 prof.txt,可以看到程序耗时最多的函数调用。除去一些内部函数,可以看到 BufferPoolManager 的 FetchPage
和 UnpinPage
的调用次数非常高,
而之前参考其它博客学到了一个思路:放在 transaction 的 page_set 中的 Page 都是已经 Fetch 过的,可以直接使用,而我们的代码中有两处没有利用到这个性质,就是插入和删除中获取父节点时,都是直接 Fetch/Unpin 的,没有考虑到父节点可能已经存在于 page_set 中。于是,添加一个函数:
先尝试从 page_set 中获取,如果失败再 FetchPage。Insert()
中:
改了个名字,
parent_tree_page
这里原来叫parent_internal_page
删除同理。这样对 parent_page
是否需要 Unpin 要根据 parent_need_unpin
决定,如果是从 page_set 中取的则 ReleaseWLatches
中会 Unpin,不要重复。
2023/03/29 更新:感谢 @卡其大米 的提醒,因为
GetPage()
只在插入和删除获取parent_page
这两个地方调用,parent_page
实际上肯定是在transaction
中的(想想 safe page 的定义,能递归到该位置说明至少到这里一定不安全),所以实际上也不需要need_unpin
判断了,写成以下形式即可:
经此优化的结果如下:
GradeScope Leaderboard 用时为 8.49s,有提升,但不多(汗)
至此博主决定还是老老实实把改进版算法实现了…
改进版算法实现
免责声明:其实博主也不确定在实现改进算法过程中有没有意外做其它可能使性能提升的操作,所以不保证实现改进算法的提升效果(x
实际上代码的更改也并不复杂,主要改 GetLeafPage()
一个函数就行。因为第二遍和第一遍搜索只是加锁的方式不一样,我们为函数添加一个 bool 参数 first_pass
,默认值为 true,这样外部调用的代码都不用改。梳理一下逻辑:
- 第一次搜索,调用之前外部已经获取了
root_latch_
的读锁,从根搜索到叶节点,一路按 crabbing 的方式加锁解锁;到了叶节点,如果是读操作加读锁,如果是插入/删除操作加写锁并加入 page_set。 - 如果叶节点不安全,将叶节点解锁,调用
GetLeafPage(..., false)
进入第二次搜索,否则直接返回叶节点。注意根据IsPageSafe()
的实现读操作总会判定节点是安全的。 - 进入第二次搜索,首先要获取
root_latch_
的写锁;另外能进入第二次说明一定是插入/删除操作,直接按基础版算法处理即可。
转化为代码:
至此实现完成,进入喜闻乐见的代码大放送环节(doge),非常欢迎指出哪里还可以改进!
Insert()
要注意一下空树的处理,因为改为优化版后 root_latch_
一开始加的是读锁,所以如果判定为空要新建根节点的话需要 “升级” 为写锁。然而 std::shared_mutex
不支持原子的 “升级” 操作,所以只能先解锁再加锁。加上写锁后还要再判定一下是否仍为空树,是的话则建根、解锁、返回,否则应该再 “降级” 为读锁继续后面的操作。
Insert()
结尾部分,现在只需要 Unpin new_tree_page
,因为 old_tree_page
都在 ReleaseWLatches()
中处理。
Remove()
本体
HandleUnderflow()
函数的变化只有:(1)GetPage() 优化 + parent_need_unpin 判断是否要 Unpin;(2)兄弟节点 Fetch 后加写锁以及 ReleaseSiblings 时解锁;(3)MergePage() 后将 right_page 加入 deleted_page_set。逻辑都没有变化,就不重复贴了,可以参考上一篇。
这次优化后的效果还是比较明显的:
可以看到耗时比已经接近实验讲义给的 3.5 的正常范围上限了,说明并发控制的优化很有效。但 GradeScope 的运行时间并不是很理想,5.51s,大概率是因为本身的实现效率不够高,尤其是 Project 1 Extendible HashTable 和 BufferPoolManager 都是直接一把大锁摆烂实现的,这个结果也比较正常,不想再优化了(逃)
那么本次 Project 2 就到此为止~
2023/03/20 更新:一些小修补
做 Project 3 的时候发现本节的实现有几处小问题:
- 删除操作中,如果根节点是叶节点且删除后树为空,应将
root_page_id_
重置为INVALID_PAGE_ID
,否则IsEmpty()
判断空树的结果是错误的。需要修改HandleUnderflow()
的开始部分:
- 获取
Begin()
迭代器时应先判空,如果是空树直接返回End()
。
IndexIterator
的实现。之前的设计是BPlusTree
通过Begin()
构造IndexIterator
时对相应 Page 加锁,IndexIterator
析构或跳到下一个页时解锁。这里有一个问题是我们没有关注迭代器的拷贝构造/赋值,如果迭代器被拷贝就会导致同一个 Page 在两个对象的析构中被解锁两次,造成 undefined behavior。实际上这种持有独占性资源的类应该是 move-only 的,所以添加如下代码,仅允许迭代器的移动赋值,移动的实现只要将双方的成员 swap 即可(因为解锁是根据 page_id_ 成员进行的,如果 page_id_ 无效则不会解锁):
CMU 15-445 数据库课程第五课文字版 - 缓冲池
熟肉视频地址:
今天的课程是关于期待已久的缓冲池的话题,其实就是 DBMS 如何管理它的内存并从磁盘来回移动数据,我们希望DBMS自己来管理这些内存与磁盘存储交换的操作,而不是把它留给操作系统。你可以从两个方面考虑数据库存储和内存管理问题:
第一个是空间控制,也就是我们从物理上考虑在磁盘上写页的位置,我们要把页面存储在磁盘的什么地方,以达到最大的收益。我们的目标是让页保持在一起,如果有一些页经常被我们的应用程序同时访问我们把它们连续地放在磁盘上。这么做的原因是顺序访问磁盘比随机访问消耗小得多野快得多。
我们需要考虑的第二个方面是时间控制。这意味着当我们从磁盘取页到内存时,我们希望 DBMS 能够以一种最小化磁盘 I/O 的方式来实现这一点:如果有一个您需要访问的页,而它目前不在内存中,那么就需要从磁盘读取,会有一个等待页从磁盘载入内存的 I/O 阻塞,我们想尽量避免这些,也就是 DBMS 需要找到一种有效的方法,将在同一时间被访问的页面以最少的 I/O 次数同时保存在内存中。
我们这样做的主要原因也是因为相对于访问内存,直接访问磁盘的耗时大太多了,是不可以接受的。所以,作为 DBMS 工程师,找出一种有效的方法来维护这个缓冲池,尽可能地将数据保存在内存中,这对我们来说是非常重要的
到目前为止,我们一直在讨论磁盘上面的数据库文件,这些文件被分成了很多页,并且有目录页,它存储从页 id 到文件中的物理位置或偏移量的映射。在磁盘文件上面有我们的缓冲池(Buffer Pool),它为执行引擎(Execution Engine)服务
例如:我们有一个执行引擎发出一个请求访问第二页,缓冲池中没有第二页,缓冲池要做的是,首先将文件目录加载到内存中,找出第二页的物理位置,然后获取它,这样我们就能返回一个内存中的第二页的指针给执行引擎。
以上是整个缓冲池如何工作的一个大概的例子,具体来说,我们今天这节课要讲的主题还是关于缓冲池的高级概念:
特别是缓冲池管理器(Buffer Pool Manager),即软件中负责管理缓冲池的部分,我们会看一下缓冲池管理器使用的不同算法。包括替换策略,如何决定哪些页要读取到内存,哪些页要从内存中删除,最后我们会看一些其他类型的可能存在于 DBMS 中的内存池。
缓冲池的结构是一个固定大小的页的数组,每一个数组条目都被称为一个帧(Frame):它是磁盘上的数据库文件的页的大小,这样我们就可以把磁盘上的页映射到缓冲池的数组槽中。当 DBMS 请求一个页时,我们要做的就是将页复制到缓冲池中的这些帧中。
实际上,我们现在还需要一个间接层才能访问这些页,即通过页表(Page Table)
页表实际上记录了存储在内存中的页的映射,类似于数据库磁盘文件的文件头的槽页。页目录记录页在磁盘上的位置,页表则是会记录页的布局,以及它们在内存缓冲池中的位置。这里我们有从第一页和第三页到缓冲池中的帧的映射,页表还将负责维护关于每个页的一些额外元数据,例如:
- dirty 标记:是一个布尔值,告诉我们页在加载到内存后是否被修改过。
- pin 标记或者引用计数:如果我们想要一个还会被使用的页留在缓冲池的内存中,我们不希望它被删除,我们可以用 pin 标记这一页。或者通过记录引用计数让我们知道哪些页还在被查询使用。
- Latch锁存器:如果我们有一堆并发的查询,我们有多个线程或查询都访问试图修改这个页表,一般需要在页表的一个位置设置一个锁存器,来防止并发修改。
这里我们需要理清一个重要的概念区别,即锁(Lock)与锁存器(Latch)
在数据库世界中的锁与锁存器,与操作系统中的锁与锁存器的概念是不一样的。在数据库的世界中:
- 锁(Lock):指的是对于数据库的逻辑抽象的保护,例如锁的可以是整张表,也可以是索引,也可以是元组,这些都是与DBMS相关的逻辑抽象。锁通常在事务期间获取并保持,并且要考虑事务回滚
- 锁存器(Latch):我们通常指的是保护某些底层关键部分的短暂的锁存器,比如保护一个内部数据结构或者数据库管理系统中发生的修改,我们不需要能够回滚这些改变。有点类似于 Mutex(互斥锁)
下一个我们想搞清楚的是页目录(Page Directory)与页表(Page Table)的区别:
页目录就是从页id到页物理位置的映射,它需要被持久化,这样就算重启我们也可以加载以便追踪我们可能需要的各个页。
页表在内存中,它是临时的。我们不需要持久化这个页表,页表可以在我们执行查询时逐步建立。
一个问题:在内存中设置了页表某一帧的 dirty 位后,如果掉电,我们会丢失对页面的更新吗?会的,如果在缓冲池中有一些页被设置了脏位,这意味着它们被一些查询修改了,它们还没有持久化到磁盘上。但是后面我们会讨论到事务保证,如果你有一个事务,那么这个事务直到所有的更改都以某种方式(后面我们会知道通过一种类似于写入提前写日志(WAL,Write Ahead Log)的方式)持久化到磁盘上之前才会提交完成,以保证事务的完整性不受宕机影响。
我们如何决定哪些页会存在于我们的缓冲池中?一般有两种策略:
- 全局策略(Global Policies):根据系统中在同一时间并发运行的所有查询进行综合考虑
- 本地策略(Local Policies):基于每个查询来加载和移除页,但是也会有页的共享
一些缓冲池优化方式:
- 多缓冲池(Multiple Buffer Pools):多个同时使用多个并发缓冲池而不是一个
- 缓存预取(Pre-fetching):提前将一些加载到缓冲池减少 I/O
- 扫描共享(Scan Sharing):多个查询共享一个扫描的结果
- 绕过缓冲池(Buffer Pool Bypass):对于某些查询,不通过缓冲池以防污染
我们从多缓冲池(Multiple Buffer Pools)的概念开始:从逻辑上讲 DBMS 有一种缓冲池,你可以把页从磁盘加载到内存中,但在物理上,它可以被实现为具有不同策略的多个单独的缓冲池。例如你的系统管理多个并发数据库,每一个都可以有自己的缓冲池。例如你可以针对不同的页类型有不同的缓冲池,例如表的页,索引的页,这些可以由完全独立的缓冲池处理。
它有很多优点,减少锁存器争用,并且可以每个缓冲池针对不同的需求使用不同的优化策略(例如针对查询的,数据库的,不同类型页的缓冲池)。但是也引入了一个问题:你如何判断你有一个页,你想让它存在与唯一一个缓冲池?
有两种常用的方法:
- 第一种方法是当你存储页时你存储一些与之相关的对象 id(Object Id):例如这里对象id是指页类型,它可以是一个存储元组和表的页,它可以是存储部分索引数据结构的页,可能是存储日志记录的页。举一个实例:假设 Q1 查询想得到记录 123,根据前面的课程,我们知道这个 123 可以解析出数据库中这个记录的位置信息,这里这个位置信息包括 ObjectId,PageId,SLotNum,根据 ObjectId 去对应的缓冲池寻找。
- 第二种方法是取哈希值:还是对于 Q1 查询想得到记录 123,对于这个记录取哈希值,然后对独立缓冲池的数量取余数得出该去哪个缓冲池去查询。
我们要讲的下一个重要优化是缓存预取(Pre-fetching),这种想法是 DBMS 可以根据查询计划在实际需要之前预取页。假设我们有一个查询 Q1 执行顺序查询扫描所有页,DBMS 可以执行一些数据预取,比如在开始扫描第 0 页的时候,就把第 0,1,2 页都加载到缓冲池中。之后到第 3 页的时候,第 0,1,2 页不再使用,可以被替换成 3,4,5 页,这样查询不用在扫描每一页的时候都阻塞。
我们接下来看这样一个查询,查询 val 在 100 ~ 250 之间的所有记录,这个查询是可以通过索引优化不用扫描所有页的。索引会在后面的课程详细讲。
目前对于 val 这个索引,你可以把它想象成一个平衡二叉树。我们将从这个根页(index-page0)开始,之后搜索左子树 index-page1,然后找到 index-page3,由于叶子节点互相之间是有指针连接起来相当于一个双向链表,所以我们不必重新遍历二叉树就能找到 index-page5。这样我们就找到了索引中我们所有要扫描的页。
这个例子告诉我们预取需要根据数据结构以及扫描方式做出改变,并不是一直顺序扫描的
问题:你怎么知道你应该分配多少资源来做预取?学术界有很多关于预取的研究,在商业系统中,是一个很大的卖点,更好的预取应该是可以计算出你知道用这种方式预取需要付出多少资源,如果你花费太多资源做预取,那么你就会阻碍系统进行的实际工作;而如果你什么都不做,就会出现太多 I/O 阻塞。所以这是你在两者之间必须达成一种微妙的平衡。
下一个是扫描共享(Scan Sharing)
其基本思想是查询可以重用从存储中检索的数据,这也被称为同步扫描(Synchronized scans),它不同于结果缓存。结果缓存主要是针对某个特定查询的,对于不同的查询一般不会生效。扫描共享则是不必是一样的查询,但是可以共享中间结果的。
如果一个查询从磁盘读取页,并将它们放入内存,可以让另一个需要访问相同页的查询重用它们,它允许多个查询附加到一个正在扫描表的游标上。查询不一定是一样的,但是他们需要访问相同的页。
假设有两个查询:
- Q1:SELECT SUM(val) FROM A
- Q2:SELECT AVG(val) FROM A
这两个查询,都是扫描 A 表的所有页。假设 Q1 先开始执行,读取到第 3 页,这时候 Q2 开始执行,Q2 和 Q1 要扫描的页是一样的,但是 Q2 可以直接附加在 Q1 的游标上继续扫描,等 Q1 扫描完,Q2 再扫描剩下的之前没有扫描到的。
但是,如果这里 Q2 加上 limit 100,这种限制,如果配合扫描共享,那么可能每次扫描出来的结果是不一样的,因为你也不确认它到底是从头扫描还是附加到其他查询的游标上以及当前游标的位置。所以,我们最好不要有这样的查询,对于所有带 Limit 的查询,最好都指定排序条件。
最后一个优化是绕过缓冲池(buffer pool bypass)
对于顺序扫描运算符,它要扫描每一页,但是这些页仅仅一次扫描之后就立刻用不到了,如果都加载到缓冲池的话,会严重影响执行效率,并且污染缓冲池。
在 Informix 这个系统中叫做轻量扫描(Light Scans)。并且 Oracle,SQLServer,PostgresSQL 中也有这种优化机制。
下一个我们要讲的是,如果缓冲池满了,在缓冲池中替换页的不同策略,在此之前,我们先来看下操作系统中的机制:
我们要执行的大多数磁盘操作都是通过操作系统提供的 api,我们讲过mmap()或者read()和write(),对于这些 api 除非你明确指定其中的参数告诉操作系统,否则操作系统会维护自己的文件系统缓存,它通常被称为页面缓存(Page Cache)。简单来说:当你请求从磁盘读取一个页面,如果它还没有加载,就从磁盘中获取它加载到页面缓存中,然后返回一个指向你的页面的指针,之后你必须从操作系统页面缓存复制到用户空间。所以你会有一个多余的复制,一个存储在操作系统页面缓存中,一个存储在你实现的缓冲池中,所以大多数 DBMS 所做的是使用 O_DIRECT 标志来绕过操作系统的页面缓存。
我们可以采用不同的策略来确定当缓冲池填满时,我们需要腾出一个帧,以便插入一个新页。我们如何决定从缓冲池中删除哪些页呢?我们要考虑不同的方面:
- 正确性(Correctness):我们不想有任何数据被破坏的问题,例如,扔掉我们没有正确地写出的脏页。
- 准确性(Correctness):即查询结果是查询想要查的数据
- 速度(Speed):缓冲池过期需要保证查询速度,也需要考虑能快速决定哪些帧被替换,如果你花了所有的时间思考去掉哪个页最合适,那么你花在这上面的时间可能比你从智能算法中得到的好处还要
- 元数据大小(MetaData):我们需要担心的是为了进行页删除,我们存储了多少元数据,不能太多
这是最简单的一种算法,LRU(Least Recently Used,最近最少使用策略),它在很多不同的系统领域被使用。简单的实现方式是为每个页面维护一个时间戳,记录它最后一次被查询访问的时间。当 DBMS 需要删除一个页时,这很简单,我们只需要找到时间戳最早的页面,也就是最近访问最少的页面。
这是一种通用的依赖于你最近使用过的页,你最近访问过的页,很快就会再次被使用这个假设的算法。
另一个策略是时钟策略(Clock Policy),和 LRU 有点不同,这也是经常被用到的一种策略。它的实现方式通常是在每页上分配一个标记位,这个引用位可以是 0 或者 1。如果这个页面被访问到了,就会标记为 1。我们有一个时针会不断的扫描每一页,如果时钟扫描的这一页标记位为 1,那么就会更新为 0,如果本来就是 0 了,就会把这个页面过期掉。过这个算法给你带来的好处是不用维护一个完整的时间戳(每页占用4个字节或者8个字节),这个算法每页只需要一个比特标记位,所以空间的消耗会小很多。
LRU 以及时钟策略都有一些问题,它们很容易受到连续洪流(Sequential Flooding)的影响。就像前面提到的,你要做的主要假设是你最近使用过的页,你最近访问过的页,很快就会再次被使用。这对于倾斜的访问模式很好,但是如果查询执行顺序扫描,需要读取每个页,会造成只读一次然后就再也不会看的页污染缓冲池。
你可以使用一些更好的策略,例如LRU-K,它不是跟踪某件东西是否被访问,也不是跟踪某件东西最近被访问的时间戳,你要看最后 K 次引用的历史。假设 K 现在是 2,即你记录了最近当某东西被访问时的两个时间戳,然后你就可以计算出以后访问的间隔。如果这个间隔时间比较长,那么就不能经常使用,我们可以把它扔掉。如果间隔时间短得多,证明它经常被访问*,我们可能会想保留它。
同时,你还可以做更高级的优化,例如:根据算法我们算出这页平均访问间隔大概是10分钟左右,你可以期望10分钟后,我需要这个页,这样你可以做一些更智能的预取。
但是,这个算法也会带来更多的元数据开销,这是我们需要权衡的。
另一种选择是使用一些本地化的策略,即以某个查询或者事务的基础决定缓冲池的过期策略,例如 Postgres 就在每个查询或事务的级别维护一个独立的唤醒缓冲池,
另一个更好的策略可能是根据不同的访问模式提供优先级提示(Priority Hints),例如,DBMS 知道访问索引和顺序扫描的访问模式的区别,因此,您可以向缓冲池提供一些提示说明哪些页面是重要的,哪些页面是我们可能不关心的。
举个例子,假设我们正在插入一些连续的 id,因为这些值是单调递增的,所以它们总是会被插入到树的右边;如果你想要执行一次扫描,你的访问路径可能会不同,但它们有一个共同点就是你总是从这个根页开始。你可以向缓冲池管理器提供的一个提示是,缓冲池保留这个根页,这两种类型的查询都需要它。
如果你的缓冲池中有一个不脏的页面,当它需要从缓冲池中剔除的时候,你可以直接删除它,覆盖它,我们不需要保留它。因为没有任何变化,它备份在磁盘上,如果我们需要我们总是可以从磁盘恢复它。但是如果是脏页的话,你需要将脏页的更新写回磁盘以保证更新的持久化。
在快速缓冲页驱除和持久写脏页之间有一种权衡。
为了减少持久化脏页的时候带来的写磁盘的 I/O 阻塞,一般会有一个后台写过程(Background writing process)。DBMS可以定期遍历页表并将脏页写入磁盘。
除了元组和索引缓冲池之外 DBMS 还需要内存来管理其他东西,包括:
- 排序与连接缓冲
- 查询缓存
- 维护缓冲
- 日志缓冲
- 词典缓冲
这些元素有些可能有底层持久化到磁盘的元素,有些只存在于内存中,我们在后面的课程可能也会涉及到。
微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer:
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:
- 知乎:https://www.zhihu.com/people/zhxhash
- B 站:https://space.bilibili.com/31359187
以上是关于CMU15-445数据库bustub Project #2:B+ Tree(下)的主要内容,如果未能解决你的问题,请参考以下文章