并发字段修改业务
最近在主要在做 "工作流引擎" 课题的预研工作, 在涉及到 "会签任务"(工作流业务概念, 这与我们今天讨论文问题没有太多关联)的时候, 遇到了一个并发修改同一个字段的应用场景.
大致是由于要等一个活动节点的所有实例任务都完成之后才能继续向下流转, 则引擎必须在每次任务提交的时候进行判断. 我选择了在数据库表中记录下每个活动节点对应的任务实例数目, 活动实例完成提交时做相应的数目修改 (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).
trx_id: 生成这条记录 (update/delete) 的事务 id
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 做比对从而得到快照结果(即从版本链头部记录开始, 顺着链开始比对, 找到可见的第一个版本记录).
版本链比对规则
如果落在绿色部分( trx_id<min_id), 表示这个版本是已提交的事务生成的, 这个数据是可见的;
如果落在红色部分( trx_id> max_id), 表示这个版本是由将来启动的事务生成的, 是肯定不可见的.
如果落在黄色部分( 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》第三版
美团技术团队 http://118.178.126.205:8005/
来源: https://www.cnblogs.com/wunsiang/p/12765096.html