并发字段修改业务

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

大致是由于要等一个活动节点的所有实例任务都完成之后才能继续向下流转,则引擎必须在每次任务提交的时候进行判断。我选择了在数据库表中记录下每个活动节点对应的任务实例数目,活动实例完成提交时做相应的数目修改(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》第三版

美团技术团队

从一次“并发修改字段业务”引出多版本并发控制与InnoDB锁的更多相关文章

  1. 曲演杂坛--使用ALTER TABLE修改字段类型的吐血教训

    --===================================================================== 事件起因:开发发现有表插入数据失败,查看后发现INT类型 ...

  2. Redis:解决分布式高并发修改同一个Key的问题

    本篇文章是通过watch(监控)+mutil(事务)实现应用于在分布式高并发处理等相关场景.下边先通过redis-cli.exe来测试多个线程修改时,遇到问题及解决问题. 高并发下修改同一个key遇到 ...

  3. 在EntityFrameworkCore中记录EF修改日志,保存,修改字段的原始值,当前值,表名等信息

    突发奇想,想把业务修改的所有字段原始值和修改后的值,做一个记录,然后发现使用EF可以非常简单的实现这个功能 覆盖父类中的 SaveShanges() 方法 public new int SaveCha ...

  4. Mysql 修改字段默认值

    环境:MySQL 5.7.13 问题描述:建表的时候,users_info表的role_id字段没有默认值,后期发现注册的时候,需要提供给用户一个默认角色,也就是给role_id字段一个默认值. 当前 ...

  5. 【转】SQL修改字段长度

    语法: alter table <表名> alter column <字段名> 新类型名(长度) 示例:假如有名T1,字段名F1,原来F1为varchar(3),现在要改为va ...

  6. sql语句修改字段长度

    sql语句修改字段长度 alter table <表名> alter column <字段名> 新类型名(长度) 例: alter table students alter c ...

  7. SQL语句增加字段、修改字段、修改类型、修改默认值

    一.修改字段默认值 alter table 表名 drop constraint 约束名字   ------说明:删除表的字段的原有约束 alter table 表名 add constraint 约 ...

  8. sql 操作常用操作语句 新增、修改字段等

    常用sql --sql 事务 BEGIN TRAN 事物名 )BEGIN ROLLBACK TRAN 事物名;RETURN;END COMMIT TRAN 事物名 --数据库清缓存 DBCC DROP ...

  9. Oracle 常用修改字段SQL语句

    1. 更新字段名称 alter table table_name rename column column_old to column_new; 2. 添加字段 ); 3. 删除字段 alter ta ...

随机推荐

  1. GO语言web框架Gin之完全指南

    GO语言web框架Gin之完全指南 作为一款企业级生产力的web框架,gin的优势是显而易见的,高性能,轻量级,易用的api,以及众多的使用者,都为这个框架注入了可靠的因素.截止目前为止,github ...

  2. MATLAB中mean的用法

    https://blog.csdn.net/wangyang20170901/article/details/78745587 MATLAB中mean的用法 转载仙女阳 最后发布于2017-12-07 ...

  3. UVA - 548 根据中序遍历和后序遍历建二叉树(关于三种遍历二叉树)

    题意: 同时给两个序列,分别是二叉树的中序遍历和后序遍历,求出根节点到叶子结点路径上的权值最小和 的那个 叶子节点的值,若有多个最小权值,则输出最小叶子结点的和. 想法: 一开始想着建树,但是没有这样 ...

  4. iOS 启动时间优化

    在 WWDC 2016 上首次提到了关于 App 应用启动速度优化的话题:Session 406 Optimizing App Startup Time. 一.冷启动与热启动 热启动是,APP会恢复之 ...

  5. leetcode并发题解

    按序打印 解法一:使用volatile public class FooWithVolatile { private volatile int count; public FooWithVolatil ...

  6. Codeforces 1329C - Drazil Likes Heap(堆+贪心)

    题目链接 题意 给出一个高度为 h 的大根堆, 要求弹出其中若干个数后高度变为 g, 并且前后大根堆都是满二叉树. 问新的大根堆所有数之和的最小值, 并要给出一种弹出数的操作序列(节点序号). h, ...

  7. ML-Agents(四)GridWorld

    目录 ML-Agents(四)GridWorld Visual Observations Masking Discrete Actions 环境与训练参数 场景基本结构 代码分析 环境初始化代码 Ag ...

  8. elasticsearch在linux上的安装,Centos7.X elasticsearch 7.6.2

    本文环境:Elasticsearch7.6.2目前最先版本   centos7.X     JDK1.8 elasticsearch介绍 官网:https://www.elastic.co/cn/pr ...

  9. DOM--选取文档元素

    大多数的客户端JavaScript程序在运行时都是在操作一个或者多个文档元素,而为了操作文档中的元素我们就必须要通过某种途径或者方法获得或者选取这些引用文档元素的Element对象.DOM定义了许多种 ...

  10. SWUST OJ 1075 求最小生成树(Prim算法)

    求最小生成树(Prim算法) 我对提示代码做了简要分析,提示代码大致写了以下几个内容 给了几个基础的工具,邻接表记录图的一个的结构体,记录Prim算法中最近的边的结构体,记录目标边的结构体(始末点,值 ...