数据库内核月报 - 2014 / 12

MySQL · 性能优化 · 5.7 Innodb事务系统

背景知识

为了便于理解下文,我们先简单梳理下Innodb中的事务、视图、多版本的相关背景知识。

在Innodb中,每次开启一个事务时,都会为该session分配一个事务对象。而为了对全局所有的事务进行控制和协调,有一个全局对象trx_sys,对trx_sys相关成员的操作需要trx_sys->mutex锁。

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对当前事务是不可见的。

那么如何判断可见性呢? 对于聚集索引,每次修改记录时,都会在记录中保存当前的事务ID,同时旧版本记录存储在UNDO中;对于二级索引,则在二级索引页中存储了更新当前页的最大事务ID,如果该事务ID大于readview->up_limit_id(对于上例,up_limit_id值为2),那么就需要回聚集索引判断记录可见性;如果小于2, 那么总是可见的,可以直接读取。

Innodb的多版本数据使用UNDO来维护的,例如聚集索引记录(1) =>(2)=>(3),从1更新成2,再更新成3,就会产生两条undo记录。当然这不是本文讨论的重点。后续在单独针对临时表的优化时会谈及undo相关的知识。

Innodb事务系统优化

在MySQL 5.7版本里,针对性的对事务系统做了比较深入的优化,主要解决了下面几个问题。

问题一:视图对象的创建需要trx_sys->mutex锁保护

trx_sys->mutex是事务系统最核心的全局锁对象,持有该锁进行的操作都不应该耗时过长。对于read view对象,完全可以将其缓存下来重复使用。这样就避免了持有锁分配视图内存。

因此在MySQL 5.7版本中,实例启动时就分配1024个视图对象;同时维护两个链表,一个是已使用的视图链表,一个是空闲的视图链表;当需要分配新的视图时,总是从空闲视图链表中分配,如果没有,再新分配一个。

在Percona Server中也做了类似的优化,但与5.7不同的是,其不集中管理所有的视图,而是每个事务对象(trx_t)上都挂载一个预分配的视图对象,在事务对象销毁时释放(事务对象本身对session而言也是重用的)。

问题二:视图对象中保存全局事务ID时,需要扫描事务链表

正如上面描述的,为了判断事务视图的可见性,在打开一个视图时需要拷贝当时活跃的事务ID。在5.5及之前版本需要遍历所有的活跃事务,而在5.6中,将事务链表拆分成了只读事务链表,和读写事务链表,这样我们只需要遍历读写事务链表,拷贝事务ID即可。

在5.7中,事务系统维持了一个全局事务ID数组,每个活跃读写事务的ID都被加入到其中,在事务提交时从其中删除,这样打开视图时只需要使用memcpy 拷贝该数组即可,无需遍历链表。在读写链表较长(高并发下)的场景,该优化可以显著的提升性能。不过就该优化点而言,Percona Serve同样走在了前面,相同的思路实现在Percona Server 5.6中。

问题三: 用户需要显式开启只读事务,才会放入只读事务链表

尽量在5.6中已经将事务链表拆分成了只读事务链表和读写事务链表(AUTOCOMMIT的SELECT不加入任何链表),但用户需要显式的指定事务以只读模式打开(START TRANSACTION READ ONLY)或者设置SESSION变量tx_read_only。

显然这种方式对用户而言是极不友好的,因此在5.7中做了比较彻底的改变,将只读事务链表从其中彻底移除了,取而代之的是,所有事务都以只读模式打开。

例如如下事务序列:

BEGIN;

SELECT; //事务开始,不分配事务ID,不分配回滚段;

UPDATE; //分配事务ID并插入全局事务数组和事务对象集合中,分配回滚段;

COMMIT;

而对于BEGIN;SELECT;SELECT;COMMIT这样的序列,整个事务周期既不分配事务ID,也不分配回滚段。

那么问题来了,既然只读的事务不分配事务ID,那么如何标示事务呢,在5.7中,使用事务对象的地址来进行计算得到一个唯一的事务ID。执行’SHOW ENGINE INNODB STATUS’不再显示活跃的只读事务,只能通过INNODB_TRX表来查询。这是一个需要注意的点,因为很多人都是通过前者来找到长时间未提交的事务。

另外一个比较有意思的小优化是,对于AUTOCOMMIT的只读查询,关闭视图时,并不是立刻从视图链表中移除,而是设置一个简单的close标记;该session下次需要打开该read view时,如果这期间没有任何读写事务,就可以直接重用上次的read view,清楚close标记,这样打开、关闭视图都无需获取trx_sys->mutex。

问题四:隐式锁转换为显式锁的开销

Innodb对于类似INSERT操作,采用的是隐式锁的方式,隐式锁不是锁,只是一种称呼而已,只有在需要的时候,才会转换为显式锁。例如如下:

Session 1: BEING; INSERT INTO t1(pk, val) VALUES (1,2); //不创建锁对象

Session 2: UPDATE t1 SET val=val+1 WHERE pk=1; //创建两个锁对象,一个是为session1创建一个记录锁对象,另外一个是给自己创建一个等待类型的记录锁对象,然后session2加入锁等待队列。

在Session 2中为Session1创建锁对象的过程即是所谓的隐式锁向显式锁转换。 当session2扫描到session 1插入的记录时,发现session 1的事务依然活跃,就会进入转换逻辑。

在5.6版本中,其转换过程如下:

1.持有lock_sys->mutex

  1. 持有trx_sys->mutex;

根据事务ID,扫描读写事务链表,找到对应的事务对象;

释放trx_sys->mutex;

3.创建显式锁对象

4.释放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

  1. 持有lock_sys->mutex;

创建显式锁对象;

释放lock_sys->mutex;

3.递减事务对象引用计数

在事务commit,释放记录锁前,会先判断引用记录数是否为0,如果不为0,表示正有其他事务为其转换显式锁,这时候需要等待,直到计数为0,才能进入释放事务记录锁阶段。

总的来说,该优化减少了隐式锁转换时持有LOCK_sys->mutex的时间,从而提升性能。

除了上述提到的几点事务优化外,在5.7版本中还对事务系统部分的代码做了重构,完全用C++重写;引入了一个POOL结构,事务对象和锁对象都可以缓存复用。大家可以阅读几个相关的worklog,以更好的理解上述优化:

http://dev.mysql.com/worklog/task/?id=6047

http://dev.mysql.com/worklog/task/?id=6578

http://dev.mysql.com/worklog/task/?id=6899

http://dev.mysql.com/worklog/task/?id=6906