【非原创】
MySQL中的事务
自动提交(autocommit)
MySQL默认采用自动提交模式。也就是说,如果不是显式地开始一个事务,则每个查询都会被当做一个事务执行提交操作。在当前连接中,可以通过设置AUTOCOMMIT变量来启用或禁用自动提交模式:
1 | show variables like 'AUTOCOMMIT'; |
1或者ON表示启用,0或者OFF表示禁用。当AUTOCOMMIT=0时,所有的查询都是在一个事务中,直到显式地执行COMMIT提交或者ROLLBACK回滚,该事务结束,同时又开始了另一个新事务。
原子性
事务就是一系列的操作,要么全部都执行,要都不执行。那么全部都执行,要么都不执行就是说明事务的原子性。
回滚日志
要想保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,而在MySQL中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后对数据库中的对应行进行写入。
这个过程其实非常好理解,为了能够在发生错误时撤销之前的全部操作,肯定是需要将之前的操作都记录下来的,这样在发生错误时才可以回滚。
回滚日志除了能够在发生错误或用户执行ROLLBACK时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这就需要回滚日志必须先于数据持久化到磁盘上。
回滚日志并不能将数据库物理地恢复到执行语句或者事务之前的样子;它是逻辑日志,当回滚日志被使用时,它只会按照日志逻辑地将数据库中的修改撤销掉看,可以理解为,我们在事务中使用的每一条 INSERT
都对应了一条 DELETE
,每一条 UPDATE
也都对应一条相反的 UPDATE
语句。
持久性
事务的持久性体现在,一旦事务被提交,那么数据一定会被写入到数据库中并持久存储起来。当事务被提交之后,也就无法回滚了,唯一能够撤回已经提交的事务的方式就是创建一个相反的事务对原操作进行补偿,这也是事务持久性的体现之一。
与原子性一样,事务的持久性也是通过日志来实现的,mysql使用重做日志(redo log)实现事务的持久性,重做日志由两部分组成,一是内存中的重做日志缓冲区。重做日志缓冲区在内存中,所以它是易失的。另一个是存储在磁盘上的重做日志文件,它是持久的。
当我们在一个事务中尝试对数据进行修改时,它会先将数据从磁盘读入内存,并更新内存中缓存的数据,然后生成一条重做日志并写入重做日志缓存,当事务真正提交时,Mysql会将重做日志缓存中的内容刷新到重做日志文件,再将内存中的数据更新到磁盘上,图中的第4、5步就是在事务提交时执行的。
在InnoDB中,重做日志都是以512字节的块的形式进行存储的,同时因为块的大小与磁盘扇区大小相同,所以重做日志的写入可以保证原子性,不会由于机器断电导致重做日志仅写入一半并留下脏数据。(不太明白)
除了所有对数据库的修改可会产生重做日志,因为回滚日志也是需要持久存储的,它们也会创建对应的重做日志,在发生错误时,数据库重启时会从重做日志中找出未被更新到数据库磁盘中的日志重新执行以满足事务的持久性。
回滚日志和重做日志
回滚日志(undo log)和重做日志(redo log)。在数据库系统中,事务的原子性和持久性是由事务日志保证的,在实现时也就是上面提到的两种日志,前者用于对事务的影响进行撤销,后者在错误处理时对提交的事务进行重做,它们能保证两点:
- 发生错误或者需要回滚的事务能够成功回滚(原子性);
- 在事务提交后,数据没来得及写入磁盘就宕机时,在下次重新启动后能够成功恢复数据(持久性);
在数据库中,这两种日志经常都是一起工作的,我们可以将它们整体看做一条事务日志,其中包含了事务的ID、修改的行元素以及修改前后的值。
一条事务日志同时包含了修改前后的值,能够非常简单地进行回滚和重做两种操作。
隔离性
隔离性是指多个用户并发访问数据库时,比如操作同一张表时,数据库为每个用户开启的事务,不能被其他事务所干扰,多个并发事务之间要相互隔离。
SQL标准中定义了四种数据库的事务的隔离级别:READ UNCOMMIT
、 READ COMMITED
、REPEATABLE READ
和SERIALIZABLE
;每个事务的隔离级别都比上一级多解决了一个问题。
READ UNCOMMITED
:- 使用查询语句不会加锁,可能会读到未提交的行(dirty read)。
- 脏读是指事务可以读取未提交的数据。
READ COMMITED
:- 提交读,又称为不可重复读。
- 一个事务开始时,只能“看见”已经提交的事务所做的修改。换句话说,一个事物从开始直到提交之前,所有的修改对其他事务是不可见的。
- 只对记录加记录锁,而不会在记录之间加间隙锁,所以允许新的记录插入到被锁定记录的附近,所以在多次使用查询语句时,可能得到不同的结果(Non-Repeatable Read);
REPEATABLE READ
:- 可重复读。可重复读解决了脏读的问题。该级别保证了在同一个事务中多次读取同样记录的结果是一致的。
- 可重复读是Mysql的默认事务隔离级别。
- 多次读取同一范围的数据会返回第一次查询的快照,不会返回不同的数据行,但是可能发生幻读(Phantom Read);
- 幻读是指当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围内的记录时,会产生幻行。
- InnoDB和XtraDB存储引擎通过多版本并发控制解决了幻读的问题。
SERIALIZABLE
:- 可串行化是最高的隔离级别。它通过强制事务串行执行,避免了幻读的问题。
- 可串行化会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。
- 只有在非常需要确保数据的一致性而且可以接受没有并发的情况下,才考虑采用该级别。
- InnoDB隐式地将全部的查询语句加上共享锁,解决了幻读的问题。
以上的所有的事务隔离级别都不允许脏写入(dirty write),也就是当前事务更新了另一个事务已经更新但是还未提交的数据,大部分的数据库中都是用了READ COMMITED
作为默认事务隔离级别,但是Mysql使用了可重复读作为默认的事务隔离级别。
下面的图用来展示各个隔离级别对脏读、不可重复读和幻读这三个问题的解决情况。
对四种隔离级别的深入理解与实践见理解 MySQL 中的四种隔离级别
隔离级别的实现
数据库对隔离级别的实现就是使用并发控制机制在同一时间执行的事务进行控制,限制不同的事务对于统一资源的访问和更新,而最重要也最常见的并发控制机制,在这里简单介绍三种最重要的并发控制机制的工作原理。
锁
锁是最为常见的并发控制机制,在一个事务中,我们并不会将整个数据库都加锁,而是只会锁住那些需要访问的数据项。MySQL和常见数据库中的锁都分为两种,共享锁和互斥锁,前者也叫读锁,后者也叫写锁。
读锁保证了读操作可以并发执行,相互不会影响,而写锁保证了在更新数据库时不会有其他的事务访问或者更改同一条记录造成不可预知的问题。
时间戳
除了锁,另一种实现事务的隔离性的方式就是通过时间戳,使用这种方式实现事务的数据库,例如 PostgreSQL 会为每一条记录保留两个字段;读时间戳中包括了所有访问该记录的事务中的最大时间戳,而记录行的写时间戳中保存了将记录改到当前值的事务的时间戳。
使用时间戳实现事务的隔离性时,往往都会使用乐观锁,先对数据进行修改,在写回时再去判断当前值,也就是时间戳是否改变过,如果没有改变过,就写入,否则,生成一个新的时间戳并再次更新数据,乐观锁其实并不是真正的锁机制,它只是一种思想。
多版本和快照隔离
通过维护多个版本的数据,数据库可以允许事务在数据被其他事务更新时对旧版本的数据进行读取,很多数据库都对这一机制进行了实现偏;因为所有的读操作不再需要等待写锁的释放,所以能够显著地提升读的性能,MySQL 和 PostgreSQL 都对这一机制进行自己的实现,也就是 MVCC,虽然各自实现的方式有所不同,MySQL 就通过文章中提到的回滚日志实现了 MVCC,保证事务并行执行时能够不等待互斥锁的释放直接获取数据。
可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
MVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。
下面通过InnoDB的简化版行为来说明MVCC是如何工作的。
InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的病史实际的时间值,而是系统版本号。没开始一个新的事务,系统版本号自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。
select
InnoDB会根据以下两个条件检查每行记录:
- InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本要么 未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
只有符合上述两个条件的记录,才能返回作为查询结果。
insert
- InnoDB为新插入的每一行保存当前系统版本号作为行版本号。
delete
- InnoDB为删除的每一行保存当前系统版本号作为行删除标识。
update
- InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。
MVCC只在REPEATABLE READ和READ COMMITED两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容,因为READ UNCOMMITED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁。
隔离性与原子性
在这里简单提一下在原子性一节中遇到的级联回滚问题了,如果一个事务对数据进行了写入,这时就会获取一个互斥锁,其他的事务如果想要获得该行数据的读锁必须等待写锁的释放,自然不会发生级联回滚问题了。
不过在大多数的数据库,比如 MySQL 中都使用了 MVCC 等特性,也就是正常的读方法是不需要获取锁的,在想要对读取的数据进行更新时需要使用 SELECT ... FOR UPDATE
尝试获取对应行的互斥锁,以保证不同事务可以正常工作。
一致性
如果一个事务在一个一致的数据库中独立运行,那么在它执行之后,数据库的状态一定是一致的。
对于这个概念,它的第一层意思就是对于数据完整性的约束,包括主键约束、引用约束以及一些约束检查等等,在事务的执行的前后以及过程中不会违背对数据完整性的约束,所有对数据库写入的操作都应该是合法的,并不能产生不合法的数据状态。而第二层意思其实是指逻辑上的对于开发者的要求,我们要在代码中写出正确的事务逻辑,比如银行转账,事务中的逻辑不可能只扣钱或者只加钱,这是应用层面上对于数据库一致性的要求。
参考文献
[1]深入浅出mysql中事务的实现:https://draveness.me/mysql-transaction
[2]InnoDB存储引擎MVCC实现原理:https://liuzhengyang.github.io/2017/04/18/innodb-mvcc/