前言
以下内容是从 https://www.aneasystone.com/archives/2017/10/solving-dead-locks-one.html 中提取出的关键内容,同时也有自己的补充。
事务四要素ACID
事务四要素,也叫“一个可靠的事务必须具备的四个特性”,分别是:
- 原子性(Atomicity):要么全部完成,要么全部不完成
 - 一致性(Consistency):一个事务单元需要提交之后才会被其他事务可见
 - 隔离性(Isolation):并发事务之间不会互相影响,设立了不同程度的隔离级别,通过适度的破坏一致性,得以提高性能
 - 持久性(Durability):事务提交后即持久化到磁盘不会丢失
 
和数据库有关的几个理论缩写:ACID、CAP、BASE。他们的区别可以看这篇博客:https://www.cnblogs.com/minikobe/p/11137256.html 。总的来说,ACID不考虑数据库是否是分布式的,都要求强一致性。CAP和BASE考虑的是分布式数据库在一致性问题上的取舍。
隔离级别
在ACID中的“隔离性”,规定了以下4种“隔离级别”。
| 隔离级别 | 概念 | 会出现的并发问题 | 不会出现的/解决的并发问题 | 
|---|---|---|---|
| 读未提交 Read Uncommitted | 可以读取到未提交的记录 | 脏读、幻读、不可重复读(所有问题都可能出现) | 无 | 
| 读已提交 Read Committed | 事务中只能看到已提交的修改  大部分数据库的默认隔离级别  | 
幻读、不可重复读 | 脏读 | 
| 可重复读 Repeatable Read | 在同一个事务内的查询结果都是一致的 MySQL InnoDb 默认的隔离级别  | 
幻读 | 脏读、不可重复读 | 
| 序列化 Serializable | 所有事务串行执行,是最高隔离级别 | 无 | 所有问题都解决 | 
它们可以达到的隔离效果从低到高,所以它们每个都会比上一个隔离级别多解决一个问题。
并发问题
并发是指对相同的一条或多条记录同时操作。在不采取任何措施的情况下,事务会出现的并发问题有4种:
- 脏读
事务一执行UPDATE未提交,事务二执行SELECT读到了事务一UPDATE未提交的结果 - 不可重复读
事务一执行UPDATE并提交,事务二中依次执行两句相同的SELECT,第一句在事务一UPDATE前读,第二句在事务一UPDATE并提交后读,两句相同的SELECT结果不一致 - 幻读
事务一执行INSERT/DELETE并提交,事务二中依次执行两句相同的SELECT COUNT(1),第一句在事务一 INSERT/DELETE 前读,第二句在事务一 INSERT/DELETE 并提交后读,两句相同的SELECT COUNT(1)结果不一致。它与“不可重复读”的区别在于,“不可重复读”关注两次SELECT的记录内容是否一致,“幻读”关注两次SELECT的记录总数是否一致 - 丢失更新
包括“提交覆盖”和“回滚覆盖”。提交覆盖指,事务一先SELECT后再根据SELECT结果进行UPDATE,事务二在事务一 两次操作的间隙中UPDATE并提交,事务一UPDATE提交后覆盖了事务二的UPDATE。回滚覆盖类似,就是事务一的回滚覆盖了事务二的UPDATE。 
这四种隔离级别,应该根据具体的业务来取舍,如果某个系统的业务里根本就没有重复读的场景,完全可以将数据库的隔离级别设置为“读已提交(RC)”,这样可以最大程度的提高数据库的并发性。
“回滚覆盖”问题由数据库本身解决,不需要用户考虑。“提交覆盖”问题,在MySQL的“可重复读(RR)”隔离级别下没有解决,原因是:
因为 MySQL 的实现和 ANSI-SQL 标准之间的差异,在标准的传统实现中,RR 隔离级别是使用持续的 X 锁(写锁)和持续的 S 锁(读锁)来实现的,由于是持续的 S 锁,所以避免了其他事务有写操作(加了读锁的表会拒绝写请求),也就不存在提交覆盖问题。但是 MySQL 在 RR 隔离级别下,普通的 SELECT 语句没有任何的加锁,和标准的 RR 是不一样的。如果要让 MySQL 在 RR 隔离级别下不发生提交覆盖,可以使用 SELECT … LOCK IN SHARE MODE 或者 SELECT … FOR UPDATE (这样的SQL会加读锁)。
隔离级别的实现方式
基于锁的并发控制
传统的隔离级别是基于锁实现的,这种方式叫做 基于锁的并发控制(Lock-Based Concurrent Control,简写 LBCC)。通过对读写操作加不同的锁,以及对释放锁的时机进行不同的控制,就可以实现四种隔离级别。
锁的种类见博客《MYSQL笔记》(直接到目录-锁机制):https://yunnight.github.io/2020/05/16/mysql-note/
四种隔离级别的加锁策略如下:
- 读未提交
有很多人认为这个隔离级别不需要加任何锁,这其实是错误的,我们上面讲过,有一种并发问题在任何隔离级别下都不允许存在,那就是“回滚覆盖”,如果不对写操作加持续 X 锁,当两个事务同时去写某条记录时,可能会出现回滚覆盖问题。对读操作不加锁。 - 读已提交
它是为了解决脏读问题,只能读取已提交的记录,要怎么做才可以保证事务中的读操作读到的记录都是已提交的呢?很简单,对读操作加上 S 锁,这样如果其他事务有正在写的操作,必须等待写操作提交之后才能读,因为 S 和 X 互斥,如果在读的过程中其他事务想写,也必须等事务读完之后才可以。这里的 S 锁是一个临时 S 锁,表示事务读完之后立即释放该锁,可以让其他事务继续写。对写操作加 “持续X锁” - 可重复读
为了让事务可以重复读,“读已提交”时加在读操作的临时 S 锁变成了持续 S 锁,也就是直到事务结束时才释放该锁,这可以保证整个事务过程中,其他事务无法进行写操作,所以每次读出来的记录是一样的。对写操作加 “持续X锁” - 序列化(Serializable)
为了解决幻读问题,行级锁做不到,需使用表级锁。 
两段锁协议(2-phase locking,简称 2PL)
没有使用这个协议时,假设一个事务中先是对记录A的读,再是对记录B的写,则先取A的读锁,读完A即释放,再取B的写锁,写完B即释放。
而这个协议,将事务中的加锁和解锁明确地分成两个阶段:加锁阶段、解锁阶段。
加锁阶段:事务在读数据前加S锁,写数据前加X锁,加锁不成功则等待,加锁成功才继续执行,,且加锁后就不能做解锁。即上面的例子中,在这个阶段会先后取A的读锁、B的写锁,在这之间不会释放A的读锁
解锁阶段:在该阶段只能进行解锁而不能再进行加锁操作。即上面的例子中,在这个阶段先后释放A的读锁、B的写锁,在这之间不会加任何锁。
若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是序列化的。
两段锁协议不能防止死锁,因为两段锁协议并不要求事务必须一次将所有要使用的数据全部加锁,因此遵守两段锁协议的事务可能发生死锁。
一次封锁法,就是 MySQL MyISAM 引擎使用的方式,它也遵循了“两段锁协议”,并且它可以避免死锁,但影响并发性能。
死锁的产生、MyISAM 引擎不会产生死锁的原因,见博客《MYSQL笔记》https://yunnight.github.io/2020/05/16/mysql-note
无锁的并发控制(MVCC)
MySQL 中采用的“无锁的并发控制”技术是:多版本并发控制(Multi-Version Concurrent Control,简写 MVCC)
虽然数据库的四种隔离级别通过 LBCC 技术都可以实现,但是它最大的问题是它只实现了并发的读读,对于并发的读写还是冲突的,写时不能读,读时不能写(因为读锁写锁互斥),当读写操作都很频繁时,数据库的并发性将大大降低,针对这种场景,MVCC 技术应运而生。
MySQL 只有支持事务的 InnoDB 引擎支持 MVCC,该引擎实现 MVCC 的原理是:
InnoDB 中,事务在写一条记录时会将其拷贝一份生成这条记录的一个原始拷贝(这份拷贝数据也叫快照数据),写操作同样还是会对原记录加锁,但是读操作会读取未加锁的拷贝出的新记录,这就保证了读写并行。要注意的是,生成的拷贝数据存放于undo log,该数据通过“回滚指针”与原数据关联,若写操作修改原记录失败,会用 undo log 中的数据恢复原记录,所以 undo log 也是实现事务回滚的关键技术。
InnoDB 中只有 RC(读已提交)和 RR(可重复读)这两个隔离级别才有 MVCC。它们在 MVCC 机制作用下的表现也不同。原博客中做了个实验发现:
- RR(可重复读)时,事务二总是读取事务一开始时的那个版本,即使事务一已提交(写锁已释放)
 - RC(读已提交)时,事务二先是读取目标记录的最新版本,如果该记录被锁住(事务一未提交,写锁未释放),则读取该记录最新的一次快照
 
由于事务二读取的都是快照数据,并不会被写操作阻塞,所以这种读操作称为快照读(Snapshot Read),也叫非阻塞读(Nonlocking Read)。RR 隔离级别下的叫做一致性非阻塞读(Consistent Nonlocking Read)。在InnoDB 中,普通 SELECT 语句不会加任何锁,这种就是快照读。【实验例子中的事务二就是用的普通 SELECT 语句
除了快照读 ,MySQL 还提供了另一种读取方式:当前读(Current Read),也叫加锁读(Locking Read)或者阻塞读(Blocking Read),这种读操作读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁。在InnoDB 中,可以给 SELECT 语句显式加锁,如SELECT … LOCK IN SHARE MODE(加行读锁)或SELECT … FOR UPDATE(加行写锁),这种就是当前读。