事务

ACID

事务所提供的安全保证,通常由众所周知的首字母缩略词 ACID 来描述。ACID 代表 原子性(Atomicity)一致性(Consistency)隔离性(Isolation) 和 持久性(Durability)

(不符合 ACID 标准的系统有时被称为 BASE,它代表 基本可用性(Basically Available)软状态(Soft State) 和 最终一致性(Eventual consistency),这比 ACID 的定义更加模糊,似乎 BASE 的唯一合理的定义是 “不是 ACID”,即它几乎可以代表任何你想要的东西。)

原子性

一般来说,原子是指不能分解成小部分的东西。这个词在计算机的不同领域中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

相比之下,ACID 的原子性并  是关于 并发(concurrent) 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写 I 中,即 隔离性open in new window

如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被 中止(abort),应用程序可以确定它没有改变任何东西,所以可以安全地重试。

ACID 原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力

一致性

ACID 一致性的概念是,对数据的一组特定约束必须始终成立。即 不变式(invariants)。例如,在会计系统中,所有账户整体上必须借贷相抵。

但是,一致性的概念取决于程序对不变式的理解,应用程序负责正确定义它的事务,并保持一致性。

原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。

隔离性

ACID 意义上的隔离性意味着,同时执行的事务是相互隔离的

如果多个数据库客户端访问相同的数据库,可能会遇到 并发 问题(竞争条件,即 race conditions)。

如下图是个简单的并发问题

持久性

数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。

完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。

弱隔离级别

如果两个事务不触及相同的数据,他们可以安全并行(parallel) 运行。当一个事务读取由另一个事务同时修改的数据,或两个事务试图修改相同的数据时,并发问题(竞争条件)才会出现。

并发 BUG 很难通过测试找到,因为这样的错误只有在特殊时序下才会触发。这样的时序问题可能非常少发生,通常很难重现。

出于这个原因,数据库一直试图通过提供 事务隔离(transaction isolation) 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:可串行的(serializable) 隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。

实际上,串行会有性能损失,所以许多系统不会使用这种隔离界别,因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。

读已提交

最基本的事务隔离级别时  读已提交(Read Committed),它有两个保障:

  1. 从数据库读时,只能看到已提交的数据(没有 脏读,即 dirty reads)。
  2. 写入数据库时,只会覆盖已经写入的数据(没有 脏写,即 dirty writes)。

脏读

如果一个事务可以看到另一个事务未提交的数据,那么我们称之为脏读。

读已提交必须防止脏读。即,事务的任何写入操作只有提交后,别人才能看到。

图1

脏写

如果两个事务如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?

我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前面的写入。

在 读已提交 的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。

通过防止脏写,避免了一些并发问题:

  • 如果事务更新多个对象,脏写会导致不好的结果

  • 读已提交并不能防止 图 1open in new window两个计数器增量之间的竞争状态。

我们来看下图:

Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样的事故。

如果存在脏写,来自不同事务的冲突写入可能会混淆在一起

实现读已提交

读已提交 是一个非常流行的隔离级别。这是 Oracle 11g、PostgreSQL、SQL Server 2012、MemSQL 和其他许多数据库的默认设置。

  • 如何防止脏写?

大部分数据库通过行锁(row-level lock)来防止脏写:当事务想要修改特定行时,它必须首先获得,然后必须持有该锁直到事务被提交或中止。一次只有一个事务持有某一行的锁,如果另一个事务要写入同一行,必须等待第一个事务提交或中止,才能获取行锁并继续。

  • 如何防止脏读?

一种选择是使用相同的锁,读行前先获取到行锁,然后读取完立即释放。这将确保不会读取到未提交的行(因为如果有未提交的事务,锁将由进行写入的事务一直持有,读事务获取不到锁)。

但是要求读锁的办法实践中效果并不好,因为一个长写入事务会迫使很多只读事务等待长写入事务完成。

