数据密集型应用系统设计 学习笔记(七):事务

事务

深入理解事务

ACID

事务所提供的安全保证即大家所熟知的 ACID ,分别代表原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)

而不符合 ACID 标准的系统有时被称为 BASE ,取另外几个特性的首字母,即基本可用性(Basically Available)软状态(Soft State)最终一致性( Eventual consistency)

原子性

ACID 中原子性所定义的特征是:在出错时中止事务,并将部分完成的写入全部丢弃

假如没有原子性保证,当多个更新操作中间发生了错误,就需要知道哪些更改已经生效,哪些没有生效,这个寻找过程会非常麻烦。或许应用程序可以重试,但情况类似,并且可能导致重复更新或者不正确的结果。原子性则大大简化了这个问题:如果事务已经中止,应用程序可以确定没有实质发生任何更改,所以可以安全地重试

一致性

ACID 中的一致性的主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。

例如一个账单系统,账户的贷款余额应和借款余额保持平衡。如果某事务从一个有效的状态开始,并且事务中任何更新操作都没有违背约束,那么最后的结果依然符合有效状态。

这种一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情:即如果提供的数据修改违背了恒等条件,数据库很难检测进而阻止该操作(数据库可以完成针对某些特定类型的恒等约束检查,例如使用外键约束或唯一性约束。但通常主要靠应用程序来定义数据的有效/无效状态,数据库主要负责存储)。

隔离性

ACID 语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。

例如,如果某个事务进行多次写入,则另一个事务应该观察到的是其全部完成(或者一个都没完成)的结果,而不应该看到中间的部分结果。

持久性

持久性即保证事务提交成功,即使存在硬件故障或数据库崩溃, 事务所写入的任何数据也不会消失

对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。在写入执行过程中,通常还涉及预写日志等, 这样万一 磁盘数据损坏可以进行恢复。而对于支持远程复制的数据库,持久性则意味着数据已成功复制到多个节点。为了实现持久性的保证,数据库必须等到这些写入或复制完成之后才能报告事务成功提交。

弱隔离级别

可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能,因而更多倾向于采用较弱的隔离级别,它可以防止某些但并非全部的并发问题。

读已提交

读已提交是最基本的的事务隔离级别,它只提供以下两个保证:

  1. 读数据库时,只能看到巳成功提交的数据(防止脏读)。
  2. 写数据库时,只会覆盖已成功提交的数据(防止脏写)。

某些数据库提供更弱的隔离级别,称为读未提交。它只防止脏写,而不防止脏读。

脏读

假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时如果可以看到尚未提交的数据的话,那就是脏读

脏写

如果两个事务同时尝试更新相同的对象,先前的写入是尚未提交的事务的一部分,如果被覆盖,那就是脏写

实现方法

数据库通常采用行级锁来防止脏写当事务想修改某个对象时,它必须首先获得该对象的锁,然后一直持有锁直到事务提交(或中止)。 给定时刻,只有一个事务可以拿到特定对象的锁,如果有另一个事务尝试更新同一个对象,则必须等待,直到前面的事务完成了提交(或中止)后,才能获得锁并继续。

但上述方法应用于脏写则不够理想。因为运行时间较长的写事务会导致许多只读的事务等待太长时间,这会严重影响只读事务的响应延迟,且可操作性差。

因此,大多数数据库采用 MVCC 的方式来实现。对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。

快照隔离级别与可重复读

快照级别隔离对于只读事务特别有效。但是,具体到实现,许多数据库却对它有不同的命名。 Oracle 称之为可串行化, PostgreSQL、MySQL 则称为可重复读。

这种命名混淆的原因是 SQL 标准并没有定义快照级别隔离,而仍然是基于老的 System R 1975 年所定义的隔离级别,而当时还没有出现快照级别隔离。标准定义的是可重复读,这看起来比较接近于快照级别隔离,所以 PostgreSQL、MySQL 称它们的快照级别隔离为可重复读,这符合标准要求(即合规性)。

实现方法

为了实现快照隔离级别,数据库采用了一种防止脏读但却更为通用的机制。考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(Multi-Version Concurrency Control,MVCC)

如果只是为了提供读已提交级别隔离,而不是完整的快照隔离级别,则只保留对象的两个版本就足够了:一个己提交的旧版本和尚未提交的新版本。所以,支持快照隔离级别的存储引擎往往直接采用 MVCC 来实现读已提交隔离。典型的做法是,在读已提交级别下,对每一个不同的查询单独创建一个快照;而快照隔离级别级别隔离则是使用一个快照来运行整个事务。

接下来看看 PostgreSQL 是如何实现基于 MVCC 的快照隔离级别 (其他实现基本类似)。

当事务开始时,首先赋予一个唯一的、单调递增的事务 ID(txid)。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务 ID。

