Skip to content

Lec 12 事务和锁

阅读资料

Concurrency Control and Recovery, franklin97

本章开始我们讨论下并发控制和恢复,如何保证更新和数据库故障时保持正确性

事务

事务提供了一种抽象,提供了all-or-nothing和before-or-after原子性。事务由ACID特性

原子性

许多动作看起来像一个整体;“全有或全无”,但实际上,动作需要时间!为了实现原子性,防止多个动作互相干扰,它们是隔离

一致性

简单说,不变量的保持。通常以约束条件的形式表达,例如, 主键约束/外键约束、触发器、或者是没有员工比他们的经理高。 需要使用不太优雅的非SQL语法(例如PL/pg SQL),通常在应用程序中完成

Postgres PL/pgSQL 触发器例子

用于实现工资检查的功能

postgresql
-- 创建 PL/pgSQL 函数用于工资检查
CREATE OR REPLACE FUNCTION sal_check() RETURNS trigger AS $sal_check$
DECLARE
    mgr_sal integer;
BEGIN
    IF NEW.salary IS NOT NULL THEN
        -- 查询该员工的经理的工资
        SELECT INTO mgr_sal salary
        FROM emp
        JOIN manages ON NEW.eid = manages.eid AND emp.eid = manages.eid
        LIMIT 1;
        
        -- 检查员工工资是否超过经理工资(如果没有经理, mgr_sal is NULL)
        IF mgr_sal < NEW.salary THEN 
            RAISE EXCEPTION 'employee cannot make more than manager';
        END IF;
    END IF;
    
    RETURN NEW;
END;

-- 创建触发器,使其在每次向 emp 表中插入新记录或修改现有记录时,都调用 sal_check 函数
$sal_check$ LANGUAGE plpgsql;
CREATE TRIGGER eid_sal BEFORE INSERT OR UPDATE ON emp
FOR EACH ROW EXECUTE FUNCTION sal_check();
  • NEW 是被添加的记录

  • mgr_sal 是本地变量

隔离性

我们如何隔离动作?

  • 串行执行:意味着一次只允许一个事务运行。
    • 存在一下几个问题
      • 无法发挥多处理器优势
      • 如果长时间运行事务,会阻塞其他,导致出现饥饿现象
  • 目标: 保持串行等价性的同时,允许并发执行。 串行等价性意味着并发执行的结果与某种串行执行的结果相同
  • 并发控制算法就是用来实现上述目标的。

持久性

TODO

可串行化

定义

一个并发事务的操作顺序实现串行等价的例子

  • RA: Read A, 读A
  • WA:Write A, 写A
  • A/B: 他们是都是一些对象——比如,记录,磁盘页等
  • 假设读和写的逻辑是任意的

不具备串行等价——T2对A的写入丢失了,这在串行调度中不可能发送

  • 在 T1-T2 的顺序中,T2 应该能看到 T1 对 A 的写入
  • 在 T2-T1 的顺序中,T1 应该能看到 T2 对 A 的写入

可串行化的验证

从图中我们可知,任何一种冲突可串行化都是视图可串行化,反之则不然

视图可串行化

在调度 S 中,指令的特定顺序当且仅当满足以下条件时,与串行顺序 S' 视图等价

  • S 中的每个读取操作获取的值,和 S' 中对应的读取操作获取的值是一样的。
  • S 和 S' 中,每个数据的最终修改都是由同一个事务完成的。
  • 简单来说,就是在 S 中,所有事务“看到”的值和它们在 S' 中“看到”的值是一样的,并且事务执行后的最终结果也是一样的。

视图可串行化例子

我们发现T2在两个调度中总是做最后一个做修改

  • 在调度 S 中读取的每个值,都是在串行顺序 S' 中由相同读取操作读取的相同值。
  • 每个对象的最终写入操作在 S 和 S' 中由相同的事务 T 完成

视图可串行化限制

  • 必须针对每一个可能的串行调度进行测试,以确定串行等价性
    • 这是一个 NP-Hard 问题
    • 对于N个并发事务,则有2N种串行化调度可能
  • 没有协议能在事务运行时确保视图可串行化
  • 冲突可串行化解决了这两个问题

冲突可串行化

满足以下条件, 两个操作视为冲突的

  • 两个操作都作用在同个对选哪个

  • 至少有个操作室写操作

  • 例子,T1WAT2RA冲突,但是T1RAT2RA不冲突

冲突可串行化定义

如果可以通过交换不冲突的操作来得出一个串行调度, 那么这个调度是冲突可串行化的。 等价地说: 对于所有成对的冲突操作 {O1 in T1 , O2 in T2 },要么:

  • O1 总是先于 O2,或者

  • O2 总是先于 O1。

  • 记作T1T2​ , T1先于T2发生

例子

Precedence(优先) 图

  • 给定事务Ti和Tj
  • 当满足以下条件时,新建一条边$T_i \rightarrow T_j $:
    • 在Tj写A之前,Ti 读/写A,即$RA_{Ti} \prec WA_{Tj} $ 或者$WA_{Ti} \prec WA_{Tj} $​
    • 在Tj读A之前,Ti写A,即$WA_{Ti} \prec RA_{Tj} $​
  • 如果在这个图中存在环路,那么调度就不是冲突可串行化的

非冲突可串行化例子

冲突可串行化例子

总结:三种方法检测冲突可串行化

  1. 检查所有的冲突操作的边 (O1在T1,O2在T2)

    • 要么O1总是在O2前执行
    • 要么O2总是在O1前执行
  2. 交换不冲突的操作以获得串行调度

  3. 构建优先图,检查是否存在环路

