DDIA-7-事务

事务主要是为了应对可能的出错情况。硬件软件的失效,应用与数据库节点之间的连接出问题,客户端竞争导致的写入覆盖等问题。

数据库事务,主要是为了简化应用层的编程模型。并非每个应用都需要事务机制。

深入理解事务

关系型数据库都支持事务,有些非关系型也支持。

很多新一代数据库(NoSQL)放弃了事务,或者替换为比其更弱的保证。

ACID

不符合ACID标准的系统有时被冠以BASE

A 原子性

提交过程中发生故障,事务会终止,并且丢弃或者撤销之前的部分更改。

C 一致性

对数据的一组特定陈述必须始终成立。即不变量(invariants),例如在会计系统中,所有账户整体上必须借贷相抵。

一致性本质上要求应用层来维护状态的一致(或者守恒)。

I 隔离性

如果两个客户端同时访问一条记录,可能会遇到并发问题(带来竞争条件)。

计数器的例子。

传统的数据库教科书将隔离性形式化为可序列化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。

然而实践中很少会使用可序列化隔离,因为它有性能损失。一些流行的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做快照隔离(snapshot isolation) 的功能,这是一种比可序列化更弱的保证。

D 持久性

持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。

在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或SSD。最近它已经适应了“复制(replication)”的新内涵。

单对象与多对象事务

数据库提供的保证是

  • 原子性, 要么全部成功,要么全部失败。
  • 隔离性, 同时运行的事务之间不应互相干扰。

违反隔离性的例子,邮件系统的未读邮件计数器和邮件,在写入的时候不应该被其他客户端读区到不一致的状态。

单对象写入

一些数据库也提供更复杂的原子操作,例如自增操作。同样流行的是 比较和设置(CAS, compare-and-set) 操作,当值没有并发被其他人修改过时,才允许执行写操作。

多对象事务

需要多对象事务的情况:

  1. 关系型,外键插入时的正确性验证。
  2. 文档型,更新非规范化(因为缺乏连接能力)信息时,一次更新多个文档。
  3. 二级索引的更新

处理错误与中止

错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像Rails的ActiveRecord和Django这样的对象关系映射(ORM, object-relation Mapping) 框架不会重试中断的事务—— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。

弱隔离级别

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

实际上不幸的是:隔离并没有那么简单。可序列化 会有性能损失,许多数据库不愿意支付这个代价。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些隔离级别难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用。

读已提交(Read Commited)

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

防止脏读

如果一个事务可以看到另一个事务还没有完全提交的数据,那么就是脏读。

读已提交的隔离级别可以防止脏读。

需要防止脏读的情况:

  1. 一个事务需要修改多个对象,并且这些对象有一致性的保证。例如电子邮件的例子。
  2. 如果事务发生中止,所有的写入都要回滚。如果发生脏读,那么会看到一些会被回滚的数据,可能会造成麻烦。

防止脏写

两个事务更新同一个对象,如果一个事务的写入操作覆盖了另一个事务尚未提交的一部分,那么就是脏写。

读已提交的隔离级别可以防止脏写。通常的方式是推迟第二个写请求,直到前面的事务提交成功(或者中止)。

二手车买卖的例子,买同一辆车,车主和销售发票的所有者要一致。

实现读已提交

数据库通常使用行级锁来防止脏写。

防止脏读,用锁太重了。大多数数据库通常都会对待更新的对象,维护旧值和当前持有写锁的事务的新值两个版本。

快照级别隔离和可重复读(Repeatable Read)

读已提交解决不了一些场景中的问题,会导致错误。

银行转账的例子。

爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。

这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew)

还有一些场景不能容忍暂止的不一致,数据备份,分析查询和完整性检查场景。

快照级别隔离是常见的解决手段。快照隔离是一个流行的功能:PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支持。

实现快照级别隔离

与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写。

但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。

为了实现快照隔离,数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)。

