提到事务大家都不陌生,最经典的例子就是转账:小明要给朋友小王转 100 元,而此时小明的账户只有 100 元。假设他们的转账通过某个转账系统进行。那么此时转账系统要保证上述的转账操作正确顺利地完成,就必须保证:
1.小明的账户余额扣减操作和小王的账户余额增加操作必须作为一个整体,要么全部成功,要么全部失败。如果这点无法保证,就会出现小明账户的钱被扣减了,小王的账户并没有增加金额的情况,那就天下大乱了。
2.小明的账户余额扣减100元,小王的账户就必须增加100元。如果转账中途系统遭遇停电,异常崩溃等故障,重启后依然能保证双方账户金额的正确。
3.小明准备向小王转账,但是并未进行最后的确认转账,那么小王查看自己的账户就不能看到增加100元。否则就会产生如下的错误:小明还未真正的进行转账,小王就看到自己的账户多了一百元,随即进行购物,但是最后小明取消了转账,小王付款时发现自己的账户余额不足。
4.如果转账的过程中,发生了停电,机器宕机等情况,能够保证再次重启后,对故障之前的转账操作进行恢复或取消。
以上也就是事务的ACID四大特性: 从1到4,依次是原子性,一致性,隔离性和持久性。
MySQL的innodb引擎实现了事务的四大特性,今天我们就来剖析innodb引擎是如何保证事务的四大特性的。
redo日志和undo日志
要开始正式开始之前,我们先来简单了解下innodb引擎的两大日志 -- undo日志和redo日志:
undo日志
undo日志:回滚日志,记录事务开始前原始的数据内容。
它是逻辑日志,采用随机写的方式对每行的记录进行记录。undo日志存放的地方被称为回滚段,存储在表空间中。
undo日志对于update和delete操作会存放数据的旧记录,对于insert操作会记录新数据行的PK(rowid),当事务需要回滚或者崩溃恢复时,可以利用undo日志的旧数据撤销未提交事务对于数据库的影响,保障事务的ACID特性。
innodb引擎利用undo回滚时,它实际做的是与之前相反的工作:对于insert,执行一个delete;对于一个delete,执行一个insert;对于一个update,执行一个完全相反的update
redo日志
redo日志:重做日志,记录的是事务运行过程中,对数据页做的改动。
redo记录的是数据在物理页上的直接改动,本身采用顺序写的方式,效率很高。它相比逻辑日志和二进制日志(binlog),恢复速度更快。比如一个sql修改了一行记录,redo日志就会记录该行数据所在的物理页和修改的偏移量以及修改的value,对于聚集索引和非聚集索引它都会记录这样的日志。这样的方式相比与二进制日志来说,保证了操作恢复的幂等。
crash-safe与WAL技术
当事务提交(Commit)时,必须先将事务的所有日志写入到redo(重做日志文件)中,进行持久化,待事务的commit完成才算完成。这也是WAL技术(Write-Ahead Logging)。
这里的事务所有日志,包括redo和undo。因此redo日志保证了事务的持久性。redo日志保证事务持久性的能力被称作:crash-safe。
总的来说,redo日志和undo日志都可以视为一种恢复操作,只不过redo是故障恢复及前滚(恢复事务作出的改动)。undo是事务回滚,恢复事务开始前数据的快照。
innodb引擎也正是利用这两个日志来保障事务的ACID特性,而undo日志和mvcc的关系又十分密切。接下来,我们就深入分析redo和undo是怎么保障事务的ACID特性的。
事务ACID的背后原理
对于事务来说,一致性是最重要的,事务的原子性,隔离性和持久性可以说都是为了保障一致性。而一致性又包括应用层面的一致性,假如你的业务逻辑有问题,破坏了一致性,比如转账给A,代码层面没有实现余额扣除,这样的问题导致的一致性破坏是数据库无能为力的。在innodb引擎中就是通过实现原子性,隔离性和持久性来保证事务的一致性的。
持久性的实现
通过上面对redo日志的介绍,我们明白redo日志的作用就是为了保证事务的持久性,那redo到底是如何进行恢复的呢?
redo日志内部会记录每一个事务写入重做日志的字节总量,简称LSN。例如:当前重做日志的LSN为1000,事务T写入了100字节的改动,LSN就变成了1100。
但LSN不仅记录在重做日志里,在每个数据页头部也会有对应的LSN,该LSN记录当前页最后一次修改的LSN号,用于在recovery时对比重做日志。
比如页P1的LSN为1000,而数据库启动时,innodb发现重做日志中的LSN为1100,并且该事务已经提交,那么数据库会进行恢复操作,但是将只会把重做日志LSN号在1000-1100范围内的数据应用在页P1中,小于页P1的LSN不进行重做。
其实这里还有一个checkpoint的概念,checkpoint表示已经刷到磁盘上的LSN,所以只需恢复checkpoint后的重做日志。就是上面例子中1000-1100范围内的数据。
redo日志如何保证自己的写入原子性?
redo日志既然作为innodb故障恢复的重要手段,它的写入原子性是如何保证的呢?
因为redo日志都是以512字节进行存储,也称作重做日志块,如果一个重做日志数量大于512字节吗,就要分割为多个重做日志进行存储。大家都知道磁盘的一个扇区的大小是512字节,而操作系统与磁盘的数据交换单位,是以扇区为基本单位。一次操作,是写一个扇区大小的数据(512字节)。我们只需要无缓冲的写入磁盘,就可以保证这次数据原子的写入到磁盘,不会出现只写了256字节,而剩下的256字节未写入的情况。redo日志正是利用了磁盘的这个特性实现了写入的原子性。
原子性的实现
那事务的原子性又是如何实现的呢?
为了实现原子性,需要通过日志:
将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。
最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash recovery的过程:
读取日志进行redo(重演将所有已经执行成功但尚未写入到磁盘的操作,保证持久性),再对所有到崩溃时尚未成功提交的事务进行undo(撤销所有执行了一部分但尚未提交的操作,保证原子性)。
crash recovery结束后,数据库恢复到一致性状态,可以继续被使用。
需要注意的是:undo是逻辑日志,只是将数据库逻辑地恢复到原来的样子,数据库和页可能和回滚之后会大不相同。
例如:用户插入大批量数据,这个事务会导致分配一个新的回滚段,这会导致表空间增大。然后执行rollback回滚,但表空间的大小不会因此收缩。
但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果。
例如,事务1需要将100元转入帐号A:先读取帐号A的值,然后在这个值上加上100。但是,在这两个操作之间,另一个事务2修改了帐号A的值,为它增加了100元。那么最后的结果应该是A增加了200元。但事实上,事务1最终完成后,帐号A只增加了100元,因为事务2的修改结果被事务1覆盖掉了。
隔离性的实现
为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。用术语来说,就是多个事务并发执行后的状态,和它们串行执行后的状态是等价的。
怎么来实现事务的隔离呢?我们可以采取加锁的方式,可以采取最暴力的加锁方式,对每一个事务需要操作的数据加锁,实施互斥,事务提交后才释放资源。这样是最简单实现的方式,事务执行有了顺序,成为串行执行,造成的后果就是并发度太低,对性能影响较大。于是就有了共享锁和排他锁:
共享锁:读读可以并行;
排他锁:读写不能并行,写写不能并行。
可以看到,如果写任务没有完成,读操作也不能执行,对并发度仍有较大的影响,有没有更好的办法呢?innodb引擎通过mvcc来实现快照读解决了读写并发的问题,大大地提高了并发度。
mvcc
mvcc的核心原理就是:
1.写任务发生时,将数据克隆一份,以版本号区分;
2.写任务操作新克隆的数据,直至提交;
3.并发读任务可以继续读取旧版本的数据,不至于阻塞;
那么innodb引擎具体实现mvcc的细节是怎么样的?
InnoDB的内核,为了实现mvcc,会对所有row数据增加三个内部属性:
(1)DB_TRX_ID,6字节,记录每一行最近一次修改它的事务ID;
(2)DB_ROLL_PTR,7字节,记录指向回滚段undo日志的指针;
(3)DB_ROW_ID,6字节,单调递增的行ID;
read-view
在了解这三个属性的具体作用前,我们先来认识一下read-view。
read-view是事务开启时,当前所有事务的一个集合,也被称为一致性读视图。它是事务开启时对整库数据做的快照,但又不是对数据的简单复制,那样代价太大。它是通过‘数据都有多个版本’的特性,来实现秒级生成快照,这也是主要依赖于mvcc记录的全局事务id。它内部有以下几个重要的信息:
1. m_ids:表示在生成ReadView时当前系统中活跃的未提交事务的事务id列表。
2. min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
3. max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
4. creator_trx_id:表示生成该ReadView的事务的事务id。
这里的当前活跃事务指的是还未提交的事务
有了这些事务ID的信息,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
1)如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
2)如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
3)如果被访问版本的trx_id属性值大于等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
4)如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中。
如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
这就是read-view实现秒级生成快照的秘密。它只需要记录当前活跃的事务id数组,非常轻量级;而不是对一个10G的数据库进行傻傻地全量复制。
当我们了解上面这些概念后,我们就可以把innodb内部实现事务隔离的流程串联起来了,我们以一次更新为例,在RR隔离级别下:
事务B开启,随后查询表中id=1这条数据,生成了自己的read-view。innodb内核根据当前事务的read-view判断当前数据的版本,拿到可以读取的数据;接着事务B对这条数据进行修改,它会先对id=1的这条记录加上排他锁,然后将这条数据的旧数据生成快照,并作为旧版本写入undo日志被其他事务读取,然后再对id=1的这条记录进行修改。
innodb引擎会在这个条数据后面记录DBTRXID:当前事务B的id,DBROLLPTR:指向undo日志中这条记录旧版本数据的DBROWID,随后事务提交。
每一次修改都会在最新数据行的DBROLLPTR里维护它关联的旧版本数据的DBROWID,这样就会形成一条旧版本的数据链,读取的时候就会innodb就会根据这个链表一个个地比对事务id,拿到可以读取的数据快照。
假如一个事务A在事务B之前先开启,它在事务B修改后再读取id=1的这条记录,仍旧读不到最新记录,因为innodb会拿它的read-view中记录的分配给下一个事务id(max_trx_id)去和Id=1的最新事务id做比较,发现id=1在它之后被修改过,所以放弃读取最新记录,去undo日志里遍历旧版本数据链表,找到符合读取条件的旧版本数据。
这是RR(可重复读级别)下的一次事务运行的流程。RC(读已提交)与RR不同的就是,RR是在事务第一次读的时候只会生成一次read-view,直到事务提交;而RC是每一次读取都会生成新的read-view。这也是RC级别产生不可重复读的原因。
innodb解决幻读
MySql5.6版本之后,RR通过间隙锁和记录锁解决了幻读的问题。
记录锁:锁定一条记录,不可以被其他事务读取,修改。上面案例中的事务B获取id=1的那条记录,即是对id=1的这条数据增加了记录锁,记录锁就是只对一条数据加锁。
间隙锁:当一条事务操作某一个范围内的数据时,就会触发间隙锁,不可以被其他事务读取,修改。
redo日志和undo日志占用空间的复用和释放
最后还要说明redo日志和undo日志的记录何时会删除:
对于undo日志来说,insert会放在insert undo log中,delete和update会放在update undo log中,这样设计的原因是因为insert操作对其余事务是不可见的,事务提交后可以直接删除,delete和update操作会被用来提供mvcc机制,因此不能事务提交后就删除,需要等purge线程判断是否可以删除,防止影响其他事务。purge线程判断是否可以删除的条件就是是否有其他事务正在引用当前记录,也就是判断有没有比当前undo时间更早的read-view。
undo回滚段所占用的内存空间会随着未提交的事务数量而增长,而redo日志所占用的内存空间可以复用。复用的依据就是根据checkpoint,checkpoint表示已经刷到磁盘上的LSN, 已经持久化的redo都是可以被覆盖的。
上面的案例中,innodb引擎还要对非聚集索引的更新执行插入缓冲,更新操作进行double write,提升数据库性能保证数据页完整;事务提交后还要写redo日志,redo日志也拥有自己的缓冲区,之后还有内部XA(两阶段提交),会等待二进制日志(binlog)同步完成。具体的细节感兴趣的话大家可以自己去了解。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。