MySql(四) InnoDB事务浅析

在写上一篇MySql锁机制的时候就一直在想关于InnoDb事务的问题,一直拖到了现在才写这篇博客。一方面是时间问题,另一方面是事务系统实在是太复杂了,查阅了很多资料梳理了很久,有很多零碎生涩的概念。文中有些地方只是粗略的带过,讲得不清楚或者是错误的希望大家包容并指出😂

事务的四个条件

事务满足的4个条件(ACID):原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)

  1. 原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态。
  2. 一致性:指的是在任何时刻,包括数据库正常提供服务的时候,数据库从异常中恢复过来的时候,数据都是一致的,保证不会读到中间状态的数据。
  3. 隔离性:允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
  4. 持久性:指的是事务commit的数据在任何情况下都不能丢。

实现:InnoDB通过undolog保证rollback的时候能找到之前的数据保证了原子性;通过crash recovery和double write buffer的机制保证数据的一致性。通过redolog保证持久性。隔离性则由锁和mvcc保证。

重要结构体的概念

  • undo segments:回滚段(数据页的修改链),链表最前面的是最老的一次修改,最后面的最新的一次修改,从链表尾部逆向操作可以恢复到数据最老的版本。与之相关的还有undo tablespace, undo segment, undo slot, undo log这几个概念。undo log是最小的粒度,所在的数据页称为undo page,然后若干个undo page构成一个undo slot。一个事务最多可以有两个undo slot,一个是insert undo slot, 用来存储insert undo log,里面主要记录了主键的信息,方便在回滚的时候快速找到这一行。另外一个是update undo slot,用来存储这个事务delete/update产生的undo log,里面详细记录了被修改之前每一列的信息,便于在读请求需要的时候构造。1024个undo slot构成了一个undo segment。然后若干个undo segemnt构成了undo tablespace。
  • history list:insert undo可以在事务提交/回滚后直接删除,没有事务会要求查询新插入数据的历史版本,但是update undo则不可以,因为其他读请求可能需要使用update undo构建之前的历史版本。因此,在事务提交的时候,会把update undo加入到一个全局链表(history list)中,链表按照事务提交的顺序排序,保证最先提交的事务的update undo在前面,这样Purge线程就可以从最老的事务开始做清理。
  • trx_t:每个连接持有一个,在创建连接后执行第一个事务开始被初始化,后续这个连接的所有事务一直复用里面的数据结构,直到这个连接断开。事务启动后会把这个结构体加入到全局事务链表中(mysql_trx_list),如果是读写事务,还会加入到全局读写事务链表中(rw_trx_list)。在事务提交的时候加入到全局提交事务链表中(trx_serial_list)。
    • state字段记录了事务四种状态:TRX_STATE_NOT_STARTED, TRX_STATE_ACTIVE, TRX_STATE_PREPARED, TRX_STATE_COMMITTED_IN_MEMORY
    •  id字段是在事务刚创建的时候分配的(只读事务永远为0,读写事务通过一个全局id产生器产生),目的就是为了区分不同的事务(只读事务通过指针地址来区分)。
    • 而no字段是在事务提交前,通过同一个全局id生产器产生的,主要是为了确定事务提交的顺序,保证加入到history list中的update undo有序,方便purge线程清理。 
    • read_view(视图)用来表示当前事务的可见范围(视图)。
    • insert undo slot和update undo slot。
    • read_only表示是否是只读事务。
  • trx_sys_t:用来维护系统的事务信息,全局唯一,在数据库启动的时候初始化。
    • max_trx_id,表示系统当前还未分配的最小事务id,如果有一个新的事务,直接把这个值作为新事务的id,然后这个字段递增。
    • descriptors,这个是一个数组,里面存放着当前所有活跃的读写事务id,当需要开启一个readview的时候,就从这个字段里面拷贝一份,用来判断记录的对事务的可见性。
    • rw_trx_list,这个主要是用来存放当前系统的所有读写事务,按照事务id排序。
    • mysql_trx_list,这里面存放所有用户创建的事务,系统的事务和奔溃恢复后的事务不会在这个链表上,但是这个链表上可能会有还没开始的用户事务。
    • trx_serial_list,按照事务no排序的已经提交的事务。
    • rseg_array,这个指向系统所有可以用的回滚段(undo segments),当某个事务需要回滚段的时候,就从这里分配。
    • rseg_history_len, 所有提交事务的update undo的长度,也就是上文提到的历史链表的长度。
    • view_list,这个是系统当前所有的readview, 所有开启的readview的事务都会把自己的readview放在这个上面,按照事务no排序。
  • read_view_t:InnoDB为了判断某条记录是否对当前事务可见,需要对此记录进行可见性判断,这个结构体就是用来辅助判断的
    • low_limit_no,这个主要是给purge线程用,readview创建的时候,会把当前最小的提交事务id赋值给low_limit_no,这样Purge线程就可以把所有已经提交的事务的undo日志给删除。
    • low_limit_id, 创建readview时的max_trx_id,即一定大于descriptors中的最大值。所有大于等于此值的记录都不应该被此readview看到。
    • up_limit_id, 是descriptors中最小的值,所有小于此值的记录都可以被readview看到
    • descriptors, 里面存了readview创建时候当前所有活跃的读写事务id,除了事务自己做的变更外,此readview应该看不到descriptors中事务所做的变更。
    • view_list,每个readview都会被加入到trx_sys中的全局readview链表中。
  • trx_rseg_t:undo segment内存中的结构体。每个undo segment都对应一个。
    • update_undo_list表示已经被分配出去的正在使用的update undo链表,
    • insert_undo_list表示已经被分配出去的正在使用的insert undo链表。
    • update_undo_cached和insert_undo_cached表示为了快速使用而缓存起来的undo链表。

