写点什么

15 张图呈现数据库事务背后的并发原理

  • 2022 年 3 月 09 日
  • 本文字数:4818 字

    阅读完需:约 16 分钟

本文分享自华为云社区《将数据库9种锁、3种读、4种隔离级别一次性串联起来,用15张图呈现背后数据库事务背后的并发原理》,作者: breakDawn。


前段时间开发时,正好遇到了 2 个进程同时更新一行记录时引发的 bug,虽然问题最终解决了,但自己对背后的运行逻辑仍旧一头雾水。事后尝试简单翻了下各种博客资料,还有《高性能 mysql》那本书时,发现大部分是将一堆八股文概念堆砌在一起,很少完整串联过这堆概念。


于是我重新完整学习了这些概念和底层原理, 通过一个转账问题的场景,将这些概念全部关联起来。


将下面这些数据库的概念单独拿出来时,相信很多人都有了解或者记忆过,但是将这些概念全部串联在一起时,可能就会很混乱。我这里举个例子:

  • 排他锁、共享锁

  • 行锁、表锁、意向锁、间隙锁、next-key 锁

  • 悲观锁、乐观锁

  • 两阶段锁协议

  • LCBB 锁并发控制协议、MVCC 多版本控制协议

  • 脏读、不可重复读、幻读

  • RU\RC\RR\SE 隔离级别

  • 然后自己问自己一个问题:

  1. 这一堆锁的关联关系究竟是什么?

  2. 各隔离级别究竟是怎么用各种锁+MVCC 来解决事务读问题的?


首先,我们完全不考虑数据库引擎、隔离级别设置之类的,就当作你用一个超简陋的儿科级别数据库来存放和更新数据。


假设你的商城服务正好在同时执行如下的 2 种事情

  • 张三给穷光蛋李四转账 100 元。

  • 李四尝试下单购买 100 元的衣服

李四在最开始余额只有 0 元钱。注意因为是同时执行,在没有做任何保护的情况下,就可能会出现下图这样的情况


可以看到李四明明没有钱,却扣费了,变成了很奇怪的-100 元。


Q:那这个有问题的读过程叫什么?

A: 这个过程就叫做脏读。 即更新回退的时,另一个事务读到了脏数据,判断失误,导致做了错误的处理。根本原因是 2 个事务都是先查后扣,却没有提前保护的形式


Q:在不修改数据库隔离级别的情况下, 我们可以如何用 sql 语句手动解决这个脏读?

A:那很显然就是加锁对事务过程做提前保护, 不让 B 去判断和扣费。sql 语句里有个 ”for update“ 语法, 会手动锁住李四那一行,在调用 commit 后释放具体见下面绿色的标注部分:



Q:刚才看到”锁住李四这一行“, 那么这个就叫行级锁。什么情况下会变成锁住整个表?

A:name ='李四’这句话, 如果 name 是索引列的话,就会加行锁如果不是索引列, 就会变成表锁。换言之, 行锁的本质是在索引节点上加锁如果无法在索引节点上加锁,那就会直接变成整张表的锁,代价就会很大。

另外表锁也可以单独用 lock table 的语法手动加锁


Q:如果一个事务 A 申请了行锁,锁住某一行, 另一个事务 B 申请了表锁,那 B 会被阻塞吗?

A:B 事务既然申请表锁,说明可能会用到 A 中的每一行。B 申请的流程可以是下面这样:

  1. 判断表是否已被其他事务用表锁锁表

  2. 判断表中的每一行是否已被行锁锁住。

  3. 但 2 这一步也太耗时了。

  4. 因此 A 申请行锁前,会优先申请一个意向锁,再申请行锁。

  5. 然后 B 申请时,第 2 步改成判断意向锁即可,有意向锁就阻塞。


简单点说, 意向锁就是行锁操作用来阻塞表锁用的。 但行锁和行锁之间不会互相阻塞,除非行有冲突。


刚才看到的 for update 会限制其他并行事务的所有读写操作,而且是 2 个事务上都加了”for update“。那么这个锁就叫做”排他锁“, 属于非常强势的锁, 相当于其他读写操作马上全部拦住了。


这里使用排他锁来解决脏读的原因是因为后面有查询余额+扣余额的代码,写这段代码的人必须做提前保护,以避免自己读到一个可能被修改的数据,导致判断和修改失误


和排他锁对应的是“共享锁”,也就是熟知的读写锁。可以让多个事务同时读,但是不允许修改 。手动加共享锁的方式:把 for update 改成 lock in share mode 即可。


Q:那么什么时候使用共享锁比排他锁要好呢?

A:可以看下面的例子:


