数据库事务原子性、一致性是怎样实现的?

数据库事务原子性、一致性的实现机制是什么?
关注者
1,524
被浏览
222,322

38 个回答

这个问题的有趣之处,不在于问题本身(“原子性、一致性的实现机制是什么”),而在于回答者的分歧反映出来的另外一个问题:原子性和一致性之间的关系是什么?


我特别关注了@我练功发自真心 的答案,他正确地指出了,为了保证事务操作的原子性,必须实现基于日志的REDO/UNDO机制。但这个答案仍然是不完整的,因为原子性并不能够完全保证一致性


按照我个人的理解,在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。


首先回顾一下一致性的定义。所谓一致性,指的是数据处于一种有意义的状态,这种状态是语义上的而不是语法上的。最常见的例子是转帐。例如从帐户A转一笔钱到帐户B上,如果帐户A上的钱减少了,而帐户B上的钱却没有增加,那么我们认为此时数据处于不一致的状态。


在数据库实现的场景中,一致性可以分为数据库外部的一致性和数据库内部的一致性。前者由外部应用的编码来保证,即某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作。如果在这个层次出现错误,这不是数据库本身能够解决的,也不属于我们需要讨论的范围。后者由数据库来保证,即在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。这就是事务处理的原子性。


为了实现原子性,需要通过日志:将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash recovery的过程:读取日志进行REDO(重演将所有已经执行成功但尚未写入到磁盘的操作,保证持久性),再对所有到崩溃时尚未成功提交的事务进行UNDO(撤销所有执行了一部分但尚未提交的操作,保证原子性)。crash recovery结束后,数据库恢复到一致性状态,可以继续被使用。


日志的管理和重演是数据库实现中最复杂的部分之一。如果涉及到并行处理和分布式系统(日志的复制和重演是数据库高可用性的基础),会比上述场景还要复杂得多。


但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果。例如,事务1需要将100元转入帐号A:先读取帐号A的值,然后在这个值上加上100。但是,在这两个操作之间,另一个事务2修改了帐号A的值,为它增加了100元。那么最后的结果应该是A增加了200元。但事实上, 事务1最终完成后,帐号A只增加了100元,因为事务2的修改结果被事务1覆盖掉了。


为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。用术语来说,就是多个事务并发执行后的状态,和它们串行执行后的状态是等价的。怎样实现隔离性,已经有很多人回答过了,原则上无非是两种类型的锁:


一种是悲观锁,即当前事务将所有涉及操作的对象加锁,操作完成后释放给其它对象使用。为了尽可能提高性能,发明了各种粒度(数据库级/表级/行级……)/各种性质(共享锁/排他锁/共享意向锁/排他意向锁/共享排他意向锁……)的锁。为了解决死锁问题,又发明了两阶段锁协议/死锁检测等一系列的技术。


一种是乐观锁,即不同的事务可以同时看到同一对象(一般是数据行)的不同历史版本。如果有两个事务同时修改了同一数据行,那么在较晚的事务提交时进行冲突检测。实现也有两种,一种是通过日志UNDO的方式来获取数据行的历史版本,一种是简单地在内存中保存同一数据行的多个历史版本,通过时间戳来区分。


锁也是数据库实现中最复杂的部分之一。同样,如果涉及到分布式系统(分布式锁和两阶段提交是分布式事务的基础),会比上述场景还要复杂得多。


@我练功发自真心 提到,其他回答者说的其实是操作系统对atomic的理解,即并发控制。我不能完全同意这一点。数据库有自己的并发控制和锁问题,虽然在原理上和操作系统中的概念非常类似,但是并不是同一个层次上的东西。数据库中的锁,在粒度/类型/实现方式上和操作系统中的锁都完全不同。操作系统中的锁,在数据库实现中称为latch(一般译为闩)。其他回答者回答的其实是“在并行事务处理的情况下怎样保证数据的一致性”。