事务开启

InnoDB 提供了多种方式来开启一个事务,所有显式开启事务的行为都会隐式的将上一条事务提交掉。

  • BEGIN、BEGIN WORK、START TRANSACTION:执行BEGIN命令并不会真的去引擎层(InnoDB)开启一个事务,仅仅是为当前线程设定标记,表示为显式开启的事务。
  • START TRANSACTION READ ONLY:为当前线程的thd->tx_read_only设置为true。当Server层接受到任何数据更改的SQL时,都会直接拒绝请求,返回错误不会进入引擎层。
  • START TRANSACTION READ WRITE:允许super用户在read_only参数为true的情况下启动读写事务。
  • START TRANSACTION WITH CONSISTENT SNAPSHOT:这种启动方式会进入引擎层层,并开启一个readview。只有在RR隔离级别下,这种操作才有效,否则会报错。

除了with consistent snapshot的方式会进入InnoDB层,其他所有的方式都只是在Server层做个标记,没有进入InnoDB做标记,在InnoDB看来所有的事务在启动时候都是只读状态,只有接受到修改数据的SQL后才把只读事务提升为读写事务。
读写事务需要分配事务id,分配回滚段,加入到全
局读写事务链表(
rw_trx_list
),把事务id加入到活跃读写事务数组中(
descriptors
)

实例分析1(RR级别)

在book表中有一条记录id:1,name:book1;按顺序执行下列语句
事务1:BEGIN;(1)
SELECT * FROM book_book WHERE id = 1;(5)
COMMIT;(6)
事务2:BEGIN;(2)
UPDATE book_book SET name = 'book2' WHERE id = 1;(3)
COMMIT;(4)复制代码

分析:结果(5)查出来的name为book2,明明事务1比事务2先,为什么事务1读到了2中提交的内容?这实际上涉及到了readview一致性读的问题,在RR级别下事务1开始时并不会去给事务系统trx_sys打快照生成readview,而是在第一条SQL语句执行时生成的readview。这时事务2已经提交,事务2id在readview中up-low之间且descriptors中不存在事务2id,所以事务1能读到事务2修改的数据。如果事务1是用with consistent snapshot方式开启事务那么便不能读到事务2修改的数据,因为此时readview中的low_limit_id等于事务2id。此外在RC级别下每次执行SQL都会生成readview。

这里讲到了一致性读(又称快照读)即普通select,对于加锁的select和delete、update、insert成为当前读。

Undo log

undo log作用:提供回滚和MVCC。在数据修改的时候,记录了相对应的undo,如果事务失败或回滚了,可以借助该undo进行回滚。 undo log是逻辑日志,记录更改前的镜像。当需要当前读的时候,它可以从undo log中分析出该行记录以前的数据提供版本信息。 另外undo log也会产生redo log,因为undo log也要实现持久性保护。