出于这个原因,大多数数据库采用 图1 的方式防止脏读:对于每个写入行,数据库只会记住旧的已提交值,和有当前持有锁事务写入的新值。当写入事务进行时,其他事务读取行都只会拿到旧值。当写入事务提交后,其他事务才能拿到新值。

可重复读

读已提交允许中止,防止读取不完整的事务结果(脏读),防止并发写入造成混乱(脏写)。这些功能很有用,但是使用此隔离界别,一些地方仍然可能出现并发错误。

读取偏差

Alice 有两个500块的账户。现在一个事务从账户2转到了账户1.如果她在事务处理过程中查看账户余额列表,她可能会在转账之前先看到账户1余额(500),转账完成后看到另一个账户余额(400)。对于Alice,她的账户余额似乎只有900了--有100块消失了。

这种异常成为不可重复读或读取偏差。如果Alice 在事务结束后再次读取账户1的余额,她将看到600。在读已提交的隔离条件下,不可重复读 被认为是可接受的:Alice 看到的帐户余额时确实在阅读时已经提交了。

对于 Alice 来说,这不是一个长久持续的问题,几秒钟后,她再次刷新页面,她就可能看到一致的账户余额。

但是有些情况下,不能容忍短暂的不一致:

  • 备份

如果备份进程运行时,数据库仍然接收写操作,备份可能包含一些旧数据和新数据。如果从这样的备份中恢复,不一致(消失的money)就会变成永久的。

  • 分析查询和完整性检查

如果查询需要扫描大部分数据,如果这些查询在不同时间点观察数据库的不同部分,可能会返回错误的结果。

快照隔离(snapshot isolation) 是这个问题最常见的解决方案。思路是,每个事务从数据库的一致快照(consistent snapshot) 中读取 —— 一个事务可以看到该事务开始时数据库中所有已提交的数据,即使这些数据随后被另一个事务更改,该事务也只能看到开始时的数据。

实现快照隔离

与读已提交的隔离类似,使用写锁来防止脏写,即写入同一行必须先持有行锁,行锁只能由一个事务持有。

但是,读取不需要任何锁定。即,读不阻塞写,写不阻塞读

为了实现快照隔离,数据库同时维护着一行数据的

多个版本,这种计数成为 多版本并发控制(MVCC, multi-version concurrency control)

如果一个数据库只需要提供 读已提交 的隔离级别,而不提供 快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用 MVCC 来实现 读已提交 隔离级别。一种典型的方法是 读已提交 为每个查询使用单独的快照,而 快照隔离 对整个事务使用相同的快照。

我们看一下 InnoDB 是如何实现的。

当事务开始时,会赋予一个唯一的事务 ID,叫做 transaction id, 它按照申请顺序严格递增。

每行数据也都是有多个版本的。每次事务更新,都会生产一个新的数据版本,并且把事务的 tansaction id赋值给行数据的事务ID,记为rot trx_id.即数据表中的每一个,都可能有多个版本(row),每个版本有自己的 row trx_id.

防止丢失更新

并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是 丢失更新(lost update) 问题。

如果应用从数据库读取一些值,修改它并写回修改的值(读取-修改-写入),则可能发生更新丢失的问题。如果两个事务同时执行,则其中一个的修改可能丢失,因为第二个写入内容没有包括第一个事务的修改。这种模式发生在各种不同的情况下:

  • 增加计数器或者更新账户余额(读取当前值,计算新值,写入新值)

  • 复杂值进行本地修改,例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)

  • 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。

这是普遍问题,我们也有一些解决方案。

原子写入

使用数据库的提供的原子更新操作,消除在应用程序代码中执行读取 - 修改 - 写入序列的需要.如果可以使用这些操作来表达,通常是最好的解决方案。

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

类似的,类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 提供了修改数据结构(如优先级队列)的原子操作, 依赖数据库的实现。

显式锁定

让应用程序显式地锁定要修改的行,然后执行读取 - 修改 - 写入序列,如果其他事务尝试读取同一行,则强制等待,直到第一个 读取 - 修改 - 写入 完成。

具体来说就是使用 for update