以下调度是否是冲突可串行化?

T1T2T3
RA
RB
WA
RB
WB
WB
RA
WA
COMMITCOMMITCOMMIT

Solution:

不是。

截屏2024-08-18 11.00.24

截屏2024-08-18 11.00.33

视图可串行化 vs 冲突可串行化

  • 视图可串行化的检测是NP-Hard问题
    • 必须考虑所有的排序可能
  • 冲突可串行化应用于实际中
    • 不是因为 NP 难题
    • 而是因为我们有办法在事务运行时强制执行它

一个视图可串行化但不是冲突可串行化的调度示例:

T1T2T3
RA
WA
WA
WA
RB
WB

问以上调度是 A) 既不是视图可串行化也不是冲突可串行化 B) 冲突可串行化,但非视图可串行化 C) 视图可串行化,但非冲突可串行化 D) 既是视图可串行化,也是冲突可串行化

截屏2024-08-18 11.25.41

等价与T1, T2, T3的顺序调度S',满足所有的读取操作读取的值在视图等价的调度中必须相同,也满足每个对象的最终写入应该由相同的事务完成。

综上, 这种情况只会发生在有盲写操作的时候

冲突可串行化的实现

  • 有几种协议
  • 但是我们这里只讨论两阶段锁(2PL)
  • 主要思想:
    • 在每次读一个对象前获取共享锁(S lock)
    • 在每次写一个对象前获取排他锁(X lock)
  • 多个事务可以持有S锁
  • 而只有一个事务可持有X锁
  • 如果一个事物不能获取锁,则它会等待(“阻塞“)

截屏2024-08-18 11.43.07

锁的兼容性表

何时释放锁

  • 每个操作完成后?

  • 还是在事务处理完变量后?

  • 都不对,下面看一个例子

    • T2在T1更新B之前"偷偷插入"并更新了A和B

    截屏2024-08-18 13.19.56

    这个调度是非可串行的.

两阶段锁

上述问题的解决方案: 两阶段锁

NOTE

规则:一个事务不能释放任何锁直到它获取了它要加锁所有的锁

截屏2024-08-18 13.24.52

截屏2024-08-18 13.29.11

我们将事务在执行过程中获取最后一个锁的时间点称为锁点(lock point),通过锁点,我们可以在并发执行的事务中推导出一个等价的串行调度

