从一次“并发修改字段业务”引出多版本并发控制与InnoDB锁

并发字段修改业务

最近在主要在做“工作流引擎”课题的预研工作,在涉及到“会签任务”(工作流业务概念,这与我们今天讨论文问题没有太多关联)的时候,遇到了一个并发修改同一个字段的应用场景。

大致是由于要等一个活动节点的所有实例任务都完成之后才能继续向下流转,则引擎必须在每次任务提交的时候进行判断。我选择了在数据库表中记录下每个活动节点对应的任务实例数目,活动实例完成提交时做相应的数目修改(active_ti_num – 1)来进行对应活动节点是否完成的判断。数据库表结构如下:

活动表字段名 id(活动主键) ai_name(活动名称) active_ti_num(当前活动未完成实例个数)
示例数据 1213398753365504001 活动1 1
任务表字段名 id(任务主键) ai_id(对应活动id,外键)
示例数据 1213400206226272258 1213398753365504001

如上所示,当同一个活动具有多个任务实例的时候,而任务实例又并发完成,就可能由于并发update导致数据错误,所以我将任务实例提交处理封成了一个事务,再使用update自减的方式修改active_ti_num字段值。

<update id="decrementActiveNum" parameterType="int">
        UPDATE wf_activtity_instance
        SET active_ti_num = active_ti_num + 1
        WHERE id = #{id}
</update>

这样在第一个事务修改了active_ti_num后,会锁住活动表中被修改的这一行,其他的事务便只能等待,等持有锁的事务锁释放之后,其他事务可以竞争锁再进行active_ti_num字段修改,从而保证了不出现数据错误。这种处理方法也是一种比较常见的处理方法。

啰啰嗦嗦说了这么多,业务问题虽然解决了,但不知道大家有没有过疑惑,虽然为了保证数据不发生错误,修改的数据被锁住了,但是MySQL究竟加的是行锁还是表锁?如果我们遇到的是并发insert操作而非update,那是否会出现新的问题?想解决这些疑惑,就需要引出我们今天的话题——“MVCC原理与在InnoDB中的实现

MVCC概念介绍

在并发操作的控制上,MySQL的大多事务型存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,他们一般都同时实现了MVCC(多版本并发控制)。可以认为MVCC是行级锁的一个变种,在很多场景下避免了加锁操作,因此开销更低。工作在 RC (读已提交)、RR(可重复度)两种隔离级别下。至于这个MVCC究竟是怎么做到既保证效果,又提高并发的,我们先来看看《高性能MySQL》中的介绍。

MVCC的实现,是通过保存数据在某个时间点的快照来实现的。MVCC是通过每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存了行的过期时间(或删除时间)。当然实际存储的不是时间而是系统版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号。

对于SELECT操作,就查找版本早于当前事务版本的数据行,行的删除版本要么未定义,要么大于当前事务版本。
对于INSERT操作,InnoDB为新插入的每一行保存当前系统版本号作为行版本号。
对于DELETE操作,Innodb为删除的每一行保存当前系统版本号作为行删除标识。
对于UPDATE操作,Innodb为插入一行新纪录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。

以上是MVCC实现的一个大致概括,各存储引擎具体实现上还是略有不同。由于InnoBD是MySQL默认的存储引擎,也是我项目使用的存储引擎,因此我们就来看看在InnoBD中MVCC的实现原理与作用是怎样的(其他存储引擎笔者也不会是吧…)。

InnoDB中MVCC的实现思路

在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC ,一条记录除了包括各个字段值,还包括了当前事务id(trx_id)一个指针(roll_pointer)

  1. trx_id:生成这条记录(update/delete)的事务id
  2. roll_pointer:之前undo_log中原来的那条记录,从而构成版本链

注:一个事务的事务id在第一次insert/delete/update时生成

我们接下来通过具体操作的实现思路来进行讲解:

Update操作