事务提交

  1.  使用全局事务id产生器生成事务no,然后把事务trx_t加入到trx_serial_list。
  2. 标记undo,如果这个事务只使用了一个undopage且使用量小于四分之三个page,则把这个page标记为(TRX_UNDO_CACHED)。如果不满足且是insert undo则标记为TRX_UNDO_TO_FREE,否则undo为update undo则标记为TRX_UNDO_TO_PURGE。标记为TRX_UNDO_CACHED的undo会被回收,方便下次重新利用。 
  3. 把update undo放入所在undo segment的history list,并递增rseg_history_len(全局)。同时更新page上的TRX_UNDO_TRX_NO, 如果删除了数据,则重置delete_mark。
  4.  把undate undo从update_undo_list中删除,如果被标记为TRX_UNDO_CACHED,则加入到update_undo_cached队列中。
  5.  mtr_commit(日志undo/redo写入公共缓冲区),至此,在文件层次事务提交。这个时候即使crash,重启后依然能保证事务是被提交的。接下来要做的是内存数据状态的更新(trx_commit_in_memory)。 
  6. 只读事务只需要把readview从全局readview链表中移除,然后重置trx_t结构体里面的信息即可。读写事务首先需要是设置事务状态为TRX_STATE_COMMITTED_IN_MEMORY,其次,释放所有行锁,接着,trx_t从rw_trx_list中移除,readview从全局readview链表中移除,另外如果有insert undo则在这里移除(update undo在事务提交前就被移除,主要是为了保证添加到history list的顺序),如果有update undo,则唤醒Purge线程进行垃圾清理,最后重置trx_t里的信息,便于下一个事务使用。

事务回滚

  1. 如果是只读事务,则直接返回。
  2. 判断当前是回滚整个事务还是部分事务,如果是部分事务,则记录下需要保留多少个undolog,多余的都回滚掉。
  3. 从update undo和insert undo中找出最后一条undo,从这条undo开始回滚。
  4. 如果是update undo则将标记为删除的记录清理标记,更新过的数据回滚到最老的版本。如果是insert undo则直接删除聚集索引和二级索引。
  5. 如果所有undo都已经被回滚或者回滚到了指定的undo则停止,并把undolog删除(由于不需要使用undo构建历史版本)。

实例分析2

在book表中有一条记录id:1,name:book1;按顺序执行下列语句
事务一:BEGIN;(1)
SELECT * FROM book_book WHERE id = 1;(3)
SELECT * FROM book_book WHERE id = 1;(6)
UPDATE book_book SET `name` = 'book3' WHERE id = 1 AND `name` = 'book2';(7)
SELECT * FROM book_book WHERE id = 1;(8)
COMMIT;(9)
事务二:BEGIN;(2)
UPDATE book_book SET `name` = 'book2' WHERE id = 1;(4)
COMMIT;(5)复制代码

分析:3、6查出来name为’book1’,7更新成功,8查询name为’book3’。事务一中3、6查询都是name为’book1’,为什么7能更新成功呢?这是因为在3时生成了read view对事务二是不可见的,6还是快照读依旧对事务二中的修改不可见,7是当前读会去通过history list中的undolog构建历史版本,从而看到事务二修改的name为book2,8还是快照读对当前事务可见所以查询结果name为book3;

事务提交流程简要分析

(1)BEGIN;
(2)UPDATE book_book SET `name` = 'book2' WHERE id = 1; undolog、redolog
(3)INSERT INTO `book_book` (`id`, `name`) VALUES ('2', 'JAVA面向对象编程'); undolog、redolog
(COMMIT)
(4)undo log buffer中undolog写入磁盘
(5)redo log buffer中redolog写入磁盘
(6)dbbuffer中数据写入磁盘复制代码

关于redolog(物理日志记录数据页的变化,保证事务的持久性用来在crash后恢复事务)和binlog(逻辑日志记录数据或者sql)等日志有兴趣的同学可以深入了解

MySQL系列其他文章:

MySql(一) 浅析MySql索引

MySQL(二) MySql常用优化

MySql(三) MySql中的锁机制

参考文章:https://dev.mysql.com/doc/refman/5.7/en/innodb-undo-logs.html
https://www.aliyun.com/jiaocheng/1107450.html?spm=5176.100033.2.5.31613561FIMCB3
http://mysql.taobao.org/monthly/2017/12/01/?spm=a2c4e.11153940.blogcont560506.19.4b32720fTYF6D3
复制代码

    原文作者:MySql
    原文地址: https://juejin.im/post/5c175babf265da616a4790c9
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