在本书中,我们对数据库系统概念采用了自下而上的方法:我们首先了解了存储结构【storage structures】。 现在,我们准备转向负责缓冲区管理【buffer management】、锁管理【lock management】和恢复【recovery】的高级组件,这是理解数据库事务的先决条件。
事务【transaction 】是数据库管理系统中不可分割的逻辑工作单元,允许您将多个操作【multiple operations 】表示为一个单独的步骤【single step】。 事务【transaction 】执行的操作包括读取和写入数据库记录。 数据库事务必须保持原子性【atomicity】、一致性【consistency】、隔离性【isolation】和持久性【durability】。 这些属性通常称为 ACID [HAERDER83]:
- Atomicity:事务步骤是不可分割的,这意味着与事务相关的所有步骤要么成功执行,要么都不成功。 换句话说,事务不应该被部分应用。 每个事务都可以提交【commit 】(使事务期间执行的写操作的所有更改可见)或中止【abort 】(回滚所有尚未可见的事务副作用)。 提交【commit 】是最终操作。 中止【abort 】后,可以重试事务。
- Consistency:一致性是特定于应用程序的保证; 事务应该只将数据库从一个有效状态转移到另一个有效状态,并维护所有数据库不变量(如约束、引用完整性等)。 一致性是定义最弱的属性,可能是因为它是唯一由用户控制的属性,而不仅仅是由数据库本身控制。
- Isolation:多个并发执行的事务应该能够不受干扰地运行,就好像没有其他事务同时执行一样。 隔离定义了对数据库状态的更改何时变得可见,以及哪些更改可能对并发事务可见。 出于性能原因,许多数据库使用的隔离级别低于给定的隔离定义。 根据用于并发控制的方式和方法,事务所做的更改可能对其他并发事务可见,也可能不可见(请参阅“Isolation Levels”)。
- Durability:一旦提交了事务,所有数据库状态修改都必须保存在磁盘上,并且能够在断电、系统故障和崩溃中存活下来。
在数据库系统中实现事务,除了在磁盘上组织和持久化数据的存储结构之外,还需要几个组件协同工作。在本地节点上,事务管理器【transaction manager】负责协调、调度和跟踪事务及其个步骤。
锁管理器【lock manager】保护对这些资源的访问,并防止可能破坏数据完整性的并发访问。每当一个锁被请求时,锁管理器【lock manager】检查它是否已经被任何其他共享【shared 】或独占【exclusive 】模式的事务持有,如果所请求的访问级别没有冲突,则授予对它的访问权。由于排他锁【exclusive locks】在任何给定时刻最多只能由一个事务持有,因此请求排他锁【exclusive locks】的其他事务必须等待,直到锁被释放,或者中止【abort】并稍后重试。一旦锁被释放或事务终止,锁管理器【lock manager】就会通知其中一个挂起的事务,让它获得锁并继续。
页面缓存【page cache】充当持久化存储【persistent storage】(磁盘)和存储引擎【storage engine】其余部分之间的中介。它在主存中暂存状态变更,并充当尚未与持久化存储【persistent storage】同步的页面的缓存。对数据库状态的所有更改首先应用于缓存的页面。
日志管理器【log manager】保存了应用于缓存页面【page cache】但尚未与持久化存储【persistent storage】同步的操作的历史记录(日志条目),以确保在崩溃时它们不会丢失。换句话说,日志用于重新应用这些操作,并在启动期间重建缓存状态。日志条目还可以用来撤消被中止的事务所做的更改。
分布式(多分区)事务需要额外的协调和远程执行。
Buffer Management
大多数数据库都是使用两级内存层次【two-level memory hierarchy】结构构建的:较慢的持久化存储【persistent storage】(磁盘)和较快的主内存【main memory】(RAM)。为了减少对持久化存储【persistent storage】的访问次数,页面【pages】被缓存在内存中。当存储层【storage layer】再次请求该页面时,将返回其缓存的副本。
假设没有其他进程修改磁盘上的数据,则可以重用内存中可用的缓存的页面。这种方法有时被称为虚拟磁盘【virtual disk】[BAYER72]。只有当内存中没有可用的页面副本时,虚拟磁盘读取才会访问物理存储。这个概念更常用的名称是页面缓存【page cache】或缓冲池【buffer pool】。页面缓存【page cache】负责将从磁盘读取的页面缓存到内存中。在数据库系统崩溃或无序关闭【unorderly shutdown】的情况下,缓存的内容会丢失。
由于术语页面缓存【page cache】更好地反映了这种结构的目的,所以本书默认使用这个名称。缓冲池【buffer pool】这个术语听起来像是它的主要目的是池化和重用空缓冲区,而不共享它们的内容,而这可以是页面缓存【page cache】的有用部分,甚至可以作为一个单独的组件,但不能准确地反映整个目的。
缓存页面的问题并不局限于数据库。 操作系统也有页面缓存的概念。 操作系统利用未使用的内存段【unused memory segments】来透明地缓存磁盘内容,以提高 I/O 系统调用的性能。
当从磁盘加载未缓存的页面时,这些页面被称为是被换入的【paged in】。如果对缓存的页面做了任何更改,则该页面被称为脏页【dirty】,直到这些更改被刷新回磁盘。
由于缓存页面所占有的内存区域通常远小于整个数据集,因此页面缓存最终会填满,并且为了换入新页面,必须将其中一个缓存页面驱逐出去。
在图 5-1 中,您可以看到 B-Tree 页面的逻辑表示、缓存版本和磁盘上的页面之间的关系。 页面缓存【page cache】无序地将页面加载到空闲槽中,因此页面在磁盘和内存中的排序方式之间没有直接映射。
Figure 5-1. Page cache
页面缓存【page cache】的主要功能可以概括为:
-
它将缓存的页面内容保存在内存中。
-
它允许对磁盘上页面的修改被缓冲到一起,并针对缓存中的版本中执行修改
-
当请求的页面不在内存中且有足够的可用空间时,页面缓存【page cache】就会对其进行换入【page in】,并返回其缓存的版本
- 如果请求了一个已经缓存的页面,则返回其缓存的版本
- 如果没有足够的空间可用于换入,则其他页面将被逐出【evicted 】并将其内容刷新【flushed 】到磁盘
Caching Semantics
对缓冲区所做的所有更改都保存在内存中,直到最终写回磁盘。由于不允许其他进程对备份文件【backing file】进行更改,因此这种同步时单向的【one-way process】:从内存到磁盘,反之亦然。页面缓存【page cache】允许数据库更好地控制内存管理和磁盘访问。您可以将其视为内核页面缓存的特定于应用程序的等效物:它直接访问块设备【block device】,实现类似的功能,并服务于类似的目的。它抽象了磁盘访问,并将逻辑写操作与物理写操作解耦。
缓存页面有助于将树部分地保留在内存中,而无需对算法进行额外更改并将对象具体化到内存中。我们所要做的就是将磁盘访问替换为对页面缓存的调用。
当存储引擎【storage engine】访问【access】(换句话说,请求【requests】)页面时,我们首先检查其内容是否已经被缓存,在这种情况下,将返回被缓存的页面的内容。如果页面内容尚未缓存,那么缓存将逻辑页面地址【logical page address】或页面号【page number】转换为物理地址【physical address】,将其内容加载到内存中,并将其缓存的版本返回给存储引擎【storage engine】。一旦返回,包含缓存页面内容的缓冲区就被称为被引用【referenced】,存储引擎【storage engine】必须将其返回给页面缓存【page cache】,或者在完成后取消对它的引用。可以通过固定页面【pinning 】,来指示页面缓存【page cache】来避免驱逐页面。
如果页面被修改(例如,向其添加了一个单元格),则将其标记为脏【dirty】。在页面上设置的脏标志表明其内容与磁盘不同步,必须刷新以确保持久性【durability】。
Cache Eviction
保持缓存被填充是好的:我们可以在不使用持久化存储【persistent storage】的情况下提供更多的操作,并且更多的同页写操作可以被缓冲在一起。但是,页面缓存【page cache】的容量有限,为了缓存新内容,旧页面迟早要被驱逐。如果旧页面内容与磁盘同步(即,已刷新或从未修改)并且该页面未固定【pinned 】或引用【referenced】,则可以立即将其逐出。脏页面必须在被驱逐之前被刷新。当其他线程正在使用引用的页面时,则不应驱逐它们。
由于在每次驱逐时触发刷新可能对性能不利,因此某些数据库使用单独的后台进程,循环遍历可能被驱逐的脏页,更新它们的磁盘版本。例如,PostgreSQL 有一个后台刷新写入器【background flush writer】就是这样做的。
要记住的另一个重要属性是持久性:如果数据库崩溃,所有未刷新的数据都会丢失。为确保所有更改都被持久化,刷新由检查点【checkpoint 】进程协调。检查点【checkpoint 】进程控制预写日志 【write-ahead log,WAL】和页面缓存【page cache】,并确保它们步调一致【lockstep】。只有与应用于已刷新的缓存页面的操作相关联的日志记录才能从 WAL 中丢弃。在此过程完成之前,无法驱逐脏页。
这意味着在几个目标之间总是要进行权衡:
-
推迟【Postpone 】刷新以减少磁盘访问次数
-
抢先【Preemptively 】刷新页面以允许快速驱逐
-
以最佳顺序选择用于驱逐和刷新的页面
-
将缓存大小保持在其内存范围内
-
避免丢失数据,因为它还没有持久化到主存储
我们将探讨几种技术,这些技术可以帮助我们改进前三个特性,同时将我们限制在其他两个特性的范围内。
Locking Pages in Cache
必须在每次读取或写入时执行磁盘 I/O 是不切实际的:后续读取可能会请求同一页面,就像后续写入可能会修改同一页面一样。 由于 B-Tree 越往顶部越“窄”,因此大多数读取都会命中高层级的节点(更靠近根的节点)。 拆分【Splits 】和合并【Merges】最终也会传播到更高层级的节点。 这意味着树中至少有一部分可以从缓存中获益。
我们可以“锁定【lock】”在最近时间被使用的概率很高的页面。 在缓存中锁定【locking】页面称为固定【pinning】。 固定的页面【Pinned pages】可以在内存中保留更长时间,这有助于减少磁盘访问次数并提高性能 [GRAEFE11]。
由于每个较低的 B-Tree 节点级别比较高的节点级别具有指数级更多的节点,并且较高层级的节点只代表树的一小部分,因此树的这一部分可以永久驻留在内存中,其他部分可以根据需要进行换入【page in】。这意味着,为了执行查询,我们不必进行 h 次磁盘访问(如“B-Tree Lookup Complexity”中所述,h 是树的高度),只有那些页面没有被缓存的低层级,才需要访问磁盘。
对子树执行的操作可能导致相互矛盾的结构更改——例如,多个删除操作导致合并,然后是写操作导致拆分,反之亦然。同样地,对于从不同的子树传播的结构变化(在树的不同部分,结构变化在时间上彼此接近,并向上传播)。这些操作可以通过仅在内存中应用更改来缓冲在一起,这可以减少磁盘写操作的次数,并摊销操作成本,因为只会执行一次写操作而不是多次写操作。
PREFETCHING AND IMMEDIATE EVICTION
页面缓存【page cache】还允许存储引擎【storage engine】对预取【prefetching 】和驱逐【eviction】进行细粒度控制。 可以指示在访问页面之前提前加载页面。 例如,当在范围扫描【range scan】中遍历叶节点【leaf nodes】时,可以预加载下一个叶节点【leaf node】。 同样,如果一个维护进程加载了页面,它可以在进程完成后立即被驱逐,因为它不太可能对进行中的查询【in-flight queries.】有用。 一些数据库,例如 PostgreSQL,使用循环缓冲区【circular buffer 】(换句话说,先进先出页面替换策略【FIFO page replacement policy】)进行大型顺序扫描。
Page Replacement
当达到缓存容量上限时,要加载新页面,必驱逐旧页面。但是,除非我们驱逐的是最不可能很快再次访问的页面,否则我们可能会在随后多次加载它们,即使我们可以一直将它们保存在内存中。我们需要找到一种方法来估计后续页面访问的可能性,以优化这一点
为此,我们可以说应该根据驱逐策略【 eviction policy】(有时也称为页面替换策略【 page-replacement policy】)驱逐页面。它试图找到最不可能很快再次访问的页面。当页面从缓存中驱逐出去,同时将新的页面加载进来。
为了使页面缓存【page cache】实现具有高性能,它需要一种有效的页面替换算法【page-replacement algorithm】。一个理想的页面替换策略【 page-replacement strategy】需要一个水晶球来预测页面将被访问的顺序,并且只驱逐那些在很长一段时间内不会被访问的页面。由于请求不一定遵循任何特定的模式或分布,因此精确预测行为可能很复杂,但使用正确的页面替换策略【page replacement strategy】可以帮助减少驱逐次数。
我们可以通过简单地使用更大的缓存来减少驱逐次数,这似乎是合乎逻辑的。然而,情况似乎并非如此。证明这种困境的一个例子被称为 Bélády 悖论 [BEDALY69]。它表明,如果使用的页面替换算法【page-replacement algorithm】不是最优的,则增加页面数量可能会增加驱逐次数。当可能很快需要的页面被驱逐,然后再次加载时,页面开始在缓存中竞争空间。正因为如此,我们需要明智地考虑我们正在使用的算法,这样它才能改善情况,而不是让情况变得更糟。
FIFO and LRU
最简单的页面替换策略【page-replacement strategy】是先进先出 (FIFO)。 FIFO 按照插入顺序维护一个关于页面 ID 的队列,将新页面添加到队列的尾部。每当页面缓存已满时,它就从队列头部获取元素,以找到在时间上最远点被换入的页面。由于它不考虑后续的页面访问,仅考虑页面换入【page in】事件,这对于大多数真实世界的系统来说被证明是不切实际的。例如,根页面和最顶层页面首先被换入【page in】,并且根据该算法,它们是最先被驱逐的候选者,尽管从树结构中可以清楚地看出这些页面很可能很快就会被再次换入。
FIFO 算法的自然扩展是最近最少使用【least-recently used,LRU】[TANENBAUM14]。它还按照插入顺序维护一个驱逐候选队列,但允许您在重复访问时将一个页面放回到队列的尾部,就像这是它第一次被换入【page in】一样。但是,在并发环境中,在每次访问时更新引用和重新链接节点的代价可能会很高。
还有其他基于 LRU 的缓存驱逐策略。例如,2Q(Two-Queue LRU)维护两个队列,在初始访问时将页面放入第一个队列,在后续访问时将它们移动到第二个热队列,让您可以区分出最近访问和经常访问的页面[JONSON94]. LRU-K 通过跟踪最后 K 次访问来识别经常引用的页面,并使用此信息以页面为基础估计访问时间 [ONEIL93]。
CLOCK
在某些情况下,效率【efficiency 】可能比精度【precision】更重要。 CLOCK 算法变体通常用作 LRU 的紧凑、缓存友好和并发的替代方案。 例如,Linux 使用 CLOCK 算法的变体。
CLOCK-sweep 在循环缓冲区【circular buffer.】中保存对页面【pages 】和相关访问位【associated access bits】的引用。 一些变体使用计数器【counters 】而不是位来计算频率。 每次访问页面时,其访问位都设置为 1。该算法通过围绕着循环缓冲区,检查访问位来工作:
-
如果访问位【access bit】为 1,并且该页面未被引用,则将其设置为 0,并检查下一页。
-
如果访问位【access bit】已经为 0,则该页面成为候选页面【candidate 】并被安排驱逐。
-
如果页面当前被引用,它的访问位【access bit】保持不变。 它假定被访问页面的访问位不能为 0,因此它不能被驱逐。这使得被引用的页面不太可能被替换。
图 5-2 显示了一个带有访问位的循环缓冲区【circular buffer】。
Figure 5-2. CLOCK-sweep example. Counters for currently referenced pages are shown in gray. Counters for unreferenced pages are shown in white. The arrow points to the element that will be checked next.
使用循环缓冲区的一个优点是时钟指针【clock hand pointer】和内容【contents 】都可以使用CAS【compare-and-swap】操作进行修改,并且不需要额外的锁定机制。 该算法易于理解和实现,并且经常在教科书[TANENBAUM14]和真实系统中使用。
LRU并不总是数据库系统的最佳替换策略。有时,考虑使用频率【usage frequency】而不是使用频次【usage recency】作为预测因素可能更实际。最后,对于一个高负载下的数据库系统,使用频次【usage recency】可能不是很有指示性,因为它只表示条目被访问的顺序。
LFU
为了改善这种情况,我们可以开始跟踪页面引用【page reference events】事件而不是页面换入事件【page-in events】。其中一种方法是跟踪使用频率最低的【least-frequently used,LFU】页面。
TinyLFU 是一种基于频率的页面驱逐策略【frequency-based page-eviction policy】 [EINZIGER17],它是这样做的:它不是根据页面换入的频次【page-in recency】来驱逐页面,而是根据使用频率【usage frequency】对页面进行排序。 它在名为 Caffeine 的流行 Java 库中实现。
TinyLFU 使用频率直方图 [CORMODE11] 来维护紧凑的缓存访问历史记录,因为保存整个历史记录对于实际用途而言可能过于昂贵。
元素可以位于以下三个队列之一中:
-
Admission, 维护新添加的元素,使用 LRU 策略实现。
-
Probation, 持有最有可能被驱逐的元素。
-
Protected, 持有要在队列中停留更长时间的元素。
这种方法不是每次都选择要驱逐的元素,而是选择要保留的元素。 只有那些频率大于因提升它们而被驱逐的条目的提条目,才能被移至 promoting 队列。 在随后的访问中,条目可以从 promoting 移动到 Protected 队列。 如果 Protected 队列已满,则可能必须将其中的一个元素放回promoting 。 更频繁访问的条目有更高的留存率,而不太常用的条目更有可能被驱逐。
图 5-3 显示了 admission、probation 和 protected 队,与频率过滤器和驱逐之间的逻辑连接。
Figure 5-3. TinyLFU admission, protected, and probation queues
还有许多其他算法可用于优化缓存驱逐。 页面替换策略的选择对延迟【latency 】和执行的 I/O 操作的数量有很大影响,必须仔细斟酌。
Recovery
数据库系统建立在多个硬件和软件层之上,这些硬件和软件层可能有自己的稳定性和可靠性问题。 数据库系统本身以及底层软件和硬件组件可能会出现故障。 数据库实现者必须考虑这些故障场景,并确保“承诺”要写入的数据实际上已经写入。
预写日志【write-ahead log】(简称 WAL,也称为提交日志【commit log】)是一种仅追加【append-only】的辅助性磁盘驻留结构【disk-resident structure】,主要用于崩溃和事务恢复。 页面缓存【page cache】允许缓冲对内存中页面内容的更改。 在缓存的内容被刷新回磁盘之前,唯一驻留在磁盘上的,用于保留操作历史的副本,就存储在 WAL 中。 许多数据库系统使用仅追加的【append-only】预写日志【write-ahead log】; 例如,PostgreSQL 和 MySQL。
预写日志【write-ahead log】的主要功能可以概括为:
-
允许页面缓存【page cache】缓冲对磁盘驻留页面【disk-resident pages】的更新,同时确保数据库系统层面更大上下文中的持久性语义【durability semantics 】。
-
在磁盘上持久化所有操作,直到受这些操作影响的页面的缓存副本同步到磁盘上为止。 每个修改数据库状态的操作都必须先记录在磁盘上,然后才能修改相关页面的内容。
-
在崩溃的情况下,允许从操作日志中重建丢失的内存更改。
除此功能外,预写日志【write-ahead log】在事务处理中也起着重要作用。 WAL 的重要性怎么强调都不过分,因为它确保数据进入持久化存储【persistent storage】并且在崩溃的情况下仍然可用,因为未提交的数据会从日志中重放,并完全恢到复崩溃前的数据库状态。 在本节中,我们经常会提到 ARIES(用于恢复【Recovery 】和隔离利用语义【Isolation Exploiting Semantics】的算法【Algorithm 】),这是一种被广泛使用和引用的最先进的恢复算法[MOHAN92].。
Log Semantics
预写日志【write-ahead log】是仅追加【append-only】的,其写入的内容是不可变的,因此对日志的所有写入都是顺序的。由于 WAL 是一个不可变的、仅追加的数据结构,当写入者【writer】继续向日志尾部追加数据时,读取者【reader】可以安全地访问它的内容直到最新的写入阈值【latest write threshold】。
WAL 由日志记录组成。每条日志记录都有一个唯一的、单调递增的日志序列号【log sequence number,LSN】。通常,LSN 由内部计数器或时间戳表示。由于日志记录不一定占据整个磁盘块,因此它们的内容被缓存在日志缓冲区【log buffer】中,并在强制【force】操作中刷新到磁盘上。强制【force】操作发生在日志缓冲区【log buffer】填满时,并且可以由事务管理器【transaction manager】或页面缓存【page cache】主动请求。所有日志记录都必须按照日志序列号【log sequence number,LSN】顺序刷新到磁盘上。
除了单独的操作记录外,WAL还保存着指示事务完成【transaction completion】的记录。在日志被强制【force】刷新到其提交记录的 LSN 之前,我们都不能将事务视为已提交。
为了确保在回滚【rollback】或恢复【recovery】期间崩溃后,系统能够继续正常运行,一些系统在撤消【undo】期间使用补偿日志记录【compensation log records,CLR】并将它们存储在日志中。
WAL 通常通过接口与主存储结构【 primary storage structure 】耦合,该接口允许在到达检查点【checkpoint】时对其进行修剪【trimming 】。日志记录是数据库最关键的正确性方面之一,要做到这一点有些棘手:即使日志修剪【trimming 】和确保数据已进入主存储结构之间存在最轻微的分歧,也可能导致数据丢失。
检查点【Checkpoints 】是一种让日志知道达到某个标记的日志记录已被完全持久化,并且不再需要的方式,这大大减少了数据库启动期间所需的工作量。强制将所有脏页刷新到磁盘上的过程通常称为同步检查点【sync checkpoint】,因为它完全同步主存储结构【primary storage structure】。
刷新磁盘上的全部内容是相当不切实际的,并且需要暂停所有正在运行的操作,直到检查点完成,因此大多数数据库系统都实现了模糊检查点【fuzzy checkpoints】。在这种情况下,存储在日志头中的 last_checkpoint 指针包含关于最后一个成功检查点的信息。模糊检查点【fuzzy checkpoints】以一个特殊的 begin_checkpoint 日志记录开始,并以 end_checkpoint 日志记录结束,包含有关脏页【dirty pages】的信息和事务表【transaction table】的内容。在此记录所指定的所有页面都被刷新之前,检查点被认为是不完整的【incomplete】。页面被异步刷新,一旦完成,last_checkpoint 记录将被更新为 begin_checkpoint 记录的 LSN ,如果发生崩溃,恢复过程也将从那里开始 [MOHAN92]。
Operation Versus Data Log
一些数据库系统,例如 System R [CHAMBERLIN81],使用影子分页【shadow paging】:一种确保数据持久性【 data durability】和事务原子性【transaction atomicity】的写时复制【copy-on-write】技术。 新内容被放置到新的未发布的影子页面【shadow pag】中,并通过指针翻转使之可见,从旧页面跳转到包含更新的内容的页面。
任何状态更改都可以通过前镜像【before-image】和后镜像【after-image】或相应的重做【redo】和撤消【undo 】操作来表示。 对前镜像【before-image】应用重做【redo】操作会产生后镜像【after-image】。 类似地,对后镜像【after-image】应用撤消【undo 】操作操作会产生前镜像【before-image】。
我们可以使用物理日志【physical log】(该物理日志存储完整的页面状态或对它的逐字节更改)或逻辑日志【logical log】(存储必须针对当前状态执行的操作)将记录【records 】或页面【pages】从一种状态移动到另一种状态, 在时间上前后移动。跟踪可以应用物理和逻辑日志记录的页面的确切状态【exact state】非常重要。
镜像前后的物理日志记录,需要记录受操作影响的整个页面。逻辑日志指定需要对页面应用哪些操作,比如“insert a data record X for key Y”,以及相应的撤销【undo】操作,比如“remove the value associated with Y。
在实践中,许多数据库系统使用这两种方法的组合,使用逻辑日志来执行撤销【undo】(为了并发性和性能),使用物理日志来执行重做【redo】(以提高恢复时间) [MOHAN92]。
Steal and Force Policies
为了确定何时必须将内存中的更改刷新到磁盘上,数据库管理系统定义了窃取/不窃取【steal/no-steal】和强制/不强制【force/no-force】策略。这些策略主要适用于页面缓存【page cache】,但最好在恢复【recovery】上下文中讨论它们,因为它们对哪些恢复方法可以与它们结合使用有重大影响。
允许在事务提交之前刷新由事务修改的页面的恢复方法【recovery method】被称为窃取策略【steal policy】。非窃取策略【no-steal policy】则不允许刷新磁盘上任何未提交的事务内容。在这里,要窃取脏页,则意味着将其内存中的内容刷新到磁盘,并在其位置上从磁盘加载不同的页面来替换它。
强制策略【 force polic】要求事务修改的所有页面在事务提交之前刷新到磁盘上。另一方面,非强制策略【no-force polic】允许事务提交,即使在此事务期间修改的一些页面尚未刷新到磁盘上。在这里强制【force 】一个脏页,意味着在提交之前将其刷新到磁盘上。
窃取【steal】和强制【force】策略对于理解很重要,因为它们对事务撤消【undo 】和重做【redo】有影响。 撤消【undo】将已提交事务的更新回滚到强制页面【forced pages】,而重做【redo】将已提交事务执行的更改应用到磁盘上。
使用非窃取策略【no-steal policy】允许仅使用重做【redo】日志条目实现恢复【recovery 】:旧副本【old copy】包含在磁盘上的页面中,而修改则存储在日志[WEIKUM01]中。在非强制【no-force】的情况下,我们可能会通过延迟它们来缓冲对页面的多次更新。由于页面内容必须在这段时间内缓存在内存中,因此可能需要更大的页面缓存【page cache】。
当使用强制策略【force policy】时,崩溃恢复不需要任何额外的工作来重建已提交事务的结果,因为这些事务修改的页面已经被刷新。使用这种方法的一个主要缺点是,由于必要的 I/O,事务需要更长的时间来提交。
更一般地说,在事务提交之前,我们需要有足够的信息来撤销【undo】它的结果。如果事务触及的任何页面被刷新,在提交之前,我们需要在日志中一直保留撤销【undo】信息,以能够回滚。否则,我们必须将重做【redo】记录保留在日志中,直到它提交为止。在这两种情况下,在将撤消【undo】或重做【redo】记录写入日志文件【logfile】之前,事务都不能提交。
ARIES
ARIES 是一种窃取/非强制恢复算法【steal/no-force recovery algorithm】。 它使用物理重做【physical redo】来提高恢复期间的性能(因为可以更快地安装更改)和逻辑撤消【logical undo】来提高正常操作期间的并发性(因为逻辑撤消操作可以独立应用于页面)。 它使用 WAL 记录在恢复期间实现重复历史【repeating history】,在撤销未提交的事务之前完全重建数据库状态,并在撤销【undo】期间创建补偿日志记录【compensation log records】 [MOHAN92]。
当数据库系统崩溃后重新启动时,恢复过程分为三个阶段:
- 分析阶段【analysis phase】识别页面缓存【page cache】中的脏页【dirty pages】和崩溃时正在进行的事务。 有关脏页的信息用于标识重做阶段【redo phase】的起点。 在撤消阶段【undo phase】使用正在进行的事务列表来回滚不完整的事务。
- 重做阶段【redo phase】重现历史直到崩溃点,并将数据库恢复到以前的状态。 此阶段针对不完整的事务以及已提交但其内容未刷新到持久存储的事务完成。
- 撤消阶段【undo phase】回滚所有未完成的事务并将数据库恢复到最后的一致状态。 所有操作都按时间倒序回滚。 如果数据库在恢复期间再次崩溃,撤消事务【undo transactions】的操作也会被记录下来,以避免重复它们。
ARIES 使用 LSN 来标识日志记录,跟踪在脏页表【dirty page table】中运行事务所修改的页面,并使用物理重做【physical redo】、逻辑撤消【logical undo】和模糊检查点【fuzzy checkpointing】。 尽管描述该系统的论文发表于 1992 年,但大多数概念、方法和范例在今天的事务处理和恢复【transaction processing and recovery 】中仍然相关。
Concurrency Control
在“DBMS Architecture”中讨论数据库管理系统体系结构时,我们提到事务管理器【transaction manager】和锁管理器【lock manager】一起工作来处理并发控制【concurrency control】。并发控制是一组用于处理并发执行事务之间的交互的技术。这些技术可以大致分为以下几类:
- Optimistic concurrency control (OCC) 允许事务执行并发读写操作,并判断合并执行的结果是否可序列化。 换句话说,事务不会相互阻塞,维护它们的操作历史,并在提交之前检查这些历史是否存在可能的冲突。 如果执行导致冲突,则中止冲突事务之一。
- Multiversion concurrency control (MVCC) 通过允许存在多个时间戳版本的记录,保证在过去某个时间点由时间戳标识的数据库的一致视图。 MVCC 可以使用验证技术来实现,只允许更新或提交事务中的一个获胜,以及使用无锁技术(如时间戳排序)或基于锁的技术(如两阶段锁定)。
- Pessimistic (also known as conservative) concurrency control (PCC) 既有基于锁的保守方法,也有非锁的保守方法,它们在管理和授予对共享资源的访问权限方面有所不同。 基于锁的方法要求事务维护数据库记录上的锁,以防止其他事务修改锁定的记录并评估正在修改的记录,直到事务释放其锁。 非锁定方法维护读取和写入操作列表并限制执行,具体取决于未完成事务的调度。 当多个事务相互等待释放锁以继续进行时,悲观调度可能会导致死锁。
在本章中,我们专注于本地节点并发控制技术, 分布式事务会在后面章节中介绍,例如例如确定性并发控制【deterministic concurrency contro】(参见 “Distributed Transactions with Calvin”)。
在我们进一步讨论并发控制之前,我们需要定义一组我们试图解决的问题,并讨论事务操作如何重叠【overlap 】以及这种重叠【overlap 】会产生什么后果。
Serializability
事务由对数据库状态执行的读写操作和业务逻辑(应用于读取内容的转换)组成。从数据库系统的角度来看,调度【schedule】是执行一组事务所需的操作列表(即,只与数据库状态交互的操作,如读、写、提交或中止【read, write, commit, or abort】操作),因为所有其他操作都被假定为没有副作用(换句话说,对数据库状态没有影响)[GARCIAMOLINA08]。
如果一个调度【schedule】包含了执行的每个事务的所有操作,那么这个调度【schedule】就是完整的【complete】。正确的调度【Correct schedule】在逻辑上等同于原始的操作列表,但它们的部分可以并行执行,或者为了优化目的而重新排序,只要这不违反ACID属性和单个事务结果的正确性 [WEIKUM01]。
当调度【schedule】中的事务完全独立且没有交叉执行时,则称其为串行的【serial】:前一个事务在下一个事务开始之前完全执行。与多个多步事务之间的所有可能交错相比,串行执行很容易推理。但是,总是一个接一个地执行事务将严重限制系统吞吐量并损害性能。
我们需要找到一种方法来并发地执行事务操作,同时保持串行调度的正确性和简单性。我们可以通过可串行化调度【serializable schedule】来实现这一点。如果一个调度等价于同一事务集上的某个完整的串行调度,那么它就是可串行化【serializable 】的。换句话说,它产生的结果与我们按照某种顺序一个接一个地执行一组事务相同。图 5-4 显示了三个并发事务,以及可能的执行历史( 3! = 6 种可能性,按照每一种可能的顺序)。
Figure 5-4. Concurrent transactions and their possible sequential execution histories
Transaction Isolation
事务数据库系统允许不同的隔离级别。 隔离级别指定事务的某些部分如何以及何时可以并且应该对其他事务可见。 换句话说,隔离级别描述了事务与其他并发执行事务的隔离程度,以及在执行过程中可能遇到的异常类型。
实现隔离是有代价的:为了防止不完整或临时的写入在事务边界上传播,我们需要额外的协调和同步,这会对性能产生负面影响。
Read and Write Anomalies
SQL 标准 [MELTON06] 提到并描述了并发事务执行期间可能发生的读取异常【read anomalies】:脏读、不可重复读和幻读【 dirty, nonrepeatable, and phantom reads.】。
脏读【dirty read】是一种事务可以从其他事务中读取到未提交的更改的情况。例如,事务 T1 使用 address 字段的新值更新用户记录,事务 T2 在 T1 提交之前读取更新的地址。事务 T1 中止并回滚其执行结果。但是,T2 已经能够读取该值,因此它访问了从未提交的值。
不可重复读【nonrepeatable read】(有时称为模糊读)是指事务两次查询同一行并得到不同结果的情况。例如,即使事务 T1 读取一行,然后事务 T2 修改该行并提交更改,也会发生这种情况。如果 T1 在完成执行之前再次请求同一行,那么结果将与前一次运行不同。
如果我们在事务期间使用范围读取(即,读取的不是单个数据记录,而是一个范围的记录),我们可能会看到幻读记录【phantom records】。虚读是指事务两次查询同一组行,并收到不同的结果。它类似于不可重复读取,但适用于范围查询。
还有一些写异常具有类似的语义:丢失更新【lost update】、脏写【dirty write】和写倾斜【write skew】。
当事务 T1 和 T2 都尝试更新 V 的值时,会发生丢失更新【lost update】。T1 和 T2 读取 V 的值。T1 更新 V 并提交,然后 T2 更新 V 并提交。由于事务不知道彼此的存在,如果它们都被允许提交,T1 的结果将被 T2 的结果覆盖,来自 T1 的更新将丢失。
脏写是这样一种情况:其中一个事务获取一个未提交的值(即脏读),修改并保存它。换句话说,当事务结果基于未提交的值时。
当每个单独的事务都遵守所需的不变量,但它们的组合不满足这些不变量时,就会发生写倾斜【write skew】。例如,事务 T1 和 T2 修改了两个帐户 A1 和 A2 的值。 A1 以 100 美元开始,A2 以 150 美元开始。账户值允许为负,只要两个账户之和为非负数:A1 + A2 >= 0。T1 和 T2 分别尝试从 A1 和 A2 提取 200 美元。由于在这些事务开始时 A1 + A2 = 250 美元,总共有 250 美元可用。两个事务都假设它们保留了不变量并被允许提交。提交后,A1 有 -100 美元,A2 有 -50 美元,这显然违反了保持账户总和为正的要求 [FEKETE04]。
Isolation Levels
最低(换句话说,最弱的)隔离级别是读未提交【read uncommitted】。在这种隔离级别下,事务系统允许一个事务观察到其他并发事务的未提交的更改。换句话说,它允许脏读的出现。
我们可以避免一些异常情况。例如,我们可以确保特定事务执行的任何读取都只能读取已经提交的更改。但是,不能保证如果事务在稍后阶段再次尝试读取相同的数据记录,它将看到相同的值。如果两次读取之间存在已提交的修改,同一事务中的两个查询将产生不同的结果。换句话说,脏读是不允许的,但幻读和不可重复读是允许的。此隔离级别称为已提交读。如果我们进一步禁止不可重复读取,我们将获得可重复读取隔离级别。
我们可以避免一些异常现象。例如,我们可以确保由特定事务执行的任何读操作只能读取已经提交的更改。但是,不能保证事务在稍后的阶段再次读取相同的数据记录时,会看到相同的值。如果在两个读取之间有一个已提交的修改,那么同一个事务中的两个查询将产生不同的结果。换句话说,脏读是不允许的,但幻读和不可重复读是允许的。这个隔离级别也被称为读已提交【read committed】。如果想进一步禁止不可重复读,则会得到一个可重复读【repeatable read 】隔离级别。
最强的隔离级别是可串行化【serializability】。正如我们在“Serializability”中已经讨论过的,它保证事务结果将以某种顺序出现,就像事务是串行执行的一样(即,在时间上没有重叠)。禁止并发执行将对数据库性能产生严重的负面影响。事务可以被重新排序,只要它们的内部不变量保持不变并且可以并发执行,但是它们的结果必须以某种顺序出现。
图 5-5 显示了隔离级别及其允许的异常情况。
Figure 5-5. Isolation levels and allowed anomalies
没有依赖关系的事务可以按照任何顺序执行,因为它们的结果是完全独立的。与线性化【linearizability】(我们在分布式系统的上下文中讨论;参见“Linearizability”))不同,串行化【serializability 】是一种以任意顺序执行的多个操作的属性。它并不暗示或试图在执行事务时强加任何特定的顺序。在 ACID 术语中,隔离【Isolation 】意味着串行化【serializability】 [BAILIS14a]。不幸的是,实现串行化【serializability 】需要协调。换句话说,并发执行的事务必须协调以保持不变量,并对冲突的执行施加一个串行顺序 [BAILIS14b]。
一些数据库使用快照隔离【snapshot isolation】。在快照隔离【snapshot isolation】下,事务可以观察到在它开始时提交的所有事务执行的状态更改。每个事务都会拍摄数据快照并对其执行查询。此快照在事务执行期间无法更改。仅当其修改的值在执行时未更改时,事务才会提交。否则,它将被中止【abort】并回滚【rollback】。
如果两个事务尝试修改相同的值,则只允许其中一个提交。这避免了丢失更新【lost update】异常。例如,事务 T1 和 T2 都试图修改 V。它们从快照中读取 V 的当前值,该快照中包含在事务开始之前提交的所有事务的状态更改。无论哪个事务首先尝试提交,都将提交,而另一个事务将不得不中止。失败的事务将重试【retry】,而不是覆盖该值。
在快照隔离【snapshot isolation】下可能会出现写倾斜【write skew】异常,因为如果两个事务读取本地状态、修改独立记录并保证本地不变量不被打破,则它们都被允许提交[FEKETE04]。 在 “Distributed Transactions with Percolator”中,我们将在分布式事务上下文中更详细地讨论快照隔离。
Optimistic Concurrency Control
乐观并发控制假设事务冲突很少发生,而不是使用锁和阻塞事务执行,我们可以验证事务以防止与并发执行的事务发生读/写冲突,并在提交结果之前确保可串行化。 一般来说,事务执行分为三个阶段 [WEIKUM01]:
- Read phase:事务在其自己的私有上下文中执行其步骤,而不会使任何更改对其他事务可见。 在这一步之后,所有事务依赖项(read set)以及事务产生的副作用(write set)都是已知的。
- Validation phase:检查并发事务的读写集【rad sets and write sets】,以确定它们的操作之间是否存在可能违反串行性的冲突。如果事务正在读取的一些数据现在已经过期,或者它将覆盖在读取阶段提交的事务写入的某些值,那么它的私有上下文将被清除,并重新启动读取阶段。换句话说,验证阶段确定提交事务是保护了 ACID 属性。
- Write phase:如果验证阶段没有确定任何冲突,事务可以将其写集【write set】从私有上下文提交到数据库状态。
可以通过检查与已经提交的事务(向后)或当前处于验证阶段(向前)的事务的冲突来完成验证。 不同事务的验证和写入阶段应该以原子方式完成。 在验证其他事务时,不允许提交任何事务。 由于验证和写入阶段通常比读取阶段短,因此这是一个可以接受的折衷方案。
验证【Validation 】可以通过检查与已经提交的事务(面向后【backward-oriented】)或当前处于验证【Validation】阶段的事务(面向前【forward-oriented】)之间的冲突来完成。不同事务间的验证阶段和写阶段应该以原子方式完成(即验证阶段与写阶段应该是一个原子操作)。在验证其他事务时,不允许提交任何事务。因为验证阶段和写阶段通常比读阶段短,所以这是一个可以接受的折衷方案。
面向后【Backward-oriented】并发控制确保对于任意一对事务 T1 和 T2 ,保持以下属性:
-
T1 在 T2 读【Read】阶段开始之前已提交,因此允许 T2 提交。
-
T1 在 T2 写【Write】阶段之前提交,并且 T1 的写集【write set】与 T2 读集【read set】不相交。 换句话说,T1 没有写入任何 T2 应该看到的值。
-
T1 的读【Read】阶段在 T2 的读【Read】阶段之前完成,T2 的写集【write set】不与 T1 的读集【read set】或写集【write set】相交。 换句话说,事务对独立的数据记录集进行了操作,因此两者都被允许提交。
-
T1的读阶段在T2的读阶段之前已经完成,T2的写集与T1的读或写集不相交。换句话说,事务是在独立的数据记录集上操作的,所以双方都可以提交。
如果验证【Validation】通常成功且事务不需要重试,则这种方法是高效的,因为重试会对性能产生显著的负面影响。当然,乐观并发仍然有一个临界区【critical section】,事务可以一次进入一个临界区。另一种允许某些操作拥有非独占所有权的方法是使用读写锁【readers-writer locks】(允许读共享)和可升级锁【upgradeable locks】(允许在需要时将共享锁转换为独占锁)。
Multiversion Concurrency Control
多版本并发控制【MVCC】是一种在数据库管理系统中实现事务一致性的方法,它允许多个记录版本,并使用单调递增的事务 ID 或时间戳。这允许读写在存储级别【storage level】上进行最小的协调,因为读取可以继续访问旧值,直到提交新值。
MVCC区分已提交和未提交版本,它们对应于已提交和未提交事务的值版本。该值的最后提交版本被认为是当前的。通常,在这种情况下,事务管理器的目标是一次最多有一个未提交的值。
根据数据库系统实现的隔离级别,读操作可能允许也可能不允许访问未提交的值[WEIKUM01]。多版本并发可以使用锁定、调度和冲突解决技术(例如两阶段锁定)或时间戳排序来实现。MVCC实现快照隔离的主要用例之一[HELLERSTEIN07]。
根据数据库系统实现的隔离级别,读操作可能允许也可能不允许访问未提交的值。多版本并发可以使用锁定、调度和冲突解决技术【locking, scheduling, and conflict resolution techniques】(例如两阶段锁定【two-phase locking】)或时间戳排序【timestamp ordering】来实现。MVCC实现快照隔离的主要用例之一[HELLERSTEIN07]。
Pessimistic Concurrency Control
基于锁的并发控制方案是一种悲观并发控制形式,它在数据库对象上使用显式锁,而不是像时间戳排序【timestamp ordering】等协议那样解析调度【resolving schedules】。 使用锁的一些缺点是争用和可伸缩性问题[REN16]。
最普遍的基于锁的技术之一是两阶段锁(two-phase locking, 2PL),它将锁管理分为两个阶段:
-
增长阶段【growing phase】(也称为扩展阶段【expanding phase】),在此期间获取事务所需的所有锁,而不释放任何锁。
-
收缩阶段【shrinking phase】,在此期间所有在增长阶段获得的锁都被释放。
从这两个定义中得出的一条规则是,事务一旦释放了至少一个锁,就不能获取任何锁。 需要注意的是,2PL 并不排除事务在这些阶段中的任何一个阶段执行步骤; 然而,一些 2PL 变体(例如 conservative 2PL)确实施加了这些限制【It’s important to note that 2PL does not preclude transactions from executing steps during either one of these phases; however, some 2PL variants (such as conservative 2PL) do impose these limitations.】。
Deadlocks
在锁定协议中,事务尝试获取数据库对象上的锁,如果不能立即授予锁,则事务必须等到锁被释放为止。 当两个事务试图获取它们需要的锁以继续执行时,可能会发生这样的情况,最终彼此等待对方释放它们持有的其他锁。 这种情况称为死锁。
图 5-6 显示了一个死锁示例:T1 持有锁 L1 并等待锁 L2 被释放,而 T2 持有锁 L2 并等待 L1 被释放。
Figure 5-6. Example of a deadlock
处理死锁的最简单方法是引入超时,并在假定它们可能处于死锁状态时中止长时间运行的事务。另一种策略,保守的 2PL【conservative 2PL】,要求事务在执行任何操作之前获取所有锁,如果不能执行则中止。然而,这些方法极大地限制了系统并发性,并且数据库系统大多使用事务管理器来检测或避免(换句话说,防止)死锁。
检测死锁通常使用等待图【waits-for graph】来完成,该图跟踪正在进行的事务之间的关系并建立它们之间的等待关系。
该图中的环【Cycle】表明存在死锁:事务 T1 正在等待 T2,而 T2 又等待 T1。死锁检测可以周期进行(每间隔直接一次)或连续进行(每次等待图更新时)。其中一个事务(通常是最近尝试获取锁的事务)被中止。
为了避免死锁并将锁获取限制在不会导致死锁的情况下,事务管理器可以使用事务时间戳来确定它们的优先级。较低的时间戳通常意味着较高的优先级,反之亦然。
如果事务 T1 尝试获取当前由 T2 持有的锁,并且 T1 具有更高的优先级(它在 T2 之前开始),我们可以使用以下限制之一来避免死锁 [RAMAKRISHNAN03]:
- Wait-die:允许 T1 阻塞并等待锁。 否则,T1 将中止并重新启动。 换句话说,一个事务只能被具有更高时间戳的事务阻塞。
- Wound-wait:T2 中止并重新启动(T1 wounds T2)。 否则(如果 T2 在 T1 之前开始),则允许 T1 等待。 换句话说,一个事务只能被时间戳较低的事务阻塞。
事务处理需要调度程序【scheduler】来处理死锁。 同时,锁存器(参见 “Latches”)依赖于程序员来确保不会发生死锁,并且不依赖于死锁避免机制。
Locks
如果两个事务同时提交,修改了重叠的数据段,那么任何一个都不应该观察另一个的部分结果,从而保持逻辑一致性。同样,来自同一事务的两个线程必须观察相同的数据库内容,并且可以访问彼此的数据。
在事务处理中,保护逻辑和物理数据完整性的机制是有区别的。相应地,负责逻辑完整性和物理完整性的两个概念是锁和闩锁。命名有点不幸,因为这里所谓的锁存器通常被称为系统编程中的锁,但我们将在本节中阐明区别和含义。
锁用于隔离和调度重叠事务并管理数据库内容,但不用于内部存储结构,并在密钥上获取。锁可以保护特定的钥匙(无论是现有的还是不存在的)或一系列钥匙。锁通常在树实现之外进行存储和管理,并代表更高级别的概念,由数据库锁管理器管理。
锁【lock】比锁存器【latches 】更重,并且在事务期间被持有。