BEGIN TRANSACTION;
SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

-- 检查玩家的操作是否有效,然后更新先前 SELECT 返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
  • FOR UPDATE 子句告诉数据库应该对该查询返回的所有行加锁。

自动检测丢失的更新

原子操作和锁是通过强制 读取 - 修改 - 写入序列 按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其 读取 - 修改 - 写入序列

这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测 丢失更新

CAS

Compare And Set,只有当值匹配才允许更新。如果当前值与先前读的值不匹配,则更新不起作用,且必须重试 读取 - 修改 - 写入序列。

可以在表里添加 version 字段,实现乐观锁。

UPDATE wiki_pages SET content = '新内容',version=version+1
  WHERE id = 1234 AND version=1;

如果 version 发生变更,则此更新不起作用,此时要检查更新是否生效,选择中止事务或者重试。

但是,如果数据库允许 WHERE 子句从旧快照中读取, 此语句无法方式丢失更新。

CAS依赖数据库的实现。

写入偏差和幻读

由于事务的并发写相同行,我们看到了脏写丢失更新,为了避免数据损坏,这些些竞争条件需要被阻止 —— 既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。

并发写之间的竞态条件还没完,还有一起更微妙的冲突。

假设,Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。

在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。

这种异常 写偏差。它既不是 脏写,也不是 丢失更新,因为这两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。

写入偏差可以视为更新丢失问题的一般化。如果两个事务读取相同的行,然后更新其中一些行,则可能发生写入偏差。多个事务更新同一行的情况下,就会发生脏写或丢失更新。

对于写偏差,我们的选择更受限制:

  • 由于涉及多行,单行的原子操作不起作用。

  • 自动检测丢失更新对比没有帮助。只有真正的串行执行才能自动防止写偏差。

  • 某些数据库允许配置约束,例如唯一键、外键或特定值约束。但是大多数数据库没有内置对多对象约束的支持。

  • 如果无法使用串行话隔离级别,次优可能是显式锁定事务所有依赖行

BEGIN TRANSACTION;
SELECT * FROM doctors
  WHERE on_call = TRUE
  AND shift_id = 1234 FOR UPDATE;

UPDATE doctors
  SET on_call = FALSE
  WHERE name = 'Alice'
  AND shift_id = 1234;

COMMIT;

写偏差的更多例子

  • 会议室预订系统

比如你想要规定不能在同一时间对同一个会议室进行多次的预订。

当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议。

可重复读下不安全

BEGIN TRANSACTION;

-- 检查所有现存的与 12:00~13:00 重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
  end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';

-- 如果之前的查询返回 0
INSERT INTO bookings(room_id, start_time, end_time, user_id)
  VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

COMMIT;

不幸的是,如果两个用户同时操作,又可能同时预定一个房间了。

  • 用户名重复

比如规定用户名在系统里要唯一。

当要创建用户名是,首先要检查用户名是否存在,如果不存在,才创建成功。

BEGIN TRANSACTION;

-- 检查所有现存的 hasagei
SELECT COUNT(*) FROM users
WHERE name = "hasagei";

-- 如果之前的查询返回 0
INSERT INTO hasagei(id,name)
  VALUES (123, "hasagei");

COMMIT;

幸运的是,唯一约束是个简单的解决方案。INSERT SQL 会因为违反了唯一约束抛出异常,导致事务回滚。

  • 双重消费

用户花钱的服务,必须检查用户的支付数额不超过其余额。

先插入一条消费记录,并汇总账户的所有账目,并检查总和是否为正,为正才可以提交事务。

有了写入偏差,可能会发生两个消费记录同时插入,导致余额变成负数。

幻读

上面的例子都遵循一个模式:

  1. SELECT 符合条件的行,检查是否符合要求

  2. 按照第一个查询结果,应用程序决定是否继续执行(可以中止,也可以继续执行)

  3. 如果继续执行,执行写入操作,并提交事务。

第3步的写入操作改变了步骤2的先决条件。即,在提交写入后,重复执行步骤1的 SELECT,会得到不同的结果。

