在数据系统的残酷现实中,许多事情可能出错:
事务 transaction 一直是简化这些问题的首选机制,应用程序通过事务将多个读写操作组合成一个逻辑单元。
从概念上讲,事务中的所有读写被当作一次操作执行:整个事务要么成功(提交,commit),要么失败(中止,abort;回滚,rollback)。如果失败,应用程序可以安全重试。有了事务,应用程序的错误处理就简单多了,因为它不必担心部分失效——即无论出于何种原因,有些操作成功、有些操作失败。
人们创造事务自由其目的,简化 访问数据库的 应用编程模型(programming model)。有了事务,应用程序便可以不去考虑某些潜在的错误场景和并发问题,因为数据库会代为处理这些问题(我们称之为 安全保证,safety guarantee)。
并非所有应用程序都需要事务,有时弱化事务保证,甚至彻底放弃事务也有好处。
几乎所有关系型数据库和一部分非关系型数据库都支持事务。
NoSQL 分布式数据库的炒作催生了观点:事务从根本上无法伸缩,任何大规模系统若想保持良好性能和高可用性,都必须放弃事务。近来事实已经证明这种观点是错误的。
CockroachDB、TiDB、Spanner、FoundationDB 和 YugabyteDB 等所谓的 “NewSQL” 数据库表明,事务系统同样可以扩展到很大的数据量和很高的吞吐量。这些系统将分片与共识协议结合起来,从而在大规模场景下提供强 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。
ACID 意义上的 隔离性 是指并发执行的事务彼此隔离,不能相互干扰。经典数据库教科书将隔离性形式化为 可串行化 :每个事务都可以假装自己是整个数据库中唯一正在运行的事务。数据库保证,所有事务提交后的结果与它们 串行 运行(逐个运行)的结果相同,尽管实际上这些事务可能是并发执行的。
然而,可串行化是有性能代价的。实践中,许多数据库采用弱于可串行化的隔离形式,也就是允许并发事务在有限范围内相互干扰。一些流行数据库(例如 Oracle)甚至没有实现可串行化:Oracle 虽有一个名为“可串行化”的隔离级别,实际实现的却是保证更弱的 快照隔离。
数据库系统的目的,是提供一个可以放心存放数据而无须担心丢失的安全场所。持久性 作出这样的承诺:事务一旦成功提交,其写入的任何数据都不会遗失,即使发生硬件故障或数据库崩溃也不例外。
在单节点数据库中,持久性通常意味着数据已经写入硬盘或 SSD
等非易失性存储。普通文件写入通常会先在内存中缓冲,稍后才送往磁盘,突然断电时便可能丢失;因此,许多数据库使用
fsync()
系统调用,确保数据确实已经落盘。数据库通常还有预写日志或类似机制,以便在写入中途崩溃后恢复。
在复制数据库中,持久性可能意味着数据已经成功复制到一定数量的节点。数据库必须等到这些写入或复制操作完成,才能报告事务已成功提交。不过,完美的持久性并不存在:如果所有硬盘和备份同时被毁,数据库显然也无能为力。
在 ACID 中,原子性和隔离性描述了客户端在同一事务中进行多次写入时,数据库应当如何处理:
原子性:
如果一系列写入执行到一半时发生错误,事务就应中止,之前完成的写入也应丢弃。
隔离性:
并发运行的事务不应相互干扰。例如,一个事务进行了多次写入,另一个事务就应当要么看到全部写入,要么一项也看不到,而不能只看到其中一部分。
插入一条新邮件,然后将未读数量标记加 1。隔离性可以防止这个问题:它保证用户 2 要么同时看到新插入的邮件和更新后的计数,要么两者都看不到,而不会看到不一致的中间状态。
如果事务进行到一半时发生错误,邮箱内容和未读计数可能失去同步。在原子事务中,如果计数器更新失败,事务就会中止,已经插入的邮件也会回滚。
在关系型数据库中,这通常以客户端到数据库服务器的 TCP
连接为依据:同一条连接上,BEGIN TRANSACTION 与
COMMIT 语句之间的所有操作都属于同一个事务。如果 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) ,它提供两项保证:
有些数据库还支持更弱的 读未提交 隔离级别。它能防止脏写,却不能防止脏读。下面详细讨论这两项保证。
设想一个事务已经向数据库写入数据,却尚未提交或中止。另一个事务能否看到这些未提交的数据?如果能,这就是 脏读。
运行在读已提交隔离级别下的事务必须防止脏读。这意味着,一个事务的所有写入都要等到该事务提交时,才会同时对其他事务可见。
防止脏读有以下几个好处:
如果两个事务并发更新数据库中的同一行,会发生什么?写入次序虽然无法预知,但我们通常认为后一次写入会覆盖前一次写入。
可是,如果前一次写入属于尚未提交的事务,后一次写入覆盖了这个未提交值,又会怎样?这就是 脏写。运行在读已提交隔离级别下的事务必须防止脏写;通常的做法是推迟第二次写入,直到执行第一次写入的事务提交或中止。
读已提交是一种非常流行的隔离级别,也是 Oracle Database、PostgreSQL、SQL Server 等许多数据库的默认设置。
数据库 防止脏写 最常见的办法是使用 行级锁 :事务想修改某一行(或文档等其他对象)时,必须先取得该行的锁,并一直持有到事务提交或中止。任何一行在同一时刻只能由一个事务持锁;另一个事务若想写入同一行,就必须等前一个事务提交或中止后,才能取得锁并继续执行。在读已提交模式(或更强的隔离级别)下,数据库会自动完成这些加锁操作。
那么怎样 防止脏读 ?一种办法仍是使用同一把锁:要求任何想读取某行的事务都短暂取得该锁,读完后立即释放。这样,当该行包含未提交的脏值时就无法读取,因为执行写入的事务仍持有这把锁。
然而,要求读操作加锁在实践中效果不佳。一个长期运行的写事务,可能迫使许多只读事务一直等到它结束。这不仅损害只读事务的响应时间,也不利于可运维性:应用某个部分一旦变慢,等待锁便可能引发连锁反应,波及完全不相干的其他部分。
尽管如此,仍有一些数据库使用锁来防止脏读,例如 IBM Db2,以及将
read_committed_snapshot=off 的 Microsoft SQL Server。
更常见的防脏读办法:对于每一行写入,数据库同时保留旧的已提交值,以及当前持有写锁的事务写入的新值。该事务进行期间,其他事务读取这一行时只会得到旧值;等新值提交后,才改为读取新值,多版本并发控制 MVCC。