正确性——直觉上

  • 一旦事务 T 达到了它的锁点:

    • T 在串行顺序中的位置已确定

    • 任何尚未获取所有锁的事务,在 T 释放锁之前,不能执行任何冲突的操作

      • 被排在后面
    • 任何已经获取所有锁的事务,必须在 T 进行之前完成它们的冲突操作(释放它们的锁

      • 被排在前面

两阶段锁协议

  • 在每个读之前需要获取一个S锁
  • 在每个写之前,需要获取一个X锁(或者是将S锁更新为X锁)
  • 只有当最后一个锁获取完毕,且对该对象的操作已经完成之后,才能释放锁

潜在问题?

  • 死锁
  • 级联中止
  • 如何知道操作完成
死锁
  • T2需要的锁,刚好被T1持有

截屏2024-08-18 13.39.19

  • 复杂的死锁情况
    • 截屏2024-08-18 13.40.11
    • 解决办法:中止其中一个事务
    • 截屏2024-08-18 13.41.38
级联回滚
  • 当T1中止了, T2读取了T1写的数据,因此T2也需要中止

截屏2024-08-18 13.44.11

能否有2PL的变体使得既不需要死锁检测也不会有级联中止吗?

严格的两阶段锁
  • 通过持有X锁直到事务结束,这样可以规避级联中止
  • 保证事务永不会读其他未提交事务的数据
严格的两阶段锁协议
  • 在每个读之前需要获取一个S锁
  • 在每个写之前,需要获取一个X锁(或者是将S锁更新为X锁)
  • 只有当最后一个锁获取完毕,且对该对象的操作已经完成之后,才能释放锁
  • 仅在获取最后一个锁并且对该对象的操作完成后,才释放共享锁
  • 仅在事务提交后才释放排他锁
  • 确保无级联回滚。
什么情况下释放?
  • • 数据库管理系统(DBMS)如何知道一个事务不再需要某个锁?
  • 这很困难,因为事务可能是交互式发出的。
  • 实际上,这意味着所有锁都会一直持有到事务结束。
  • 这被称为更严格的两阶段锁
更严格的两阶段锁协议
  • 在每个读之前,需要获取一个S锁
  • 在每个写之前,需要获取一个X锁(或者是将S锁更新为X锁)
  • 只有当最后一个锁获取完毕,且对该对象的操作已经完成之后,才能释放锁
  • 仅在事务提交后才释放所有锁
  • 确保无级联回滚,并且
  • COMIT 顺序等于串行化顺序

这样做有什么问题?

死锁!

截屏2024-08-26 15.53.04

如何处理?

  • 要求按顺序获取锁(避免死锁)。
  • 这样做有什么问题?会减少并发性。
  • 通过在等待图中查找循环来检测死锁。
    • 那接下来呢?"杀掉"一个事务——例如,强制它中止。

幽灵问题

当我们对单个数据项进行加锁时,如果一个事务 T1 扫描了一个数据范围,而另一个事务 T2 在该范围内插入数据并且 T2 先于 T1 提交,那么这个操作顺序就不能被视为串行等价。这是因为锁的对象(单个数据项)与系统中实际查询的逻辑对象(数据范围)不匹配。

为了解决这个问题,可以使用谓词锁,它允许锁一组逻辑上的值范围。然而,这个方法存在几个问题:

  • 执行成本:检测两个谓词是否冲突的计算代价非常高,尤其是在锁管理器的内部循环中进行这种检测会严重影响性能。
  • 悲观锁:这种方法可能过于保守,导致一些本来不冲突的操作无法同时进行。例如,T1 锁了“母亲”的数据范围,而 T2 锁了“父亲”的数据范围,理论上它们不应该冲突,但谓词锁可能会阻止这种情况。
  • 确定锁范围的难度:很难决定哪些范围需要锁,这需要人为判断。

由于上述问题,实际中通常不会使用任意 SQL 语句生成的谓词锁。一个更实际的解决方案是在 B+树结构中加锁。通过锁每次范围扫描后的下一个键(称为“下一个键锁”),可以防止在这个范围内插入新的数据。

除了锁机制外,还有其他协议可以用来实现串行调度。例如,乐观并发控制(Optimistic Concurrency Control,OCC)是一种不依赖锁的并发控制方法,下一次会探讨它的性能和应用。

论文阅读:Frankin的并发控制和恢复

Concurrency Control and Recovery, franklin97

1. 介绍

像很多银行、机场、医院等DBMS场景需要两个核心功能,一是保护保存到数据库中的数据、二是即使会有各种软硬件故障,也能提供正确且高可用的用户并发访问的能力。这个功能就在于DBMS软件中的并发控制和恢复模块。其中并发控制保证单个用户能够看到数据库一致的状态,即便有很多用户交错执行了部分操作,而恢复保证了数据库时可容错。而这些功能,也让应用能够无需担心并发和容错的情况正确写入数据。

在数据库系统中,并发访问正确性与事务的概念关系密切,一个事务就是一个单位的工作,可能包含了多个数据的访问和更新,但是原子性的提交或中断。正式地说,事务有ACID四个特性。

image-20250327182452646

看上面的错误的并发执行的例子。有很多并发控制和恢复的解决方法被提出,并且这些也运用在很多领域,比如文件系统,内存系统。ACID 模型有两个显著特点,使事务与其他方法区别开来。首先,它将隔离性(并发控制)和容错性(恢复)问题结合在一起。其次,它关注于将对多个数据项的多次写入和/或读取操作视为一个原子性、隔离性的工作单元处理。

2. 原理

2.1 并发控制

可串行化

在并发执行的正确性的概念上,最被广泛接受的就是 可串行化Serializability。其是一种性质,执行的结果与其中某一个非交错执行的结果相同。我们将关注可串行化的一个特殊变体,称为冲突可串行化Conflict serializability,它之所以能被广泛采纳是因为有高效、且容易实现的技术来检测或者强制满足这个性质;另外一个比较出名的变体称为视图可串行化View serializability,视图可串行化限制更少,但由于实现难度较大,视图可串行性及其他变体主要具有理论意义,在实际系统中不太实用。

事务调度

调度定义的顺序可以是部分排序, 因为它只需满足以下两种依赖关系的要求:

  • 事物内部操作的顺序: 对于给定事务中已明确指定执行顺序的所有操作,必须在调度中按照该顺序执行
  • 跨事务的冲突操作顺序: 来自不同事务的所有冲突操作的执行顺序必须在调度中明确指定

调度的概念提供了一种机制表达和推测(可能)当前事务的执行状态。串行调度(serial schedule)是说每个事务的所有操作连续出现,比如在REPORTSUM之后TRANSFER串行执行如下

r0[A]w0[A]r0[B]w0[B]c0r1[A]r1[B]c1

在这种表示法中,每个操作由其首字母表示,操作的下标表示执行该操作的事务编号,大写字母(用方括号括起来)表示数据库中的特定数据项(适用于读和写操作)。事务编号是由DBMS为每次事务执行分配。在上述示例中,TRANSFER 的执行被分配为 tr 0,而 REPORTSUM 的执行被分配为 tr 1。两个操作之间的右箭头 (→) 表示左侧操作的执行顺序在右侧操作之前。此顺序关系具有传递性,因此由传递性隐含的顺序不会明确绘出。

例如,图 1 中展示的 TRANSFERREPORTSUM 的交错执行将产生以下调度:

r0[A]w0[A]r1[A]r1[B]c1r0[B]w0[B]c0

可串行性的正式定义基于等价调度的概念。两个调度被称为equivalent()等价,当且仅当满足以下条件:

  • 包含相同的事务和操作;且
  • 对所有非中止事务的冲突操作,它们的执行顺序在两个调度中是相同的。

基于此等价调度的概念,如果一个调度等价于某个串行调度,则称该调度是可串行的。例如,以下并发调度是可串行的,因为它与调度 (1) 等价。

r0[A]w0[A]r1[A]r0[B]w0[B]c0r1[B]c1

相比之下,调度 (2) 的交错执行不可串行。来看为什么? 注意在TRANSFER 和 REPORTSUM 的任何串行执行中,要么 TRANSFER 的两个写操作都在 REPORTSUM 的两个读操作之前,要么反之。然而,在调度 (2) 中,存在以下执行顺序关系:$w_0[A] \rightarrow r_1[A] $, $r_1[B] \rightarrow w_0[B] $,因此调度(2)不等价与任何可能的串行调度,因而不可串行。

可串行化的验证

通过 优先图(precedence graph)轻松测试调度是否可串行。优先图是一个有向图,每个顶点代表已提交事务的执行(未提交的事务可忽略),如果在调度中,事务执行Ti中的某个操作必须在事务执行Tj中某个操作之前执行(ij),则在图中添加TiTj的有向边。如果一个额调度的优先图是无环的,则该调度是可串行的;反之如果存在环,则不可串行。实际上有很多种方式实现冲突可串行化

2.2 恢复

缓冲区管理问题

UNDO,是为了移除对中止或者未完成的事务的影响,以达到原子性的要求。而REDO是重新构建已经提交的事务的影响,以达到持久性的要求。Recovery的工作量,取决于DBMS缓冲区管理器如何工作的,即如何处理正在执行、正在提交的事务的数据。Buffer Manager是DBMS中协调主存储器与磁盘之间传输的组件。可以原子性写入非易失性存储的存储单元称为页面(page),更新操作首先对(volatile)缓冲池中的页面副本进行修改,这些副本稍后才被写入非易失性存储。Steal策略——如果缓冲区管理器允许未提交事务的更新覆盖非易失性存储中数据项的最近提交值(反之则是No-Steal策略);force策略——如果缓存区管理器确保事务的所有更新在该事务提交前已被写入非易失性存储,则称其为支持Force策略(反之称为No-Force)。

支持Steal策略意味着,如果事务需要回滚(事务失败或者系统崩溃),则需要恢复该事务更新的任何在非易失性存储上的数据,恢复到它先前提交的状态。相比之下,No-steal时钟保证非易失性存储上的数据是有效的,因此不需要恢复操作。

NO-FORCE 策略可能会导致在系统崩溃时,某些已提交的数据值丢失,因为无法保证它们已经被写入非易失性存储。因此,为了保证已提交更新的持久性(durability),可能需要执行大量的重做(REDO)操作。而 FORCE 策略则确保已提交的更新在事务提交时被写入非易失性存储,因此即使发生系统崩溃,非易失性存储中的数据库副本仍能反映这些更新。

上述讨论, NO-STEAL和FORCE策略的组合对UNDO和REDO的需求最少,然而对于在正常操作而言效率很低。NO-STEAL强制缓冲区管理器在事务提交之前将更新后的数据一直保持到内存中(或者是将数据写入到非易失性存储上的临时区域,比如swap区),直到事务提交才写入到易失性存储,即占用内存,限制事务并发。FORCE 策略的问题在于,它可能在事务提交的关键路径上引入显著的磁盘写入开销。因此,许多缓冲区管理器支持STEAL 和 NO-FORCE(STEAL/NO-FORCE)策略。

日志

为了处理UNDO和REDO的需求,DBMS通用依赖对log的使用。log是一个顺序文件,存储着事务的信息和系统的状态,每个日志实体称为日志记录(log record),每个事务通常会涉及到多个日志记录的写入,当一条日志记录创建时,就会分配日志序列号(LSN),自增的。很多系统会将新日志记录的LSN写入到包括更新数据的页面,通过这种记录方式,恢复系统能够能够将数据页的状态日志中的更新关联起来,从而判断特定日志记录是否已反映在数据页的当前状态中。

日志记录还用于记录事务管理活动,例如事务的提交或中止。此外,有时还会写入日志记录来描述系统在特定时间段的状态。例如,此类日志记录是作为Checkpoint过程的一部分写入的。在系统正常运行期间, checkpoint会定期执行,以限制崩溃时所需的工作量。这些记录可能包含:缓冲池的内容、当前事务的信息。具体内容取决于所使用的检查点方法。一种非侵入性的检查点方法是由ARIES 恢复方法使用的检查点技术。

对于事务更新操作,日志记录有两种基本类型:物理日志和逻辑日志。物理日志通常指示数据库中被修改数据的位置(例如某个页面的具体位置),如果系统支持UNDO操作,则日志记录中会包含更新前的数据值,称为该数据项的前镜像(before image),如果系统支持REDO操作,则会记录更新后的数据值,称为该数据的后镜像(after image),因此在采用STEAL/NO-FORCE缓冲管理策略的DBMS中,物理日志记录通常同时包含数据项的旧值和新值。使用物理日志进行恢复时,恢复操作(如 UNDO 或 REDO)具有幂等性,即无论执行多少次,效果都相同。这种特性非常重要,尤其是在系统多次失败(例如由于电源故障设备损坏)而重复执行恢复操作时,能够保证数据的一致性和正确性。

逻辑日志(有时候称操作日志)记录高层次的执行动作信息,而非实际的变化。比如,在关系中插入一个元祖可能需要数据库的物理变化,比如空间分配,索引更新,和重新组织等。物理日志需要为所有这些变更写入日志记录。而逻辑日志则仅记录插入操作的发生及被插入元组的值。在逻辑日志系统中,REDO过程必须确定完整恢复该插入操作所需执行的动作集合;同理,UNDO逻辑也需确定构成该日志操作逆操作的动作集合。

逻辑日志的优势在于能最小化需写入日志的数据量。此外,其内在吸引力在于可将复杂操作的诸多实现细节隐藏在UNDO/REDO逻辑中。然而实践中,基于逻辑日志的恢复机制难以实现,因为构成日志操作的各个动作并非原子性执行。即当系统崩溃后重启时,数据库可能未处于与复杂操作相对应的动作一致状态——崩溃前可能只有部分操作更新被写入非易失性存储。这导致恢复系统难以判断逻辑更新中哪些部分已反映在恢复后的数据库状态中。相较而言,物理日志虽不存在此问题,但可能产生显著更高的日志记录开销。

实践中,系统往往采用介于物理日志和逻辑日志之间的折中方案——physiological logging。该方案要求每条日志记录仅涉及单个页面,但可记录该页面上的逻辑操作。例如,针对页面插入操作的physiological日志记录会标明新增元组的值,但不会记录插入引发的空闲空间调整或页面数据重组的细节——插入操作的REDO和UNDO逻辑自行推导。若元组插入需更新多个页面(如数据页及多个索引页),则每个被修改的页面都会生成独立的physiological日志记录。ARIES恢复算法便是采用physiological日志的典型范例。

预写日志

。WAL协议能确保:在使用STEAL/NO-FORCE缓冲管理策略时,即使系统崩溃,恢复日志仍包含足够信息来执行必要的UNDO和REDO操作。该协议具体保证以下两点:

  1. 修改页面前必先写日志:在允许覆盖非易失性存储中的页面之前,必须先将与该页面更新相关的所有日志记录写入非易失性存储;
  2. 提交事务前必先写日志:只有当事务的所有日志记录(包括提交记录)都写入稳定存储后,该事务才被视为已提交。

第一条确保在崩溃发生时,日志中始终存在因STEAL策略所需的UNDO信息;同理,第二条则保证所有REDO信息。WAL协议由DBMS缓冲区管理器提供实现。

3. 最佳实践

3.1 并发控制

3.1.1 两阶段锁

最常见的并发控制实现技术是锁。通常支持两个类型锁,共享(S)锁和独占(X)锁。根据兼容性矩阵,如果一个事务请求的锁因锁冲突而无法被授予,该事务将被阻塞(即,禁止继续进行),直到其他事务释放所有冲突的锁。

SX
Syn
Xnn

表中定义的 S 锁和 X 锁直接模拟了在冲突可串行化(conflict serializability)定义中使用的冲突语义。锁的机制通过阻止事务因锁冲突而产生的非串行调度,从而确保了事务的执行顺序是可串行化的。可串行化意味着最终结果和串行执行的结果相同。

通过使用两阶段锁 (2PL),可以强制实现可串行化。两阶段锁要求所有事务都是Well-form(”良构“,意味着读取时总持有S或X锁,写入时持有X锁),并且需要遵守如下规则

IMPORTANT

一旦事务释放了锁,它就不再被允许获取任何额外的锁

这个规则导致事务有两个阶段:

  1. 增长阶段,在该阶段事务正在获取锁;
  2. 缩减阶段,在该阶段锁被释放。

两阶段规则规定,事务在第一次释放锁时从增长阶段转入缩减阶段。为了理解 2PL 如何强制实现可串行化,可以再次考虑调度 (2)

截屏2024-08-27 09.40.14

因为w0[A]r1[A]r1[B]w0[B]​,在2PL下不可能生成这种调度,因为当事务1尝试读取A(Report-Sum)时,他将被阻塞。因为事务0将持有A的X锁。在获得B的X锁之前,事务0将不允许释放该X锁,因此它要么中止,要么在事务1被允许继续执行之前已经更新完B。

NOTE

串行调度在2PL中是允许的,2PL还允许(可串行化)交错调度

2PL是实现可串行化的充分但非必要条件,比如调度3就是个例子

截屏2024-08-27 08.49.49

为了实现 2PL,数据库管理系统 (DBMS) 包含一个称为锁管理器的组件。锁管理器负责授予或阻止锁请求,管理被阻塞事务的队列,以及在锁被释放时解除事务的阻塞。此外,锁管理器还负责处理死锁情况。数据库系统通过两种通用技术之一来处理死锁:避免或检测。死锁避免可以通过规定获取数据项锁的顺序、要求事务预先声明其锁需求,或者在某些情况下中止事务而不是阻塞它们来实现。

另一方面,死锁检测可以通过超时或显式检查来实现。超时是最简单的技术;如果一个事务被阻塞超过一定时间,则假定发生了死锁。然而,选择超时间隔可能会带来问题。如果时间间隔太短,系统可能会错误地推断出存在实际上不存在的死锁。如果时间间隔太长,死锁可能会在较长时间内未被检测到。另一种方法是使用一种称为等待图 (waits-for graph) 的结构来显式检查死锁。等待图是一个有向图,其中每个活动事务对应一个顶点。锁管理器通过在事务 Ti 和事务 Tj 之间放置一条边来构建图,如果 Ti 被阻塞,正在等待 Tj 持有的锁。如果等待图包含一个循环,则表明所有参与循环的事务都在等待彼此,因此它们陷入了死锁。当检测到死锁时,涉及的一个或多个事务会被回滚

3.1.2 隔离级别

事务隔离会以潜在并发性为代价。事务阻塞可能会显著增加事务响应时间。在某些应用中,串行化并不是严格必要的。为了允许事务在控制范围内为一致性与并发性做出权衡。基于锁、依赖关系和异常现象(即在串行调度中不可能发生的结果)定义了四个一致性级别。这些级别被命名为 0-3 级,其中 0 级是一致性最差的,3 级则旨在等同于串行执行。然而,为了解决幻读问题,需要对一致性级别的定义进行扩展。

幻读问题的一个例子如下:假设事务 Ti 读取了一组满足查询谓词的元组。另一个事务 Tj 插入了一个满足该谓词的新元组。如果 Ti 再次执行查询,它将看到这个新元组,因此它的第二次查询结果与第一次不同。这种行为在串行调度中永远不会发生,因为一个“幻影”元组出现在事务执行的中间,因此这种执行是异常的。幻读问题是我们迄今为止使用的仅包含对单个数据项的读写操作的事务模型的产物。实际上,事务包含基于谓词动态定义的数据集的查询。当查询被执行时,所有在该时刻满足谓词的元组都会被锁。然而,这些单个锁并不能防止后来添加的满足谓词的新元组

幻读问题的一个明显解决方案是锁谓词而不是(或者除了)单个数据项【Eswa76】。然而,这种解决方案由于检测一组任意谓词重叠的复杂性而难以实现。谓词锁可以通过基于锁数据簇或索引值范围的技术来近似实现。然而,这些技术超出了本章的讨论范围。在此讨论中,我们将假设可以锁谓词,而不具体说明如何实现这一点的技术细节(详见 Gray93, Moha92a)。

基于锁的隔离级别定义是基于读和/或写操作是否是良构的,如果是良构的,那么再基于锁是长时间锁还是短时间锁。长时间锁一直保持到事务结束 (EOT)(即当它提交或中止时);短时间锁则可以提前释放。当事务 T₀ 对数据项 A 进行了写操作后,T₁ 也对同一个数据项进行了写操作。随后,如果 T₀ 中止,根据常规的恢复机制,数据库会尝试使用 T₀ 的前镜像(before image)恢复 A 到 T₀ 进行写操作前的状态。在这种情况下,用 T₀ 的原始镜像恢复 A 是不正确的,因为这会覆盖 T₁ 的更新。简单地忽略 T₀ 的中止也是不正确的。如果 T₁ 随后中止,恢复其前镜像将重新安装 T₀ 写入的值。出于这个原因以及为了简化,锁系统通常会在数据项上持有长时间的锁。这有时被称为严格锁

w0[A]w1[A]a0

假设所有级别都在写操作方面是良构的,并且对更新的数据项持有长时间的写(即排他)锁。定义了四个级别(从最弱到最强):

  • 未提交读Read Uncommitted:这一级别提供最弱的一致性保证,允许事务读取其他未提交事务写入的数据。在锁实现中,这个级别通过在读取时不获取读取锁)来实现。
  • 提交读Read Committed:这一级别确保事务只能看到已提交的事务所做的更新。这个级别通过在单个数据项读取时良构,但仅持有短时间读取锁来实现。在这个级别运行的事务面临看到不可重复读的风险(除更严格级别的风险外)。即,一个事务 T₀ 可能会两次读取数据项并看到两个不同的值。如果在 T₀ 的两次读取之间,另一个事务更新了该数据项并提交,这种异常现象可能会发生。
  • 可重复读(Repeatable Read): 这一届别确保对单个数据项的读取是可重复的。但是不保护之前描述的幻读问题。这个级别通过在单个数据项读取时良构,并持有这些锁较长时间来实现。
  • 可串行化(SERIALIZABLE):这一级别保护所有较低级别的问题,包括幻读问题。它通过对谓词和单个数据项读取时良构,并持有所有锁较长时间来实现

