当我谈事务时,我谈些什么

1,890 阅读14分钟

题外话

笔者认为一般面试官可能会从两个维度考察面试者。当面试官针对一个问题,不断的追问直到你回答不上来或者面试官满意(一般不太可能[手动偷笑]),这是在考察面试者知识储备的深度。当面试官说:“谈谈你对xx的理解”,或者“能够讲一下xx?”这一类比较抽象的问题时,这是在考察面试者的知识储备的广度。本文就以事务为例,针对后一种情况跟大家讨论一下,一般遇到这种问题时该如何组织回答问题的思路。

Begin

假如有一天,某一个人问你,“谈谈你对mysql事务的理解”,“说一说mysql的事务”(放心,除了面试官别人是不会这么无聊的问你这样的问题的),你心里在想:“啥,这我能有啥理解?你不问清楚点,这我可咋回答”。别慌,就像当我们谈论到厚朴(笔者)的时候,我们首先想到这是一个帅小伙(嘿嘿嘿)。所以我们首先可以用通俗的语言简单的表述一下“别人”问的是个什么东东。

这里先说一声抱歉,请允许我使用Begin作为这个小标题,因为实在不知道该起一个什么样的标题,考虑到事务可以
通过Begin开启,那么就叫Begin吧。

事务是个啥子

简单的说,事务就是一个或多个对数据库的操作,并通过其特性对操作过程提供一系列的保障,以保证业务逻辑的准确执行。想想我们编写事务的时候,通过start transaction/begin开启事务,然后编写一系列的数据库操作语句,然后通过commit提交事务,只有事务中的操作全部执行成功时,整个事务才提交,其中有语句执行失败或者数据库发生错误,事务将自动回滚。这里只需要将你对事务的理解用自己的语言表述出来即可,重要的是我们对待这种问题,首先要讲清楚是什么!

前提

由于事务是在存储引擎层实现的,所以对于比较常用的存储引擎而言,如果我们要使用事务,那么就不能够使用MyISAM存储引擎,而应该使用Innodb存储引擎,实际上事务是Innodb存储引擎提供的核心功能之一。

事务的特性

前面提到事务通过其特性对一系列的数据库操作提供保障,这里我们就需要明确事务有哪些特性。事务共有四个特性,分别是

  1. 原子性(Atomicity)
  2. 一致性(Consistency)
  3. 隔离性(Isolation)
  4. 持久性(Durability)

这个也就是大家都知道的事务的ACID四个特性。

可以提供哪些保障

讲清楚了事务具有哪些特性,下一步就是该扯扯这些特性都是可以提供哪些保障?毕竟得消耗一下时间,以使面试官没有时间问一些难的问题。

  • 事务的原子性可以提供这一系列数据库操作的原子性保障,属于一个事务中的一系列数据库操作要么全部成功,要么全部失败,不存在一些语句执行成功了,另一些语句却执行失败了。
  • 一致性是指事务在执行前后需要始终符合现实世界中的逻辑规范,并在事务的执行前后始终处于一个一致的状态,这是我对一致性的理解。很多文章中解释一致性是用转账例子说明的,A给B转账一毛钱,那么A的账户应该减一毛钱,B的账户应该多一毛钱,但是二者之间的余额总和应该始终处于一个一致的状态,这符合现实世界中的规范。还有若干规范比如年龄不能为负数,性别不能是男和女之外的其他值等,一致性就是对现实世界中一套逻辑规范性的缩影。
  • 隔离性是指每个事务的操作不能影响其他事务的正常操作,对应到现实世界中就是两个状态转换操作不能彼此受到影响,这很容易理解,想象一下张三给朋友转账的时候,李四转账会受到影响这将是多么糟糕的体验。
  • 持久性是指事务一旦提交,对数据库中的记录或者数据所作出的修改将永久性的保存到磁盘,即使断电也不会受到任何的影响。

实现原理

看到这里,回过头去看看,这个问题也不是没啥可说的吧,实际上可以讲的东西还有很多,在提到了事务的特性以及这些特性提供了什么保障之后,接下来就该讲讲事务是如何做到这些保障的。

原子性