表中的每行都有 created_by 字段,其中包含了创建该行的事务 ID 。还有一个 deleted_by 字段,初始为空。 如果事务要删除某行,该行实际上并未从数据库中删除,而只是将 deleted_by 字段设置为请求删除的事务 ID (标记删除) 。事后,当确定没有其他事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除井释放存储空间。这样一笔更新操作在内部会被转换为一个删除操作加一个创建操作。

如上图,事务 13 从账户中扣除 100 美元,余额从 500 美元减为 400 美元。 accounts 表里会出现两行账户 2 。一行是余额为 500 但标记为删除(由事务 13 删除),另一个余额为 400 ,由事务 13 创建。

一致性快照的可见规则

当事务读数据库时·,通过事务 ID 可以决定哪些对象可见,哪些不可见。要想对上层应用维护好快照的一致性,需要精心定义数据的可见性规则。例如:

  • 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务(即尚未提交或中止),然后忽略这些事务完成的部分写入(尽管之后可能会被提交),即不可见。
  • 所有中止事务所做的修改全部不可见。
  • 较晚事务ID(即晚于当前事务)所做的任何修改不可见,不管这些事务是否完成了提交。
  • 除此之外,其他所有的写入都对应用查询可见。

换句话说,仅当以下两个条件都成立, 主数据对象对事务可见:

  • 事务开始的时刻,创建该对象的事务已经完成了提交。

  • 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交。

防止丢失更新

写事务并发除了脏写以外,还会带来其他一些值得关注的冲突问题,最著名的就是更新丢失问题。

更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值 (read-modify-write)。 当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一事务的修改值可能会丢失。

这种冲突还可能在其他不同的场景下发生,例如:

  • 递增计数器,或更新账户余额(需要读取当前值,计算新值井写回更新后的值)。
  • 对某复杂对象的一部分内容执行修改,例如对 JSON 文档中一个列表添加新元素(需要读取并解析文档,执行更改井写回修改后的文档)。
  • 两个用户同时编辑 wiki 页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效 。

并发写事务冲突是一个普遍问题,目前有多种可行的解决方案。

原子写操作

许多数据库提供了原子更新操作,以避免在应用层代码完成读取-修改-写回操作,如果支持的话,通常这就是最好的解决方案。例如,以下指令在多数关系数据库中都是井发安全的:

1
UPDATE counters SET value = value + 1 WHERE key = 'foo';

原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前不会让其他事务读取它。这种技术有时被称为游标稳定性。另一种实现方式是强制所有的原子操作都在单线程上执行

显式加锁

如果数据库不支持内置原子操作,另一种防止更新丢失的方法是由应用程序显式锁定待更新的对象。 然后,应用程序可以执行读取-修改-写回这样的操作序列。此时如果有其他事务尝试并发读取对象,则必须等待当前正在执行的序列全部完成。

例如这样一个场景,在一个多人游戏中,几个玩家可以同时移动同 个数字。只靠原子操作可能还不够,因为应用程序还需要确保玩家的移动还需遵守其他游戏规则,这涉及应用层逻辑,不可能将其剥离转移给数据库层在查询时执行。此时,可以使用锁来防止两名玩家同时操作相同的棋子,如下列代码:

1
2
3
4
5
6
7
8
9
BEGIN TRANSACTION;
SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- FOR UPDATE 指令指示数据库对返回的所有结果行要加锁。

-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;

原子比较和设置

原子操作和锁都是通过强制读取-修改-写回操作序列串行执行来防止丢失更新。另一种思路则是先让他们并发执行,但是如果事务管理器检测到了更新丢失的风险,则会中止当前事务,并强制回退到安全的读取-修改-写回方式。

该方法的一个优点是数据库完全可以借助快照级别隔离来高效地执行检查。的确,PostgreSQL 的可重复读, Oracle 的可串行化以及 SQL Server 的快照级别隔离等,都可以自动检测何时发生了更新丢失,然后会中止违规的那个事务。但是,MySQL/InnoDB 的可重复读却并不支持检测更新丢失。有一些观点认为,数据库必须防止更新丢失,要不然就不能宣称符合快照级别隔离,如果基于这样的定义,那么 MySQL 就属于没有完全支持快照级别隔离。

冲突解决和复制

对于支持多副本的数据库,防止丢失更新还需要考虑另一个维度 :由多节点上的数据副本,不同的节点可能会并发修改数据,因此必须采取一些额外的措施来防止丢失更新。

加锁和原子修改都有个前提即只有一个最新的数据副本。然而,对于多主节点或者无主节点的多副本数据库,由于支持多个井发写 ,且通常以异步方式来同步更新,所以会出现多个最新的数据副本。此时加锁和原子比较将不再适用。

多副本数据库通常支持多个井发写,然后保留多个冲突版本,之后由应用层逻辑或依靠特定的数据结构来解决、合并多版本。