这些步骤可能以不同顺序执行。例如双重消费的例子,可以先写入,再执行SELECT,最后根据结果决定提交或者放弃。

一个事务的写入改变另一个事务搜索查询的结果,称为幻读。可重复读避免了只读查询中的幻读,但是读写事务,幻读会导致写入偏差的发生。

物化冲突

如果幻读的问题是因为没有对象可以加锁,那么我们可以人为的在数据库引入锁对象。

例如,在会议室预定场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。

现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。

这种方法被称为 物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突。

不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可串行化(Serializable) 的隔离级别是更可取的。

可串行化

读已提交 和 可重复读 会阻止某些竞态条件,但不会阻止另外一些。例如 写入偏差 和 幻读。

一直以来,都是一个答案: 使用 可串行化(serializable) 的隔离级别!

如果可串行化比弱隔离界要好的多,为什么没人用呢?我们看看可串行化的具体实现

  • 字面意义串行执行(单线程)

  • 两阶段锁定(2PL two-phase locking)

真串行

避免并发问题最简单的方案是不要并发,即单线程执行所有事务。

2PL

大约 30 年来,在数据库中只有一种广泛使用的串行化算法:两阶段锁定(2PL,two-phase locking) 。

2PL不是2PCopen in new window

请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。

只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问(exclusive access) 权限:

  • 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。

  • 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(读取旧版本的对象在 2PL 下是不可接受的)。

在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 读不阻塞写,写也不阻塞读,这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。

2PL 的实现

2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别。

读写阻塞是通过数据库为每个对象加锁实现的。锁可以处于 共享模式(shared mode) 或 独占模式(exclusive mode)

  • 若事务读对象,必须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务持有对象的排他锁,读事务必须等待。

  • 若事务写入对象,必须以独占模式获取锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),例如事务A持有读锁,事务B不能获得独占锁了,只有事务A可以升级为独占锁。如果对象上持有任何锁,呢么该事务必须等待。(避免了数据被修改)

  • 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。

  • 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁

2PL 性能

2PL 的两阶段锁定的巨大缺点,以及 70 年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。

一部分是获取和释放锁的开心,更重要的是因为并发性的降低。

谓词锁

写入偏差时由幻读导致的,即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防止 幻读

谓词锁(predicate lock),类似于 共享/排他 锁,但不属于具体的对象,它数据符合某些搜索条件的对象。

SELECT * FROM bookings
WHERE room_id = 123 AND
      end_time > '2018-01-01 12:00' AND
      start_time < '2018-01-01 13:00';

谓词锁限制访问,如下所示:

  • 如果事务 A 想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的 共享谓词锁(shared-mode predicate lock)。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。

  • 如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续。

这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。

索引范围锁(间隙锁)

谓词锁会存在性能问题:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。 因此,大多数实现 2PL 的数据实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁。

通过使谓词匹配到一个更大范围的集合来简化谓词锁时安全的。如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段,或者锁定 12:00~13:00 时间段的所有房间(不只是 123 号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。

在房间预定数据库中,room_id列上应该有一个索引,并且 / 或者在 start_time 和 end_time 上有索引(否则前面的查询在大型数据库上的速度会非常慢):

  • 假设你的索引位于 room_id 上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。

  • 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将 12:00~13:00 时间段标记为用于预定。

无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要更新同一个房间和 / 或重叠时间段的预订,它不得不更新索引的相同部分,这个过程中,它会遇到共享间隙锁,它将被迫等待锁释放。

如果没有可以挂载范围锁的索引,数据库将退化到表锁。这对性能不利,但它是个安全的回退位置。

InnoDB 在弱隔离级别下,使用 for update 会使用到间隙锁,防止幻读。

总结

ACID

并发控制

  • 脏读

  • 脏写

  • 不可重复读

  • 更新丢失

  • 写偏差

  • 幻读

参考文档

设计数据密集型应用open in new window

Last Updated:
Contributors: himcs