在关系型数据库中,事务的重要性不言而喻,只要对数据库稍有了解的人都知道事务具有 ACID 四个基本属性,而我们不知道的可能就是数据库是如何实现这四个属性的;在这篇文章中,我们将对事务的实现进行分析,尝试理解数据库是如何实现事务的,当然我们也会在文章中简单对 MySQL 中对 ACID 的实现进行简单的介绍。
Transaction-Basics
事务其实就是并发控制的基本单位;相信我们都知道,事务是一个序列操作,其中的操作要么都执行,要么都不执行,它是一个不可分割的工作单位;数据库事务的 ACID 四大特性是事务的基础,了解了 ACID 是如何实现的,我们也就清楚了事务的实现,接下来我们将依次介绍数据库是如何实现这四个特性的。
#原子性
在学习事务时,经常有人会告诉你,事务就是一系列的操作,要么全部都执行,要都不执行,这其实就是对事务原子性的刻画;虽然事务具有原子性,但是原子性并不是只与事务有关系,它的身影在很多地方都会出现。
Atomic-Operation
由于操作并不具有原子性,并且可以再分为多个操作,当这些操作出现错误或抛出异常时,整个操作就可能不会继续执行下去,而已经进行的操作造成的副作用就可能造成数据更新的丢失或者错误。
事务其实和一个操作没有什么太大的区别,它是一系列的数据库操作(可以理解为 SQL)的集合,如果事务不具备原子性,那么就没办法保证同一个事务中的所有操作都被执行或者未被执行了,整个数据库系统就既不可用也不可信。
#回滚日志
想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,而在 MySQL 中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后在对数据库中的对应行进行写入。
Transaction-Undo-Log
这个过程其实非常好理解,为了能够在发生错误时撤销之前的全部操作,肯定是需要将之前的操作都记录下来的,这样在发生错误时才可以回滚。
回滚日志除了能够在发生错误或者用户执行 ROLLBACK
时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这也就需要回滚日志必须先于数据持久化到磁盘上,是我们需要先写日志后写数据库的主要原因。
回滚日志并不能将数据库物理地恢复到执行语句或者事务之前的样子;它是逻辑日志,当回滚日志被使用时,它只会按照日志逻辑地将数据库中的修改撤销掉看,可以理解为,我们在事务中使用的每一条 INSERT
都对应了一条 DELETE
,每一条 UPDATE
也都对应一条相反的 UPDATE
语句。
Logical-Undo-Log
在这里,我们并不会介绍回滚日志的格式以及它是如何被管理的,本文重点关注在它到底是一个什么样的东西,究竟解决了、如何解决了什么样的问题,如果想要了解具体实现细节的读者,相信网络上关于回滚日志的文章一定不少。
#事务的状态
因为事务具有原子性,所以从远处看的话,事务就是密不可分的一个整体,事务的状态也只有三种:Active、Commited 和 Failed,事务要不就在执行中,要不然就是成功或者失败的状态:
Atomitc-Transaction-State
但是如果放大来看,我们会发现事务不再是原子的,其中包括了很多中间状态,比如部分提交,事务的状态图也变得越来越复杂。
Nonatomitc-Transaction-State
事务的状态图以及状态的描述取自 Database System Concepts 一书中第 14 章的内容。
- Active:事务的初始状态,表示事务正在执行;
- Partially Commited:在最后一条语句执行之后;
- Failed:发现事务无法正常执行之后;
- Aborted:事务被回滚并且数据库恢复到了事务进行之前的状态之后;
- Commited:成功执行整个事务;
虽然在发生错误时,整个数据库的状态可以恢复,但是如果我们在事务中执行了诸如:向标准输出打印日志、向外界发出邮件、没有通过数据库修改了磁盘上的内容甚至在事务执行期间发生了转账汇款,那么这些操作作为可见的外部输出都是没有办法回滚的;这些问题都是由应用开发者解决和负责的,在绝大多数情况下,我们都需要在整个事务提交后,再触发类似的无法回滚的操作。
Shutdown-After-Commited
以订票为例,哪怕我们在整个事务结束之后,才向第三方发起请求,由于向第三方请求并获取结果是一个需要较长时间的操作,如果在事务刚刚提交时,数据库或者服务器发生了崩溃,那么我们就非常有可能丢失发起请求这一过程,这就造成了非常严重的问题;而这一点就不是数据库所能保证的,开发者需要在适当的时候查看请求是否被发起、结果是成功还是失败。
#并行事务的原子性
到目前为止,所有的事务都只是串行执行的,一直都没有考虑过并行执行的问题;然而在实际工作中,并行执行的事务才是常态,然而并行任务下,却可能出现非常复杂的问题:
Nonrecoverable-Schedule
当 Transaction1 在执行的过程中对 id = 1
的用户进行了读写,但是没有将修改的内容进行提交或者回滚,在这时 Transaction2 对同样的数据进行了读操作并提交了事务;也就是说 Transaction2 是依赖于 Transaction1 的,当 Transaction1 由于一些错误需要回滚时,因为要保证事务的原子性,需要对 Transaction2 进行回滚,但是由于我们已经提交了 Transaction2,所以我们已经没有办法进行回滚操作,在这种问题下我们就发生了问题,Database System Concepts 一书中将这种现象称为不可恢复安排(Nonrecoverable Schedule),那什么情况下是可以恢复的呢?
A recoverable schedule is one where, for each pair of transactions Ti and Tj such that Tj reads a data item previously written by Ti , the commit operation of Ti appears before the commit operation of Tj .
简单理解一下,如果 Transaction2 依赖于事务 Transaction1,那么事务 Transaction1 必须在 Transaction2 提交之前完成提交的操作:
Recoverable-Schedule
然而这样还不算完,当事务的数量逐渐增多时,整个恢复流程也会变得越来越复杂,如果我们想要从事务发生的错误中恢复,也不是一件那么容易的事情。
Cascading-Rollback
在上图所示的一次事件中,Transaction2 依赖于 Transaction1,而 Transaction3 又依赖于 Transaction1,当 Transaction1 由于执行出现问题发生回滚时,为了保证事务的原子性,就会将 Transaction2 和 Transaction3 中的工作全部回滚,这种情况也叫做级联回滚(Cascading Rollback),级联回滚的发生会导致大量的工作需要撤回,是我们难以接受的,不过如果想要达到绝对的原子性,这件事情又是不得不去处理的,我们会在文章的后面具体介绍如何处理并行事务的原子性。
#持久性
既然是数据库,那么一定对数据的持久存储有着非常强烈的需求,如果数据被写入到数据库中,那么数据一定能够被安全存储在磁盘上;而事务的持久性就体现在,一旦事务被提交,那么数据一定会被写入到数据库中并持久存储起来。
Compensating-Transaction
当事务已经被提交之后,就无法再次回滚了,唯一能够撤回已经提交的事务的方式就是创建一个相反的事务对原操作进行『补偿』,这也是事务持久性的体现之一。
#重做日志
与原子性一样,事务的持久性也是通过日志来实现的,MySQL 使用重做日志(redo log)实现事务的持久性,重做日志由两部分组成,一是内存中的重做日志缓冲区,因为重做日志缓冲区在内存中,所以它是易失的,另一个就是在磁盘上的重做日志文件,它是持久的。
Redo-Logging
当我们在一个事务中尝试对数据进行修改时,它会先将数据从磁盘读入内存,并更新内存中缓存的数据,然后生成一条重做日志并写入重做日志缓存,当事务真正提交时,MySQL 会将重做日志缓存中的内容刷新到重做日志文件,再将内存中的数据更新到磁盘上,图中的第 4、5 步就是在事务提交时执行的。
在 InnoDB 中,重做日志都是以 512 字节的块的形式进行存储的,同时因为块的大小与磁盘扇区大小相同,所以重做日志的写入可以保证原子性,不会由于机器断电导致重做日志仅写入一半并留下脏数据。
除了所有对数据库的修改会产生重做日志,因为回滚日志也是需要持久存储的,它们也会创建对应的重做日志,在发生错误后,数据库重启时会从重做日志中找出未被更新到数据库磁盘中的日志重新执行以满足事务的持久性。
#回滚日志和重做日志
到现在为止我们了解了 MySQL 中的两种日志,回滚日志(undo log)和重做日志(redo log);在数据库系统中,事务的原子性和持久性是由事务日志(transaction log)保证的,在实现时也就是上面提到的两种日志,前者用于对事务的影响进行撤销,后者在错误处理时对已经提交的事务进行重做,它们能保证两点:
- 发生错误或者需要回滚的事务能够成功回滚(原子性);
- 在事务提交后,数据没来得及写会磁盘就宕机时,在下次重新启动后能够成功恢复数据(持久性);
在数据库中,这两种日志经常都是一起工作的,我们可以将它们整体看做一条事务日志,其中包含了事务的 ID、修改的行元素以及修改前后的值。
Transaction-Log
一条事务日志同时包含了修改前后的值,能够非常简单的进行回滚和重做两种操作,在这里我们也不会对重做和回滚日志展开进行介绍,可能会在之后的文章谈一谈数据库系统的恢复机制时提到两种日志的使用。
#