最后回到原来的问题(“原子性、一致性的实现机制是什么”)。我手头有本Database System Concepts(4ed,有点老了),在第15章的开头简明地介绍了ACID的概念及其关系。如果你想从概念上了解其实现,把这本书的相关章节读完应该能大概明白。如果你想从实践上了解其实现,可以找innodb这样的开源引擎的源代码来读。不过,即使是一个非常粗糙的开源实现(不考虑太复杂的并行处理,不考虑分布式系统,不考虑针对操作系统和硬件的优化之类),要基本搞明白恐怕也不是一两年的事。

尝试简单回答下

讨论数据库的事务原子性,先看最极端的情况,即全局一把锁,所有事务排队执行,这种情况下没有原子性问题,因为所有事务看到的都是在自己之前已经提交的数据。

为了提高性能,充分利用多核,我们需要让多个事务能够并行的执行,但是还要保证这些事务“看起来”是串行执行的(external consistency)。这里需要考虑事务的三种关系,即读与读的关系,写与写的关系,读与写的关系。

  1. 读事务与读事务的关系最简单,因为不对数据进行修改,因此读与读之间可以直接并行
  2. 写事务与写事务的关系,对同一条记录的修改,需要保证串行,不能出现lost update的情况,一般通过行锁(oracle/mysql/oceanbase),或者事先通过SQL分析将可能冲突的事务排队后执行(calvin/oceanbase)
  3. 读事务与写事务的关系,这个最复杂,因为数据库操作中,几乎没有只写的情况,一般都是“read-modify-write”,比如最简单的update ... where 还有 update set c=c+1 whete,绝大部分写事务都是在读取一些数据之后,再修改数据,我们称这类事务为“读写事务”。因此读与写关系,会涉及两类关系:
    1. 只读事务与读写事务的关系,一般使用多版本的方式保存数据,每个事务分配一个全局唯一且递增的“事务版本号”,更新数据时将事务版本号也保存在数据中。在全局维护一个“最大已提交的”版本号(committed version),一般就是一个64或128位的整数,每个只读事务开始时,原子的读取这个commited version,读取数据时,只读取版本号小于等于它的内容。多版本的实现方式各家不尽相同;数据存储方面oracle与innodb都是使用data block + undo block的方式,历史版本保存在undo block中,oceanbase内存引擎则简单的将一行所有的修改历史串成反向链表;对于事务版本号,oracle与oceanbase都在事务提交时生成版本号,可以保证版本号的大小严格遵守事务提交顺序,但是需要在事务提交时(oceanbase)或提交后(oracle)将事务版本号回填到数据内容中,mysql则简单的在事务开启时生成版本号,因此读取逻辑相对复杂,需要过滤掉开始事务时尚未结束事务对数据的修改。
    2. 读写事务之间的关系,这里需要考虑的是一个读写事务T1在执行过程中,它刚刚读过的数据被其他事务修改的情况,这种情况下T1需要回滚重做(单语句事务)或报告事务冲突(交互型事务),一般的做法是在T1提交时对涉及到的行加锁后检查版本号或内容,在read committed隔离级别下这个特性并非标准所要求,但是oracle/mysql/oceanbase都在语句级别实现了,也成为了事实标准,按oracle的叫法叫做transaction set consistency

参考文献:

[1] Thomson A, Diamond T, Weng S C, et al. Calvin: fast distributed transactions for partitioned database systems[C]//Proceedings of the 2012 ACM SIGMOD International Conference on Management of Data. ACM, 2012: 1-12. MLA

[2] Berenson H, Bernstein P, Gray J, et al. A critique of ANSI SQL isolation levels[C]//ACM SIGMOD Record. ACM, 1995, 24(2): 1-10.

[3] LI Kai,HAN Fu-Sheng. Memory transaction engine of OceanBase[J]. Journal of East China Normal University(Natural Sc, 2014, 2014(5): 147-163.

[4] Lewis J. Oracle Core: Essential Internals for DBAs and Developers[M]. Apress, 2011.