事务的原子性是在事务执行失败的时候能够恢复到事务执行之前的数据库状态的一种机制。要实现这种机制,就需要对数据库所作出的更改进行撤销,就好像我们发了一条消息,然后感觉不妥,又撤回了一样。事务的实现依赖于mysql的三大日志之一的Undo log,也叫撤销日志。Undo log如果展开细聊,恐怕又得需要几万字了,这里怕引起大家的生理不适,就不展开细谈了。总得来说Undo log是一种逻辑日志,所谓的逻辑日志是指的它记录的是对数据库的操作,而不是操作后的内存是什么样的。要想撤销我们对数据库的修改,只要在Undo log中记录相反的操作就可以了。例如,如果我们向数据库中插入一条记录,那么Undo log中记录的就是对应的删除这条语句。如果我们修改了数据库中的某行记录,那么Undo log中记录的就是对应的修改为之前数据的记录,如果我们删除了数据库中的某行记录,Undo log中记录的就是对应的添加这行记录的逻辑,实际上Undo log为了实现上述逻辑,分别实现不同类型的Undo日志。同时针对修改记录又会根据修改前后记录行占用的存储空间是否发生变化来决定是否可以重用之前的存储空间。讲这么多总结一下就是Undo log中记录的我们对数据库修改的相反的逻辑操作,同时记录的并不是内存数据,而是逻辑操作。

Undo log采用的是追加写的方式,当一个Undo log达到了其规定的最大值时,其会开启一个新的文件作为Undo log继续写。那么当事务失败的时候是如何通过Undo log回滚的呢?这个需要了解了mysql记录的行格式。mysql记录中有两个隐藏列分别是transanction_id和roll_pointer,此外,如果表中没有主键或者unique键,Innodb还会为其生成一个6字节的row_id作为主键。大概是这样的

记录行格式

其中只有当修改记录的时候,Innodb才会为其分配一个trx_id。而roll_pointer是一个指向对应的Undo log的指针,这样当事务出错时,我们就可以通过事务的id以及roll_pointer找到对应的Undo log从而恢复到事务执行之前的状态。

持久性

当事务提交的时候,根据持久性所做出的保障,这时我们对事务所做出的修改应该永久的反映到了磁盘,但是事实却并不是这样。Innodb是以页为单位与磁盘进行交互的,一个页的大小默认是16KB,有时候我们修改了一条记录,可能只是占用一页非常小的空间,如果我们每对数据库中的记录做出修改都要将其持久化到磁盘,那么修改一点点就要向磁盘写入16KB的数据,而磁盘IO是十分慢的,这样可受不了。于是设计Mysql的大叔设计了一个称为Buffer pool的缓冲池,mysql读取数据时首先去Buffer pool中查找,如果找到,那么从内存中读取指定的数据,如果指定条件的记录不在Buffer pool中,那么需要先从磁盘加载指定的页面到Buffer pool中然后进行相应的操作。而写入数据的时候同样也是先写入到Buffer pool,这些写入的页面中的数据已经也磁盘中的页面数据不一致了,我们称之为脏页(Dirty Page)。这时通过将这些脏页使用链表链接起来。然后在后续合适的时间将修改后的脏页刷新到磁盘(实际上操作系统还有一个缓冲区,是先刷新到操作系统的缓冲区,然后操作系统再将缓冲区中的数据刷新到磁盘)。

这里就出现问题了,如果事务提交成功之后,修改的脏页还没来得及刷盘,结果系统断电了怎么办。这时就需要Mysql的三大日志中的另一个主角登场了,那就是redo log。也就是说事务的持久性是通过redo log实现的。redo log是物理日志,他记录的并不是修改语句,而是对内存中的那个页面的哪个偏移量处修改为了什么,所以它记录的是内存中真实的数据。同样这里也不打算展开介绍redo log,因为详细讲的话面试官的心脏病可能会发作哈哈。

那么当事务提交后,redo log是如何崩溃恢复,从而实现持久性的呢?答案就是在我们对mysql中的记录进行修改(插入,修改,删除)时,会先记录相应的redo log,然后将redo log刷新到磁盘,redo log记录的内容就很少了,而且相对于对记录的修改可能很多情况下是随机IO,而redo log是顺序IO,这就快了许多。这样即便事务提交后,数据库挂了,那么重启后只需要使用redo log重放一下就可以了,是不是棒棒的!

由于当修改后的脏页刷新到磁盘后,对应的记录便永久的修改到磁盘,这样对应的redo log便没什么用了,所以redo log是可以覆盖,当redo log写满之后,就会从头开始继续的复用。这就有点扯远了。总之,事务的持久化是通过redo log实现的。

