---
title: '想掌握数据库事务背后的并发原理，请看这15张图'
pubDate: 2022-03-29
tags:
  - 'data'
description: '想掌握数据库事务背后的并发原理，请看这15张图'
---

> 摘要：通过一个转账问题的场景，将数据库9种锁、3种读、4种隔离级别一次性串联起。

本文分享自华为云社区[《将数据库9种锁、3种读、4种隔离级别一次性串联起来，用15张图呈现背后数据库事务背后的并发原理》](https://bbs.huaweicloud.com/blogs/335953?utm_source=oschina&utm_medium=bbs-ex&utm_campaign=ei&utm_content=content)，作者： breakDawn。

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

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

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

我这里举个例子：

- 排他锁、共享锁
- 行锁、表锁、意向锁、间隙锁、next-key锁
- 悲观锁、乐观锁
- 两阶段锁协议
- LCBB锁并发控制协议、MVCC多版本控制协议
- 脏读、不可重复读、幻读
- RU\\RC\\RR\\SE隔离级别

然后自己问自己一个问题：

1. 这一堆锁的关联关系究竟是什么？
2. 各隔离级别究竟是怎么用各种锁+MVCC来解决事务读问题的？

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

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

张三给穷光蛋李四转账100元。 李四尝试下单购买100元的衣服 李四在最开始余额只有0元钱。

注意因为是同时执行，在没有做任何保护的情况下，就可能会出现下图这样的情况

![](https://source.mkshell.com/mweb/16485192446514.jpg)

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

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

**Q:** 在不修改数据库隔离级别的情况下， 我们可以如何用sql语句手动解决这个脏读？ **A:** 那很显然就是加锁对事务过程做提前保护， 不让B去判断和扣费。 sql语句里有个 ”for update“ 语法， 会手动锁住李四那一行，在调用commit后释放 具体见下面绿色的标注部分：

![](https://source.mkshell.com/mweb/16485192649882.jpg)

**Q:** 刚才看到”锁住李四这一行“， 那么这个就叫行级锁。 什么情况下会变成锁住整个表？ **A:** name ='李四’这句话， 如果name是索引列的话，就会加行锁 如果不是索引列， 就会变成表锁。 换言之， 行锁的本质是在索引节点上加锁 如果无法在索引节点上加锁，那就会直接变成整张表的锁，代价就会很大。

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

**Q:** 如果一个事务A申请了行锁，锁住某一行， 另一个事务B申请了表锁，那B会被阻塞吗？ **A:** B事务既然申请表锁，说明可能会用到A中的每一行。 B申请的流程可以是下面这样：

1. 判断表是否已被其他事务用表锁锁表
2. 判断表中的每一行是否已被行锁锁住。

但2这一步也太耗时了。 因此A申请行锁前，会优先申请一个意向锁，再申请行锁。 然后B申请时，第2步改成判断意向锁即可，有意向锁就阻塞。 简单点说， 意向锁就是行锁操作用来阻塞表锁用的。 但行锁和行锁之间不会互相阻塞，除非行有冲突。

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

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

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

**Q:** 那么什么时候使用共享锁比排他锁要好呢？ **A:** 可以看下面的例子：

![](https://source.mkshell.com/mweb/16485193116415.jpg)

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

- 如果都加排他锁，各种select操作就会很慢。
- 但如果不加共享锁， T6这边删除时，就可能产生冗余数据，所以还是得加锁。

**Q:** 那我加的共享锁（S锁）和排他锁（X)什么时候释放呢？是每次执行完update马上释放吗？ **A:** 这里就涉及了“两阶段锁”协议。

- 加锁阶段：在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁（共享锁，其它事务可以继续加共享锁，但不能加排它锁），在进行写操作之前要申请并获得X锁（排它锁，其它事务不能再获得任何锁）。加锁不成功，则事务进入等待状态，直到加锁成功才继续执行。
- 解锁阶段：当事务释放了一个封锁以后，事务进入解锁阶段，在该阶段只能进行解锁操作不能再进行加锁操作。

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

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

**Q:** 两阶段锁协议可以避免死锁吗？ **A:** 不能避免，但是可以通过死锁检测算法进行事务解除。

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

![](https://source.mkshell.com/mweb/16485193445413.jpg)

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

刚才一个小小的脏读，就已经解决了下面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》书里的这段描述：

![](https://source.mkshell.com/mweb/16485193914204.jpg)

简而言之

- 查的时候，只查当前事务之前的记录，或者回滚版本比当前大的已删记录。
- 增的时候，加新版本的记录
- 删的时候，把老记录标记上回滚版本
- 改的时候，本质上是加新记录， 同时把老记录标上回滚版本

**Q:** MVCC机制下， 什么是快照读，什么是当前读？ **A:**

- 快照读：对于select读操作，统一默认不加锁，使用历史版本数据。
- 当前读：对于insert、update、delete操作，仍然需要加X锁，因为涉及了数据变更，必须使用最新数据进行修改

**Q:** 那么回到刚才的脏读问题， MVCC究竟是怎么在读不加锁的情况下， 解决脏读的？ **A:** 首先，每次select都不用任何锁， 每次都是快照读，不会阻塞，因此会变成下面这样：

![](https://source.mkshell.com/mweb/16485194181776.jpg)

总结这个图，就是

1. 每次读时，会生成一个readView，用来记录当前还没提交的事务版本号。
2. 根据自己事务的版本号version，去寻找小于自己当前版本且不在readView集合中的记录。 这样的话就保证了读的数据必须是已经完成提交的，是不是很简单？

**Q:** 如果事务B中不做余额判断，支持直接赊账+扣费， 那是不是会导致先扣费，然后回滚成0这样的情况？ **A:** 不会。 上面提过， MVCC中更新操作都是“当前读”，仍然需要加X锁， 且因为涉及了数据变更，必须使用最新数据版本进行修改

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

![](https://source.mkshell.com/mweb/16485194358615.jpg)

**Q:** 上面这个过程有什么隐患 **A:** 如果1个事务中连续读2次余额，可能有“不可重复读”的风险，即前后读的数据发生了不一致 如下所示

![](https://source.mkshell.com/mweb/16485194447709.jpg)

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

**Q:** RR（可重复读，Repeat Read)的隔离级别又是怎么解决上面这个问题的？ **A:** 本质上就是readView生成时的区别 上面RC不可重复读的图中可以看到，每次读时，都取了最新的readView。 这可能导致事务A提交后， 事务B观察到的readView集合发生了变化。

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

![](https://source.mkshell.com/mweb/16485194447709.jpg)

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

![](https://source.mkshell.com/mweb/16485194699737.jpg)

**Q:** RR隔离级别中的MVCC机制可以解决上面的问题吗？ **A:** 可以解决。 通过查询的快照读，能够保证只查询到同一批数据。

![](https://source.mkshell.com/mweb/16485194777814.jpg)

**Q:** 那如果像下面这样， 事务A连续做两次更新呢，单纯靠MVCC能避免更新操作的幻读么？ **A:** 如果只依靠MVCC，那就无法避免了， 因为update操作是”当前读“，每次取最新版本做更新， 这会导致update中的读操作出现幻读，前后更新的记录数量不一样了。

![](https://source.mkshell.com/mweb/16485194863901.jpg)

**Q:** 那数据库怎么处理这种2次updete中间做insert的幻读情况呢？ **A:** 之前有了解到， update过程仍然会加锁，

RR级别会启用一个叫”间隙锁“（Gap锁）的玩意，专门来防这样情况。 即调用 update xxx where name ='李四’时， 不仅仅在李四的行上加锁， 更会在中间所有行的间隙、左右边界的两边，加上一个gap间隙锁，就像下面这个图一样：

![](https://source.mkshell.com/mweb/16485194945106.jpg)

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

**Q:** 那行锁、间隙锁、next-key锁是什么区别？ **A:** 行锁就是单个行（单个索引节点）加锁 间隙锁就是在行（索引节点之间）加锁 next-key就是“行锁+间隙锁”，一起使用。

**Q:** 如果name这个字段不是索引，而是普通字段，那间隙锁会怎么加？ **A:** 那就会给整个表的所有间隙都加上锁！ 因为数据库无法确认到底是哪个范围，所以干脆全加上。 这就会导致整表锁住，性能很差。

**Q:** 那是不是只要name是索引，就不会给整个表全加间隙锁了？ **A:** 不对， 如果where条件写的有问题，不符合最左匹配原则，那也会导致索引失效， 以至于给整个表加锁。

**Q:** 刚才看到说RR可以解决2次select之间的幻读， 也能解决2次update之间的幻读， 那为什么很多资料里，仍然说RR不能解决幻读？ **A:** 这个问题我也是翻了好多资料， 终于找到了一个合理的解释。 看下面这个场景：

![](https://source.mkshell.com/mweb/16485195050634.jpg)

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

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

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

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

![](https://source.mkshell.com/mweb/16485195138430.jpg)

**Q:** 那串行化serializable隔离级别，为什么就能避免幻读了？ **A:** Se级别时，会从MVCC并发控制退化为基于锁的并发控制（LCBB）。 不区别快照读和当前读 所有的读操作都是当前读，读加读锁(S锁)，写加写锁(X锁)。在该隔离级别下，读写冲突，因此并发性能急剧下降，在MySQL/InnoDB中不建议使用。

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

_先这样吧_

更多地关注[造壳](https://www.mkshell.com)。
