MySQL 事务和锁
目录
1. 什么是事务
2. 事务的特性
3. 如何实现原子性
4. 如何实现持久性
5. 事务的隔离性
6. 事务的隔离级别
7. 锁
7.1 锁信息
7.2 共享锁和独占锁
7.3 意向锁
7.4 索引记录锁
7.5 间隙锁
7.6 临键锁
7.7 插入意向锁
7.8 AUTO_INC Locks
7.9 死锁
8. 事务的隔离级别
9. 多版本并发控制 (MVCC)
10. 读未提交与脏读
11. 读已提交与不可重复读
12. 可重复读与幻读
13. 串行化
1. 什么是事务
事务是把一组SQL语句打包成为一个整体,在这组SQL的执行过程中,要么全部成功,要么全部失败。
如果不用事务管理,当执行第一条SQL语句之后,服务器崩溃了那么整体的数据就不正确了
2. 事务的特性
Atomicity(原子性):一个事务中的所有操作,要么全部成功,要么全部失败,不会出现只执行了一半的情况,如果事务在执行过程中发生错误,会回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样;
Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性不会被破坏。这表示写入的数据必须完全符合所有的预设规则,包括数据的精度、关联性以及关于事务执行过程中服务器崩溃后如何恢复;
Isolation(隔离性):数据库允许多个并发事务同时对数据进行读写和修改,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务可以指定不同的隔离级别,以权衡在不同的应用场景下数据库性能和安全,后面的小节会详细介绍;
Durability(持久性):事务处理结束后,对数据的修改将永久的写入存储介质,即便系统故障也不会丢失。
需要重点说明的是,事务最终要保证数据的可靠和一致,也就是说ACID中的Consistency(一致性)是最终的目的,那么当事务同时满足了Atomicity(原子性),Isolation(隔离性)和Durability(持久性)时,也就实现了一致性。
3. 如何实现原子性
在一个事务的执行过程中,如果多条DML语句顺利执行,那么结果最终会写入数据库;如果在事务的执行过程中,其中一条DML语句出现异常,导致后面的语句无法继续执行或即使继续执行也会导致数据不完整、不一致,这时前面执行的语句已经对数据做了修改,如果要保证一致性,就需要对之前的修改做撤销操作,这个撤销操作称为回滚rollback
在事务执行每个DML之前,把原始数据记录在一个日志里,做为回滚的依据,这个日志称为Undo Log(回滚日志或撤销日志),在不考虑缓存和刷盘的条件下,执行过程如下所示:
当需要回滚操作时,MySQL根据操作类型,在Insert Undo链或Update Undo链中读取相应的日志记录,并反向执行修改,使数据还原,完成回滚。
通过Undo Log实现了数据的回滚操作,这时就可以保证在事务成功的时候全部的SQL语句都执行成功,在事务失败的时候全部的SQL语句都执行失败,实现在原子性。
4. 如何实现持久性
提交的事务要把数据写入(持久化到)存储介质,比如磁盘。在正常情况下大多没有问题,可是在服务器崩溃或突然断电的情况下,一个事务中的多个修改操作,只有一部分写入了数据文件,而另一部分没有写入,如果不做针对处理的话,就会造成数据的丢失,从而导致数据不完整,也就不能保证一致性。
在真正写入数据文件之前,MySQL会把事务中的所有DML操作以日志的形式记录下来,以便在服务器下次启动的时候进行恢复操作,恢复操作的过程就是把日志中没有写到数据文件的记录重新执行一遍,保证所有的需要保存的数据都持久化到存储介质中,我们把这个日志称为Redo Log(重做日志);生成重做日志是保证数据一致性的重要环节。在持久化的处理过程中,还包括缓冲池、Doublewrite Buffer(双写缓冲区)、Binary Log(二进制日志)等知识点
5. 事务的隔离性
MySQL服务可以同时被多个客户端访问,每个客户端执行的DML语句以事务为基本单位,那么不同的客户端在对同一张表中的同一条数据进行修改的时候就可能出现相互影响的情况,为了保证不同的事务之间在执行的过程中不受影响,那么事务之间就需要要相互隔离,这种特性就是隔离性。
6. 事务的隔离级别
多个事务在对同一个表中的同一条数据进行修改时,如果要实现事务间的隔离也可以通过锁来完成,在MySQL中常见的锁包括:读锁,写锁,行锁,间隙锁,Next-Key锁等,不同的锁策略联合多版本并发控制可以实现事务间不同程度的隔离,称为事务的隔离级别
在 MySQL 的 InnoDB 引擎中事务的隔离级别有四种 :
READ UNCOMMITTED,读未提交
READ COMMITTED,读已提交
REPEATABLE READ,可重复读(默认)
SERIALIZABLE,串行化
从上到下由最不安全到最安全
7. 锁
实现事务隔离级别的过程中用到了锁,所谓锁就是在事务A修改某些数据时,对这些数据加一把锁,防止其他事务同时对这些数据执行修改操作;当事务A完成修改操作后,释放当前持有的锁,以便其他事务再次上锁执行对应的操作。不同存储引擎中的锁功能并不相同,这里我们重点介绍InnoDB存储引擎中的锁。
7.1 锁信息
锁的信息包括锁的请求(申请),锁的持有以及阻塞状态等等,都保存在 performance_schema库的 data_locks 表中,可以通过以下方式查看 : select * from performance_schema.data_locks\G
锁类型 :
锁类型依赖于存储引擎,在InnoDB存储引擎中按照锁的粒度分为,行级锁 RECORD 和表级锁
行级锁也叫行锁,是对表中的某些具体的数据行进行加锁
表级锁也叫表锁,是对整个数据表进行加锁
锁模式 :
锁模式,用来描述如何请求(申请)锁,分为共享锁(S)、独占锁(X)、意向共享锁(S)、意向独占锁(IX)、记录锁、间隙锁、Next-Key锁、AUTO-INC锁、空间索引的谓词锁等
7.2 共享锁和独占锁
InnoDB实现了标准的行级锁,分为两种分别是共享锁(S锁)和独占锁(x锁),独占锁也称为排他锁
共享锁(S锁):允许持有该锁的事务读取表中的一行记录,同时允许其他事务在锁定行上加另一个共享锁并读取被锁定的对象,但不能对其进行写操作
独占锁(X锁):允许持有该锁的事务对数据行进行更新或删除,同时不论其他事务对锁定行进行读取或修改都不允许对锁定行进行加锁
如果事务T1持有R行上的共享锁(S),那么事务T2请求R行上的锁时会有如下处理:
1. T2请求S锁会立即被授予,此时T1和T2都对R行持有S锁;
2. T2请求X锁不能立即被授予,阻塞到T1释放持有的锁
如果事务T1持有R行上的独占锁(X),那么T2请求R行上的任意类型锁都不能立即被授予,事务T2必须等待事务T1释放R行上的锁。
读锁是共享锁的一种实现,写锁是排他锁的一种实现
7.3 意向锁
InnoDB支持多粒度锁,允许行锁和表锁共存
InnoDB使用意向锁实现多粒度级别的锁,意向锁是表级别的锁,它并不是真正意义上的加锁,而只是在data_locks 中记录事务以后要对表中的哪一行加哪种类型的锁(共享锁或排他锁)(将要发生的加锁操作),意向锁分为两种 :
意向共享锁(IS):表示事务打算对表中的单个行设置共享锁。
意向排他锁(IX):表示事务打算对表中的单个行设置排他锁。
在获取意向锁时有如下协议:
在事务获得表中某一行的共享锁(S)之前,它必须首先获得该表上的IS锁或更强的锁。
在事务获得表中某一行的排他锁(X)之前,它必须首先获得该表上的IX锁。
意向锁可以提高加锁的性能,在真正加锁之前不需要遍历表中的行是否加锁,只需要查看一下表中的意向锁即可
在请求锁的过程中,如果将要请求的锁与现有锁兼容,则将锁授予请求的事务,如果与现有锁冲突,则不会授予;事务将阻塞等待,直到冲突的锁被释放;意向锁与行级锁的兼容性如下表:
除了全表锁定请求之外,意向锁不会阻止任何锁请求;意向锁的主要目的是表示事务正在锁定某行或者正在意图锁定某行。
7.4 索引记录锁
索引记录锁总是锁定索引行,在表没有定义索引的情况下,InnoDB创建一个隐藏的聚集索引,并使用该索引进行记录锁定,当使用索引进行查找时,锁定的只是满足条件的行
7.5 间隙锁
间隙锁锁定的是索引记录之间的间隙,或者第一个索引记录之前,再或者最后一个索引记录之后的间隙。
例如有如下SQL,锁定的是ID(10,20)之间的间隙,注意不包括10和20的行,目的是防止其他事务将ID值为15的列插入到列account表中(无论是否已经存在要插入的数据列),因为指定范围值之间的间隙被锁定了 : SELECT * FROM account WHERE id BETWEEN 10 and 20 For UPDATE;
间隙可以跨越单个或多个索引值
对于使用唯一索引查询到的唯一行,不使用间隙锁
不同事务的间隙锁可以共存,一个事务的间隙锁不会阻止另一个事务在相同的间隙上使用间隙锁;共享间隙锁和独占间隙锁之间没有区别。
当事务隔离级别设置为READCOMMITTED时间隙锁会被禁用,对于搜索和索引I扫描不再使用间隙锁定
7.6 临键锁
Next-key锁是索引记录锁和索引记录之前间隙上间隙锁的组
InnoDB搜索或扫描一个表的索引时,执行行级锁策略,具体方式是:在扫描过程中遇到的索引记录上设置共享锁或排他锁,因此,行级锁策略实际上应用的是索引记录锁。索引记录上的next-key 锁也会影响该索引记录之前的"间隙",也就是说,next-key 锁是索引记录锁加上索引记录前面的间隙锁。如果一个会话对索引中的一条记录R具有共享锁或排他锁,则另一个会话不能在索引记录R之前的空白中插入新的索引记录行。
假设索引包含值10、11、13和20,这些索引可能的next-key 锁覆盖以下区间,其中圆括号表示不包含区间端点,方括号表示包含端点 :
默认情况下,REPEATABLE READ事务隔离级别开启next-key 锁并进行搜索和索引扫描,可以防止幻象行,从而解决幻读问题
7.7 插入意向锁
插入意向锁是一个特殊的间隙锁,在向索引记录之前的间隙进行 insert 操作插入数据时使用,如果多个事务向相同索引间隙中不同位置插入记录,则不需要彼此等待。假设已经存在值为 10 和 20 的索引记录,两个事务分别尝试插入索引值为 15 和 16 的行,在获得插入行上的排他锁之前,每个事务都用插入意向锁锁住 10 到 20 之间的间隙,但不会相互阻塞,因为他们所操作的行并不冲突 (记录的是当前操作将来要获取什么样的锁,以锁信息的形式保存在data_locks表里)
7.8 AUTO_INC Locks
AUTO-INC 锁也叫自增锁是一个表级锁,服务于配置了AUTO_INCREMENT自增列的表。在插入数据时会在表上加自增锁,并生成自增值,同时阻塞其他的事务操作,以保证值的唯一性。需要注意的是,当一个事务执行新增操作已生成自增值,但是事务回滚了,申请到的主键值不会回退,这意味着在表中会出现自增值不连续的情况。
7.9 死锁
由于每个事务都持有另一个事务所需的锁,导致事务无法继续进行的情况称为死锁。死锁一旦发生,程序就会阻塞,是非常严重的问题,必须要解决
死锁产生的条件 :
互斥访问:如果线程1获取到了锁A,那么线程2就不能同时得到锁A锁 (锁自身的性质不能变)
不可抢占:获取到锁的线程,只能自己主动释放锁,别的线程不能从他的手中抢占锁 (锁自身的性质不能变)
保持与请求:线程1已经获得了锁A,还要在这个基础上再去获取锁B (常见的场景,不建议改)
循环等待:线程1等待线程2释放锁,线程2也等待线程1释放锁,死锁发生时系统中一定有由两个或两个以上的线程组成的一条环路,该环路中的每个进程都在等待着下一个线程释放锁(常用方法)
InnoDB对死锁的检测
InnoDB在运行时会对死锁进行检测,当死锁检测启用时(默认),InnoDB自动检测事务死锁,并回滚一个或多个事务来打破死锁。
1. InnoDB尝试选择小事务进行回滚,其中事务的大小由插入、更新或删除的行数决定
2. 谁触发死锁,谁回滚
如果系统变量innodb_table_locks=1 (默认)和autocommit = 0,InnoDB可以检测到表级锁和行级锁级别发生的死锁,否则,无法检测到由lock TABLES语句设置的表锁或由非InnoDB存储引擎设置的锁,对于无法检测到的死锁,可以通过设置系统变量innodb_lock_wait_timeout的值来指定锁的超时时间来解决死锁问题
当超过200个事务等待锁资源或等待的锁个数超过1,000,000个时也会被视为死锁,并尝试将等待列表的事务回滚
在高并发系统中,多个线程等待相同的锁时,死锁检测可能会导致性能降性变慢,此时禁用死锁检测并依赖innodb_lock_wait_timeout设置进行事务回滚可能性能更高。可以通过设置系统变量 innodb_deadlock_detect[={OFF|ON}]禁用死锁检测。
如何避免死锁
使用事务而不是使用LOCK TABLES语句手动加锁,并使用innodb_lock_wait_timeout变量设置锁的超时时间,保证任何情况下锁都可以自动释放
经常使用SHOW ENGINE INNODB STATUS命令来确定最近一次死锁的原因。这可以帮助我们修改应用程序以避免死锁
如果出现频繁的死锁警告,可以通过启用innodb_print_all_deadlocks变量来收集调试信息。对于死锁的信息,都记录在MySQL错误日志中,调试完成后记得禁用此选项
如果事务由于死锁而失败,记得重新发起事务,再执行一次
尽量避免大事务,保持事务粒度小且持续时间短,这样事务之间就不容易发生冲突,从而降低发生死锁的概率
修改完成后立即提交事务也可以降低死锁发生的概率。特别注意的是,不要在一个交互式会话中长时间打开一个未提交的事务
当事务中要修改多个表或同一表中的不同行时,每次都要以一致的顺序执行这些操作,使事务中的修改操作形成定义良好的队列,可以避免死锁。而不是在不同的位置编写多个类似的 INSERT、 UPDATE 和 DELETE 语句。我们写的程序其实就是把一系列操作组织成一个方法或函数
向表中添加适当的索引,以便查询时扫描更少的索引并设置更少的锁,可以使用EXPLAINSELECT来确定哪些索引I用于当前的查询
使用表级锁防止对表进行并发更新,可以避免死锁,但代价是系统的并发性降低
如果在查询时加锁,比如SELECT...FOR UPDATE或SELECT...FOR SHARE,尝试使用较低的隔离级别,比如 READ COMMITTED
8. 事务的隔离级别
场景:现在的MYSQL课程,大家能看到的时候,肯定是我以后之后的版本
1. 读未提交 (指的是事务还没有提交)
我备课的时候,一位同学拷走了备课的代码,发现交付的代码与他拷走的代码不一样,与就意味他拷走了一份我已经删除的代码
对应到事务中,表示的是事务B访问到了事务A回滚之前的数据 ----- 脏读(读到了脏数据)
2. 读已提交(只能访问已提交的事务)
我录完课之后把板书放在了公共平台,一共有50个文件,这时同学们就可以访问了
当同学们访问某一份代码的时候,这时我想修改代码的内容,修改完成后又提交了一版
当同学再次打开代码文件的时候,发现和第一次访问的内容不一样
对应到事务中,表示,同一个事务中两次访问同一条数据得到的结果不一致 ----- 不可重复读
3. 可重复读(表示一个事务中访问同一条数据的结果始终一致)
当同学访问某份代码的时候,给我发了个私信,说自己正在看代码,让我先不要修改,这个过程就相当于把自己访问的这份代码加了个锁,这时同学每次访问到的结果都是一致
由于同学并没有对其他的文件加锁,所以我可以新增和删除一些文件,比如我新增了2个文件
当同学刷新目录的时候发现与第一次目录结果集中的文件不一致,从50个变成52个
对应于事务中,表示,同一个事务使用相同的条件,多次查询出来的结果集不一致 ------ 幻读
InnoDB存储引擎中,使用next-key锁,锁住了目标行和之前的间隙,解决了部分的幻读问题
4. 串行化
同学在访问某个文件时,锁住了整个目录,所有的修改都不能进行
对应到事务中,表示,事务锁住了整个表
9. 多版本并发控制 (MVCC)
InnoDB与锁配合,同时采用另一种事务隔离性的实现机制MVCC,即 Multi-VersionedConcurrencyControl 多版本并发控制,用来解决脏读、不可重复读等事务之间读写问题,MVCC在某些场景中替代了低效的锁,在保证了隔离性的基础上,提升了读取效率和并发性。
事务的隔离性是通过锁和MVCC共同实现的
实现原理
MVCC 的实现是基于Undo Log版本链和 ReadView 来完成的,Undo Log 做为回滚的基础,在执行Update 或 Delete操作时,会将每次操作的上一个版本记录在 Undo Log 中,每条 Undo Log 中都记录一个叫做 roll_pointer 的引用信息,通过 roll_pointer 就可以将某条数据对应的 Undo Log 组织成一个 Undo 链,在数据行的头部通过数据行中的 roll_pointer 与 Undo Log 中的第一条日志进行关联,这样就构成一条完整的数据版本、
每一条被修改的记录都会有一条版本链,体现了这条记录的所有变更,当有事务对这条数据进行修改时,将修改后的数据链接到版本链接的头部
版本链是以数据行为单位,版本链在所有事务中共享,也就是说所有事务访问的都是同一条版本链
ReadView
每条数据的版本链都构造好之后,在查询时具体选择哪个版本呢?这里就需要使用 ReadView 结构来实现了,所谓 ReadView 是一个内存结构,顾名思义是一个视图,在事务使用 select 查询数据时就会构造一个 ReadView ,里面记录了该版本链的一些统计值,这样在后续查询处理时就不用遍历所有版本链了,这些统计值具体包括:
m_ids:当前所有活跃事务的集合,没有结束的事务
m_up_limit_id : 活跃事务集合中最小事务 ld,如果Undo版本链中的日志对应的事务 Id 小于此值,也就意味着日志对应的事务已经提交
m_low_limit_id:下一个将被分配的事务 ld,也就是最大的 事务 Id + 1,还没有创建的事务,对于当前事务来说是不可见的
m_creator_trx_id:创建当前 ReadView 的事务 ld,当前事务 ld
构造好 ReadView 之后需要根据一定的查询规则找到唯一的可用版本,这个查找规则比较简单,以下图的版本链为例,在 m_creator_trx_id=201 的事务执行 select 时,会构造一个 ReadView 同时对相应的变量赋值
m_ids:活跃事务集合为[90,100,200],不包含当前新创建的事务
m_up_limit_id:活跃事务最小事务ld=90
m_low_limit_id:预分配事务ID= 202y 最大事务ld= 预分配事务ID- 1 = 201
m_creator_trx_id : 当前创建 ReadView 的事务ld=201
接下来找到版本链头,从链头开始遍历所有版本,根据四步查找规则,判断每个版本:
第一步:判断该版本是否为当前事务创建,若 m_creator_trx_id 等于该版本事务 id,意味着读取自己修改的数据,可以直接访问如果不等则到第二步
第二步:若该版本事务 Id < m_up_limit_id (最小事务ld),意味着该版本在 ReadView 生成之前已经提交,可以直接访问,如果不是则到第三步
第三步:或该版本事务ld >= m_low_limit_id(最大事务ld),意味着该版本在 ReadView 生成之后才创建,所以肯定不能被当前事务访问,所以无需第四步判断,直接遍历下一个版本,如果不是则到第四步
第四步:若该版本事务 ld 在 m_up_limit_id(最小事务ld)和 m_low_limit_id(最大事务Id)之间,同时该版本不在活跃事务列表中,意味着创建 ReadView 时该版本已经提交,可以直接访问,如果不是则遍历并判断下一个版本
遍历到 200 这个 undo 链
因为 200 在 活跃的事务中, 所以会继续向下遍历
MVCC 是否可以解决 不可重复读 与 幻读
首先幻读无法通过MVCC单独解决
对于不可重复读问题,在事务中的第一个查询时创建一个ReadView,后续查询都是用这个ReadView进行判断,所以每次的查询结果都是一样的,从而解决不可重复读问题,在REPEATABLEREAD可重复读,隔离级别下就采用的这种方式 (在可重复读的隔离级别中,整个事务周期只使用第一个查询所创建的ReadView)
如果事务每次查询都创建一个新的ReadView,这样就会出现不可重复读问题,在 READCOMMITTED读已提交的隔离级别下就是这种实现方式 (在读已提交的隔离级别中,整个事务可能会创建多个ReadView)
10. 读未提交与脏读
实现方式 :
读取时:不加任何锁,直接读取版本链中的最新版本,也就是当前读,可能会出现脏读,不可重复读、幻读问题;
更新时:加共享行锁(S锁),事务结束时释放,在数据修改完成之前,其他事务不能修改当前数据,但可以被其他事务读取
存在问题 :
事务的READ UNCOMMITTED隔离级别不使用独占锁,所以并发性能很高,但是会出现大量的数据安全问题,比如在事务A中执行了一条INSERT语句,在没有执行COMMIT的情况下,会在事务B中被读取到,此时如果事务A执行回滚操作,那么事务B中读取到事务A写入的数据将没有意义,我们把这个理象叫做“脏读"。
11. 读已提交与不可重复读
实现方式 :
读取时:不加锁,但使用快照读,即照MVCC机制读取符合ReadView要求的版本数据,每次查询都会构造一个新的ReadView,可以解决脏读,但无法解决不可重复读和幻读问题;
更新时:加独占行锁(X),事务结束时释放,数据在修改完毕之前,其他事务不能修改也不能读取这行数据
存在问题 :
为了解决脏读问题,可以把事务的隔离级别设置为READ COMMITTED,这时事务只能读到了其他事务提交之后的数据,但会出现不可重复读的问题,比如事务A先对某条数据进行了查询,之后事务B对这条数据进行了修改,并且提交(COMMIT)事务,事务A再对这条数据进行查询时,得到了事务B修改之后的结果,这导致了事务A在同一个事务中以相同的条件查询得到了不同的值,这个现象要"不可重复读"。
12. 可重复读与幻读
实现方式 :
读取时:不加锁,也使用快照读,按照MVCC机制读取符合ReadView要求的版本数据,但无论事务中有几次查询,只会在首次查询时生成一个ReadView,可以解决脏读、不可重复读,配合Next-Key行锁可以解决一部分幻读问题;
更新时:加Next-Key行锁,事务结束时释放,在一个范围内的数据修改完成之前,其他事务不能对这个范围内的数据进行修改、插入和删除操作,同时也不能被查询。
存在问题 :
事务的REPEATABLE READ隔离级别是会出现幻读问题的,在InnoDB中使用了Next-Key行锁来解决大部分场景下的幻读问题,那么在不加Next-Key行锁的情况下会出现什么问题吗?
我们知道 Next-Key 锁,锁住的是当前索引记录以及索引记录前面的间隙,那么在不加 Next-Key锁的情况下,也就是只对当前修改行加了独占行锁(X),这时记录前的间隙没有被锁定,其他的事务就可以向这个间隙中插入记录,就会导致一个问题:事务A查询了一个区间的记录得到结果集A!事务B向这个区间的间隙中写入了一条记录,事务A再查询这个区间的结果集时会查到事务B新写入的记录得到结果集B,两次查询的结果集不一致,这个现象就是"幻读"。
问题重现 :
由于REPEATABLE READ隔离级别默认使用了Next-Key锁,为了重现幻读问量,我们把隔离级回退到更新时只加了排他锁的READ COMMITTED(读已提交的隔离级别不开启间隙锁,所以没有使用next-key锁,只使用了索引记录锁)
13. 串行化
实现方式 :
读取时:加共享表锁,读取版本链中的最新版本,事务结束时释放;
更新时:加独占表锁,事务结束时释放,完全串行操作,可以解决所有事务问题。