插入一条新的记录,把原来的记录放到undo日志中去,再把新纪录的roll_pointer指针指向原来的那条记录(从而加入版本链

Select操作

当执行查询sql时会生成一致性视图read-view,它由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id( max_id)组成,查询的数据结果需要跟read-view做比对从而得到快照结果(即从版本链头部记录开始,顺着链开始比对,找到可见的第一个版本记录)。

版本链比对规则

  1. 如果落在绿色部分( trx_id< min_id),表示这个版本是已提交的事务生成的,这个数据是可见的;

  2. 如果落在红色部分( trx_id> max_id),表示这个版本是由将来启动的事务生成的,是肯定不可见的。

  3. 如果落在黄色部分( min_id<=trx_id<= max_id),那就包括两种情况

    a.若row的trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见,当前自己的事务是可见的。

    b.若row的trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见

delete操作

对于删除的情况可以认为是 update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的trx_id,同时在该条记录的头信息( record header)里的( deleted flag)标记位写上true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果 delete flag标己位为true,意味看记录已被删除,则不返回数据。

知道了MVCC的实现机制,那现在我们可以思考下MVCC是如何实现可重复读的和读已提交的呢?

MVCC是如何实现可重复读的和读已提交的?

可重复读隔离级别下,SELECT一致性视图(readview)沿用第一次生成的(这是mvcc实现可重复读的关键,即使其他事务commit,但由于readview还是第一次select时生成的那个,所以当前事务还是看不到),而读已提交隔离级别下,每次SELECT操作生成最新的一致性视图(readview)

:readview是在当前会话(事务)第一条sql语句执行时生成的,在可重复读的隔离级别下,后面的语句都沿用这个readview(也就是说生成的readview是查哪个表用都有效的)

由此可见,可重复读也解决了幻读问题,因为新插入的记录的trx_id肯定会出现在select事务readview的未提交事务id数组/大于最大事务id,所以对于该事务肯定不可见,从而解决了幻读问题。

到这可能有读者会疑惑,之前说的都是对于读数据的并发控制,可是你的业务是更新啊!这还不是一回事啊!

别急,接下来我们就要说到啦!

快照读与当前读的区别?以及在MVCC中的应用

咦?怎么读还有两个?

“读”与“读”的区别

我们且看,在RR(可重复读)级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。(比如说并发情况下自增或者先读再增(更新值对原数据值有依赖性))

对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)

快照读其实就是普通的select操作,如

select * from table ….;

当前读则是特殊的读操作,插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁

select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;

由此我们可以想到,事务的隔离级别实际上都是定义了当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”,就需要另外的模块来解决了。记下来,我们详细来说说当前读

当前读(“写”)

事务的隔离级别中虽然只定义了读数据的要求,实际上这也可以说是写数据的要求。上文的“读”,实际是讲的快照读;而这里说的“写”就是当前读了。

读问题在上文中已经解决了,根据MVCC的定义,并发提交数据时会出现冲突,那么冲突时如何解决呢?我们再来看看InnoDB中RR级别对于写数据的处理。

InnoDB使用了Next-Key锁解决当前读中的幻读问题。首先我们看下什么是Next-Key锁。

Next-key Lock:锁定索引项本身和索引范围。即Record Lock和Gap Lock的结合。可解决幻读问题。

Record Lock:对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;

Gap Lock:对索引项之间的“间隙”加锁,锁定记录的范围(对第一条记录前的间隙或最后一条将记录后的间隙加锁),不包含索引项本身。其他事务不能在锁范围内插入数据,这样就防止了别的事务新增幻影行。

接下来我们可以看看RR级别和RC级别的对比,来体会Next-key锁的作用。

RC级别:

RR级别:

通过对比我们可以发现,在RC级别中,事务A修改了所有teacher_id=30的数据,但是当事务Binsert进新数据后,事务A发现莫名其妙多了一行teacher_id=30的数据,而且没有被之前的update语句所修改,这就是“当前读”的幻读。

RR级别中,事务A在update后加锁,事务B无法插入新数据,这样事务A在update前后读的数据保持一致,避免了幻读。这个锁,就是Gap锁。

InnoDB是这么实现的:

在class_teacher这张表中,teacher_id是个索引,那么它就会维护一套B+树的数据关系。
而InnoDB使用的是聚集索引,teacher_id身为二级索引,就要维护一个索引字段和主键id的树状结构,学过数据结构的同学都会知道,在树节点内部关键字保持顺序排列如下图(意会)。

如上图索引结构,Innodb将这段数据分成几个个区间

(negative infinity, 5],
(5,30],
(30,positive infinity);

update class_teacher set class_name=‘初三四班’ where teacher_id=30;不仅用行锁,锁住了相应的数据行;同时也在两边的区间,(5,30]和(30,positive infinity),都加入了gap锁。这样事务B就无法在这个两个区间insert进新数据。

因此,受限于这种实现方式,Innodb很多时候会锁住不需要锁的区间。如下图所示

update的teacher_id=20是在(5,30]区间,即使没有修改任何数据,Innodb也会在这个区间加gap锁,导致事务B必须等待,而其它区间不会影响,事务C正常插入。

此外,如果(where条件)使用的是没有索引的字段,比如update class_teacher set teacher_id=7 where class_name=‘初三八班(即使没有匹配到任何数据)’,那么会给全表加入gap锁。同时,它不能像上文中行锁一样经过MySQL Server过滤自动解除不满足条件的锁,因为没有索引,则这些字段也就没有排序,也就没有区间。除非该事务提交,否则其它事务无法插入任何数据。

行锁防止别的事务修改或删除,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在写数据时的幻读问题。

总结

MVCC不可重复读的保证其实是由快照读和当前读两个方面着手,快照读通过mvcc的版本控制来解决,不需要真正加锁。当前读通过行锁和GAP锁(锁的范围为索引B+树中当前索引两边的区间,要是没有索引就锁表)结合形成的的Next-Key锁来解决不可重复度和幻读的问题。

参考资料

《高性能MySQL》第三版

美团技术团队