<b>背景知识</b>
为了便于理解下文,我们先简单梳理下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相关的知识。
<b>innodb事务系统优化</b>
在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
2. 持有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
2. 持有lock_sys->mutex;
创建显式锁对象;
释放lock_sys->mutex;
3.递减事务对象引用计数
在事务commit,释放记录锁前,会先判断引用记录数是否为0,如果不为0,表示正有其他事务为其转换显式锁,这时候需要等待,直到计数为0,才能进入释放事务记录锁阶段。
总的来说,该优化减少了隐式锁转换时持有lock_sys->mutex的时间,从而提升性能。
除了上述提到的几点事务优化外,在5.7版本中还对事务系统部分的代码做了重构,完全用c++重写;引入了一个pool结构,事务对象和锁对象都可以缓存复用。大家可以阅读几个相关的worklog,以更好的理解上述优化:
<a href="http://dev.mysql.com/worklog/task/?id=6047">http://dev.mysql.com/worklog/task/?id=6047</a>
<a href="http://dev.mysql.com/worklog/task/?id=6578">http://dev.mysql.com/worklog/task/?id=6578</a>
<a href="http://dev.mysql.com/worklog/task/?id=6899">http://dev.mysql.com/worklog/task/?id=6899</a>
<a href="http://dev.mysql.com/worklog/task/?id=6906">http://dev.mysql.com/worklog/task/?id=6906</a>