康威定律指出,设计反映了产生它的组织结构。稍微扩展一下,我们可能会预料到,由两个人设计和最初制作的软件制品可能在某种程度上反映了,不仅是组织的结构,而且还反映了每个人带来的内部偏见和理念。我们中的一位(Seltzer)在文件系统和数据库管理系统领域之间度过了她的职业生涯。如果被问到,她会争辩说这两者从根本上来说是一样的,而且,操作系统和数据库管理系统本质上都是资源管理器和便捷抽象的提供者。差异“仅仅”是实现细节。另一位(Bostic)则相信基于工具的软件工程方法,以及基于更简单构建块构建组件的方法,因为此类系统在重要的“-bility”方面总是优于单片架构:可理解性、可扩展性、可维护性、可测试性和灵活性。
将这两种观点结合起来,您不难理解,我们一起花了近二十年的时间致力于 Berkeley DB——一个提供快速、灵活、可靠和可扩展数据管理的软件库。Berkeley DB 提供了许多人们从更传统的系统(例如关系数据库)中期望的功能,但打包方式不同。例如,Berkeley DB 提供快速数据访问(包括键控和顺序访问),以及事务支持和故障恢复。但是,它以一个直接与需要这些服务的应用程序链接的库的形式提供这些功能,而不是由独立的服务器应用程序提供。
在本章中,我们将深入了解 Berkeley DB,并发现它是由一系列模块组成的,每个模块都体现了 Unix 的“做好一件事”的理念。嵌入 Berkeley DB 的应用程序可以直接使用这些组件,或者它们可以通过更熟悉的获取、放置和删除数据项的操作隐式地使用它们。我们将重点关注架构——我们是如何起步的,我们在设计什么,以及我们最终得到了什么以及为什么。设计可以(当然也会!)被迫适应和改变——关键是在一段时间内保持原则和一致的愿景。我们还将简要考虑长期软件项目的代码演变。Berkeley DB 拥有超过二十年的持续开发历史,这不可避免地会对良好的设计造成影响。
Berkeley DB 可以追溯到 Unix 操作系统专属于 AT&T 并且有数百个实用程序和库的时代,这些库的谱系具有严格的许可约束。Margo Seltzer 是加州大学伯克利分校的研究生,Keith Bostic 是伯克利计算机系统研究小组的成员。当时,Keith 正在努力从伯克利软件发行版中移除 AT&T 的专有软件。
Berkeley DB 项目始于一个适度的目标,即用一个新的改进的哈希实现来替换内存中的 hsearch
哈希包和磁盘上的 dbm/ndbm
哈希包,该实现能够在内存中和磁盘上运行,并且可以自由地重新分发而无需专有许可证。Margo Seltzer 编写的 hash
库 [SY91] 基于 Litwin 的可扩展线性哈希研究。它拥有一个巧妙的方案,允许在哈希值和页面地址之间进行恒定时间映射,以及处理大型数据的能力——大于底层哈希桶或文件系统页面大小的数据项,通常为 4 到 8 千字节。
如果哈希表很好,那么 B 树和哈希表会更好。Mike Olson 也是加州大学伯克利分校的研究生,他编写了许多 B 树实现,并同意再编写一个。我们三个人将 Margo 的哈希软件和 Mike 的 B 树软件转换为一个与访问方法无关的 API,其中应用程序通过数据库句柄引用哈希表或 B 树,这些句柄具有用于读取和修改数据的句柄方法。
基于这两种访问方法,Mike Olson 和 Margo Seltzer 撰写了一篇研究论文 ([SO92]),描述了 LIBTP,一个在应用程序地址空间中运行的程序化事务库。
哈希和 B 树库已合并到最终的 4BSD 版本中,名称为 Berkeley DB 1.85。从技术上讲,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 引入了事务;Berkeley DB 3.0(1999 年)是一个重新设计的版本,增加了更多抽象级别和间接级别以适应不断增长的功能。Berkeley DB 4.0(2001 年)引入了复制和高可用性,而 Oracle Berkeley DB 5.0(2010 年)增加了 SQL 支持。
在撰写本文时,Berkeley DB 是世界上使用最广泛的数据库工具包,数以亿计的已部署副本运行在从路由器和浏览器到邮件程序和操作系统的各种设备中。尽管已有二十多年的历史,但 Berkeley DB 基于工具和面向对象的的方法使其能够逐步改进和重新设计自身,以满足使用它的软件的要求。
设计教训 1
对于任何复杂的软件包的测试和维护至关重要的是,软件应设计和构建为一组具有明确定义的 API 边界的协作模块。边界可以(也应该!)根据需要发生变化,但它们始终需要存在。这些边界的出现阻止了软件变成无法维护的意大利面条代码。Butler Lampson 曾经说过,计算机科学中的所有问题都可以通过另一层间接性来解决。更重要的是,当被问及面向对象意味着什么时,Lampson 说这意味着能够在 API 后面拥有多个实现。Berkeley DB 的设计和实现体现了这种方法,允许在通用接口后面有多个实现,提供面向对象的外观和感觉,即使库是用 C 编写的。
在本节中,我们将回顾 Berkeley DB 库的架构,从 LIBTP 开始,并重点介绍其演变的关键方面。
图 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 设计之间唯一的重大区别是去除了进程管理器。LIBTP 要求每个控制线程向库注册自身,然后同步各个线程/进程,而不是提供子系统级同步。如 第 4.4 节 所述,最初的设计可能对我们更有帮助。
图 4.3:实际的 Berkeley DB 2.0.6 架构。
设计和实际发布的 db-2.0.6 架构(如图 图 4.3 所示)之间的差异说明了实现健壮恢复管理器的现实情况。恢复子系统以灰色显示。恢复包括驱动程序基础架构(在恢复框中描述),以及一组恢复重做和撤消例程,这些例程恢复访问方法执行的操作。这些由标记为“访问方法恢复例程”的圆圈表示。Berkeley DB 2.0 中的恢复处理方式具有一致的设计,而不是 LIBTP 中特定于特定访问方法的手工编码日志记录和恢复例程。这种通用设计还在各个模块之间产生了更丰富的接口。
图 4.4 说明了 Berkeley DB-5.0.21 架构。图中的数字引用了 表 4.1 中表格中列出的 API。虽然原始架构仍然可见,但当前架构显示了其随着新模块的添加、旧模块的分解(例如,log
已成为 log
和 dbreg
)以及模块间 API 数量的显着增加而逐渐老化的状态。
经过十多年的发展、数十个商业版本和数百个新功能的发布,我们发现架构比其祖先复杂得多。需要注意的关键事项是:首先,复制为系统添加了全新的层,但它以干净的方式执行此操作,通过与历史代码相同的 API 与系统的其余部分交互。其次,log
模块被拆分为 log
和 dbreg
(数据库注册)。这将在 第 4.8 节 中详细讨论。第三,我们将所有模块间调用都放置到以下划线开头的命名空间中,这样应用程序就不会与我们的函数名称发生冲突。我们将在设计教训 6 中进一步讨论这一点。
第四,日志记录子系统的 API 现在基于游标(没有 log_get
API;它被 log_cursor
API 替换)。从历史上看,Berkeley DB 在任何时间点都从未有过多个控制线程读取或写入日志,因此库对日志中的当前查找指针有一个单一的概念。这从来不是一个好的抽象,但在复制中,它变得无法使用。就像应用程序 API 使用游标支持迭代一样,日志现在也使用游标支持迭代。第五,访问方法内的 fileop
模块提供了对事务性保护的数据库创建、删除和重命名操作的支持。我们尝试了多次才能使实现变得可接受(它仍然不像我们希望的那样干净),并且在多次重新设计之后,我们将其提取到自己的模块中。
设计教训 2
软件设计只是在尝试解决问题之前迫使自己思考整个问题的几种方法之一。熟练的程序员为此目的使用不同的技术:一些人编写第一个版本并将其丢弃,一些人编写广泛的手册页或设计文档,另一些人填写代码模板,其中每个需求都已识别并分配给特定函数或注释。例如,在 Berkeley DB 中,我们在编写任何代码之前为访问方法和底层组件创建了一套完整类似 Unix 的手册页。无论使用何种技术,在代码调试开始后很难清晰地思考程序架构,更不用说大型架构更改通常会浪费先前的调试工作了。软件架构需要与调试代码不同的思维方式,而您在开始调试时拥有的架构通常就是您在此版本中交付的架构。
图 4.4:Berkeley DB-5.0.21 架构
应用程序 API | ||||
---|---|---|---|---|
1. DBP 句柄操作 | 2. DB_ENV 恢复 | 3. 事务 API | ||
打开 | 打开(… DB_RECOVER …) | DB_ENV->txn_begin | ||
获取 | DB_TXN->abort | |||
放置 | DB_TXN->commit | |||
删除 | DB_TXN->prepare | |||
游标 | ||||
访问方法使用的 API | ||||
4. 到锁 | 5. 到内存池 | 6. 到日志 | 7. 到数据库注册 | |
__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. 锁模块内部 | 9. 内存池模块内部 | 10. 日志模块内部 | 11. 数据库注册模块内部 | 12. 事务模块内部 |
__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. 锁模块内部 | 14. 内存池模块内部 | 15. 日志模块内部 | 16. 数据库注册模块内部 | |
__lock_vec | __memp_sync | __log_cursor | __dbreg_invalidate_files | |
__lock_downgrade | __memp_nameop | __log_current_lsn | __dbreg_close_files | |
__dbreg_log_files | ||||
复制系统内部的API | ||||
17. 来自日志模块 | 18. 来自事务模块 | |||
__rep_send_message | __rep_lease_check | |||
__rep_bulk_message | __rep_txn_applied | |||
__rep_send_message | ||||
复制系统外部的API | ||||
19. 锁模块内部 | 20. 内存池模块内部 | 21. 日志模块内部 | 22. 数据库注册模块内部 | 23. 事务模块内部 |
__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 的每个组件,了解其功能以及它如何在更大的框架中发挥作用。
Berkeley DB 访问方法提供了可变和固定长度字节字符串的键查找和迭代功能。Btree 和 Hash 支持可变长度的键/值对。Recno 和 Queue 支持记录号/值对(其中 Recno 支持可变长度的值,而 Queue 仅支持固定长度的值)。
Btree 和 Hash 访问方法之间的主要区别在于,Btree 为键提供了局部性引用,而 Hash 没有。这意味着 Btree 几乎适用于所有数据集;但是,对于数据量如此之大以至于连 Btree 索引结构都无法容纳在内存中的数据集,Hash 访问方法是合适的。在那种情况下,最好将内存用于数据而不是索引结构。这种权衡在 1990 年更有意义,当时主内存通常比现在小得多。
Recno 和 Queue 之间的区别在于,Queue 支持记录级锁定,但代价是需要固定长度的值。Recno 支持可变长度的对象,但与 Btree 和 Hash 一样,仅支持页面级锁定。
我们最初设计 Berkeley DB 时,CRUD 功能(创建、读取、更新和删除)是基于键的,并且是应用程序的主要接口。后来我们添加了游标来支持迭代。这种顺序导致库内部出现了混乱且浪费的大量重复代码路径。随着时间的推移,这变得难以维护,我们把所有基于键的操作都转换为游标操作(基于键的操作现在分配一个缓存的游标,执行操作,然后将游标返回到游标池)。这是软件开发中一个不断重复的规则的应用:除非您知道这样做是必要的,否则不要以任何损害清晰度和简单性的方式优化代码路径。
设计教训 3
软件架构不会优雅地老化。软件架构的退化与对软件进行的更改次数成正比:错误修复会腐蚀分层,新功能会给设计带来压力。决定软件架构何时退化到需要重新设计或重写模块的程度是一个艰难的决定。一方面,随着架构的退化,维护和开发变得更加困难,最终会成为一个遗留软件,只有通过为每次发布配备一支蛮力测试人员才能维护,因为没有人理解软件内部的工作原理。另一方面,用户会对因根本性更改而导致的不稳定性和不兼容性表示强烈不满。作为软件架构师,您唯一可以保证的是,无论您选择哪条路径,都会有人对您感到愤怒。
我们省略了对 Berkeley DB 访问方法内部的详细讨论;它们实现了相当知名的 Btree 和哈希算法(Recno 是 Btree 代码之上的一个层,而 Queue 是一个文件块查找函数,尽管由于添加了记录级锁定而变得复杂)。
随着时间的推移,随着我们添加了更多功能,我们发现应用程序和内部代码都需要相同的高级功能(例如,表连接操作使用多个游标来迭代行,就像应用程序可能使用游标来迭代这些相同的行一样)。
设计教训 4
您如何命名变量、方法、函数以及使用什么注释或代码风格并不重要;也就是说,有很多格式和风格是“足够好的”。真正重要的是命名和风格的一致性。熟练的程序员可以从代码格式和对象命名中获取大量信息。您应该将命名和风格不一致视为某些程序员花费时间和精力来欺骗其他程序员,反之亦然。不遵守内部编码约定是解雇的理由。
因此,我们将访问方法 API 分解成明确定义的层。这些接口例程层执行所有必要的通用错误检查、特定于函数的错误检查、接口跟踪以及其他任务,例如自动事务管理。当应用程序调用 Berkeley DB 时,它们会根据对象句柄中的方法调用第一级接口例程。(例如,__dbc_put_pp
是 Berkeley DB 游标“put”方法的接口调用,用于更新数据项。"_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 功能时使用的相同函数。
这种分解是在一个活动密集的时期演变而来的,当时我们正在确定在复制环境中工作时需要采取哪些具体措施。在对代码库进行了多次非平凡的迭代后,我们将所有这些序言检查拆分出来,以便在下一次我们发现其问题时更容易更改。
访问方法的底层有四个组件:缓冲区管理器、锁管理器、日志管理器和事务管理器。我们将分别讨论它们,但它们都有一些共同的架构特征。
首先,所有子系统都有自己的 API,最初每个子系统都有自己的对象句柄,所有该子系统的方法都基于该句柄。例如,您可以使用 Berkeley DB 的锁管理器来处理您自己的锁或编写您自己的远程锁管理器,或者您可以使用 Berkeley DB 的缓冲区管理器来处理共享内存中的您自己的文件页面。随着时间的推移,为了简化 Berkeley DB 应用程序,子系统特定的句柄已从 API 中删除。尽管子系统仍然是独立的组件,可以独立于其他子系统使用,但它们现在共享一个公共对象句柄,即 DB_ENV
“环境”句柄。此架构特性强制执行分层和泛化。即使层会不时移动,并且仍然有一些地方一个子系统会跨越到另一个子系统,但程序员将系统的各个部分视为独立的软件产品本身也是一个好的习惯。
其次,所有子系统(实际上,所有 Berkeley DB 函数)都会将错误代码返回到调用堆栈。作为库,Berkeley DB 无法通过声明全局变量来占用应用程序的名称空间,更不用说强制错误通过调用堆栈中的单一路径返回可以强制执行良好的程序员纪律。
设计教训 6
在库设计中,尊重命名空间至关重要。使用您的库的程序员不需要记住数十个函数、常量、结构和全局变量的保留名称以避免应用程序和库之间的命名冲突。
最后,所有子系统都支持共享内存。由于 Berkeley DB 支持在多个运行进程之间共享数据库,所有有趣的数据结构都必须驻留在共享内存中。这种选择最重要的影响是,内存中的数据结构必须使用基地址和偏移量对而不是指针,以便基于指针的数据结构在多个进程的上下文中工作。换句话说,不是通过指针间接访问,Berkeley DB 库必须从基地址(共享内存段映射到内存的地址)加上偏移量(该映射段中特定数据结构的偏移量)创建一个指针。为了支持此功能,我们编写了一个 Berkeley 软件发行版queue
包的版本,该版本实现了各种各样的链表。
设计课 7
在我们编写共享内存链表包之前,Berkeley DB 工程师手动编写了各种不同的共享内存数据结构,这些实现很脆弱且难以调试。共享内存列表包(以 BSD 列表包(queue.h
)为模型)取代了所有这些工作。一旦调试完成,我们就再也不用调试其他共享内存链表问题了。这说明了三个重要的设计原则:首先,如果您有多个地方出现的功能,请编写共享函数并使用它们,因为在您的代码中存在两个任何特定功能的副本,就保证其中一个实现不正确。其次,当您开发一组通用例程时,为该例程集编写一个测试套件,以便您可以隔离调试它们。第三,代码越难编写,越需要单独编写和维护;几乎不可能阻止周围的代码感染和腐蚀一段代码。
Berkeley DB Mpool 子系统是一个内存中的文件页面缓冲池,它隐藏了主内存是有限资源的事实,要求库在处理大于内存的数据库时将数据库页面移入和移出磁盘。将数据库页面缓存到内存中是最初的哈希库能够显著优于历史hsearch
和ndbm
实现的原因。
虽然 Berkeley DB Btree 访问方法是相当传统的 B+树实现,但树节点之间的指针表示为页号,而不是实际的内存指针,因为库的实现也使用磁盘上的格式作为其内存中的格式。这种表示的优点是可以将页面从缓存中刷新而无需格式转换;缺点是遍历索引结构需要(更昂贵的)重复缓冲池查找,而不是(更便宜的)内存间接寻址。
从底层假设 Berkeley DB 索引的内存表示实际上是磁盘持久数据的一个缓存,还会产生其他性能影响。例如,每当 Berkeley DB 访问缓存页面时,它首先将页面固定在内存中。此固定操作可以防止任何其他线程或进程将其从缓冲池中逐出。即使索引结构完全适合缓存并且永远不需要刷新到磁盘,Berkeley DB 仍然在每次访问时获取和释放这些固定操作,因为 Mpool 提供的底层模型是缓存,而不是持久存储。
Mpool 假设它位于文件系统之上,通过 API 导出文件抽象。例如,DB_MPOOLFILE
句柄表示磁盘上的文件,提供将页面获取/放入文件的方法。虽然 Berkeley DB 支持临时和纯内存中的数据库,但由于底层 Mpool 抽象,这些数据库也由DB_MPOOLFILE
句柄引用。get
和put
方法是主要的 Mpool API:get
确保页面存在于缓存中,获取页面上的固定操作并返回指向页面的指针。当库完成对页面的操作时,put
调用取消固定页面,释放它以进行逐出。早期版本的 Berkeley DB 并没有区分为了读取访问而固定页面与为了写入访问而固定页面。但是,为了提高并发性,我们扩展了 Mpool API,允许调用者指示他们更新页面的意图。这种区分读取访问和写入访问的能力对于实现多版本并发控制至关重要。一个被固定以进行读取但碰巧是脏的页面可以写入磁盘,而一个被固定以进行写入的页面则不能,因为它可能在任何时刻都处于不一致的状态。
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?无论如何,这种纪律是有用的,并且使软件更易于维护、测试、调试和扩展。
与 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 分配器,尽管它们当然可以。
锁对象是任意长的不透明字节字符串,表示被锁定的对象。当两个不同的锁具想要锁定特定对象时,它们使用相同的非透明字节字符串来引用该对象。也就是说,应用程序负责就如何用非透明字节字符串描述对象达成约定。
例如,Berkeley DB 使用 DB_LOCK_ILOCK 结构来描述其数据库锁。此结构包含三个字段:文件标识符、页号和类型。
在几乎所有情况下,Berkeley DB 只需要描述它想要锁定的特定文件和页面。Berkeley DB 在创建时为每个数据库分配一个唯一的 32 位数字,将其写入数据库的元数据页面,然后在 Mpool、锁定和日志记录子系统中将其用作数据库的唯一标识符。这就是我们在 DB_LOCK_ILOCK 结构中引用的fileid
。毫不奇怪,页码指示我们希望锁定特定数据库的哪个页面。当我们引用页面锁时,我们将结构的类型字段设置为 DB_PAGE_LOCK。但是,我们也可以根据需要锁定其他类型的对象。如前所述,我们有时会锁定数据库句柄,这需要 DB_HANDLE_LOCK 类型。DB_RECORD_LOCK 类型允许我们在队列访问方法中执行记录级锁定,DB_DATABASE_LOCK 类型允许我们锁定整个数据库。
设计课 9
Berkeley DB 选择使用页面级锁定的原因是合理的,但我们发现这种选择有时存在问题。页面级锁定限制了应用程序的并发性,因为一个控制线程修改数据库页面上的一个记录将阻止其他控制线程修改同一页面上的其他记录,而记录级锁允许这种并发性,只要两个控制线程没有修改相同的记录。页面级锁定增强了稳定性,因为它限制了可能的恢复路径的数量(在恢复期间,页面始终处于两种状态之一,而不是如果多个记录正在添加到页面和从页面删除时页面可能处于的无限种可能状态)。由于 Berkeley DB 旨在用作嵌入式系统,在该系统中没有数据库管理员可以修复出现损坏的情况,因此我们选择稳定性而不是更高的并发性。
我们将讨论的锁定子系统的最后一个抽象是冲突矩阵。冲突矩阵定义了系统中存在的不同类型的锁以及它们如何交互。让我们将持有锁的实体称为持有者,将请求锁的实体称为请求者,并且让我们还假设持有者和请求者具有不同的锁具 ID。冲突矩阵是一个由[请求者][持有者]
索引的数组,其中每个条目包含一个零表示没有冲突,表示可以授予请求的锁,以及一个一表示存在冲突,表示无法授予请求。
锁管理器包含一个默认的冲突矩阵,这恰好是 Berkeley DB 所需要的。然而,应用程序可以自由地设计自己的锁模式和冲突矩阵以满足其自身的目的。对冲突矩阵的唯一要求是它是方形的(行数和列数相同),并且应用程序使用基于 0 的顺序整数来描述其锁模式(例如,读、写等)。表 4.2 显示了 Berkeley DB 冲突矩阵。
持有者 | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
请求者 | 无锁 | 读 | 写 | 等待 | i写 | i读 | i读写 | 用户读 | 曾写 | |||||||
无锁 | ||||||||||||||||
读 | ✓ | ✓ | ✓ | ✓ | ||||||||||||
写 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||||||||
等待 | ||||||||||||||||
i写 | ✓ | ✓ | ✓ | ✓ | ||||||||||||
i读 | ✓ | ✓ | ||||||||||||||
i读写 | ✓ | ✓ | ✓ | ✓ | ||||||||||||
用户读 | ✓ | ✓ | ✓ | |||||||||||||
i曾写 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
表 4.2:读写冲突矩阵。
在解释 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 级锁定所需的锁定模式。因此,只需以不同的方式配置锁管理器,我们就可以提供所需的锁定支持。(遗憾的是,更改访问方法并不那么容易;访问方法代码中仍然有很大一部分用于处理这种特殊的并发访问模式。)
日志管理器提供了结构化、仅追加文件的抽象。与其他模块一样,我们打算设计一个通用的日志记录功能,但是日志记录子系统可能是我们最不成功的地方。
设计经验 11
当你发现一个不想“现在”修复的架构问题,并且倾向于放任不管时,请记住,被鸭子啄死和被大象踩死一样致命。不要犹豫改变整个框架以改进软件结构,并且当你进行更改时,不要进行部分更改,以为以后会清理——一次性全部完成,然后继续前进。正如经常重复的那样,“如果你现在没有时间做正确的事情,以后就不会找到时间做这件事。” 并且在你更改框架时,也编写测试结构。
日志在概念上非常简单:它获取不透明的字节字符串并将它们顺序写入文件,并为每个字符串分配一个唯一的标识符,称为日志序列号 (LSN)。此外,日志必须提供通过 LSN 进行高效的前向和后向遍历和检索。有两个棘手的地方:首先,日志必须保证在任何可能的故障后都处于一致状态(其中一致意味着它包含一系列连续的未损坏的日志记录);其次,由于必须将日志记录写入稳定存储以使事务提交,因此日志的性能通常是限制任何事务应用程序性能的因素。
由于日志是仅追加数据结构,因此它可以无限增长。我们将日志实现为一系列按顺序编号的文件,因此可以通过简单地删除旧日志文件来回收日志空间。鉴于日志的多文件架构,我们将 LSN 形成文件号和文件内偏移量的对。因此,给定一个 LSN,日志管理器可以轻松地找到该记录:它跳转到给定日志文件的给定偏移量并返回写入该位置的记录。但是日志管理器如何知道从该位置返回多少字节呢?
日志必须持久化每个记录的元数据,以便在给定 LSN 的情况下,日志管理器可以确定要返回的记录的大小。至少,它需要知道记录的长度。我们在每个日志记录之前添加一个日志记录头,其中包含记录的长度、前一个记录的偏移量(以方便向后遍历)以及日志记录的校验和(以识别日志损坏和日志文件末尾)。此元数据足以让日志管理器维护日志记录的顺序,但不足以实际实现恢复;该功能编码在日志记录的内容以及 Berkeley DB 如何使用这些日志记录中。
Berkeley DB 使用日志管理器在更新数据库中的项之前写入数据的前后镜像 [HR83]。这些日志记录包含足够的信息来重做或撤消数据库上的操作。然后,Berkeley DB 使用日志进行事务中止(即,在丢弃事务时撤消事务的任何影响)以及在应用程序或系统故障后的恢复。
除了用于读取和写入日志记录的 API 之外,日志管理器还提供了一个 API 用于强制将日志记录写入磁盘 (DB_ENV->log_flush
)。这允许 Berkeley DB 实现预写日志记录——在从 Mpool 逐出页面之前,Berkeley DB 检查页面上的 LSN 并要求日志管理器保证指定的 LSN 位于稳定存储中。只有这样,Mpool 才会将页面写入磁盘。
设计经验 12
Mpool 和 Log 使用内部句柄方法来促进预写日志记录,在某些情况下,方法声明比它运行的代码更长,因为代码通常只是比较两个整数值,仅此而已。为什么要费心使用如此微不足道的函数,仅仅是为了保持一致的分层?因为如果您的代码并非面向对象到让您感到痛苦的地步,那么它就不是足够的面向对象。每个代码片段都应该执行少量操作,并且应该有一个高级设计鼓励程序员从更小的功能块构建功能,依此类推。如果在过去的几十年中,我们在软件开发方面学到任何东西,那就是我们构建和维护重要软件的能力是脆弱的。构建和维护重要的软件是困难且容易出错的,作为软件架构师,您必须尽一切努力,尽早,尽可能频繁地最大限度地提高软件结构中传达的信息量。
Berkeley DB 对日志记录施加结构以促进恢复。大多数 Berkeley DB 日志记录描述事务更新。因此,大多数日志记录对应于数据库的页面修改,这些修改是代表事务执行的。此描述为识别 Berkeley DB 必须附加到每个日志记录的元数据提供了基础:数据库、事务和记录类型。事务标识符和记录类型字段在每个记录中的相同位置存在。这允许恢复系统提取记录类型并将记录分派到相应的处理程序,该处理程序可以解释记录并执行适当的操作。事务标识符允许恢复过程识别日志记录所属的事务,以便在恢复的各个阶段,它知道是否可以忽略该记录或必须对其进行处理。
还有一些“特殊”的日志记录。检查点记录可能是这些特殊记录中最熟悉的一种。检查点是指使数据库的磁盘状态在某个时间点保持一致的过程。换句话说,Berkeley DB 积极地在 Mpool 中缓存数据库页面以提高性能。但是,这些页面最终必须写入磁盘,并且我们越早这样做,在应用程序或系统故障的情况下,我们就越快能够恢复。这意味着检查点频率和恢复长度之间的权衡:系统越频繁地进行检查点,它就能够越快地恢复。检查点是事务函数,因此我们将在下一节中描述检查点的详细信息。在本节中,我们将讨论检查点记录以及日志管理器如何在成为独立模块和专用 Berkeley DB 组件之间挣扎。
一般来说,日志管理器本身没有记录类型的概念,因此理论上它不应该区分检查点记录和其他记录——它们只是日志管理器写入磁盘的不透明字节字符串。在实践中,日志维护元数据以表明它确实理解某些记录的内容。例如,在日志启动期间,日志管理器检查它可以找到的所有日志文件以识别最近写入的日志文件。它假设该文件之前的所有日志文件都是完整且完好的,然后着手检查最新的日志文件并确定其中有多少包含有效的日志记录。它从日志文件的开头读取,如果/当遇到校验和不正确的日志记录头时停止,这表示日志的末尾或日志文件损坏的开始。在这两种情况下,它都会确定日志的逻辑结束。
在读取日志以查找当前末尾的过程中,日志管理器会提取 Berkeley DB 记录类型,查找检查点记录。它将找到的最后一个检查点记录的位置保留在日志管理器元数据中,作为对事务系统的“恩惠”。也就是说,事务系统需要查找最后一个检查点,但为了避免日志管理器和事务管理器都读取整个日志文件来做到这一点,事务管理器将此任务委托给日志管理器。这是一个为了性能而违反抽象边界 的经典例子。
这种权衡会带来什么影响?想象一下,除了 Berkeley DB 之外,还有其他系统使用日志管理器。如果它碰巧在 Berkeley DB 放置其记录类型的相同位置写入对应于检查点记录类型的值,那么日志管理器会将该记录识别为检查点记录。但是,除非应用程序请求日志管理器提供该信息(通过直接访问日志元数据中的cached_ckp_lsn
字段),否则此信息不会影响任何内容。简而言之,这要么是有害的分层违规,要么是明智的性能优化。
文件管理是日志管理器和 Berkeley DB 之间界限模糊的另一个地方。如前所述,大多数 Berkeley DB 日志记录必须识别一个数据库。每个日志记录都可以包含数据库的完整文件名,但这在日志空间方面会很昂贵,而且很笨拙,因为恢复必须将该名称映射到某种句柄,以便它可以用来访问数据库(文件描述符或数据库句柄)。相反,Berkeley DB 通过一个整数标识符(称为日志文件 ID)在日志中识别数据库,并实现了一组称为dbreg
(表示“数据库注册”)的函数来维护文件名和日志文件 ID 之间的映射。当数据库打开时,此映射的持久版本(记录类型为DBREG_REGISTER
)会被写入日志记录。但是,我们还需要此映射的内存表示来促进事务中止和恢复。哪个子系统应该负责维护此映射?
理论上,文件到日志文件 ID 的映射是高级 Berkeley DB 函数;它不属于任何子系统,这些子系统原本应该对更大的图景一无所知。在最初的设计中,此信息保留在日志子系统的数据结构中,因为日志系统似乎是最佳选择。但是,在反复发现和修复实现中的错误后,映射支持从日志子系统代码中提取出来,并放入它自己的小型子系统中,该子系统具有自己的面向对象的接口和私有数据结构。(事后看来,此信息在逻辑上应该与 Berkeley DB 环境信息本身一起放置,位于任何子系统之外。)
设计教训 13
很少有真正不重要的错误。当然,偶尔会出现错别字,但通常错误意味着有人没有完全理解他们在做什么,并且实现了错误的东西。当你修复错误时,不要寻找症状:寻找根本原因,如果你愿意的话,寻找误解,因为这会导致对程序架构有更好的理解,并揭示设计本身的根本缺陷。
我们的最后一个模块是事务管理器,它将各个组件联系在一起,以提供事务的ACID特性,即原子性、一致性、隔离性和持久性。事务管理器负责开始和完成(提交或中止)事务,协调日志和缓冲区管理器进行事务检查点,并协调恢复。我们将按顺序访问这些领域。
Jim Gray 发明了 ACID 首字母缩略词来描述事务提供的关键属性 [Gra81]。原子性意味着在事务中执行的所有操作都以单个单元的形式出现在数据库中——它们要么都存在于数据库中,要么都不存在。一致性意味着事务将数据库从一个逻辑一致的状态转移到另一个逻辑一致的状态。例如,如果应用程序指定所有员工都必须分配到数据库中描述的部门,则一致性属性会强制执行此操作(使用正确编写的交易)。隔离性意味着从事务的角度来看,事务似乎是顺序运行的,没有任何并发事务运行。最后,持久性意味着一旦事务提交,它就会保持提交状态——任何故障都不能导致已提交的事务消失。
事务子系统在其他子系统的协助下强制执行 ACID 属性。它使用传统的事务开始、提交和中止操作来限定事务的开始和结束点。它还提供了一个准备调用,该调用有助于两阶段提交,这是一种跨分布式事务提供事务属性的技术,本章不讨论此技术。事务开始分配一个新的事务标识符,并向应用程序返回一个事务句柄DB_TXN
。事务提交写入提交日志记录,然后强制将日志写入磁盘(除非应用程序指示它愿意放弃持久性以换取更快的提交处理),确保即使在发生故障的情况下,事务也将被提交。事务中止向后读取属于指定事务的日志记录,撤消事务执行的每个操作,将数据库恢复到事务前的状态。
事务管理器还负责进行检查点。文献中有很多不同的检查点技术 [HR83]。Berkeley DB 使用模糊检查点的一种变体。从根本上说,检查点涉及将缓冲区从 Mpool 写入磁盘。这是一个可能很昂贵的操作,重要的是系统在执行此操作时继续处理新事务,以避免长时间的服务中断。在检查点开始时,Berkeley DB 检查当前活动事务集以查找其中任何一个写入的最低 LSN。此 LSN 成为检查点 LSN。然后,事务管理器要求 Mpool 将其脏缓冲区刷新到磁盘;写入这些缓冲区可能会触发日志刷新操作。在所有缓冲区安全地写入磁盘后,事务管理器然后写入一个包含检查点 LSN 的检查点记录。此记录表明检查点 LSN 之前日志记录中描述的所有操作现在都安全地写入磁盘。因此,检查点 LSN 之前的日志记录不再需要进行恢复。这有两个含义:首先,系统可以回收检查点 LSN 之前的任何日志文件。其次,恢复只需要处理检查点 LSN 之后的记录,因为检查点 LSN 之前的记录中描述的更新反映在磁盘状态中。
请注意,检查点 LSN 和实际检查点记录之间可能存在许多日志记录。没关系,因为这些记录描述了在检查点之后逻辑上发生的并且如果系统发生故障可能需要恢复的操作。
事务拼图的最后一部分是恢复。恢复的目标是将磁盘上的数据库从可能不一致的状态移动到一致的状态。Berkeley DB 使用相当传统的两遍方案,该方案与“相对于最后一个检查点 LSN,撤消任何从未提交的事务并重做任何已提交的事务”大致对应。细节稍微复杂一些。
Berkeley DB 需要重建其日志文件 ID 与实际数据库之间的映射,以便它可以重做和撤消对数据库的操作。日志包含DBREG_REGISTER
日志记录的完整历史记录,但由于数据库保持打开状态很长时间,并且我们不希望要求日志文件在数据库打开的整个持续时间内都存在,因此我们希望有一种更有效的方法来访问此映射。在写入检查点记录之前,事务管理器会写入一组描述从日志文件 ID 到数据库的当前映射的DBREG_REGISTER
记录。在恢复期间,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 映射。当它到达日志末尾时,其映射应该与系统停止时存在的映射完全一致。此外,在此过程中,恢复会跟踪遇到的任何事务提交记录,记录其事务标识符。任何出现日志记录但其事务标识符未出现在事务提交记录中的事务,要么已中止,要么从未完成,应视为已中止。当恢复到达日志末尾时,它会反转方向并开始向后读取日志。对于遇到的每个事务日志记录,它都会提取事务标识符并查阅已提交的事务列表,以确定是否应撤消此记录。如果发现事务标识符不属于已提交的事务,它将提取记录类型并调用该日志记录的恢复例程,指示它撤消所描述的操作。如果记录属于已提交的事务,恢复会在向后传递时忽略它。此向后传递一直持续到检查点 LSN1。最后,恢复最后一次向前读取日志,这次重做属于已提交事务的任何日志记录。当此最终传递完成后,恢复将进行检查点。此时,数据库完全一致,并准备开始运行应用程序。
因此,恢复可以概括为
理论上,最后的检查点是不必要的。在实践中,它限制了未来恢复的时间,并将数据库置于一致状态。
设计教训 14
数据库恢复是一个复杂的话题,难以编写,更难以调试,因为恢复本身不应该经常发生。在图灵奖演讲中,艾兹赫尔·迪克斯特拉认为编程本质上是困难的,智慧的开端是承认我们无法胜任这项任务。我们作为架构师和程序员的目标是利用我们现有的工具:设计、问题分解、评审、测试、命名和风格约定以及其他良好的习惯,将编程问题约束在我们可以解决的问题范围内。
Berkeley DB 现在已经超过 20 年历史了。它可以说是第一个通用的事务性键值存储,也是 NoSQL 运动的鼻祖。Berkeley DB 继续作为数百种商业产品和数千种开源应用程序(包括 SQL、XML 和 NoSQL 引擎)的基础存储系统,并在全球范围内拥有数百万次部署。我们在其开发和维护过程中学到的经验教训都体现在代码中,并在上面概述的设计技巧中进行了总结。我们希望这些技巧对其他软件设计师和架构师有所帮助。