可以看到没有查自身+更新自身的操作, 仅仅是查+更新其他表,表之间也互不关联,对余额的实时性也不是要求太高。

  • 如果都加排他锁,各种 select 操作就会很慢。

  • 但如果不加共享锁, T6 这边删除时,就可能产生冗余数据,所以还是得加锁。


Q:那我加的共享锁(S 锁)和排他锁(X)什么时候释放呢?是每次执行完 update 马上释放吗?

A:这里就涉及了“两阶段锁”协议。

  • 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得 S 锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得 X 锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。

  • 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。


说人话, 就是在事务中需要加锁时再加锁, 直到 commit 完一次性解锁。


为什么要两阶段锁,看到的一句话是若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的。


Q:两阶段锁协议可以避免死锁吗?

A:不能避免,但是可以通过死锁检测算法进行事务解除。


重新回到张三李四转账+下单的场景上来。for update 这种锁,其实也是一种“悲观锁” ,加锁解锁比较耗时, 默认经常发生竞争。但如果我的转账和下单过程要求非常快,每次只有几毫秒,那加悲观锁成本就太大了这时候就可以手动使用乐观锁, 需要你自己在余额表里增加 version 列,增加后如下所示:


这样就不需要特地加锁了,每次循环判断即可,前提是冲突发生概率比较低,阻塞时间比较短。


刚才一个小小的脏读,就已经解决了下面 3 个问题

  • 排他锁和共享锁的区别:前者是拒绝所有读写 , 后者是允许并发读拒绝写

  • 行锁和表锁的区别: 前者是对单行加锁 , 后者是对整表加锁, 区别是 是否涉及索引

  • 悲观锁和乐观锁的区别: 前者主动用数据库自带的锁, 后者自己添加 version 版本号

  • 外加一个两阶段锁协议


继续回到脏读问题, 前面我们学习的所有概念,都是和数据库自身隔离级别无关,使用数据库的锁语法或者 version 版本号来避免。


但数据库发展这么强大,怎么可能需要我们频繁自己写这种复杂逻辑,于是数据库诞生了隔离级别设置。

前面会发生脏读的隔离级别, 叫做 RU(read uncommited)即 RU 级别时, 我可以在别的事务没完全 commit 好时就读到数据。


Q:先来个小问题,RU 级别没有任何锁,对吗?

A:错误, RU 级别做 update 等增删改操作时,仍然会默认在事务更新操作中增加排他锁,避免 update 冲突。切记脏读的发生原因,是查询+更新+回滚时没加锁导致其他查询操作出现失误判断。即查询这块可能读到没提交的数据,导致错误,而不是更新的并发问题。


Q:当我们的数据库被设置成 RC 级别(Read commited)时, 可以解决脏读, 那么背后是怎么解决的呢?

A:业界有两种方式

  • LBCC 基于锁的并发控制(Lock-Based Concurrency Control))

  • MVCC 基于多版本的并发控制协议(Multi-Version Concurrency Control)


LBCC 其实就是类似前面手动用悲观锁的方式, 事务操作中查询时默认试图加锁,因此就可能被 update 的排他锁阻塞住,避免了脏读。


但代价就是效率很低。很多场景下,select 的次数是远大于 update 的。


所以 InnoDb 基于乐观锁的概念, 想了一个 MVCC,自己在事务的背后实现了一套类似乐观锁的机制来处理这种情况。 确保了尽可能不在读操作上加锁, 排他锁只对更新操作生效。


Q:MVCC 究竟是怎么做的呢?

A:简单来说,就是默认给每个数据行加了一个版本号列 TRX_ID 和回滚版本链 ROLL_BT,具体可以看《高性能 mysql》书里的这段描述:


简而言之

  • 查的时候,只查当前事务之前的记录,或者回滚版本比当前大的已删记录。

  • 增的时候,加新版本的记录

  • 删的时候,把老记录标记上回滚版本

  • 改的时候,本质上是加新记录, 同时把老记录标上回滚版本


Q:MVCC 机制下, 什么是快照读,什么是当前读?

A:

  • 快照读:对于 select 读操作,统一默认不加锁,使用历史版本数据。

  • 当前读:对于 insert、update、delete 操作,仍然需要加 X 锁,因为涉及了数据变更,必须使用最新数据进行修改


Q:那么回到刚才的脏读问题, MVCC 究竟是怎么在读不加锁的情况下, 解决脏读的?

A:首先,每次 select 都不用任何锁, 每次都是快照读,不会阻塞,因此会变成下面这样:


总结这个图,就是

  1. 每次读时,会生成一个 readView,用来记录当前还没提交的事务版本号。

  2. 根据自己事务的版本号 version,去寻找小于自己当前版本且不在 readView 集合中的记录。

这样的话就保证了读的数据必须是已经完成提交的,是不是很简单?


