事务

在数据系统的残酷现实中,许多事情可能出错:

事务 transaction 一直是简化这些问题的首选机制,应用程序通过事务将多个读写操作组合成一个逻辑单元。

从概念上讲,事务中的所有读写被当作一次操作执行:整个事务要么成功(提交,commit),要么失败(中止,abort;回滚,rollback)。如果失败,应用程序可以安全重试。有了事务,应用程序的错误处理就简单多了,因为它不必担心部分失效——即无论出于何种原因,有些操作成功、有些操作失败。

人们创造事务自由其目的,简化 访问数据库的 应用编程模型(programming model)。有了事务,应用程序便可以不去考虑某些潜在的错误场景和并发问题,因为数据库会代为处理这些问题(我们称之为 安全保证,safety guarantee)。

并非所有应用程序都需要事务,有时弱化事务保证,甚至彻底放弃事务也有好处。

事务到底是什么

几乎所有关系型数据库和一部分非关系型数据库都支持事务。

NoSQL 分布式数据库的炒作催生了观点:事务从根本上无法伸缩,任何大规模系统若想保持良好性能和高可用性,都必须放弃事务。近来事实已经证明这种观点是错误的。

CockroachDB、TiDB、Spanner、FoundationDB 和 YugabyteDB 等所谓的 “NewSQL” 数据库表明,事务系统同样可以扩展到很大的数据量和很高的吞吐量。这些系统将分片与共识协议结合起来,从而在大规模场景下提供强 ACID 保证。

ACID 的含义

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

不符合 ACID 标准的系统有时称为 BASE,即 基本可用 Basically Available、软状态 Soft State 和 最终一致性 Eventual Consistency。

原子性

ACID 的原子性描述的是另一类情况:客户端想执行多次写入,却在其中一些写入处理完毕后遇到故障——例如进程崩溃、网络连接中断、磁盘空间耗尽,或者违反了某项完整性约束。如果这些写入被组合到一个原子事务中,而故障使事务无法完成(提交),那么事务就会 中止,数据库必须丢弃或撤销该事务此前完成的所有写入。

如果没有原子性,在多项变更执行到一半时发生错误,应用程序很难知道哪些变更已经生效、哪些尚未生效。它可以再试一次,却可能把同一项变更执行两遍,造成重复或错误的数据。原子性简化了这个问题:如果事务已经中止,应用程序便能确定它没有改变任何内容,因此可以安全重试。

遇到错误时中止事务,并丢弃该事务的所有写入,正是 ACID 原子性的定义性特征。或许称为 可中止性(abortability)比 原子性 更贴切。

一致性

一致性 这个词被严重滥用:

ACID 一致性的基本思想是:关于数据的某些陈述(即 不变式,invariant)必须始终成立。例如,在会计系统中,所有账户的贷记与借记必须始终相抵。如果事务开始时数据库满足这些不变式,事务中的所有写入又能维持其有效性,就可以确信不变式始终成立。(不变式在事务执行期间可以暂时被打破,但到事务提交时必须重新得到满足。)

如果希望由数据库强制执行不变式,就需要把它们声明为模式中的 约束(constraint)。外键约束、唯一性约束或检查约束(限制单行中可以出现的值)常用来表达特定类型的不变式。更复杂的一致性要求有时也可以用触发器或物化视图来表达。

不过,数据库通常提供的约束可能很难、甚至根本无法表达复杂的不变式。此时,应用程序就有责任正确定义事务,使其维持一致性。如果应用写入了违反不变式的错误数据,却没有事先声明这些不变式,数据库也无从阻止。因此,ACID 中的 C 往往取决于应用程序如何使用数据库,并非数据库自身的属性。

隔离性

大多数数据库都会同时接受多个客户端的访问。如果各客户端读写数据库的不同部分,自然没有问题;但如果它们访问相同的数据库记录,就可能遇到并发问题(竞态条件)。

