英文原文链接:http://www.aosabook.org/en/bdb.html
中文翻译连接:http://www.ituring.com.cn/article/details/8111
作者:Margo Seltzer 和 Keith Bostic
康威法则(Conway’s law)说明了设计反映了产生它的组织的结构。展开来说,我们也许会预见一款由两个人设计和完成最初制作的软件不仅会在一定程度上反映组织的结构,还会反映每一位带来的内在偏见和哲学理念。我们中的一位(Seltzer)在文件系统和数据库管理系统的世界中度过她的职业生涯。如果被问及于此,她会辩解说此二者基本上是等同物,进一步地,操作系统和数据库管理系统实质上都既是资源管理器又是便利抽象层的提供者。它们的区别“仅仅”在于实现的细节。另一位(Bostic)则信仰软件工程中基于工具的方法和基于简单构造块的组件构建方法,因为这样的系统在各种重要“能力”方面总是优于单体式体系结构:可理解性、可扩展性、可维护性、可测试性和灵活性。
当把这两种理念结合起来,你就不会奇怪我们花了过去二十年间的大部分时光共事于Berkeley DB(一个提供高速、灵活、可靠和可扩展的数据管理的软件库)了。Berkeley DB提供了人们所期待的传统系统(例如关系型数据库)中的大多数的同样功能,但是打包方式不同。例如,Berkeley DB提供了按键值的和按顺序的两种快速数据访问,同时还有事务支持和故障恢复。但是,它以库的形式提供这些特性,与需要这些服务的应用程序链接到一起,而不是作为一个独立的服务器应用提供服务。
在本章中,我们将要更深入地观察Berkeley DB,看到它由一组模块组成,每个模块都体现了Unix的“把一件事做好”的哲学。嵌入了Berkeley DB的应用程序能够直接使用这些模块或者通过更加熟悉的操作获取、存放和删除数据项来间接使用它们。我们将集中关注体系结构——我们是如何开始的,我们设计了什么,我们在哪结束了以及为什么。设计能够(而且一定将要)被强迫去适应和改变——重要的是随时间的推移而维护原则和一致的愿景。我们也将简要的谈及长期软件项目的代码演进。Berkeley DB有超过20年的持续开发,这难免会给好的设计造成负面影响。
4.1 开端
Berkeley DB起源于Unix操作系统还专属于AT&T的时代。那时有几百种实用工具和函数库的血统还带有严格的许可限制。Margo Seltzer那时是加州大学伯克利分校的研究生,Keith Bostic是伯克利计算机系统研究组的一员。当时Keith正在从伯克利软件发行版(BSD)中删除AT&T的专属软件。
Berkeley DB项目开始于一个适度的目标——用一个新的、改进的、可同时支持内存和磁盘操作的哈希实现来替代内存哈希软件包hsearch和磁盘哈希软件包dbm/ndbm,以及允许不带专有许可的*分发。Margo Seltzer写的哈希库 SY91 基于Litwin的可扩展线性哈希研究成果。它宣称采用了一种聪明的方法来达到哈希值和页面地址之间的常量时间映射,以及处理较大数据的能力——大于底层的哈希桶或文件系统页大小的项,通常是4到8KB。
如果哈希表很好,那么B树加上哈希表将会更好。Mike Olson,也是加州大学伯克利分校的研究生,曾写过一些B树的实现,同意再写一个。我们三个人把Margo的哈希软件和Mike的B树软件转换成了一套和存取方法无关的API,应用程序通过数据库句柄来引用哈希表或B树,句柄带有读取或修改数据的处理方法。
基于这两种存取方法,Mike Olson和Marge Seltzer写了一篇关于LIBTP(一个运行于应用程序地址空间的可编程事务函数库)的研究论文 SO92 。
这套哈希和B树函数库以Berkeley DB 1.85的名称被集成到了最终的4BSD发行版中。从技术上看,该B树存取方法实现的是B+ link树,不过在本章的后续部分我们将采用B树一词,因为它是存取方法的名称。Berkeley DB 1.85的结构和API对用过Linux或BSD衍生系统的人而言很可能比较熟悉。
Berkeley DB 1.85沉寂了一些年,直到1996年Netscape与Margo Seltzer和Keith Bostic签约来实现LIBTP论文中描述的全部事务设计并且实现一个生产质量级的版本。这项工作产生了Berkeley DB的第一个事务性版本,版本2.0。
Berkeley DB的后续历史就是一个更简单、传统的大事年表了:Berkeley DB 2.0(1997)引入了事务;Berkeley DB 3.0 (1999)是一个重新设计的版本,增加了更多级别的抽象和间接性以支持不断增长的功能;Berkeley DB 4.0 (2001)引入了复制和高可用;Oracle Berkeley DB 5.0 (2010)增加了SQL支持。
在写作本文的时候,Berkeley DB 是世界上使用最广泛的数据库工具集,有几亿份部署的拷贝运行在从路由器、浏览器、邮件系统到操作系统的各种系统中。虽然已经有超过20年的历史了,Berkeley DB 基于工具和面向对象的设计方法使得它可以增量改进和重构以满足使用它的软件的需求。
设计教训1
对任何复杂的软件包的测试和维护来说,将其设计和构建成带有良好定义的API边界的、一组互相协作的模块至关重要。在有需求时,这些边界能够(而且必须!)移动,但是边界总得存在。这些边界的存在可以防止软件变成一堆不可维护的意大利面条。Butler Lampson曾说过,计算机科学中的所有问题都可以通过添加一个间接层来解决。更确切的是,当被问及面向对象的东西是什么意思时,Lampson说这意味着能够在一套API之后有多种实现。Berkeley DB的设计和实现体现了这种同一套接口之后允许多种实现的方法,提供了面向对象的观感,虽然函数库是用C实现的。
4.2 体系结构概述
本节我们将从LIBTP开始回顾Berkeley DB的体系结构,强调它演进中的关键问题。
图4.1摘自Seltzer和Olson的原始论文,说明了原先的LIBTP体系结构;而图4.2则展现了Berkeley DB 2.0设计时的体系结构。
图4.1:LIBTP原型系统的体系结构
图4.2:Berkeley DB 2.0预期的体系结构
LIBTP实现和Berkeley DB 2.0设计之间唯一显著的区别是删除了进程管理器(process manager)。LIBTP要求每个线程注册到库中,然后同步各个线程/进程,而不是提供子系统级的同步。正如4.4节中讨论的那样,原先的设计可能更好。
图4.3:实际的Berkeley DB 2.0.6体系结构
设计和实际发布的Berkeley DB 2.0.6(见图4.3)在体系结构上的区别体现在后者实现了一个强壮的恢复管理器(recovery manager)。恢复子系统在图中用灰色表示。恢复既包括用recovery框表示的驱动结构,也包括用于恢复存取方法所执行操作的重做(redo)和撤销(undo)例程的集合。这些在图中用“access method recovery routines”标注的椭圆形表示。与LIBTP中针对特定存取方法编写日志和恢复例程不同,Berkeley DB 2.0中对恢复的处理是一种一致的设计。这个通用的设计也产生了不同模块间更丰富的接口。
图4.4展现了Berkeley DB 5.0.21的体系结构。图中的数字表示表4.1中列出的API。虽然仍可以看出原始的体系结构的样子,当前的体系结构体现了新模块的增加,旧模块的分解(例如log变成了log和dbreg),以及模块间API的显著增加。
经过十年多的演进,几十个商业发布,以及几百个新特性的增加之后,我们看到体系结构明显比以前更复杂了。值得注意的关键点是:首先,复制(replication)在系统中增加了全新的一层,不过做得很清晰,就像前期的代码一样通过同样的API与系统的其他部分交互。其次,log模块被分成了log和dbreg(database registration)。在4.8节对此有更详细的描述。第三,我们把所有模块间的调用放到了一个以下划线打头的命名空间内,这样应用软件就不会与我们的函数名冲突了。我们在设计教训6中对此进一步讨论。
第四,日志子系统的API现在是基于游标的了(API log_get不复存在,代之以API log_cursor)。过去,Berkeley DB中在同一时刻读写日志的线程从来就没有多于一个,因此函数库中只有一个日志的当前扫描指针。这从来都不是一个好的抽象(但还可以工作),但有了复制之后它变得不可用了。就像应用层API用游标实现循环一样,日志现在也通过游标来支持循环了。第五,存取方法中的fileop模块提供了事务保护的数据库创建、删除和重命名操作。我们尝试了多次以使得实现使人满意(它仍然不是我们期望的那样清晰),在许多次改造之后,我们把它抽成一个独立的模块。
设计教训2
软件设计绝对是迫使你自己在试图解决问题前通盘考虑整个问题的几种方法之一。有经验的程序员采用不同的技术来达到这个目的:有些先写第一版然后扔掉,有些写出大量的手册或设计文档,其他的则设计出代码模板并识别出每个需求,分派到一个具体的函数或一段注释。例如,在Berkeley DB中,我们在写代码之前为存取方法和底层模块创建了一份完整的Unix风格的手册。不管采用的具体技术如何,在代码调试开始后都很难想清楚程序的体系结构,更不要说大的体系结构变化通常会浪费前期的调试努力。软件体系结构设计需要一种与代码调试不同的思维方式,当你开始调试时的软件体系结构通常就是你在该版本中将会交付的结构。
图4.4:Berkeley DB 5.0.21的体系结构
应用程序API | ||||
---|---|---|---|---|
1. DBP句柄处理操作 | 2. DB_ENV恢复 | 3. 事务API | ||
open | open(… DB_RECOVER …) | DB_ENV->txn_begin | ||
get | DB_TXN->abort | |||
put | DB_TXN->commit | |||
del | DB_TXN->prepare | |||
cursor | ||||
存取方法用到的API | ||||
4. Into Lock | 5. Into Mpool | 6. Into Log | 7. Into Dbreg | |
__lock_downgrade | __memp_nameop | __log_print_record | __dbreg_setup | |
__lock_vec | __memp_fget | __dbreg_net_id | ||
__lock_get | __memp_fput | __dbreg_revoke | ||
__lock_put | __memp_fset | __dbreg_teardown | ||
__memp_fsync | __dbreg_close_id | |||
__memp_fopen | __dbreg_log_id | |||
__memp_fclose | ||||
__memp_ftruncate | ||||
__memp_extend_freelist | ||||
恢复的API | ||||
8. Into Lock | 9. Into Mpool | 10. Into Log | 11. Into Dbreg | 12. Into Txn |
__lock_getlocker | __memp_fget | __log_compare | __dbreg_close_files | __txn_getckp |
__lock_get_list | __memp_fput | __log_open | __dbreg_mark_restored | __txn_checkpoint |
__memp_fset | __log_earliest | __dbreg_init_recover | __txn_reset | |
__memp_nameop | __log_backup | __txn_recycle_id | ||
__log_cursor | __txn_findlastckp | |||
__log_vtruncate | __txn_ckp_read | |||
事务模块用到的API | ||||
13. Into Lock | 14. Into Mpool | 15. Into Log | 16. Into Dbreg | |
__lock_vec | __memp_sync | __log_cursor | __dbreg_invalidate_files | |
__lock_downgrade | __memp_nameop | __log_current_lsn | __dbreg_close_files | |
__dbreg_log_files | ||||
复制子系统的API | ||||
17. From Log | 18. From Txn | |||
__rep_send_message | __rep_lease_check | |||
__rep_bulk_message | __rep_txn_applied | |||
__rep_send_message | ||||
复制子系统用到的API | ||||
19. Into Lock | 20. Into Mpool | 21. Into Log | 22. Into Dbreg | 23. Into Txn |
__lock_vec | __memp_fclose | __log_get_stable_lsn | __dbreg_mark_restored | __txn_recycle_id |
__lock_get | __memp_fget | __log_cursor | __dbreg_invalidate_files | __txn_begin |
__lock_id | __memp_fput | __log_newfile | __dbreg_close_files | __txn_recover |
__memp_fsync | __log_flush | __txn_getckp | ||
__log_rep_put | __txn_updateckp | |||
__log_zero | ||||
__log_vtruncate |
表4.1:Berkeley DB 5.0.21的API
为什么把事务函数库设计成多个模块而不是为单一用途优化?针对这个问题有三个答案。首先,它促使一个更严谨的设计。其次,代码中若没有明显的边界,复杂的软件包必然会恶化成为一堆不可维护的东西。第三,你不可能预见用户使用你的软件的所有方式,如果你授权用户访问软件的内部模块,他们将会用你从未想到过的方式来使用这些模块。
在随后的章节中,我们会讨论Berkeley DB中的每个组件,理解它做了什么以及它在整个系统中的位置。
4.3 存取方法:B树、哈希、记录号和队列
Berkeley DB的存取方法同时提供了基于变长和定长字节串的按键值查找和循环。B树和哈希支持变长的键/值对。记录号和队列支持记录号/值对(其中记录号支持变长值而队列仅支持定长值)。
Notes:Btree、Hash、Recno、Queue在这里属于专用名词,保留英文似乎更好。
B树和哈希存取方法之间的主要区别在于B树提供了键值引用的局部性,而哈希则没有。这意味着对几乎所有的数据集B树都是合适的存取方法,而哈希存取方法则适合于大到连B树索引都在内存中放不下的数据集。此时,把内存用来存放数据比存放索引要更好。1990年那时的内存比今天要小很多,这种权衡显得更有道理。
记录号和队列之间的差别在于队列以只支持定长值为代价来支持记录级锁定;记录号支持变长对象,但和B树以及哈希一样,仅支持页级锁定。
我们最初把Berkeley DB设计成CRUD功能(创建、读取、更新和删除)是基于键的,而且是给应用的主要接口。后来我们增加了游标以支持循环。这个需求导致了函数库中大量的重复代码,造成了混乱和资源浪费。随着时间的推移,这变得不可维护,我们把所有基于键的操作都转换成了游标操作(现在,基于键的操作会分配一个缓存的游标,执行操作,然后将游标返回到游标池)。这是软件开发中不断重复的规则之一的应用:在你知道必须去做之前,不要以任何方式优化一条减少清晰度和简洁性的代码路径。
设计教训3
软件体系结构不会优雅地老化。软件体系结构的退化与软件本身的改动数量成正比:缺陷修复会腐蚀软件的层次,新特性会使设计产生应力。确定什么时候软件体系结构退化到该重新设计或重写一个模块是一个很难的决定。一方面,在设计退化时,维护和开发变得更困难,最终变成一个老化的软件。它的每次发布只能靠一群暴力测试者来维持。因为没有人知道该软件内部是怎么工作的。另一方面,用户会强烈抱怨根本性改动带来的不稳定和不兼容。作为一个软件架构师,你唯一的保证是无论选择那条路,总有人对你不满。
我们略去了对Berkeley DB存取方法内部的详细讨论。他们实现了众所周知的B树和哈希算法(记录号是B树代码之上的一层;队列是一个文件块查找功能,尽管它被记录级锁定弄复杂了。)
4.4 函数库的接口层
随着时间的推移,我们增加了更多的功能,发现应用程序和内部代码都需要相同的上层功能(例如内部的表连接操作要用到多个游标来遍历行,应用程序也会用游标来遍历同样这些行。)
设计教训4
你怎么命名变量、方法和函数,采用什么注释或代码风格并不重要;也就是说有大量的格式和风格“足够好”。重要和非常重要的是命名和风格保持一致。有经验的程序员从代码格式和对象命名中得到大量信息。你应当将命名和风格的不一致视为某些程序员将时间和精力花费来欺骗另外的程序员,反之亦然。不能遵循内部编码规范是一种该被解雇的行为。
正因如此,我们把存取方法的API分拆为准确定义的层次。这些接口例程层处理所有必要的通用错误检查,函数特有的错误检查,接口追踪以及其他如自动事务管理等任务。当应用程序调用进Berkeley DB时,它们调用的是基于对象句柄内的方法的第一层接口例程(例如Berkeley DB游标的“put”方法就是调用__dbc_put_pp接口来更新数据项的)。我们用后缀“_pp”来标识所有可以被应用程序调用的函数。
Berkeley DB的接口层处理的任务之一是追踪哪些线程正在Berkeley DB库内运行。这是必要的,因为有些内部的Berkeley DB操作只可以在库内没有线程运行时被执行。Berkeley DB通过在每个库API开始时标记线程在库内运行,在API调用返回时清除标记来追踪线程。这些进入/退出检查总是在接口层进行检查,与此类似的是检查调用是否在复制环境中执行。
很明显的一个问题是“为什么不传递一个线程标识符到函数库,这难道不是更简单吗?”答案是肯定的,那将容易很多,我们当然希望已经那么做了。可是,这种变化将导致每个Berkeley DB应用程序,以及每个应用程序中对Berkeley DB的大部分调用,这在大部分情况下需要应用程序的重构。
设计教训5
软件架构师必须慎重选择升级路径:用户会接受小的改动来升级到新的版本(如果你保证升级期间只出现编译时错误也就是明显的错误;升级的变化绝不应该导致难以理解的失败。)但是要做出真正根本性的变化,你必须承认这是一个新的基础代码,所以需要现有用户的移植。显然,新的基础代码和应用移植在时间或资源上算都不便宜,但是二者都不会像告诉他们一个大改动实际是一次小升级那样惹恼你的用户群。
接口层负责的另一个任务是事务的产生。Berkeley DB支持一种每个操作都隐含一个事务的模式(这可以省去应用程序显式创建和提交事务的麻烦)。支持这种模式需要在应用程序未指定自己的事务调用API时,自动为其创建一个事务。
最后,所有的Berkeley DB API都需要参数检查。在Berkeley DB中有两种类型的错误检查——判断数据库是否在前一个操作中被破坏了的通用性检查,以及我们是否正在一个复制状态变化的中间(例如,改变哪个副本以允许写入)。也有针对具体API的检查:标记的正确使用,参数的正确使用,选项的正确组合,以及其他可以在真正执行请求的操作前检查的错误。
API相关的检查都被封装在以“_arg”为后缀的函数中。因此,与游标的put方法相关的错误检查就位于__dbc_put_arg中,它被函数__dbc_put_pp调用。
最后,当所有参数检验和事务产生完成时,我们调用真正执行操作的辅助方法(在上述例子中是__dbc_put),这也是我们内部调用游标put功能时用的函数。
这种模块拆分在一段开发密集期间逐渐形成,那时我们正在决策需要采取哪些行动以支持复制环境。在基础代码中迭代开发不少次后,我们把前面所说的所有检查都抽出来以使得以后发现问题时更容易修改。
4.5 底层模块
在存取方法之下有四个模块:缓冲区管理器、锁管理器、日志管理器和事务管理器。我们将分别讨论每个模块,不过它们有一些共同的体系结构特性。
首先,所有的这些子系统都有自己的API,而且最初每个子系统都有自己的对象句柄,子系统的所有方法都基于该句柄。例如,你可以用Berkeley DB的锁管理器来处理你自己的锁或者写自己的远程锁管理器。你也可以用Berkeley DB的内存管理器来处理自己的共享内存中的文件页。随着时间的推移,这些子系统特性的句柄被从API中删除了以简化Berkeley DB应用程序。虽然这些子系统仍然是可以被独立于其他子系统使用的独立模块,它们现在共享一个通用的对象句柄,也就是DB_ENV“环境”句柄。这个体系结构的特性强化了分层和通用性。虽然层不时在移动,而且还有些地方一个子系统跨越到另一个子系统,让程序员把系统的不同部分理解为各自独立的软件产品是一个不错的原则。
其次,所有的这些子系统(实际上,所有的Berkeley DB函数)都给上层返回错误码。作为一个函数库,Berkeley DB不能通过定义全局变量侵入应用程序的名字空间。更何况强制错误从调用栈通过单一路径返回强化了好的程序员纪律。
设计经验6
在函数库的设计中,重视名字空间是至关重要的。用你的函数库的程序员应该不需要去记住几十个函数、常量、结构、全局变量的保留名字以避免应用和函数库的命名冲突。
最后,所有这些子系统都支持共享内存。因为Berkeley DB支持在多个运行的进程之间共享数据库,所有共享数据结构都必须放在共享内存中。这个选择的最明显的结果是内存数据结构都必须采用一对基地址和偏移量而不是指针,以使得基于指针的数据结构都可以在多进程的环境下工作。换句话说,不通过指针做间接转换,Berkeley DB函数库必须通过基地址(共享内存段被映射到进程中的内存地址)加上一个偏移量(给定数据结构在映射的内存段中的偏移位置)来创建指针。为了支持这个特性,我们写了一个BSD版本的queue软件包,它实现了各种各样的链表。
设计教训7
在我们写共享内存的链表软件包之前,Berkeley DB的工程师们手工编写了共享内存中的各式不同的数据结构,而且这些实现容易出错和很难调试。共享内存链表软件包,仿照BSD链表软件包(queue.h)实现,代替了所有这些努力。在它一旦调试通过后,我们再也不需要去调试共享内存链表问题了。这体现了三个重要的设计原则:第一,如果一个功能出现了多次,那就写出共享的函数并使用它们,因为对于任何特定功能而言,两份拷贝的存在一定说明其中一份实现得不正确。其次,当你开发一系列通用的例程时,给这些例程写一个测试集,这样你就可以分开调试它们。第三,代码越难以书写,单独书写并维护它就越重要。因为基本上不可能防止外围代码感染和侵蚀一份代码。
4.6 缓冲区管理器:Mpool
Berkeley DB的Mpool子系统是文件页面的内存缓冲池,它隐藏了这样一个事实:内存是一种有限资源,当处理超过内存大小的数据库时,需要函数库在磁盘和内存间来回移动数据库页。将数据库页缓存在内存中使得原先的哈希库大大优于先前的hsearch和hdbm实现。
虽然Berkeley DB的B树存取方法是一个相当传统的B+树实现,树节点之间的指针用页面号而不是内存指针表示,因为函数也把磁盘页格式用作内存页格式。这种表示的优势在于页面可以不需要格式转换就能被从缓存刷出到磁盘,劣势在于遍历索引结构时需要(代价稍高的)重复的缓冲池查找而不是(代价稍低的)内存操作。
底层假设Berkeley DB索引的内存表示实际上是磁盘上持久数据的缓存,这还有其他的一些对性能的影响。例如,每当Berkeley DB访问一个缓存的页面时,首先要pin住内存中的页面。Pin操作防止任何其他的线程或进程将该页从内存池中换出。即便整个索引结构都可以在缓冲中放下,并且从不需要被刷新到磁盘,Berkeley DB仍然在每个操作时要获取和释放这些pin,因为Mpool底层的模型是一个缓存而不是一个持久存储。
4.6.1 Mpool的文件抽象
Mpool假设它位于文件系统之上,通过其API暴露文件抽象。例如,DB_MPOOLFILE句柄表示一个磁盘文件,提供了从文件中获取页面和写页面到文件的方法。虽然Berkeley DB也支持临时的和纯粹的内存数据库,这二者也是通过DB_MPOOLFILE句柄引用的,因为底层都是Mpool抽象层。Get和put方法是主要的Mpool API:get确保页面在缓存中,获得页面上的一个pin并返回指向页面的指针。当函数库用完页面时,put调用unpin页面并允许页面被换出。Berkeley DB的早期版本不区分读访问的pin页面和写访问的pin页面。然而,为了增加并发性,我们扩展了Mpool的API以允许调用者指示更新页面的意图。区分读访问和写访问的能力对多版本并发控制的实现至为重要。为读访问pin住的脏页面是可以被写入磁盘的,而为写访问pin住的脏页面就不能,因为后者可能在任何时刻都处于不一致的状态。
4.6.2 先写日志
Berkeley DB采用先写日志(WAL)实现故障恢复的事务机制。术语先写日志定义了一个策略,要求任何修改所对应的日志记录都要先于它实际的数据更新被写到磁盘。Berkeley DB采用WAL作为其事务机制对Mpool有重要的影响,Mpool必须在通用的缓存机制以及支持WAL协议的需要之间找到设计的平衡点。
Berkeley DB将日志顺序号(LSN)写到所有数据页上,以记录每个特定页的最近更新所对应的日志记录。实施WAL需要Mpool在写页面到磁盘前验证页面上的LSN对应的日志记录已经安全地记录到磁盘了。设计的挑战在于提供该功能而不要求所有Mpool的客户采用和Berkeley DB完全一致的页面格式。Mpool通过提供一系列的set(和get)方法指引其行为来解决这个挑战。DB_MPOOLFILE的方法set_lsn_offset提供了页面内的字节偏移,告诉Mpool到哪儿去找LSN以实现WAL。如果这个方法从未被调过,Mpool就不实现WAL。类似的,set_clearlen方法告诉Mpool页内有多少字节表示元数据,在缓存中创建一个页面前需要显式的清除掉这些字节。这些API允许Mpool提供了支持Berkeley DB事务所必要的功能,而不是迫使Mpool的所有用户去自己实现。
设计教训8
先写日志是另一个提供封装和分层的例子,即使是这个特性不会对其他的软件有用:毕竟有多少程序会关心缓存中的LSN?不管怎样,这个原则是有用的,而且使得软件容易维护、测试、调试和扩展。
4.7 锁管理器:Lock
像Mpool一样,锁管理器也被设计成一个通用模块:它被设计成支持对象层次的*(例如独立的数据项、数据项所在的页面、甚至是一组文件)的一个层次式锁管理器(参看GLPT76)。在描述锁管理器的特性时,也将同时解释Berkeley DB是怎么用它的。然而,就像Mpool一样,其他的应用程序可以用完全不同的方式使用锁管理器,不过那没问题——它被设计得很灵活并支持很多不同的用法。
锁管理器有三个关键的抽象:“*者”标识锁是代表谁获取的,“*对象”标识被锁定的项,以及一个“冲突矩阵”。
*者是32位无符号整数。Berkeley DB把这个32位的名字空间划分为事务性*者和非事务性*者(虽然这种区分对锁管理器而言是透明的)。当Berkeley DB使用锁管理器时,它把范围从0到0x7fffffff之间的ID分给非事务性*者,把从0x80000000到0xffffffff的分给事务性*者。例如,当应用程序打开数据库时,Berkeley DB获取该数据库上的一个长的读锁以保证它在被使用时没有其他的线程删除或重命名它。因为这是一个长锁,所以它不属于任何一个事务,持有该锁的*者就是非事务性的。
任何使用锁管理器的应用程序都需要分配*者ID,所以锁管理器的API同时提供了DB_ENV->lock_id和DB_ENV->lock_id_free调用用以分配和释放*者。因此应用程序不需要实现自己的*者ID分配器,虽然他们也可以这么做。
4.7.1 锁对象
锁对象是表示被*对象的任意长度的不透明(opaque)字节串。当两个不同的*者试图锁住一个特定对象时,他们采用同样的不透明字节串来引用该对象。也就是说,应用程序负责定义描述对象的不透明字节串的约定。
例如,Berkeley DB采用一个DB_LOCK_ILOCK结构来描述其数据库锁。这个结构包含三个字段:文件标识符、页号和类型。
在几乎所有情况下,Berkeley DB都只需要描述它想锁定的特定文件和页面。Berkeley DB在数据库创建时给每个库分配一个唯一的32位数字,并把它写到数据库的元数据页中。以后就在Mpool、*、日志子系统中将它用作数据库的唯一标识符。这就是我们在DB_LOCK_ILOCK结构中引用的fileid字段。不出所料,页面号表示我们想要锁定的特定数据库中的某个页。当我们引用页面锁时,我们将结构中的type字段设置为DB_PAGE_LOCK。然而,我们我们也可以在需要时锁定其他类型的对象。正如前面提到的,我们有时会锁住数据库句柄,它就需要DB_HANDLE_LOCK类型。DB_RECORD_LOCK类型使我们可以处理队列存取方法中的记录级锁定,而DB_DATABASE_LOCK类型则让我们锁定整个数据库。
设计教训9
Berkeley DB选择采用页面级别的锁定是有足够理由的,但是我们发现该选择有时也是有问题的。当一个线程在修改数据库页面中的一条记录时,页级锁定将不允许其他线程修改同一页面中的其他记录,这限制了应用程序的并发性。而只要两个线程不在修改同一个记录,记录级锁定就允许这样的并发。页级锁定增强了稳定性,因为它限制了可能的恢复路径(在恢复过程中,页面总是在几个状态之一,而不是在允许多个记录被同时在页内增加或删除时导致的无数的可能状态)。因为Berkeley DB是为嵌入式系统使用的,一旦有破坏,不会有数据库系统管理员来修复问题,我们选择了稳定性而不是更好的并发。
4.7.2 冲突矩阵
我们将讨论的*子系统的最后一个抽象是冲突矩阵。冲突矩阵定义了系统中不同类型的锁以及它们之间的交互。让我们将持有锁的实体称为持有者,请求锁的称为请求者,并且假设持有者和请求者具有不同的*者ID。冲突矩阵就是一个以[请求者][持有者]为下标的数组,其中如果没有冲突的格子为0,表明请求的锁可以被授予,如果有冲突则为1,表明请求不能被授予。
锁管理器含有一个缺省的冲突矩阵,它碰巧正是Berkeley DB所需要的。然而,应用程序可以*定义自己的*模式和冲突矩阵以满足它自己的需求。对冲突矩阵的唯一要求是它必须是方的(它有相同的行数和列数)并且应用程序用从0开始的整数描述其*模式(例如读、写等)。表4.2列出了Berkeley DB的冲突矩阵。
持有者 | |||||||||
---|---|---|---|---|---|---|---|---|---|
请求者 | No-Lock | Read | Write | Wait | iWrite | iRead | iRW | uRead | wasWrite |
No-Lock | |||||||||
Read | |||||||||
Write | |||||||||
Wait | |||||||||
iWrite | |||||||||
iRead | |||||||||
iRW | |||||||||
uRead | |||||||||
iwasWrite |
表4.2:读写冲突矩阵
4.7.3 对层次*的支持
在解释Berkeley DB冲突矩阵中不同的*模式之前,让我们谈谈*子系统是怎么支持层次*的。层次*指的是一种锁定同一层次结构中不同项的能力。例如文件包含页面,而页面包含不同的元素。当在一个层次*系统中修改一个页面元素时,我们仅想锁住该元素;如果我们要更新页面中的每个元素,仅锁定页面将更有效,而如果我们要修改文件中的每个页面,最好的就是锁定整个文件。此外,层次*必须理解容器的层次,因为锁定一个页面也意味着在某种程度上锁定了文件,文件中有页面在被修改时,你不能修改页面所在的文件。
那么问题在于怎么允许不同的*者在不同层级进行*又不引起混乱。答案是一种叫做意向锁的结构。*者获取容器上的一个意向锁以说明将要锁定容器内的东西的意向。于是,获取页面上的读锁隐含着获取文件上的一个意向读锁。类似的,要写页面中的一个元素,你必须同时获取页面和文件上的意向写锁。在上面的冲突矩阵中,iRead、iWrite和iWR锁都是意向锁,它们分别表示读的意向、写的意向和同时读写的意向。
因此,在处理层次*时,不是在某个东西上请求单一的一个锁,可能有必要请求很多锁:最终要操作的实体上的锁以及所有包含该实体的实体的意向锁。这个需求引入了Berkeley DB中的DB_ENV->lock_vec接口,它接受一个锁请求的数组然后原子性的授予(或拒绝)。
虽然Berkeley DB内部没有采用层次*,它利用了这个能力来指定不同的冲突矩阵,以及一次性指定多个锁请求。在提供事务支持时,我们采用缺省的冲突矩阵;但采用另一个冲突矩阵以支持不带事务的简单的并发存取和恢复支持。我们采用DB_ENV->lock_vec来处理锁的耦合,这是一种增强B树遍历的并发性的技术Com79。在锁耦合中,你只用持有锁足够的时间以获取下一个锁。也就是说,你只需要锁住一个内部的B树页面足够长的时间以读到选择和锁定下一级页面的信息。
设计教训10
Berkeley DB的通用设计在我们增加并发数据存储功能时获得了很好的回报。最初Berkeley DB只提供了两种操作模式:要么没有写并发性的运行,要么支持全部事务支持。事务支持给开发人员带来了一定程度的复杂性,我们发现有些应用程序想提高并发性又不想要全事务支持的额外代价。为了提供这个特性,我们增加了API级别的*以允许并发性,同时保证没有死锁。这需要一个新的和不同的*模式以支持游标。与其在锁管理器中增加特殊目的的代码,我们能够创建另外一种锁矩阵以支持API级锁定只需要的*模式。于是,仅仅通过将锁管理器配置得不同,我们就能提供我们需要的*支持。(不幸的是,修改存取方法就不那么容易了,存取方法中还有相当大的一部分代码要处理这种并发存取的特殊模式)
4.8 日志管理器:Log
日志管理器提供了一个结构化的、仅限追加的文件的抽象。与其他模块一样,我们试图设计出一个通用的日志设施,然而日志子系统可能是我们做的最不成功的一个模块。
设计教训11
当你发现一个体系结构上的问题而又不想立即修复时,你其实倾向于放过它。请记住被蚕食而死和被大象踩住都一定会要你的命。别太犹豫而不去修改整个框架来改进软件结构,而且当你做出修改时,不要以为你以后会清理它而做出不完全的修改——一次做完并继续向前进。就像经常说的,“如果你现在没有时间去做,以后也不会有时间去做”。此外,在你修改框架时,同时也要写测试结构。
日志在概念上很简单:它拿到不透明的字节串并将它们顺序地写到文件中,给每笔日志一个称作日志顺序号(LSN)的唯一标识。此外,日志必须提供通过LSN的高效的正向和反向遍历和检索。这里有两个需要慎重处理的地方:第一,日志必须要保证在任何可能的故障后处于一个一致的状态(这里“一致”指的是未损坏的日志记录的连续序列);其次,因为日志记录被写到稳定存储中以支持事务的提交,日志的性能通常会限定事务性应用的性能。
因为日志是一个仅限追加的数据结构,它可能会无限制增长。我们把日志实现为一组顺序编号的文件,因此,日志空间可以通过简单的删除旧日志文件来回收。在这种多文件的日志结构下,我们把LSN定义为文件号和文件内偏移组成的对。于是,给定一个LSN,日志管理器定位日志记录就很简单了:它移动到给定日志文件的给定偏移,并返回该位置的日志记录。但是日志管理器怎么知道从该位置返回多少字节呢?
4.8.1 日志记录格式
日志必须保留每个日志记录的元数据以保证给定一个LSN,日志管理器可以判断待返回的记录的大小。至少,它需要知道日志记录的长度。我们假定每个日志记录都有一个包含记录长度的日志记录头、前一个日志记录的偏移位置(以支持反向遍历),以及一个日志记录的校验和(以标识日志的损坏和日志文件的结束)。这些元数据足够让日志管理器维护日志记录的顺序了,但是这还不足以支持恢复的实现;该功能要靠日志记录中的内容以及Berkeley DB怎么用这些日志记录来实现。
Berkeley DB通过日志管理器在数据库中更新数据项前写下数据的前像和后像 HR83 。这些日志记录包含了重做或撤销数据库中操作的足够信息。Berkeley DB利用这些信息处理事务撤销(即,在事务撤销时撤销该事务的所有影响)和应用故障或系统故障后的恢复。
除了读写日志记录的API之外,日志管理器还提供了一个强制将日志记录刷出到磁盘的API(DB_ENV->log_flush)。该API允许Berkeley DB实现WAL——在Mpool中回收页面前,Berkeley DB检查页面的LSN并且要求日志管理器保证该LSN已经在稳定存储上了。只有这样,Mpool才会将页面写到磁盘。
设计教训12
Mpool和Log用内部的处理方法来处理WAL,在某些情况下,方法的声明比本身的代码还要长,因为代码除了比较两个整数之外什么也不做。为什么弄这些不太重要的方法仅仅去维护一致的层次呢?因为如果你的代码不是面向对象到了让你牙疼的话,它还不够面向对象。每段代码应该做少量的事情并且应该有个鼓励程序员在小的功能块之上构建新功能的上层设计。如果说我们在过去的几十年中学到了什么软件开发的东西的话,那就是我们构建和维护大量软件的能力是很弱的。构建和维护大量软件是困难和容易出错的,作为软件架构师,你必须尽你所能、尽早、尽量频繁的最大化软件结构表达的信息。
Berkeley DB在日志记录上施以结构以减少恢复的难度。大部分Berkeley DB的日志记录描述的是事务性更新。也就是说,大部分日志记录对应于以事务身份所做的数据库页面更新。这个描述有助于我们识别哪些是Berkeley DB必须附加到每条日志记录的元数据:数据库、事务和记录类型。事务标识和记录类型字段在每个记录的同一位置出现。这使得恢复系统可以抽取出日志类型并且将记录分发到可以解释和执行相关动作的合适的处理者。事务标识让恢复过程识别日志记录属于哪个事务,使得在恢复的不同阶段中,它知道该记录是否可以被忽略还是必须被处理。
4.8.2 打破抽象层
还有一些“特殊的”日志记录。检查点记录可能是这些特殊记录中最熟悉的。做检查点是使数据库的磁盘状态在某个时间点一致的过程。换句话说,Berkeley DB为了性能尽量将数据库页缓存在Mpool中。然而,这些页面最终必须被写到磁盘,而我们越早做这个,在应用或系统故障时我们就能更快得恢复。这意味着需要在做检查点的频率和恢复时间长短之间权衡:系统做检查点越频繁,它就能更快得恢复。做检查点是一个事务性功能,因为我们将在下一节介绍它的细节。就本节而言,我们将谈谈检查点记录以及日志管理器如何在成为一个独立的模块和一个专用的Berkeley DB组件之间挣扎的。
总之,日志管理器本身没有记录类型的概念,因此在理论上,它不需要区分检查点记录和其他的记录——它们都仅仅是需要日志管理器写到磁盘的不透明字节串。实际上,日志管理器维护了元数据,说明了它确实理解一些记录的内容。例如,在日志启动过程中,日志管理器检查所有它能找到的日志文件并且识别出最近写过的日志文件。它假定所有该文件之前的所有日志文件都是完整无缺的,然后开始检查最近的日志文件并确定它含有哪些有效的记录。它从日志文件的开头开始读,直到遇到一个不能正确校验的日志记录头才停下来,这意味着到了日志尾或日志文件损坏了。这两种情况都确定了日志的逻辑结尾。
在读取日志以找到当前日志尾的过程中,日志管理器抽取Berkeley DB的记录类型以寻找检查点记录。作为对事务系统的“帮忙”,它把找到的最后一个检查地点记录的位置保留在日志管理器的元数据中。也就是说,事务系统需要找到最后的检查点,但是与其让日志管理器和事务管理器都去读取整个日志来干这件事,事务管理器把该任务代理给了日志管理器。这是一个违背抽象边界而换来性能的典型例子。
这个权衡意味着什么呢?假设Berkeley DB之外有个系统在使用日志管理器。如果它碰巧写了一个检查点日志类型对应的值到了Berkeley DB放置自己的记录类型的同一个位置,那么日志管理器将把该记录识别为一个检查点记录。然而,除非应用程序找日志管理要这些信息(通过直接读取日志元数据中的cached_ckp_lsn字段),这些信息不会影响任何事情。简而言之,这既不是一个有害的对分层的违背,也不是一个精明的性能优化。
文件管理部分是日志管理器与Berkeley DB其他模块间的分离比较模糊的另一个例子。就像前面提到的一样,大部分Berkeley DB的日志记录需要标识一个数据库。每条日志记录都可能包含数据库的全名,但这样在日志空间的角度看将是很昂贵的,也比较难看,因为恢复将需要把这个名字映射到某种形式的句柄以便能够访问数据库(要么是一个文件描述符要么是一个数据库句柄)。实际上,Berkeley DB在日志中用一个整数标识数据库,称为一个日志文件ID,并实现了一系列的函数,统称为dbreg(database registration的简称),来维护文件名和日志文件ID的映射。当数据库被打开时,这个映射的持久化版本(记录类型为DBREG_REGISTER)被写到日志记录中。然而,我们也需要这个映射的内存表示以支持事务的撤销和恢复。哪个子系统应该负责维护这个映射呢?
理论上,文件到日志文件ID的映射是一个高层的Berkeley DB函数;它不属于任何一个子系统,子系统不应有全局概念。在最初的设计中,这些信息被留在日志子系统的数据结构中,因为日志系统看起来是最好的选择。然而,在不断地发现和修复实现中的缺陷时,这个映射支持被从日志子系统代码中抽取出来形成了它自己小子系统,有了自己的面向对象的接口和私有的数据结构。(回过来看,这些信息逻辑上本应该被放在Berkeley DB环境信息本身中,在所有子系统之外。)
设计教训13
极少存在“不重要的Bug”这样的事情。确实,不时会有一些笔误,但通常一个Bug意味着有人没有完全理解他们在做的事情并实现错了。当你修复Bug时,不要仅看现象,要看底层的原因。如果你愿意的话,还应该看看产生误解的原因,因为这样可以更好的理解程序的体系结构并发现设计本身更本质的缺陷。
4.9 事务管理器:Txn
我们的最后一个模块是事务管理器,它把各个独立的模块联系在一起以提供事务的ACID属性(原子性、一致性、隔离性和持久性)。事务管理器负责事务的开始和结束(要么提交,要么撤销),协调日志管理器和缓冲区管理器做事务检查点并组织恢复。我们将按顺序逐一讨论这些领域。
Jim Gray发明了ACID这个缩写词来描述事务提供的关键属性 Gra81 。原子性的意思是一个事务中执行的所有操作在数据库中表现为一个单一的单元——它们要么都在数据库中,要么都不在。一致性的意思是事务把数据库从一个逻辑一致的状态转移到另一个。例如,如果应用程序要求每个员工都必须被安排到一个已在数据库中定义了的部门,那么一致性属性将会确保它(在事务正确书写时)。隔离性的意思是从每个事务的角度看,它就像是在没有任何其他并发事务在运行时顺序执行的。最后,持久性的意思是一旦事务被提交,它就保持提交状态——没有故障可以使得已经提交的事务消失掉。
事务子系统在其他子系统的协助下确保ACID属性。它采用传统的事务开始、提交和撤销操作来分隔事务的开始点和结束点。它也提供了一个prepare调用以实现两阶段提交,两阶段提交是在分布事务之间提供事务属性的技术,本章对此没有描述。事务开始要分配一个新的事务标识符并返回一个事务句柄DB_TXN给应用程序。事务提交要写一个提交日志记录然后强制刷出日志到磁盘(除非应用程序表明它愿意放弃持久性以换取更快的提交处理),保证即使在出现故障时,事务也会被提交。事务撤销会反向读取属于对应事务的日志记录,撤销该事务已经做的每个操作,将数据库退回到该事务开始前的状态。
4.9.1 检查点处理
事务管理器也负责做检查点。在学术界有很多不同的技术来做检查点 HR83 。Berkeley DB采用了模糊检查点的一个变种。从根本上看,做检查点需要需要将缓冲区从Mpool中写到磁盘。这是一个很可能代价昂贵的操作,重要的是系统同时能继续处理新的事务以避免长时间的服务中断。在检查点开始时,Berkeley DB检查当前活动的事务集合以找到它们当中任何一个所写的最小的LSN。该LSN就是检查点LSN。事务管理器然后要求Mpool去刷新缓存中的脏页到磁盘,写这些缓冲可能会触发日志的刷出操作。在所有这些缓冲都被安全写到磁盘后,事务管理器会写一个包含检查点LSN的检查点记录。该记录表明在检查点LSN之前的日志记录描述的所有操作现在都安全的存在磁盘上了。因此,在检查点LSN之前的日志记录就不再需要用来恢复了。这有两重意思:第一,系统可以回收检查点LSN之前的任意日志文件。第二,恢复只需要处理检查点LSN之后的日志记录。因为检查点LSN之前的日志记录所描述的更新已经被反映在磁盘状态中了。
注意在检查点LSN和实际的检查点记录之间可能存在很多的日志记录。这没什么问题,因为那些记录描述的操作逻辑上发生在检查点之后,因此如果系统故障了还是需要做恢复的。
4.9.2 恢复
事务性难题的最后一个部分是恢复。恢复的目标是将磁盘数据库从一个可能不一致的状态转到一个一致的状态。Berkeley DB采用一个相当传统的两遍模式,大致对应于“相对于最后的检查点LSN,撤销所有没有提交的事务并重做所有已经提交的事务”。后面将介绍更多的细节。
Berkeley DB需要重新构造从日志文件ID到实际的数据库之间的映射以便它可以重做和撤销数据库中的操作。日志中包含了DBREG_REGISTER日志记录的完整历史,但是数据库会长时间处于打开状态,我们不想保留整个数据库打开期间的日志文件,而需要一个更有效的方法来访问这个映射。在写检查点记录前,事务管理器写下一组DBREG_REGISTER记录来描述当前的从日志文件ID到数据库的映射。在恢复期间,Berkeley DB使用这些日志记录去重新构造文件映射。
当恢复开始时,事务管理器检查日志管理器的cached_ckp_lsn值来判断最后一个检查点记录的位置。该记录包含检查点的LSN。Berkeley DB需要从该检查点LSN开始恢复,但是为了做这件事,它需要重新构造在该检查点LSN处的日志文件ID映射;这些信息在该检查点LSN之前的检查点记录中。因此,Berkeley DB必须查找在该检查点LSN之前的最近一个检查点记录。检查点记录不仅包含了检查点LSN,还有前一个检查点的LSN(以支持这个查找过程)。恢复从最近的检查点开始,采用每个检查点记录中的prev_lsn字段去反向遍历日志直到它找到了一个出现在检查点LSN之前的检查点记录。算法如下:
ckp_record = read (cached_ckp_lsn)
ckp_lsn = ckp_record.checkpoint_lsn
cur_lsn = ckp_record.my_lsn
while (cur_lsn > ckp_lsn) {
ckp_record = read (ckp_record.prev_ckp)
cur_lsn = ckp_record.my_lsn
}
从前面的算法找到的检查点开始,恢复算法顺序读取到日志尾以重新构造日志文件ID映射。当它到达日志尾时,映射应该准确的对应系统停止时存在的映射。也是在这个阶段中,恢复算法跟踪遇到的每个事务提交记录,记录它们的事务标识。所有有日志记录但是其日志标识未在事务提交记录中出现的事务要么是被回滚了,要么是从未完成从而也应被视为回滚了。当恢复到日志尾时,它调转方向并开始反向读取日志。对于遇到的每个事务日志记录,它抽取出事务标识并构造出已经提交事务的列表,以决定该记录是否该被回滚。如果它找到事务标识不属于提交的事务,就抽取出记录类型并且调用一个该日志记录的恢复例程,指导它去撤销对应的操作。如果该记录属于一个已提交的事务,恢复在反向扫描时忽略它。反向扫描一直进行到检查点LSN(Notes:这里有个脚注)。最终,恢复再以正向方式最后读一遍日志,这次重做所有属于已提交事务的日志记录。当这最后一个阶段完成时,恢复做一个检查点。此时,数据库完全一致了,可以开始运行应用程序了。
总之,恢复可以被总结为:
- 找到最近检查点记录中检查点LSN之前的那个检查点
- 正向读取日志以恢复日志文件ID映射并构造出已提交事务的列表
- 反向读取日志到检查点LSN,撤销未提交事务的所有操作
- 正向读取日志,重做已提交事务的所有操作
- 做检查点
理论上,最后一个检查点是不必要的。实际上,它减少了未来的恢复的时间并使得数据库处于一个一致的状态。
设计教训14
数据库恢复是一个复杂的主题,很难写,更难调试,因为恢复根本不会频繁的发生。在他的图灵奖演讲中,Edsger Dijkstra认为编程天生很难,承认我们不擅此道是智慧的开端。我们作为架构师和程序员的目的是使用我们所掌握的工具:设计、问题分解、评审、测试、命名和风格规范以及其它好的习惯来限制编程问题为我们能解决的问题。
4.10 结束语
Berkeley DB现在已经年满20岁了。它可以说是第一个通用的事务性键/值存储,也是NoSQL运动的鼻祖。Berkeley DB继续作为几百个商业产品和几千个开源应用软件(包括SQL、XML和NoSQL引擎)的底层存储系统,并在全球有几百万个部署。我们在它的开发和维护过程中所学到的经验教训都体现在代码和上面总结的设计提示中了。我们分享并希望其他的软件设计者和架构师发现它们有用。
脚注
请注意我们只需要返回到检查点LSN,而不是它前面的检查点记录。