text: 一文读懂SQL事物机制
  事务是指一个或者多个数据库操作，要么全部没有执行，要么全部成功执行。中途失败需要回滚到指定状态，全部执行成功需要确保持久保存在数据库中。事务拥有四个特性，习惯上被称之为ACID特性。为了更直观的解释ACID特性，下面先说明A, B, C之间互相转账的过程。假设A有10元，B有15元，C有8元A给B转账5元，操作记为T1。T1操作的大体流程，先读取A到账户余额，将A的账户余额扣减5元后再写入数据库中，读取B的账户余额，将B的账户余额增加5元后再写入到数据库中。同时，C给B转账4元，操作记为T2。T1操作的大体流程，先读取C的账户余额，将C的账户余额扣减4元后再写入数据库中，读取B的账户余额，将B的账户余额增加4元后再写入到数据库中。事务作为一个整体被执行，包含在其中的对数据库的操作要么全部被执行，要么都不执行，是一个最小执行单元，不可分割。A给B转账5元的操作T1是包含多个读写操作，这些操作要么全部执行，要么全部不执行。假设由于断电等意外事件，导致T1只执行了部分操作，如这就会导致A凭空少了5元，并且B没有收到A转的5元，因此事务需要保证保证在事务执行过程中出现错误时，将已经执行的操作“撤销”，恢复到原始状态。事务应确保数据库的状态从一个一致状态转变为另一个一致状态。假设A有10元，B有15元，C有8元，不管A, B, C之间如何进行转账(没有其他人参与)，三个点账户总余额一定是33元，而不会是其它值。多个事务并发执行时，一个事务的执行不应影响其他事务的执行。A给B转账5元，同时C给B转账4元，这个两个事务应该是互相隔离的，互不影响。最终A余额为5元，C余额为4元，B总共收到两次转账，余额应该为24元。假设T1, T2可以交叉执行，如下图所示。最终结果看起来B只收到了A的5元转账，余额为20元。已被提交的事务对数据库的修改应该永久保存在数据库中。MySQL操作一般是先写入缓存，满足指定条件后才将缓存更新到磁盘磁盘写操作相当耗时，而且同一个事务可能修改多个数据页面，而且可能执行页面中一个字节的数据。因此每次数据库提交执行缓存刷新磁盘操作不太合理，MySQL设计人员通过redo日志来持久化最小量的数据来达到相同的效果。为了保证事务的持久性，在事务提交动作完成之前，需要把该事务修改所有页面都刷新到磁盘，但是存在以下问题随机I/O比较慢，一个事务可能修改到多个页面，这些页面在磁盘中可能不相邻，可能需要多次长距离移动磁盘读写磁头。刷新完整的数据页面较慢，一个事务也可能值修改页面中一个字节，却要同步整个页面到磁盘上。为了解决上述到问题，InnoDB设计了redo日志，把事务修改的内容采用特定的格式按顺序保存到磁盘上，即使在系统崩溃之后，按照redo日志重新更改数据页进行数据恢复即可。redo日志通用格式如下图所示type，redo日志类型，通过定义不同类型可以达到节省空间的目的space id，表空间idpage number，页号data，redo日志具体内容往表中插入一条记录，可能产生多条redo日志，因为可能产生聚簇索引对应B+树页面的分裂操作，可能需要性申请数据页，金额可能要修改各种段、区的统计信息等，最终插入一条记录可能产生多条redo日志，这些日志是不可分割的，在崩溃恢复时，也是将这一组日志作为不可分割的整体来处理，类似的，将一组不可分割的redo日志称为Mini-Transaction，即MTR为了避免频繁的磁盘IO，并不是每生成一条redo日志就同步到磁盘上。而是先将redo日志放到缓冲区，在特定时机刷新到磁盘。redo日志缓冲区页面结构如下图所示。redo日志是以MTR为单位写入到redo日志缓冲区的，redo日志缓冲区是有若干个512B大小的block构成的一片连续的内存空间，InnoDB引擎使用lsn(log sequence number)来记录系统当前有多少redo日志写入到缓冲区InnoDB会将lsn相关信息写入到flush链表中，进而方便判断哪些redo日志文件可以被重复使用，因为只要脏页被刷新到磁盘，相应的redo日志内容就没有存在的意义了，而且redo日志文件大小也有限。Buffer Pool中页面会在控制块中记录页面的修改信息oldest_modification: 第一次修改Buffer Pool中某个页面时，将MTR开始时的lsn写入该变量newest_modification: 每次修改Buffer Pool中某个页面时，将MTR结束时的lsn写入该变量flush链表与oldest_modification(o_m)和newest_modification(n_m)的关系如下图所示。flush链表的基节点start指针出发，flush链表的脏页时按照第一次修改发生的时间倒序排列的，也就是按照oldest_modification代表的lsn值倒叙排列，当页面被多次更新时，会更新对应页面的newest_modification变量的值。当页面1被属性到磁盘，从页面2的控制块可以看出，lsn低于8916的redo日志可以被覆盖，系统会将8916赋值到redo文件的checkpoint_lsn的操作。InnoDB将检查flush链表最小的oldest_modification的lsn值称为checkpoint操作。磁盘上存在多个redo日志文件，会被循环使用，这一组redo日志文件称为redo日志文件组。和redo日志缓冲区一样，redo日志文件也是由若干个512B构成的block组成其中，redo日志文件的头2048个字节用于存储一些管理信息，系统会将checkpoint操作得到的checkpoint_lsn赋值到checkpoint1的checkpoint_lsn上。崩溃恢复会从checkpoint_lsn在日志文件组中对应的偏移量开始。除了前面阐述的checkpoint，redo日志刷盘时机还包括redo日志缓存不足时事务提交时后台线程周期性刷盘正常关闭服务器当遇到异常情况导致服务器挂掉，在重启时可以根据redo日志文件恢复到奔溃前的状态。InnoDB从redo日志文件组的第一个文件的checkpoint信息，然后从checkpoint_lsn在日志文件组中对应的偏移量开始，一直扫描日志文件中的，直到某个block的写入量的值不等于512，根据redo日志格式将修改的内容恢复到奔溃前状态。在事务执行过程中可能遇到各种错误，导致中途就结束事务了，但是在遇到错误退出前，可能修改多个行记录，但是为了保证事务的原子性，需要将数据恢复到事务开启前，这个恢复过程就称为回滚。为了回滚，就需要将事务修改的内容记录下来，包括插入的行记录、修改行记录的内容、删除的行记录，保存事务执行过程中修改内容的东西称为undo日志。InnoDB会将聚簇索引行结构如下图所示InnoDB会将聚簇索引行结构补充trx_id和roll_pointer两个隐藏列trx_id: 一个事务某次对某条聚簇索引记录进行改动时，都会把该事务的事务ID赋值给trx_id，事务ID是单调递增的roll_pointer: 每次对某条聚簇索引进行改动时，都会把旧版本写入到undo日志中，并以聚簇索引为起点构成一个从最新到最旧的单向链表结构，这个链表就称为版本链roll_pointer结构如下图所示is_insert, 表示该指针指向的undo日志是否是TRX_UNDO_INSERT大类的undo日志resg id, 表示该指针指向的undo日志的回滚段编号page number, 表示该指针指向的undo日志所在页面的页号offset, 表示该指针指向的undo日志在页面中的偏移量如果需要回滚插入操作，只需要将插入的记录删除即可，因此在记录undo日志时，只需要记录插入的记录的主键信息即可，通过主键能找到唯一的记录插入操作的对应的undo日志类型为TRX_UNDO_INSERT_REC，结构如下图所示undo type, 即TRX_UNDO_INSERT_RECundo no, 事务执行过程中，每生成一条undo日志，undo no就增加1，且从0开始table id, 该undo日志对应的记录所在表的table id在事务中执行删除操作，会将记录的deleted_flag标识为值为1，但该记录依然在正常记录链表，并没有移动到垃圾记录链表，这个过程称为delete mark。在事务提交后，才把该记录从正常记录链表挪到垃圾记录链表删除操作产生TRX_UNDO_DEL_MARK_REC类型的日志，结构如下图所示，在对一条记录进行delete mark操作前，将该记录的trx_id和roll_pointer的旧值保存到undo日志的trx_id和roll_pointer变量中。假设一个事务对某条记录先更新再删除，这样就能通过TRX_UNDO_DEL_MARK_REC找到更新的undo日志。更新操作的场景较复杂，InnoDB将其分为更新主键和不更新主键两种场景。不更新主键更新的每个列在更新前后占用的存储空间不变，则可以进行就地更新更新前后占用存储空间有变，需要将旧记录从聚簇索引删除，再创建一条新的记录更新主键，旧记录执行delete mark操作，创建新纪录插入聚簇索引针对以上各种情况，InnnoDB设计了对应的undo日志格式，限于篇幅这里就不展开说明。和InnoDB普通页面结构类型，undo日志页面结构及页面链表如下图所示。一个undo日志页面只能存储一种类型，InnoDB将undo日志分为两大类，TRX_UNDO_INSERT，由insert语句产生undo日志，或者update语句更新主键也会产生该类型的undo日志TRX_UNDO_UPDATE，除了TRX_UNDO_INSERT类型的undo日志，其它类型的undo日志都属于这个大类InnoDB对临时表和普通表产生的undo日志分开记录，因此一个事务最多可能需要4个undo页面链表。同一个时刻，可能存在多个事务在执行，为了更好的管理undo页面链表，InnoDB设计了Rollback Segment Header页面用于存放各个Undo页面链表的第一个undo页面的页号，即undo slot。在奔溃恢复时，需要将未提交事务的修改回滚掉，通过undo slot找到undo页面链表，通过判断undo页面链表的Undo Log SegmentHeader的TRX_UNDO_STATE属性值，如果为TRX_UNDO_ACTIVE，则进一步通过undo链表最后一个页面的Undo Log Header中找到该事务对应的事务ID，然后通过undo日志内容将该事务修改的内容全部撤销，从而保证事务的原子性。脏写(Dirty Write)一个事务修改了另外一个未提交事务修改过的数据。脏读(Dirty Read)一个事务读取了另外一个未提交事务修改过的数据。不可重复读(Non-repeatable Read)一个事务修改了另一个未提交事务读取的数据。幻读(Phantom)一个事务根据某些搜索条件查询出一些记录，在该事务未提交时，另一个事务写入了一些符合搜索条件的记录，再次以相同条件查询，前后两次结果不一致。SQL标准中定义了四种隔离级别读未提交(Read Uncommitted), 读已提交(Read Committed), 可重复读(Repeatable Read), 串行化(Serializable)。不同隔离级别对应的可能和不可能发生的一致性问题如下图所示。其中脏写问题是不允许发生的。InnnoDB存储引擎的聚簇索引的版本链如下图所示假设在某个时刻，事务321、315、301对某条记录进行Updata操作后，形成的版本链如下图所示。其中事务301对这条记录更新了两次Multi-Version Concurrency Control, 即多版本并发控制，多版本并发控制机制利用聚簇索引的版本链来控制并发事务访问相同记录时的行为，从而解决脏读和不可重复读的不一致性问题。ReadView，即一致性视图，通过这个视图可以判断版本链的某个版本是否可被当前事务访问，中ReadView包含以下4个比较重要的数据creator_trx_id，生成该ReadView的事务对应的事务IDm_ids，在生成ReadView时当前系统中活跃的事务ID列表min_trx_id，m_ids的最小值max_trx_id，生成ReadView时，系统的下一个事务ID值。判断是否可见的规则如果被访问版本的trx_id值与ReadView中的creator_trx_id值相同，说明是当前事务在访问自己修改过的记录，该版本可以被当前事务访问如果被访问版本的trx_id值小于ReadView中min_trx_id值，说明该版本在当前事务生成ReadView前已经提交，因此该版本可以被访问如果被访问版本的trx_id值不小于ReadView中的max_trx_id值，说明该版本的事务在当前事务生成ReadView后启动，因此该版本不可访问如果被访问版本的trx_id值在ReadView的min_trx_id和max_trx_id之间如果trx_id在m_ids列表中，说明ReadView生成时该版本的事务依然活跃，因此该版本不可访问如果trx_id不在在m_ids列表中，说明ReadView生成时该版本的事务已经提交，因此该版本可以被访问顺着版本链重复上述操作，直到找到可以访问的版本，或者到达版本链末尾。如果版本链最后一个版本依然不可见，则查询结果为记录不存在在读已提交和可重复读隔离级别下，ReadView生成的时机有所不同，读已提交在每一次进行普通Select操作前都会生成一个ReadView，确保了读取到都是已提交事务的数据可重复读只在第一次进行普通Select操作前生产一个ReadView, 之后查询操作都重复使用这个ReadView，保证了同一个事务内不同时间读到相同数据