图种说明了,如何在PostgreSQL中实现基于MVCC的快照隔离【31】(其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长[^vii]的事务ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。

表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个 deleted_by 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。

一致性快照的可见性规则

当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。

索引与快照级别隔离

如何支持索引?

  1. 直接指向对象的所有版本。
  2. Copy on write。每次修改时候,复制一个B-tree。后台回收和压缩。

防止更新丢失

例子,两个并发的计数器更新。

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

场景,递增计数器;更新账户余额;对复杂对象的一部分修改;两个用户同时编辑wiki页面。

原子写操作

有些数据库支持的原子操作。可以避免在应用层的“读取-修改-写入”操作。

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

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

显示加锁

Select … for update 加行锁

这是有效的,但要做对,你需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。

自动检查更新丢失

另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。

丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。

原子比较与设置

CAS,

例如,为了防止两个用户同时更新同一个wiki页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:

1
2
3
-- 根据数据库的实现情况,这可能也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';

ABA问题?

冲突解决和复制

在复制数据库中(参见第5章),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。

如“检测并发写入”一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。

另一方面,最后写入为准(LWW)的冲突解决方法很容易丢失更新,如“最后写入为准(丢弃并发写入)”中所述。不幸的是,LWW是许多复制数据库中的默认值。

写入偏差与幻读

医院排班on call的例子。并发请假,导致应用的错误。

写偏差的特征

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

如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行

1
2
3
4
5
6
7
8
9
10
11
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;

更多写偏差的例子

  1. 会议室预定系统,double booking
  2. 多人游戏, 棋盘上同时移动不同的棋子,但是要预防违反游戏规则。
  3. 申请同一个用户名,重名问题,可以使用Unique Key
  4. 防止双重开支,超额支付信用或者存款。

产生写偏差的原因

一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。

物化冲突

如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象。

例如会议室预定,可以想象创建一个关于时间槽和房间的表。要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。

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

在大多数情况下。可序列化(Serializable) 的隔离级别是更可取的。

串行化(Serialize)

最强的隔离级别。

数据库保证,如果事务在单独运行时行为正确,则它们在并发运行时仍然正确,换句话说,数据库防止所有可能的竞争条件。

目前大多数提供可序列化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。

  1. 字面意义上地串行顺序执行事务(参见“真的串行执行”)
  2. 两相锁定(2PL, two-phase locking),几十年来唯一可行的选择。(参见“两相锁定(2PL)”)
  3. 乐观并发控制技术,例如可序列化的快照隔离(serializable snapshot isolation)(参阅“可序列化的快照隔离(SSI)”

真的串行执行

单线程循环执行事务。

基础:

  1. 内存中可以加载应用需要的所有数据,之后的事务操作都在内存中,快。
  2. OLTP一般都很快,只是少量的读写。

串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现。

使用存储过程

具有单线程串行事务处理的系统不允许交互式的多语句事务。应用程序必须提前将整个事务代码作为存储过程提交给数据库。

存储过程优缺点

缺点
  1. 厂商们在存储过程的语言不一致;
  2. 代码难以管理;难调试;难测试;
  3. 如果写了不好的存储过程,会对数据库性能产生很大的影响;
优点
  1. 吞吐量高

分区下的串行

需要对所有分区加锁。如果有多个二级索引,性能会很差。

串行小结

在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。

  1. 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
  2. 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢^x。
  3. 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
  4. 跨分区事务是可能的,但是它们的使用程度有很大的限制。

两阶段加锁2PL

不是分布式中的两阶段提交2PC。

对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:

  1. 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
  2. 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (读取旧版本的对象在2PL下是不会出现的。)

在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。

而快照隔离使得读不阻塞写,写也不阻塞读。

实现2PL

每个对象有一个读写锁来隔离写操作。

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

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

锁规则:

  1. 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
  2. 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
  3. 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
  4. 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。

由于使用了这么多锁,所以很容易发生事务A被卡住等待事务B释放它的锁,反之亦然。这种情况称为死锁。数据库自动检测死锁之后会终止事务,然后重启事务排队。

2PL性能

缺点,性能差(吞吐量低,响应时间不确定)。

谓词锁

会议室预定例子。查询所有会议室,和update/insert会议室预定。

锁定一个范围的查询对象。

索引区间锁

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

可串行化的快照隔离(SSI)

性能不好(2PL)或者扩展性不好(串行执行)的可序列化隔离级别。

更好的选择,一个称为可序列化快照隔离(SSI, serializable snapshot isolation) 的算法是非常有前途的。

悲观与乐观的并发控制

2PL是悲观机制,如果有竞争可能出错,那么等到安全之后再做。像多线程编程种的互斥。串行执行可以称为悲观到了极致。

相比之下,序列化快照隔离是一种乐观(optimistic) 的并发控制技术。如果可能发生冲突,那么先继续执行,等到提交时候,数据库检查是否冲突。如果有冲突则中止,接下来重试。

如果事务之间竞争不大,乐观并发控制会比悲观控制高效很多。如果冲突很多,则性能不佳。

SSI,所有的读操作都是基于一致性快照。通过算法检测冲突,来决定是否中止事务。

需要考虑的冲突情况

检测对旧MVCC对象版本的读取(读之前存在未提交的写入)

在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。

为何在提交时才检查?为了高效支持长时间读事务的性能。

检测影响先前读取的写入(读之后发生写入)

当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。

性能

对比2PL,串行。对于读密集的负载性能好。

事务中止的比例会影响SSI的性能。