因此,这些隔离级别提供了一种强大的工具,使得应用程序编写者或用户可以在一致性和改进并发性之间进行权衡。对于不基于锁的并发控制方法来说,定义这些隔离级别一直是个问题。

值得注意的是,迄今为止对锁的讨论忽略了数据库中通常存在的重要数据类别,即索引。因为索引是辅助信息。索引可以在非两阶段(2PL)的方式下访问,而不会牺牲可串行化性。此外,许多索引(如 B-树)的层次结构使其在结构的上层可能成为并发瓶颈,因为这些位置会出现高度的竞争。基于这个原因,大量研究工作致力于开发能够为索引提供高并发访问的方法

3.1.3 分层锁

在前面的并发控制讨论中,示例主要涉及对单一数据项粒度(例如元组)的操作。然而,在实际应用中,冲突和锁的概念可以应用于许多不同的粒度。例如,可以在页、关系甚至整个数据库的粒度上执行加锁。在选择执行锁的适当粒度时,存在潜在并发性和锁开销之间的基本权衡。以细粒度(如单个元组)进行锁,允许最大程度的并发,因为只有真正访问相同元组的事务才有可能发生冲突。然而,这种细粒度锁的缺点是,访问大量元组的事务将不得不获取大量锁。每个锁请求都需要调用锁管理器,这种开销可以通过在较粗粒度上进行锁来减少,但粗粒度锁增加了虚假冲突的可能性。例如,在页级锁下,更新同一页上不同元组的两个事务会发生冲突,而在元组级锁下则不会。

