今天主要分享下Innodb事务系统的一些优化相关,以下基于mysql 5.7。
一、Innodb中的事务、视图、多版本
1. 事务
在Innodb中,每次开启一个事务时,都会为该session分配一个事务对象。而为了对全局所有的事务进行控制和协调,有一个全局对象trx_sys,对trx_sys相关成员的操作需要trx_sys->mutex锁。
mysql数据库遵循的是两段锁协议,将事务分成两个阶段,加锁阶段和解锁阶段(所以叫两段锁)
- 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
- 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。
2. 视图
Innodb使用一种称做ReadView(视图)的对象来判断事务的可见性(也就是ACID中的隔离性)。根据可见性原则,某个新开启的事务不应该看到其他未提交的事务。 Innodb在执行一个SELECT或者显式开启START TRANSACTION WITH CONSISTENT SNAPSHOT (后者只应用于REPEATABLE-READ隔离级别) 会创建一个视图对象。对于RR隔离级别,视图的生命周期到事务提交结束,对于RC隔离级别,则每条查询开始时重分配事务。
通常一个视图中包含创建视图的事务ID,以及在创建视图时活跃的事务ID数组。例如,当开启一个视图时,当前事务的事务ID为5, 事务链表上活跃事务ID为{2,5,6,9,12},那么就会把{2,6,9,12}存储到当前的视图中(5是当前事务的ID,不记录到视图中),{2,6,9,12}对应的事务所做的修改对当前事务而言都是不可见的,小于2的事务ID对当前事务都是可见的,大于12的事务ID对当前事务是不可见的。
那么如何判断可见性呢?
InnoDB表数据的组织方式为主键聚簇索引。由于采用索引组织表结构,记录的ROWID是可变的(索引页分裂的时候,Structure Modification Operation,SMO),因此二级索引中采用的是(索引键值, 主键键值)的组合来唯一确定一条记录。无论是聚簇索引,还是二级索引,其每条记录都包含了一个DELETED BIT位,用于标识该记录是否是删除记录。除此之外,聚簇索引记录还有两个系统列:DATA_TRX_ID,DATA_ROLL_PTR。DATA _TRX_ID表示产生当前记录项的事务ID;DATA _ROLL_PTR指向当前记录项的undo信息。
聚簇索引行结构(与多版本一致读有关的部分,DELETED BIT省略):
二级索引行结构:
从聚簇索引行结构,与二级索引行结构可以看出,聚簇索引中包含版本信息(事务号+回滚指针),二级索引不包含版本信息。
对于聚集索引,每次修改记录时,都会在记录中保存当前的事务ID,同时旧版本记录存储在UNDO中;对于二级索引,则在二级索引页中存储了更新当前页的最大事务ID,如果该事务ID大于readview->up_limit_id(对于上例,up_limit_id值为2),那么就需要回聚集索引判断记录可见性;如果小于2, 那么总是可见的,可以直接读取。
3. 多版本(MVCC)
为了便于理解MVCC的实现原理,这里简单介绍一下undo log的工作过程
在不考虑redo log 的情况下利用undo log工作的简化过程为:
说明:
- 为了保证数据的持久性数据要在事务提交之前持久化
- undo log的持久化必须在在数据持久化之前,这样才能保证系统崩溃时,可以用undo log来回滚事务
MVCC只在READ COMMITED 和 REPEATABLE READ 两个隔离级别下工作。READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE 则会对所有读取的行都加锁。
(1) SELECT
InnoDB 会根据两个条件来检查每行记录:
- InnoDB只查找版本(DB_TRX_ID)早于当前事务版本的数据行(行的系统版本号<=事务的系统版本号,这样可以确保数据行要么是在开始之前已经存在了,要么是事务自身插入或修改过的)
- 行的删除版本号(DB_ROLL_PTR)要么未定义(未更新过),要么大于当前事务版本号(在当前事务开始之后更新的)。这样可以确保事务读取到的行,在事务开始之前未被删除。
(2) INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号
(3) DELETE
InnoDB为删除的每一行保存当前的系统版本号作为行删除标识
(4) UPDATE
InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
Innodb的多版本数据使用UNDO来维护的,例如聚集索引记录(1) =>(2)=>(3),从1更新成2,再更新成3,就会产生两条undo记录。
二、Innodb事务系统优化
在MySQL 5.7版本里,针对性的对事务系统做了比较深入的优化,主要解决了下面几个问题。
1. 视图对象的创建需要trx_sys->mutex锁保护
trx_sys->mutex是事务系统最核心的全局锁对象,持有该锁进行的操作都不应该耗时过长。对于read view对象,完全可以将其缓存下来重复使用。这样就避免了持有锁分配视图内存。
因此在MySQL 5.7版本中,实例启动时就分配1024个视图对象;同时维护两个链表,一个是已使用的视图链表,一个是空闲的视图链表;当需要分配新的视图时,总是从空闲视图链表中分配,如果没有,再新分配一个。
2. 视图对象中保存全局事务ID时,需要扫描事务链表
为了判断事务视图的可见性,在打开一个视图时需要拷贝当时活跃的事务ID。
在5.7中,事务系统维持了一个全局事务ID数组,每个活跃读写事务的ID都被加入到其中,在事务提交时从其中删除,这样打开视图时只需要使用memcpy 拷贝该数组即可,无需遍历链表。在读写链表较长(高并发下)的场景,该优化可以显著的提升性能。
3. 用户需要显式开启只读事务,才会放入只读事务链表
mysql5.7将只读事务链表从其中彻底移除了,取而代之的是,所有事务都以只读模式打开。
例如如下事务序列:
- BEGIN;
- SELECT; //事务开始,不分配事务ID,不分配回滚段;
- UPDATE; //分配事务ID并插入全局事务数组和事务对象集合中,分配回滚段;
- COMMIT;
而对于BEGIN;SELECT;SELECT;COMMIT这样的序列,整个事务周期既不分配事务ID,也不分配回滚段。
4. 隐式锁转换为显式锁的开销
Innodb对于类似INSERT操作,采用的是隐式锁的方式,隐式锁不是锁,只是一种称呼而已,只有在需要的时候,才会转换为显式锁。例如如下:
- Session 1: BEING; INSERT INTO t1(pk, val) VALUES (1,2); //不创建锁对象
- Session 2: UPDATE t1 SET valval=val+1 WHERE pk=1; //创建两个锁对象,一个是为session1创建一个记录锁对象,另外一个是给自己创建一个等待类型的记录锁对象,然后session2加入锁等待队列。
在Session 2中为Session1创建锁对象的过程即是所谓的隐式锁向显式锁转换。 当session2扫描到session 1插入的记录时,发现session 1的事务依然活跃,就会进入转换逻辑。
在5.6版本中,其转换过程如下:
- 持有lock_sys->mutex
- 2持有trx_sys->mutex;根据事务ID,扫描读写事务链表,找到对应的事务对象;释放trx_sys->mutex;
- 创建显式锁对象
- 释放lock_sys->mutex
可以看到,在该操作的过程中,全程持有lock_sys->mutex,持有锁的原因是防止事务提交掉。当读写事务链表非常长时(例如高并发写入时),这种开销将是不可接受的。
在5.7版本中,上述逻辑则优化成:
(1) 持有trx_sys->mutex
- 根据事务ID找到对应的事务对象(直接查找trx_sys->rw_trx_set,其保存了trx_id和事务对象的映射关系,因此无需扫描读写事务链表)
- 增加事务对象引用计数(++trx->n_ref)
- 释放trx_sys->mutex
(2) 持有lock_sys->mutex;
- 创建显式锁对象;
- 释放lock_sys->mutex;
(3) 递减事务对象引用计数
在事务commit,释放记录锁前,会先判断引用记录数是否为0,如果不为0,表示正有其他事务为其转换显式锁,这时候需要等待,直到计数为0,才能进入释放事务记录锁阶段。
总的来说,该优化减少了隐式锁转换时持有LOCK_sys->mutex的时间,从而提升性能。