如果操作可交换(顺序无关,在不同的副本上以不同的顺序执行时仍然得到相同的结果),则原子操作在多副本情况下也可以工作。例如,计数器递增或向集合中添加元素等都是典型的可交换操作。

写倾斜与幻读

写倾斜

当两笔事务根据读取相同的一组记录进行条件判断通过后,更新了不同的记录对象,刚刚的写操作改变了决定的前提条件,结果可能违背了业务约束要求,此时的异常情况称为写倾斜。只有可串行化的隔离级别才能防止这种异常。

写倾斜看起来很晦涩拗口,下面举几个常见场景:

  • 会议室预订系统:要求在同一时间内,同一个会议室不能被预订两次。
  • 用户名:网站通常要求每个用户有唯一的用户名,两个用户可能同时尝试创建相同的用户名。
  • 防止双重开支:支付或积分相关的服务通常需要检查用户的花费不能超过其限额。如果有两笔交易同时进行,两个交易各自都不能超额,也不能加在一起后超额。

可以将写倾斜视为一种更广义的更新丢失问题。 即如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜。不同的事务如果更新的是同一个对象,则可能发生脏写或更新丢失。

幻读

事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。 快照隔离可以防止只读查询的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁。

串行化

可串行化隔离通常被认为是最强的隔离级别它保证即使事务可能会井行执行,但最终的结果与每次一个即串行执行结果相同。 这意味着,如果事务在单独运行时表现正确,那么它们在并发运行时结果仍然正确,换句话说,数据库可以防止所有可能的竞争条件。

目前大多数提供可串行化的数据库都使用了以下三种技术之一:

  • 严格按照串行顺序执行
  • 两阶段加锁
  • 乐观井发控制技术,例如可串行化的快照隔离。

严格串行执行

解决并发问题最直接的方法是避免并发即在一个线程上按顺序方式每次只执行一个事务。这样我们完全回避了诸如检测、防止事务冲突等问题,其对应的隔离级别是严格串行化的。

当满足以下约束条件时,串行执行事务可以实现串行化隔离:

  • 事务必须简短而高效,否则一个缓慢的事务将会影响到所有事务的执行性能。
  • 仅限于活动数据集完全可以加载到内存的场景。有些很少访问的数据可能会被移动到磁盘,但万一单线程事务需要访问它,就会严重拖累性能 。
  • 写入吞吐量必须足够低,才能在单个 CPU 核上处理,否则就需要采用分区,最好没有跨分区事务。
  • 跨分区事务虽然也可以支持,但是占比必须很小。

两阶段加锁

虽然两阶段加锁(2PL)听起来和两阶段提交(two-phase commit,2PC)很相近,但它们是完全不同的东西。

近三十年来,可以说数据库只有一种被广泛使用的串行化算法,那就是两阶段加锁(two-phase locking,2PL)

两阶段加锁与前面提到的加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,但只要出现任何写操作(包括修改或删除),则必须加锁以独占访问

  • 如果事务 A 已经读取了某个对象,此时事务 B 想要写入该对象,那么就必须等到 事务 A 提交或中止之才能继续。以确保事务 B 不会在事务 A 执行的过程中间去修改对象。
  • 如果事务 A 已经修改了对象, 此时事务 B 想要读取该对象, 则事务 B 必须等到事务 A 提交或者中止之后才能继续。对于 2PL ,不会出现读到旧值的情况。

因此 2PL 不仅在并发写操作之间互斥,读取也会和修改产生互斥。另一方面,因为 2PL 提供了串行化,所以它可以防止前面讨论的所有竞争条件,包括更新丢失和写倾斜。

实现方法

目前, 2PL 已经用于 MySQL(InnoDB)和 SQL Server 中的可串行化, 以及 DB2 中的可重复读。

数据库的每个对象都有一个读写锁来隔离读写操作,即锁可以处于共享模式或独占模式。 基本用法如下:

  • 如果事务要读取对象 ,必须先以共享模式获得锁。 可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待。
  • 如果事务要修改对象,必须以独占模式获取锁。 不允许多个事务同时持有该锁(包括共享或独占模式),换句话说,如果对象上已被加锁,则修改事务必须等待。
  • 如果事务先是读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。 升级锁的流程等价于直接获得独占锁。
  • 事务获得锁之后,一直持有锁直到事务结束(包括提交或中止)。 这也是名字两阶段的来由,在第一阶段(事务执行前)获取锁,第二阶段(事务结束后)释放锁。

由于使用了很多锁机制,所以很容易出现死锁 ,例如事务 A 可能在等待事务 B 释放它的锁, 事务 B 在等待事务 A 释放所持有的锁(循环等待)。数据库系统会自动检测事务之间的死锁情况(常见方法是超时或者回路检测),并强行中止其中的一个以打破僵局,这样另一个可以继续向前执行,而被中止的事务需要由应用层来重试。