引入分层锁多粒度锁的概念是为了允许并发事务在不同粒度上获取锁,以优化上述权衡【Gray 75】。在分层锁中,在粒度层次结构中的某个级别对某个粒度的锁隐式地锁了该粒度所包含的所有项目。例如,对关系的S锁隐式锁了该关系中的所有页和元组。因此,持有这种锁的事务可以读取关系中的任何元组,而无需请求其他锁。分层锁引入了除S和X之外的额外锁模式。这些额外的模式允许事务声明它们打算对粒度层次结构中较低级别的对象执行操作。新的模式包括IS(意图共享)、IX(意图独占)和SIX(共享与意图独占)。对某个粒度的IS(或IX)锁并不提供对该粒度的权限,但表示持有者打算在一个或多个更细的粒度上获取S(或X)锁SIX锁将整个粒度的S锁与IX锁结合在一起。SIX锁支持对粒度中的项目(例如关系中的元组)进行扫描,并根据其值选择更新其中一部分的常见访问模式。与S和X锁类似,这些锁模式可以用兼容性矩阵来描述。

这些模式的兼容性矩阵如表2所示。为了使在不同粒度上进行锁的事务共存,所有事务必须遵循相同的分层锁协议,从粒度层次结构的根节点开始。此协议如表3所示。