给出了这类问题的一个简单例子。假设两个客户端同时递增数据库中的一个计数器。每个客户端都要读取当前值,将其加 1,再把新值写回去(假设数据库没有内置的递增操作)。在图中,计数器经过两次递增,本应从 42 变成 44,却因竞态条件最终只变成了 43。

图 8-1 两个客户端并发递增计数器之间的竞态条件

ACID 意义上的 隔离性 是指并发执行的事务彼此隔离,不能相互干扰。经典数据库教科书将隔离性形式化为 可串行化 :每个事务都可以假装自己是整个数据库中唯一正在运行的事务。数据库保证,所有事务提交后的结果与它们 串行 运行(逐个运行)的结果相同,尽管实际上这些事务可能是并发执行的。

然而,可串行化是有性能代价的。实践中,许多数据库采用弱于可串行化的隔离形式,也就是允许并发事务在有限范围内相互干扰。一些流行数据库(例如 Oracle)甚至没有实现可串行化:Oracle 虽有一个名为“可串行化”的隔离级别,实际实现的却是保证更弱的 快照隔离。

持久性

数据库系统的目的,是提供一个可以放心存放数据而无须担心丢失的安全场所。持久性 作出这样的承诺:事务一旦成功提交,其写入的任何数据都不会遗失,即使发生硬件故障或数据库崩溃也不例外。

在单节点数据库中,持久性通常意味着数据已经写入硬盘或 SSD 等非易失性存储。普通文件写入通常会先在内存中缓冲,稍后才送往磁盘,突然断电时便可能丢失;因此,许多数据库使用 fsync() 系统调用,确保数据确实已经落盘。数据库通常还有预写日志或类似机制,以便在写入中途崩溃后恢复。

在复制数据库中,持久性可能意味着数据已经成功复制到一定数量的节点。数据库必须等到这些写入或复制操作完成,才能报告事务已成功提交。不过,完美的持久性并不存在:如果所有硬盘和备份同时被毁,数据库显然也无能为力。

单对象与多对象操作

在 ACID 中,原子性和隔离性描述了客户端在同一事务中进行多次写入时,数据库应当如何处理:

原子性:

如果一系列写入执行到一半时发生错误,事务就应中止,之前完成的写入也应丢弃。

隔离性:

并发运行的事务不应相互干扰。例如,一个事务进行了多次写入,另一个事务就应当要么看到全部写入,要么一项也看不到,而不能只看到其中一部分。

图 8-2 违反隔离性:一个事务读取另一个事务的未提交写入 脏读

插入一条新邮件,然后将未读数量标记加 1。隔离性可以防止这个问题:它保证用户 2 要么同时看到新插入的邮件和更新后的计数,要么两者都看不到,而不会看到不一致的中间状态。

如果事务进行到一半时发生错误,邮箱内容和未读计数可能失去同步。在原子事务中,如果计数器更新失败,事务就会中止,已经插入的邮件也会回滚。

图 8-3 原子性确保如果发生错误,该事务的任何先前写入都会被撤消,以避免不一致的状态

在关系型数据库中,这通常以客户端到数据库服务器的 TCP 连接为依据:同一条连接上,BEGIN TRANSACTIONCOMMIT 语句之间的所有操作都属于同一个事务。如果 TCP 连接中断,事务就必须中止。

许多非关系型数据库并没有这种组合操作的机制。即便提供了多对象 API(例如键值存储可能提供一次更新多个键的 multi-put 操作),也不一定具备事务语义:命令可能只更新了其中一些键,另一些却失败了,使数据库停留在部分更新的状态。

单对象写入

原子性和隔离性也适用于单个对象的变更。例如,假设你正在向数据库写入一份 20 KB 的 JSON 文档:

几乎所有存储引擎都力求在单个节点的单个对象(例如键值对)层面提供原子性和隔离性。原子性可以通过日志和崩溃恢复来实现,隔离性则可以通过为每个对象加锁来实现(同一时刻只允许一个线程访问对象)。

有些数据库还提供更复杂的原子操作,例如递增操作,从而不必执行图 8-1 中的 读取—修改—写入循环。同样常见的还有 条件写入:只有当该值未被其他人并发修改时,写入才会发生(条件写入 比较并设置)。它类似于共享内存并发中的比较并设置或比较并交换(CAS)操作。