Q:如果事务 B 中不做余额判断,支持直接赊账+扣费, 那是不是会导致先扣费,然后回滚成 0 这样的情况?

A:不会。上面提过, MVCC 中更新操作都是“当前读”,仍然需要加 X 锁, 且因为涉及了数据变更,必须使用最新数据版本进行修改。


换言之, update 等操作, 还是会加锁,且用最新版本更新,避免了脏更新的问题,如下:



Q:上面这个过程有什么隐患?

A:如果 1 个事务中连续读 2 次余额,可能有“不可重复读”的风险,即前后读的数据发生了不一致如下所示


因此 RC 隔离级别无法解决 “不可重复读的问题”


Q:RR(可重复读,Repeat Read)的隔离级别又是怎么解决上面这个问题的?

A:本质上就是 readView 生成时的区别上面 RC 不可重复读的图中可以看到,每次读时,都取了最新的 readView。 这可能导致事务 A 提交后, 事务 B 观察到的 readView 集合发生了变化。


因此 RR 机制改变了 readView 的生成方式, 每次读时只使用事务 B 最开始拿到的那个 readView,这样永远就只取老的数据了。



Q:那读问题中的幻读又是什么?

A:刚才的”不可重复读“,是一个事务中查询 2 次结果,发现值对不上。而”幻读“,是指一个事务中查询 2 批结果,发现这 2 批数量对不上,就好象发生了幻觉。就像下图所示展示:



Q:RR 隔离级别中的 MVCC 机制可以解决上面的问题吗?

A:可以解决。通过查询的快照读,能够保证只查询到同一批数据。



Q:那如果像下面这样, 事务 A 连续做两次更新呢,单纯靠 MVCC 能避免更新操作的幻读么?

A:如果只依靠 MVCC,那就无法避免了, 因为 update 操作是”当前读“,每次取最新版本做更新, 这会导致 update 中的读操作出现幻读,前后更新的记录数量不一样了。



Q:那数据库怎么处理这种 2 次 updete 中间做 insert 的幻读情况呢?

A:之前有了解到, update 过程仍然会加锁。RR 级别会启用一个叫”间隙锁“(Gap 锁)的玩意,专门来防这样情况。即调用 update xxx where name ='李四’时, 不仅仅在李四的行上加锁, 更会在中间所有行的间隙、左右边界的两边,加上一个 gap 间隙锁,就像下面这个图一样:


可以看到,订单 D 的插入过程被 update 过程的间隙锁拦住了,于是无法插入,置到事务结束才会释放。因此事务中两次 update 之间的幻读是可以避免的,也能。


Q:那行锁、间隙锁、next-key 锁是什么区别?

A:行锁就是单个行(单个索引节点)加锁间隙锁就是在行(索引节点之间)加锁 next-key 就是“行锁+间隙锁”,一起使用。


Q:如果 name 这个字段不是索引,而是普通字段,那间隙锁会怎么加?

A:那就会给整个表的所有间隙都加上锁!因为数据库无法确认到底是哪个范围,所以干脆全加上。这就会导致整表锁住,性能很差。


Q:那是不是只要 name 是索引,就不会给整个表全加间隙锁了?

A:不对, 如果 where 条件写的有问题,不符合最左匹配原则,那也会导致索引失效, 以至于给整个表加锁。


Q:刚才看到说 RR 可以解决 2 次 select 之间的幻读, 也能解决 2 次 update 之间的幻读, 那为什么很多资料里,仍然说 RR 不能解决幻读?

A:这个问题我也是翻了好多资料, 终于找到了一个合理的解释。看下面这个场景:


发现什么区别没, 事务 B 的 insert 操作,发生在了事务 A 的 update 之前。因此事务 B 的 insert 操作没有被间隙锁阻塞。


而 update 用的是当前读, 于是更新的数量和 最初 select 的数量匹配不上了。


Mysql 官方给出的幻读解释是:只要在一个事务中,第二次 select 多出了 row 就算幻读,所以这个场景下,算出现幻读了。


这也就是下面这个图的来源:



Q:那串行化 serializable 隔离级别,为什么就能避免幻读了?

A:Se 级别时,会从 MVCC 并发控制退化为基于锁的并发控制(LCBB)。不区别快照读和当前读所有的读操作都是当前读,读加读锁(S 锁),写加写锁(X 锁)。在该隔离级别下,读写冲突,因此并发性能急剧下降,在 MySQL/InnoDB 中不建议使用。


这就是我们文章最开头手动加锁的那个过程了。


点击关注,第一时间了解华为云新鲜技术~​


发布于: 刚刚阅读数: 2
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
15张图呈现数据库事务背后的并发原理_数据库_华为云开发者社区_InfoQ写作平台