例如,要读取单个记录,事务将获取数据库、关系和页上的IS锁,然后对特定元组获取S锁。如果事务想要读取页上的所有或大多数元组,它可以获取数据库和关系上的IS锁,然后获取整个页的S锁。通过遵循这个统一的协议,可以检测最终在不同粒度上获取S和/或X锁的事务之间的潜在冲突。分层锁的一个有用扩展被称为锁升级(lock esclation)。锁升级允许DBMS根据事务的行为自动调整获取锁的粒度。如果系统检测到事务正在获取组成较大粒度的大部分小粒度上的锁,它可以尝试为事务授予较大粒度上的锁,这样在随后访问该粒度中的其他对象时不再需要额外的锁。自动升级很有用,因为事务将产生的访问模式通常在事先并不清楚。

截屏2024-08-27 15.14.40

3.1.4 其他并发控制方法

这一段讨论了几种替代传统两阶段锁定(2PL)的并发控制方法。两阶段锁定是一种悲观方法,它假设事务之间可能会发生冲突,因此使用锁定和阻塞来避免冲突。与此相对的是乐观并发控制,它允许事务在不锁定的情况下执行,只有在提交之前才会验证是否发生了冲突。如果检测到冲突,相关事务将被中止并重新执行。这种方法在资源充足的情况下可能更为有效,但在资源有限的环境中,传统的锁定方法可能更优。