这些单对象操作很有用,因为它们能避免多个客户端同时写入同一对象时发生丢失更新)。但它们并不是通常意义上的事务。例如,Cassandra 和 ScyllaDB 的“轻量级事务”功能,以及 Aerospike 的“强一致性”模式,都能在单个对象上提供线性一致的读取和条件写入,却不为多个对象之间的操作提供保证。

多对象事务的需求

真的需要多对象事务吗?能否只靠键值数据模型和单对象操作来实现任何应用程序?

有些用例只需插入、更新或删除单个对象就足够了。但在许多其他场景中,必须协调对多个不同对象的写入:

这类应用即使没有事务也能实现,不过没有原子性,错误处理就会复杂很多;缺少隔离性,又可能引发并发问题。

处理错误和中止

事务的一个关键特性是:遇到错误时,可以中止并安全重试。ACID 数据库遵循这样的原则:只要原子性、隔离性或持久性保证有遭到破坏的风险,数据库宁可彻底放弃事务,也不会让它停留在半完成状态。

不过,并非所有系统都遵循这一原则。尤其是采用 无主复制的数据存储 ,更多是以“尽力而为”的方式工作。概括来说就是:“数据库能做多少便做多少;如果遇到错误,也不会撤销已经完成的操作。”因此,从错误中恢复成了应用程序的责任。

错误不可避免,许多软件开发者却宁愿只考虑一切顺利的正常路径,不愿面对错误处理的复杂细节。例如,Rails 的 ActiveRecord 和 Django 等流行的对象关系映射(ORM)框架都不会重试已中止的事务,错误通常会以异常的形式沿调用栈向上传播,于是用户输入被丢弃,只收到一条错误消息。这很可惜,因为中止的意义恰恰在于允许安全重试。

尽管重试已中止的事务是一种简单有效的错误处理机制,却并非完美无缺:

弱隔离级别

如果两个事务不访问相同的数据,或者两者都是只读事务,它们就可以安全地并行运行,因为彼此并无依赖。只有当一个事务读取的数据正被另一个事务并发修改,或者两个事务试图同时修改相同数据时,才会出现并发问题(竞态条件)。

并发缺陷很难通过测试发现,因为它们只有在时序碰巧不利时才会触发。这种时序问题可能极少发生,通常也难以重现。并发行为本身同样难以推理,尤其是在大型应用中,你未必知道还有哪些代码正在访问数据库。即使每次只有一个用户,应用开发也已经不容易;面对许多并发用户则更为困难,因为任何数据都可能随时发生意料之外的变化。

正因如此,数据库长期以来一直试图通过 事务隔离(transaction isolation) 向应用开发者隐藏并发问题。理论上,隔离让你可以假装并发根本不存在:可串行化 隔离意味着,数据库保证事务产生的效果与 串行 运行(即逐个运行、毫无并发)相同。

实践中的隔离并没有这么简单。可串行化隔离有性能代价,许多数据库不愿为此买单。因此,系统普遍采用较弱的隔离级别,只防范 一部分 而非全部并发问题。这些隔离级别更难理解,也可能引发微妙的错误,却仍在实践中广泛使用。

弱事务隔离导致的并发缺陷绝非纸上谈兵:它们曾造成巨额资金损失,招致财务审计人员介入调查,也曾破坏客户数据。这类问题曝光后,人们常会说:“处理财务数据就该使用 ACID 数据库!”但这没有抓住要害。即使是许多通常被视为“ACID”的流行关系型数据库,也采用弱隔离,因而未必能防止这些问题。

即使并发问题在正常运行中很少发生,也必须考虑攻击者向 API 刻意发送一波高度并发的请求,专门利用并发缺陷的可能性。因此,要构建可靠且安全的应用,就必须系统性地防范这类缺陷。

读已提交