谓词锁

在前面的章节我们提到了幻读的问题,那么可串行化是如何解决幻读的呢?

我们需要引入一种谓词锁。它的作用于之前描述的共享/独占锁, 区别在于,它并不属于某个特定的对象(行),而是作用于满足某些搜索条件的所有查询对象(区间)

谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读)。将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。

索引区间锁

但是由于谓词锁性能不佳,如果活动事务中存在过多的锁,那么检查匹配这些锁就变得非常耗时。因此,大多数使用 2PL 的数据库实际上实现的是索引区间锁index-range locking,也称为next-key locking)。本质上它是对谓词锁的简化或者近似。

简化谓词锁的方式是将其保护的对象扩大化到一整个索引上。 例如存在下面这样一个场景,我们有一个酒店的管理系统,假设谓词锁保护的查询条件是:房间 123 ,时间段是周六 。

  • 如果索引建立在房间号上:此时数据库会将共享锁附加到房间号索引上,此时会锁定 123 号房间的所有记录。
  • 如果索引建立在日期上:此时数据库会将共享锁附加到日期上,此时会锁定周六的所有记录。

无论哪种方式,查询条件的近似值都附加到某个索引上。 接下来,如果另一个事务想要插入、更新或删除同一个房间或重叠时间段的预订,则肯定需要更新这些索引,一定就会与共享锁冲突,因此会自动处于等待状态直到共享锁释放。

这样就有效防止了 写倾斜和幻读问题。 的确,索引区间锁不像谓词锁那么精确,但由于开销低得多,可以认为是一种很好的折中方案。

如果没有合适的索引可以施加区间锁,则数据库可以回退到对整个表施加共享锁。 这种方式的性能肯定不好,它甚至会阻止所有其他事务的写操作,但的确可以保证安全性。

性能

两阶段加锁的主要缺点在于其性能,其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。

其主要原因有以下几点:

  • 锁的获取和释放本身的开销大:开销大的同时也降低了事务的并发性。两个并发事务如果做任何可能导致竞争条件的事情,其中一个必须等待对方完成。一旦出现多个事务同时访问同一对象,会形成一个等待队列,事务就必须等待队列前面所有其他事务完成之后才能继续。
  • 数据库的访问延迟具有非常大的不确定性:某个事务本身很慢,或者是由于需要访问大量数据而获得了许多锁, 则它还会导致系统的其它部分都停顿下来。如果应用需要稳定的性能,这种不确定性就是致命的。
  • 死锁变得更为频繁:如果事务由于死锁而被强行中止,应用层就必须从头重试,假如死锁过于频繁,则最后的性能和效率必然大打折扣。

可串行化的快照隔离

两阶段加锁虽然可以保证串行化,但性能差强人意且无法扩展(由于串行执行)。弱级别隔离虽然性能不错,但容易引发各种边界条件(如更新丢失,写倾斜 ,幻读等)。那有什么方法可以使性能和串行化隔离得到均衡吗?

这时候就需要提到可串行化的快照隔离(Serializable Snapshot Isolation, SSI) 算法。它提供了完整的可串行性保证,而性能相比于快照隔离损失很小。目前, SSI 可用于单节点数据库(如 PostgreSQL 9.1 之后的可串行化隔离)或者分布式数据库(如 FoundationDB 采用了类似的算法)。

实现

可串行化的快照隔离是一种乐观井发控制如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事。而当事务提交时(只有可串行化的事务被允许提交),数据库会检查是否确实发生了冲突,如果是的话,中止事务并接下来重试。

SSI 基于快照隔离,也就是说,事务中的所有读取操作都是基于数据库的一致性快照。这是与早期的乐观并发控制主要区别。在快照隔离的基础上, SSI 新增加了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。

性能

与两阶段加锁相比,可串行化快照隔离的一大优点是事务不需要等待其他事务所持有的锁。 这一点和快照隔离一样 ,读写通常不会互相阻塞。这样的设计使得查询延迟更加稳定、可预测。特别是,在一致性快照上执行只读查询不需要任何锁,这对于读密集的负载非常有吸引力。

与串行执行相比,可串行化快照隔离可以突破单个 CPU 的限制。 FoundationDB 将冲突检测分布在多台机器上,从而提高总体吞吐量。即使数据可能跨多台机器进行分区,事务也可以在多个分区上读、写数据并保证可串行化隔离。

但是,事务中止的比例会显著影响 SSI 的性能表现。 例如,一个运行很长时间的事务,读取和写入了大量数据,因而产生冲突并中止的概率就会增大,所以 SSI 要求读写型事务要简短(而长时间执行的只读事务则没有此限制)。但总体讲,相比于两阶段加锁与串行执行, SSI 更能容忍那些执行缓慢的事务。

Built with Hugo
主题 StackJimmy 设计