此外,文中还介绍了多版本并发控制(MVCC),这是一种允许并发读写操作的技术。在这种方法中,更新事务会保留数据项的旧版本,使得只读事务能够看到一致但可能过时的数据库快照。这种方法特别适合需要高并发读操作的场景。

3.2 故障恢复

故障恢复系统被认为是DBMS中最难设计的部分之一,主要有两个原因:

  1. 恢复系统必须在故障情况下工作,并且必须能够正确处理大量可能的系统和数据库状态
  2. 恢复系统依赖于DBMS中许多其他组件的行为,例如并发控制、缓冲区管理、磁盘管理和查询处理。

3.2.1 ARIES 概述

ARIES是对预写日志(Write-Ahead Logging, WAL)协议的一个相对较新的改进。WAL协议使得STEAL/NO FORCE缓冲区管理策略的使用成为可能,这意味着可以随时覆盖稳定存储中的页面,并且在提交事务时不需要将数据页面强制写入磁盘。与其他WAL实现一样,数据库中的每个页面都包含一个日志序列号(Log Sequence Number, LSN),该编号唯一标识应用于该页面的最新更新的日志记录。这个LSN(称为pageLSN)在恢复期间用于确定是否需要重新执行页面的更新。LSN信息还用于确定系统崩溃后重新启动时REDO操作开始的日志位置。LSN通常使用日志记录在日志中的物理地址来实现,从而可以有效地定位给定LSN的日志记录。

ARIES算法的力量和相对简单性在很大程度上归功于其“重复历史”的REDO范式,这意味着它会重做所有事务的更新——包括那些最终将被撤销的事务。重复历史使得ARIES能够采用之前描述的生理日志记录技术的变体:它使用页面导向的REDO和一种形式的逻辑UNDO。页面导向的REDO意味着REDO操作仅涉及单个页面,并且受影响的页面在日志记录中被指定。这是生理日志记录的一部分。在ARIES的上下文中,逻辑UNDO意味着撤销更新时执行的操作不需要是原始更新操作的精确逆操作。

在ARIES中,逻辑UNDO用于支持细粒度(例如,元组级)锁定和高并发的索引管理。对于后者的问题,考虑以下情况:事务T1在给定页面P1上更新了一个索引条目。在T1完成之前,另一个事务T2可能会分割P1,从而将索引条目移动到一个新页面(P2)上。如果必须撤销T1,物理的、页面导向的方法将失败,因为它会错误地尝试在P1上执行UNDO操作。逻辑UNDO通过使用索引结构找到索引条目,并在其新位置应用UNDO操作来解决这个问题。与UNDO不同,页面导向的REDO可以使用,因为重复历史范式确保REDO操作总是能在日志记录中引用的页面上找到索引条目——任何影响索引操作位置的操作都会在重新执行该日志记录之前被重播。

ARIES使用三步算法进行重新启动恢复。第一步是分析(Analysis)阶段,该阶段从最近的检查点开始向前处理日志。此阶段确定了关于脏页和活跃事务的信息,这些信息在随后的步骤中使用。第二步是REDO阶段,在此阶段通过从最早的可能需要重做的日志记录开始向前处理日志来重复历史,从而确保所有记录的操作都已被应用。第三步是UNDO阶段,该阶段从日志的末尾开始向后进行,移除不必要的操作。

3.2.2 分析阶段

分析阶段在重启恢复过程中的任务有三方面:

  1. 确定REDO阶段的日志位置
  2. 确定在崩溃时哪些页面可能是脏页,从而在REDO阶段避免不必要的I/O操作
  3. 确定在崩溃时尚未提交的事务,用于UNDO阶段处理