隔离性

隔离性应该是事务实现过程中原理最为复杂的了,如果详细了解一下,你就会发现Mysql在隔离性上下了很大的功夫,因为并发问题始终是一个比较棘手的问题,事务要实现隔离性,就绕不开如何使mysql高效的并发。

mysql共有四种隔离级别,分别是未提交读、提交读、可重复读、序列化读四种隔离级别。相信这些大家已经相当了解了,包括四种隔离级别下会出现哪些问题?下面以表格的形式给大家再温习一下,知道各位大佬都已经知道。

隔离级别脏读不可重复读幻读
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDNot PossiblePossiblePossible
REPEATABLE READNot PossibleNot PossiblePossible
SERIALIZABLENot PossibleNot PossibleNot Possible

那么Mysql是如何实现不同的隔离级别的呢?这个就要提一提我们之前说过的记录行格式中的roll_pointer隐藏列。我们说roll_pointer是一个指向对应的undo log的指针,那么每个undo log中又存在一个old_roll_pointer指向更早修改的历史记录,这样所有的历史修改都以链表的形式串联起来。就像这样(图片来自网络),我们依次修改name为刘备、关羽、张飞、赵云、诸葛亮。这样对于我们的改动,通过roll_pointer串联起来,形成了一个版本链的形式。

记录版本链

对于未提交读,很明显每次只要读取最新修改的记录就好了,这就不可避免的出现脏读的现象了,别的事务还没改好呢,你就读了,肯定是想吃热豆腐了!

而对于序列化读,Innodb是通过加锁实现的,加锁也就意味着不存在并发共享的问题了,这是一种简单粗暴的解决方法。

而对于提交读和可重复读就有点麻烦了,但是这些都难不倒设计mysql的大叔,于是大名鼎鼎的多版本并发控制(MVCC)就出世了。

简单来说,提交读和可重复读是通过生成一个ReadView的东东来实现的。ReadView可以简单的理解为拍了一个快照,就是那一时刻mysql记录的状态情况。通过这个Readview,事务可以决定该条记录的版本链上的哪些记录是可见的,哪些是不可见的。ReadView通过维护一个ReadView生成时刻的活跃事务id列表实现,如果对应的历史版本中的事务id小于活跃的最小id,那么说明这个事务在生成Readview时已经提交过了,当然可以读,如果对应的历史版本中的事务id大于活跃的最大事务id,那么说明该版本的事务是在当前事务开启之后才开启的,自然不能读取,如果在活跃列表中,那么需要另行判断,版本链中对应的记录的事务id是否在活跃列表中,如果在说明事务还未提交,否则说明事务已经提交了。

而提交读与可重复读的最大区别就是生成Readview的时机不同,提交读在每次执行语句之前都生成一个ReadView,这样当前事务就可以读取到其他事务提交后的最新值,从而存在不可重复读的问题。而可重复读是在事务一开始时生成ReadView,后面继续沿用当前的ReadView,这样事务可以看到的历史版本在事务一开启时就确定了,从而避免了不可重复读的问题。

一致性

一致性的实现比较特殊,原子性与隔离性是实现一致性的基础,但并不是说实现了原子性和隔离性,就一定可以保证一致性,也不是说,一致性没有实现,就一定是原子性与隔离性的问题。事实上,mysql在DDL语句中有一些一致性操作检查,比如check,但是实际上你即便使用了check也并没有起到什么检查作用。一致性的实现主要靠以下三个方面:

  1. mysql在ddl语句中做了一定的一致性检查,比如数据类型,长度,非空与否等。
  2. 依靠mysql的存储过程实现,但是存储过程一般不常用。
  3. 将一致性检查交给编写业务逻辑的程序员实现。

实际上最主要就是靠我们程序员自己编写业务检查代码实现。

总结

当我们被问到谈谈Innodb的事务机制时,我们可以从以下角度考虑。

  1. 首先用自己的理解说出事务是什么东东,但是不需要背定义,只要根据自己的理解把意思传递给别人就可以了。
  2. 说一下事务具备哪些特征。
  3. 这些特征可以提供哪些保障
  4. Innodb为了实现这些保障是如何做到的?背后的原理是什么?

相信把这些说完之后,也需要占用不少时间了,“那个人”还着急下班呢,就不会再问你关于事务的问题了!