旧游无处不堪寻
无寻处,惟有少年心
『数据密集型应用系统设计』读书笔记(七)

在一个苛刻的数据存储环境中,会有许多可能出错的情况:

  1. 数据库软件或硬件可能会随时失效
  2. 应用程序可能随时崩溃
  3. 应用与数据库节点之间的链接可能随时会中断
  4. 多个客户端可能同时写入数据库

为了系统高可靠的目标,我们必须处理好上述问题。

事务技术一直是简化这些问题的首选机制。
事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元。整个事务要么成功(提交)、要么失败(回滚)。

那该如何判断是否需要事务呢?为了回答这个问题,我们首先需要确切地理解事务能够提供哪些安全性保证,背后的代价又是什么。

本章,我们将分析可能出错的各种场景,探讨数据库防范这些问题的基本方法和算法设计。

深入理解事务


目前几乎所有的关系数据库和一些非关系数据库都支持事务处理。

ACID 的含义

每种数据库事务所提供的安全保证即大家所熟知的 ACID, 分别代表原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)与持久性(Durability)。
实际上,各家数据库所实现的 ACID 并不尽相同。例如,隔离性存在很多含糊不清的争议。

原子性

原子是指不可分解为更小粒度的东西。这个术语在计算机的不同领域里有着相似但却微妙的差异。多线程编程中,如果某线程执行一个原子操作,这意味着其他线程是无法看到该操作的中间结果。它只能处于操作之前或操作之后的状态,而不是两者之间的状态。
而 ACID 中的原子性并不关乎多个操作的并发性,后者其实是由 ACID 的隔离性所定义

ACID 原子性其实描述了客户端发起一个包含多个写操作的请求时可能发生的情况,假如没有原子性保证,当多个更新操作中间发生了错误,就需要知道哪些更改已经生效,哪些没有生效,原子性则大大简化了这个问题。
ACID中原子性所定义的特征是: 在出错时中止事务,并将部分完成的写入全部丢弃

一致性

在 ACID 中,一致性是指数据库处于应用程序所期待的”预期状态”,任何数据更改必须满足状态约束(或者恒等条件)。

例如,对于一个账单系统,账户的贷款余额应和借款余额保持平衡。

隔离性

大多数数据库都支持多个客户端同时访问。那么如果访问相同的记录,则可能会遇到并发问题。

ACID 语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。虽然实际上它们可能同时运行,但数据库系统要确保当事务提交时,其结果与
串行执行完全相同。
实践中,由于性能问题很少使用串行化隔离。一些流行的数据库,如 Oracle 11g 甚至根本就没有实现它。

持久性

数据库系统本质上是提供一个安全可靠的地方来存储数据而不用担心数据丢失。
持久性就是这样的承诺,它保证一且事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。

处理错误与中止

事务的一个关键特性是,如果发生了意外,所有操作被中止,之后可以安全地重试。
重试中止的事务虽然是一个简单有效的错误处理机制,但它并不完美。
例如如果在数据库之外,事务还产生其他副作用,即使事务被中止,这些副作用可能己事实生效。例如,假设更新操作还附带发送一封电子邮件,肯定不希望每次重试时都发送邮件。如果想要确保多个不同的系统同时提交或者放弃,可以考虑采用两阶段提交。

弱隔离级别


当出现某个事务修改数据而另一个事务同时要读取该数据,或者两个事务同时修改相同数据时,就会引发并发问题。

并发性相关的错误很难通过测试发现,这类错误通常只在某些特定时刻才会触发,稳定重现比较团难。正因如此,数据库一直试图通过事务隔离来对应用开发者隐藏内部的各种并发问题。

实现隔离绝不是想象的那么简单,可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能,因而更多倾向于采用较弱的隔离级别。
接下来我们分析几个实际中经常用到的弱级别(非串行化)隔离,并详细讨论可能发生的竞争条件,有了这些认识之后,可以帮助判断自己的应用更适合什么
样的隔离级别。

读未提交

读未提交是最弱的隔离级别,只提供以下一个保证:

  1. 写数据库时,只会覆盖已成功提交的数据,但并不保证读数据库时只能看到已成功提交的数据

读已提交

读已提交只提供以下两个保证:

  1. 读数据库时,只能看到已成功提交的数据
  2. 写数据库时,只会覆盖已成功提交的数据

当有以下需求时,需要防止脏读:
如果事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新。观察到部分更新的数据可能会造成用户的困惑,并由此引发一些不必要的后续操作。

快照级别隔离与可重复读

在使用读已提交隔离级别时,仍然有很多场景可能导致并发错误。如下:

这种异常现象被称为不可重复读取。快照级别隔离是解决级上述问题最常见的手段。其总体想法是,每个事务都从数据库的一致性快照中读取。

与读已提交隔离类似,快照级别隔离的实现通常采用写锁来防止脏写。但是,读取则不需要加锁。从性能角度看,快照级别隔离的一个关键点是读操作
不会阻止写操作。

为了实现快照级别隔离,数据库采用了一种更为通用的机制。数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(MVCC)。
需要注意: 支持快照级别隔离的存储引擎往往直接采用 MVCC 来实现读已提交隔离,只保留对象的两个版本就足够了: 一个已提交的旧版本和尚未提交的新版本。

快照级别隔离具体到实现,许多数据库却对它有着不同的命名。Oracle 称之为可串行化,PostgreSQL 和 MySQL 则称为可重复读。这种命名混淆的原因是 SQL 标准并没有定义快照级别隔离。

防止更新丢失

我们前面提到的都是读写或者写读并发时的解决方案,没有触及另一种情况,即两个写事务并发会出现哪些问题。
写事务并发最著名的就是更新丢失问题。目前有多种可行的解决方案:

原子写操作

例如:

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

更新指令在多数关系数据库中都是并发安全的。如果原子操作可行,那么它就是推荐的最佳方式。

显式加锁

如果数据库不支持内置原子操作,另一种防止更新丢失的方法是由应用程序显式锁定待更新的对象。此时如果有其他事务尝试同时读取对象,则必须等待当前正在执行的序列全部完成。该方式被称为悲观并发控制。

BEGIN TRANSACTION; 

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

UPDATE figures SET position = 'c4' WHERE id= 1234;

COMMIT;

自动检测更新丢失

原子操作和锁都是通过强制操作序列串行执行来防止丢失更新。另一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退,该方式被称为乐观并发控制。