分析阶段从最近的检查点开始,向前扫描直到日志的末尾。它会重建事务表(Transaction Table)和脏页表(Dirty Page Table),以确定系统在崩溃时的状态。

  • 当遇到一个事务不在事务表中的日志记录时,该事务将被添加到事务表中
  • 当遇到事务提交或中止的日志记录时,相应的事务将从事务表中删除
  • 当遇到一个更新页面且不在脏页表中的日志记录时,该页面将被添加到脏页表中,并且导致该页面被添加到表中的日志记录的LSN将被记录为该页面的恢复LSN。

恢复LSN是表示该页面自上次写入磁盘后第一次被修改的日志位置。在恢复过程中,系统会从这个恢复LSN开始,重新应用对该页面的所有修改操作,以确保页面恢复到正确的状态。

在分析阶段结束时,脏页表将包含一个保守的列表,即所有在系统崩溃时可能是脏页的数据库页面(因为某些页面可能已经被刷新到非易失性存储中)。事务表则包含了那些在UNDO阶段实际需要进行撤销处理的事务。所有脏页表中条目的最早恢复LSN,称为firstLSN,被用作开始REDO阶段的日志位置。

3.2.3 重做阶段

ARIES采用了一种称为“重复历史”的重做模式。也就是说,它会重新应用所有事务(无论是否提交)的更新操作。“重复历史”的效果是,在重做阶段结束时,数据库在日志记录的更新方面恢复到崩溃发生时的状态。重做阶段从分析阶段确定的firstLSN所对应的日志记录开始,并从那里向前扫描。为了重做某个更新,系统会重新应用已记录的操作,并将页面上的pageLSN设置为被重做的日志记录的LSN。重做操作本身不会生成新的日志记录。对于每一条日志记录,使用以下算法来判断该更新是否需要重做:

  • 如果受影响的页面不在脏页表中,说明该页面在崩溃前已经被写入到磁盘,因此不需要重做该更新。
  • 如果受影响的页面在脏页表中,则如果页面表项中的recovery LSN大于正在检查的日志记录的LSN,说明该页面已经包含了更新内容,因此不需要重做。
  • 否则,需要检查存储在页面上的LSN(即pageLSN)。这可能需要将页面从磁盘中读取。如果pageLSN大于或等于正在检查的日志记录的LSN,说明页面上的数据已经包含了该日志记录中的更新操作,因此也不需要重做。否则,必须重做该更新操作

3.2.4 撤销阶段

撤销阶段从日志的末尾向前扫描。在撤销阶段,所有在崩溃发生时尚未提交的事务必须被撤销。在ARIES中,撤销是一种非条件操作。这意味着不会检查受影响页面的pageLSN,因为撤销操作必须被执行。这是由于重复历史的特点,确保所有记录的更新已经被应用到页面上。

当一个更新被撤销时,撤销操作会应用到页面上,并且会记录到一个称为补偿日志记录 (CLR) 的特殊类型的日志记录中。除了撤销操作,CLR还包含一个名为UndoNxtLSN的字段。UndoNxtLSN 是下一个必须为该事务撤销的日志记录的LSN,它被设置为正在撤销的日志记录的prevLSN字段的值。以这种方式记录CLR使得ARIES能够避免重复撤销某些更新,从而限制必须撤销的工作量,并在多次崩溃的情况下减少工作量。

如果在向后扫描时遇到一个CLR,不会对页面执行任何操作,向后扫描将继续进行,跳转到UndoNxtLSN字段中指向的日志记录,从而跳过已撤销的更新以及该事务中已被撤销的其他更新(多事务的情况将在稍后讨论)。图4展示了一个示例执行。在图4中,系统第一次崩溃前,一个事务记录了三个更新(LSNs 10, 20, 和 30)。在重做阶段,数据库被更新到与日志一致的状态(即,如果10, 20, 和/或 30不在非易失性存储中,它们将被重做),但是由于该事务在崩溃时仍在进行中,这些更新必须被撤销。在撤销阶段,更新30被撤销,生成了一个带有LSN 40的CLR,该CLR的UndoNxtLSN值指向20。然后,更新20被撤销,生成了一个带有LSN 50的CLR,该CLR的UndoNxtLSN值指向10。然而,系统在更新10被撤销之前再次崩溃。再次重做历史时,数据库状态恢复到应用LSN 50(20的CLR)之后的状态。在第二次重启期间,当撤销开始时,它首先检查日志记录50。由于该记录是CLR,不会对页面执行任何修改,并且撤销将跳到CLR中UndoNxtLSN字段所存储的日志记录(即,LSN 10)。因此,它将继续撤销具有LSN 10的日志记录中描述的更新。这是第二次崩溃时撤销阶段被中断的地方。注意,第二次崩溃并未导致额外的日志记录。

为了撤销多个事务,重启撤销阶段会保存一个列表,该列表包含每个正在撤销的事务中下一个需要撤销的LSN。当在撤销阶段处理日志记录时,prevLSN(或在CLR中为UndoNxtLSN)会被输入为该事务中下一个需要撤销的LSN。然后撤销阶段会移动到最近的需要撤销的LSN所对应的日志记录。撤销会在日志中向后进行,直到列表中的所有事务都被撤销到其第一个日志记录为止。事务回滚的撤销与上述的重启撤销阶段类似。唯一的区别是,在事务回滚期间,只需撤销单个事务(或部分事务)。因此,回滚不需要保存多个事务的待撤销LSN列表,只需沿着该事务的日志记录链向后追踪即可。