最基本的事务隔离级别是 读已提交(read committed) ,它提供两项保证:

  1. 从数据库读取时,只能看到已经提交的数据(没有 脏读,dirty read)。
  2. 向数据库写入时,只能覆盖已经提交的数据(没有 脏写,dirty write)。

有些数据库还支持更弱的 读未提交 隔离级别。它能防止脏写,却不能防止脏读。下面详细讨论这两项保证。

没有脏读

设想一个事务已经向数据库写入数据,却尚未提交或中止。另一个事务能否看到这些未提交的数据?如果能,这就是 脏读

运行在读已提交隔离级别下的事务必须防止脏读。这意味着,一个事务的所有写入都要等到该事务提交时,才会同时对其他事务可见。

图 8-4 没有脏读:用户 2 只有在用户 1 的事务提交后才能看到 x 的新值

防止脏读有以下几个好处:

没有脏写

如果两个事务并发更新数据库中的同一行,会发生什么?写入次序虽然无法预知,但我们通常认为后一次写入会覆盖前一次写入。

可是,如果前一次写入属于尚未提交的事务,后一次写入覆盖了这个未提交值,又会怎样?这就是 脏写。运行在读已提交隔离级别下的事务必须防止脏写;通常的做法是推迟第二次写入,直到执行第一次写入的事务提交或中止。

图 8-5 发生脏写时,不同事务的冲突写入可能混杂在一起

实现读已提交

读已提交是一种非常流行的隔离级别,也是 Oracle Database、PostgreSQL、SQL Server 等许多数据库的默认设置。

数据库 防止脏写 最常见的办法是使用 行级锁 :事务想修改某一行(或文档等其他对象)时,必须先取得该行的锁,并一直持有到事务提交或中止。任何一行在同一时刻只能由一个事务持锁;另一个事务若想写入同一行,就必须等前一个事务提交或中止后,才能取得锁并继续执行。在读已提交模式(或更强的隔离级别)下,数据库会自动完成这些加锁操作。

那么怎样 防止脏读 ?一种办法仍是使用同一把锁:要求任何想读取某行的事务都短暂取得该锁,读完后立即释放。这样,当该行包含未提交的脏值时就无法读取,因为执行写入的事务仍持有这把锁。

然而,要求读操作加锁在实践中效果不佳。一个长期运行的写事务,可能迫使许多只读事务一直等到它结束。这不仅损害只读事务的响应时间,也不利于可运维性:应用某个部分一旦变慢,等待锁便可能引发连锁反应,波及完全不相干的其他部分。

尽管如此,仍有一些数据库使用锁来防止脏读,例如 IBM Db2,以及将 read_committed_snapshot=off 的 Microsoft SQL Server。

更常见的防脏读办法:对于每一行写入,数据库同时保留旧的已提交值,以及当前持有写锁的事务写入的新值。该事务进行期间,其他事务读取这一行时只会得到旧值;等新值提交后,才改为读取新值,多版本并发控制 MVCC。

快照隔离与可重复读

多版本并发控制 MVCC

观察一致快照的可见性原则

索引与快照隔离

快照隔离、可重复读和命名混淆

防止丢失更新

原子写操作

显式锁定

自动检测丢失更新

条件写入(比较并设置)

冲突解决与复制

写偏差与幻读

写偏差的特征

写偏差的更多例子

导致写偏差的幻读

物化冲突

可串行化

实际串行执行

将事务封装在存储过程中

存储过程的利弊

分片

串行执行总结

两阶段锁定 2PL

两阶段锁定的实现

两阶段锁定的性能

谓词锁

索引范围锁

可串行快照隔离 SSI

悲观并发控制与乐观并发控制

基于过时前提的决策

检测陈旧的 MVCC 读取

检测影响先前读取的写入

可串行化快照隔离的性能

分布式事务

两阶段提交 2PC

系统承诺

协调者失败

三阶段提交

跨不同系统的分布式事务

恰好一次消息处理

XA 事务

存疑时持有锁

从协调者失效中恢复

XA 事务的问题

数据库内部的分布式事务

再谈恰好一次消息处理