在关系型数据库中,事务的重要性不言而喻,只要对数据库稍有了解的人都知道事务具有 ACID 四个基本属性,而我们不知道的可能就是数据库是如何实现这四个属性的;在这篇文章中,我们将对事务的实现进行分析,尝试理解数据库是如何实现事务的,当然我们也会在文章中简单对 MySQL 中对 ACID 的实现进行简单的介绍。
原子性(atomicity)
一致性(consistency)
隔离性(isolation)
持久性(durability)
事务其实就是并发控制的基本单位;相信我们都知道,事务是一个序列操作,其中的操作要么都执行,要么都不执行,它是一个不可分割的工作单位;接下来我们将依次介绍数据库是如何实现这四个特性的。
事务的特性
原子性
在学习事务时,经常有人会告诉你,事务就是一系列的操作,要么全部都执行,要都不执行,这其实就是对事务原子性的刻画;虽然事务具有原子性,但是原子性并不是只与事务有关系,它的身影在很多地方都会出现。
由于操作并不具有原子性,并且可以再分为多个操作,当这些操作出现错误或抛出异常时,整个操作就可能不会继续执行下去,而已经进行的操作造成的副作用就可能造成数据更新的丢失或者错误。
事务其实和一个操作没有什么太大的区别,它是一系列的数据库操作(可以理解为 SQL)的集合,如果事务不具备原子性,那么就没办法保证同一个事务中的所有操作都被执行或者未被执行了,整个数据库系统就既不可用也不可信。
隔离性
事务的隔离性要求每个读写事务的对象对其他事务的操作对象能互相分离,即该事务提交前对其他事物都不可见,通常这使用锁来实现。当前数据库系统中都提供一种粒度锁的策略,允许事务仅锁住一个实体对象的子集,以此来提高事物之间的并发度。
持久性
既然是数据库,那么一定对数据的持久存储有着非常强烈的需求,如果数据被写入到数据库中,那么数据一定能够被安全存储在磁盘上;而事务的持久性就体现在,一旦事务被提交,那么数据一定会被写入到数据库中并持久存储起来。
当事务已经被提交之后,就无法再次回滚了,唯一能够撤回已经提交的事务的方式就是创建一个相反的事务对原操作进行『补偿』,这也是事务持久性的体现之一。
一致性
一致性是指事务将数据库从一种状态变为下一种状态。在事务开始之前和事务结束之后,数据库的完整性约束没有被破坏。即在事务的执行的前后以及过程中不会违背对数据完整性的约束,所有对数据库写入的操作都应该是合法的,并不能产生不合法的数据状态。
事务的实现
事务的隔离性由锁来实现,原子性、一致性、持久性通过数据库的redo log和undo log来完成。redo log称为重做日志,用来保证事物的原子性和持久性。undo log称为回滚日志,用来保证事务的一致性,帮助事务回滚及MVCC的功能。
redo恢复提交事务修改的页操作,而undo回滚行记录到某个特定版本。因此两者记录的内容不同,redo通常是物理日志,记录的是页的物理修改操作。undo是逻辑日志,根据每行记录进行记录。
重做日志(redo log)
重做日志由两部分组成:一是内存中的重做日志缓冲,其是易失的;二是重做日志文件,其是持久的。
当事务提交时,必须先将该事物的所有日志写入到重做日志文件进行持久化,呆事物的COMMIT操作完成才算完成。
这里的事务日志指的就是redo log和undo log。
当我们在一个事务中尝试对数据进行修改时,它会先将数据从磁盘读入内存,并更新内存中缓存的数据,然后生成一条重做日志并写入重做日志缓存,当事务真正提交时,MySQL 会将重做日志缓存中的内容刷新到重做日志文件,再将内存中的数据更新到磁盘上,图中的第 4、5 步就是在事务提交时执行的。
在 InnoDB 中,重做日志都是以 512 字节的块的形式进行存储的,同时因为块的大小与磁盘扇区大小相同,所以重做日志的写入可以保证原子性,不会由于机器断电导致重做日志仅写入一半并留下脏数据。
除了所有对数据库的修改会产生重做日志,因为回滚日志也是需要持久存储的,它们也会创建对应的重做日志,在发生错误后,数据库重启时会从重做日志中找出未被更新到数据库磁盘中的日志重新执行以满足事务的持久性。
在MySQL数据库中还有一种二进制日志,其用来进行POINT-IN-TIME的恢复及主从复制环境的建立。从表面上看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而本质上来看,两者有着非常大的不同。
首先,重做日志是在InnoDB存储引擎层产生,而二进制日志是在MySQL数据库的上层产生,并且二进制日志不仅仅针对于InnoDB存储引擎,MySQL数据库中的任意存储引擎对于数据库的更改都会产生二进制日志。
其次,两种日志记录的内容形式不同,二进制日志是一种逻辑日志,记录对应的SQL语句。重做日志是物理格式日志,记录的是对于每个页的修改。
此外,二进制日志只在事务提交完成后进行一次写入。而重做日志在事务进行中不断地被写入。
回滚日志(undo log)
想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,而在 MySQL 中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后在对数据库中的对应行进行写入。
这个过程其实非常好理解,为了能够在发生错误时撤销之前的全部操作,肯定是需要将之前的操作都记录下来的,这样在发生错误时才可以回滚。
回滚日志除了能够在发生错误或者用户执行ROLLBACK时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这也就需要回滚日志必须先于数据持久化到磁盘上,是我们需要先写日志后写数据库的主要原因。
回滚日志并不能将数据库物理地恢复到执行语句或者事务之前的样子;它是逻辑日志,当回滚日志被使用时,它只会按照日志逻辑地将数据库中的修改撤销掉看,可以理解为,我们在事务中使用的每一条INSERT都对应了一条DELETE,每一条UPDATE也都对应一条相反的UPDATE语句。
这是因为在大量事务并发时,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。此时不能将页回滚到事务开始的样子,因为这样会影响其他的事务正在进行的工作。
重做日志与回滚日志
发生错误或者需要回滚的事务能够利用回滚日志进行回滚(原子性);
在事务提交后,数据没来得及写会磁盘就宕机时,在下次重新启动后能够利用重做日志恢复数据(持久性);
重做日志存放在重做日志文件中,而回滚日志存放在数据库内部的一个特殊段(segment)中,这个段称为undo段。undo段位于共享表空间中。
在数据库中,这两种日志经常都是一起工作的,我们可以将它们整体看做一条事务日志,其中包含了事务的 ID、修改的行元素以及修改前后的值。
对事务操作的统计
由于InnoDB存储引擎是支持事务的,因此InnoDB存储引擎的应用需要在考虑每秒请求数(QPS)的同时,关注每秒事务处理(TPS)的能力。
计算TPS的方法是(com_commit+com_rollback)/time。
事务的隔离级别
数据库的隔离性和一致性其实是一个需要开发者去权衡的问题,为数据库提供什么样的隔离性层级也就决定了数据库的性能以及可以达到什么样的一致性;
在 SQL 标准中定义了四种数据库的事务的隔离级别:READ UNCOMMITED、READ COMMITED、REPEATABLE READ 和 SERIALIZABLE;每个事务的隔离级别其实都比上一级多解决了一个问题:
RAED UNCOMMITED:可能会读到未提交的行(Dirty Read),即可能发生脏读;
READ COMMITED:没有脏读,但多次读取某一行的值可能会发生变化,即可能发生不可重复读;
REPEATABLE READ:可重复读,每次读取同一行的值都是不变的,但可能读取到之前没有的行,即可能发生幻读;
SERIALIZABLE:解决了幻读的问题;
由于锁的存在,以上的所有的事务隔离级别都不允许脏写入(Dirty Write),也就是当前事务更新了另一个事务已经更新但是还未提交的数据。
大部分的数据库中都使用了 READ COMMITED 作为默认的事务隔离级别,但是 InnoDB 使用了 REPEATABLE READ 作为默认配置,但与标准SQL不同的是,InnoDB存储引擎在REPEATABLE READ事务隔离级别下,使用Next-key Lock锁的算法,因此避免幻读的产生。
一般情况来说,从 RAED UNCOMMITED 到 SERIALIZABLE,随着事务隔离级别变得越来越严格,数据库对于并发执行事务的性能也逐渐下降。
参考