MySQL老大难事务和锁,一次性讲清楚!
点击上方☝SpringForAll社区 轻松关注!
本文来源:http://r6d.cn/acuaX
众所周知,事务和锁
是mysql中非常重要功能,同时也是面试的重点和难点。本文会详细介绍事务和锁
的相关概念及其实现原理,相信大家看完之后,一定会对事务和锁
有更加深入的理解。
“本文主要内容是根据掘金小册《从根儿上理解 MySQL》整理而来。如想详细了解,建议购买掘金小册阅读。
”
什么是事务
在维基百科中,对事务的定义是:事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。
事务的四大特性
事务包含四大特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)(ACID)。
原子性(Atomicity) 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。以转账场景为例,一个账户的余额减少,另一个账户的余额增加,这两个操作一定是同时成功或者同时失败的。 一致性(Consistency) 一致性是指数据库的完整性约束没有被破坏,在事务执行前后都是合法的数据状态。这里的一致可以表示数据库自身的约束没有被破坏,比如某些字段的唯一性约束、字段长度约束等等;还可以表示各种实际场景下的业务约束,比如上面转账操作,一个账户减少的金额和另一个账户增加的金额一定是一样的。 隔离性(Isolation) 隔离性指的是多个事务彼此之间是完全隔离、互不干扰的。隔离性的最终目的也是为了保证一致性。 持久性(Durability) 持久性是指只要事务提交成功,那么对数据库做的修改就被永久保存下来了,不可能因为任何原因再回到原来的状态。
事务的状态
根据事务所处的不同阶段,事务大致可以分为以下5个状态:
活动的(active) 当事务对应的数据库操作正在执行过程中,则该事务处于 活动
状态。部分提交的(partially committed) 当事务中的最后一个操作执行完成,但还未将变更刷新到磁盘时,则该事务处于 部分提交
状态。失败的(failed) 当事务处于 活动
或者部分提交
状态时,由于某些错误导致事务无法继续执行,则事务处于失败
状态。中止的(aborted) 当事务处于 失败
状态,且回滚操作执行完毕,数据恢复到事务执行之前的状态时,则该事务处于中止
状态。提交的(committed) 当事务处于 部分提交
状态,并且将修改过的数据都同步到磁盘之后,此时该事务处于提交
状态。
事务隔离级别
前面提到过,事务必须具有隔离性。实现隔离性最简单的方式就是不允许事务并发,每个事务都排队执行,但是这种方式性能实在太差了。为了兼顾事务的隔离性和性能,事务支持不同的隔离级别。
为了方便表述后续的内容,我们先建一张示例表hero
。
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
事务并发执行遇到的问题
在事务并发执行时,如果不进行任何控制,可能会出现以下4类问题:
脏写(Dirty Write) 脏写是指一个事务修改了其它事务未提交的数据。 如上图, Session A
和Session B
各开启了一个事务,Session B
中的事务先将number
列为1的记录的name
列更新为'关羽',然后Session A
中的事务接着又把这条number
列为1的记录的name
列更新为张飞。如果之后Session B
中的事务进行了回滚,那么Session A
中的更新也将不复存在,这种现象就称之为脏写。脏读(Dirty Read) 脏读是指一个事务读到了其它事务未提交的数据。 如上图, Session A
和Session B
各开启了一个事务,Session B
中的事务先将number
列为1的记录的name
列更新为'关羽'
,然后Session A
中的事务再去查询这条number
为1的记录,如果读到列name
的值为'关羽'
,而Session B
中的事务稍后进行了回滚,那么Session A
中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。不可重复读(Non-Repeatable Read) 不可重复读指的是在一个事务执行过程中,读取到其它事务已提交的数据,导致两次读取的结果不一致。 如上图,我们在 Session B
中提交了几个隐式事务(mysql会自动为增删改语句加事务),这些事务都修改了number
列为1的记录的列name
的值,每次事务提交之后,如果Session A中
的事务都可以查看到最新的值,这种现象也被称之为不可重复读。幻读(Phantom) 幻读是指的是在一个事务执行过程中,读取到了其他事务新插入数据,导致两次读取的结果不一致。 如上图, Session A
中的事务先根据条件number > 0
这个条件查询表hero
,得到了name
列值为'刘备'
的记录;之后Session B
中提交了一个隐式事务,该事务向表hero
中插入了一条新记录;之后Session A
中的事务再根据相同的条件number > 0
查询表hero
,得到的结果集中包含Session B
中的事务新插入的那条记录,这种现象也被称之为幻读。
“不可重复读和幻读的区别在于不可重复读是读到的是其他事务修改或者删除的数据,而幻读读到的是其它事务新插入的数据。
”
脏写的问题太严重了,任何隔离级别都必须避免。其它无论是脏读,不可重复读,还是幻读,它们都属于数据库的读一致性的问题,都是在一个事务里面前后两次读取出现了不一致的情况。
四种隔离级别
在SQL
标准中设立了4种隔离级别,用来解决上面的读一致性问题。不同的隔离级别可以解决不同的读一致性问题。
READ UNCOMMITTED
:未提交读。READ COMMITTED
:已提交读。REPEATABLE READ
:可重复读。SERIALIZABLE
:串行化。
各个隔离级别下可能出现的读一致性问题如下:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读(READ UNCOMMITTED) | 可能 | 可能 | 可能 |
已提交读(READ COMMITTED) | 不可能 | 可能 | 可能 |
可重复读(REPEATABLE READ) | 不可能 | 不可能 | 可能(对InnoDB不可能) |
串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
InnoDB
支持四个隔离级别(和SQL
标准定义的基本一致)。隔离级别越高,事务的并发度就越低。唯一的区别就在于,InnoDB
在可重复读(REPEATABLE READ)
的级别就解决了幻读的问题。这也是InnoDB
使用可重复读
作为事务默认隔离级别的原因。
MVCC
MVCC(Multi Version Concurrency Control),中文名是多版本并发控制,简单来说就是通过维护数据历史版本,从而解决并发访问情况下的读一致性问题。
版本链
在InnoDB
中,每行记录实际上都包含了两个隐藏字段:事务id(trx_id
)和回滚指针(roll_pointer
)。
trx_id
:事务id。每次修改某行记录时,都会把该事务的事务id赋值给trx_id
隐藏列。roll_pointer
:回滚指针。每次修改某行记录时,都会把undo
日志地址赋值给roll_pointer
隐藏列。
假设hero
表中只有一行记录,当时插入的事务id为80。此时,该条记录的示例图如下:id
分别为100
、200
的事务对这条记录进行UPDATE
操作,操作流程如下:undo
日志记录下来,并用roll_pointer
指向undo
日志地址。因此可以认为,对该条记录的修改日志串联起来就形成了一个版本链
,版本链的头节点就是当前记录最新的值。如下:
ReadView
如果数据库隔离级别是未提交读(READ UNCOMMITTED)
,那么读取版本链中最新版本的记录即可。如果是是串行化(SERIALIZABLE)
,事务之间是加锁执行的,不存在读不一致的问题。但是如果是已提交读(READ COMMITTED)
或者可重复读(REPEATABLE READ)
,就需要遍历版本链中的每一条记录,判断该条记录是否对当前事务可见,直到找到为止(遍历完还没找到就说明记录不存在)。InnoDB
通过ReadView
实现了这个功能。ReadView
中主要包含以下4个内容:
m_ids
:表示在生成ReadView
时当前系统中活跃的读写事务的事务id列表。min_trx_id
:表示在生成ReadView
时当前系统中活跃的读写事务中最小的事务id,也就是m_ids
中的最小值。max_trx_id
:表示生成ReadView
时系统中应该分配给下一个事务的id值。creator_trx_id
:表示生成该ReadView
事务的事务id。
有了ReadView
之后,我们可以基于以下步骤判断某个版本的记录是否对当前事务可见。
如果被访问版本的 trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。如果被访问版本的 trx_id
属性值小于ReadView
中的min_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。如果被访问版本的 trx_id
属性值大于或等于ReadView
中的max_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。如果被访问版本的 trx_id
属性值在ReadView
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。
在MySQL
中,READ COMMITTED
和REPEATABLE READ
隔离级别的的一个非常大的区别就是它们生成ReadView
的时机不同。READ COMMITTED
在每次读取数据前都会生成一个ReadView
,这样就能保证每次都能读到其它事务已提交的数据。REPEATABLE READ
只在第一次读取数据时生成一个ReadView
,这样就能保证后续读取的结果完全一致。
锁
事务并发访问同一数据资源的情况主要就分为读-读
、写-写
和读-写
三种。
读-读
即并发事务同时访问同一行数据记录。由于两个事务都进行只读操作,不会对记录造成任何影响,因此并发读完全允许。写-写
即并发事务同时修改同一行数据记录。这种情况下可能导致脏写
问题,这是任何情况下都不允许发生的,因此只能通过加锁
实现,也就是当一个事务需要对某行记录进行修改时,首先会先给这条记录加锁,如果加锁成功则继续执行,否则就排队等待,事务执行完成或回滚会自动释放锁。读-写
即一个事务进行读取操作,另一个进行写入操作。这种情况下可能会产生脏读
、不可重复读
、幻读
。最好的方案是读操作利用多版本并发控制(MVCC
),写操作进行加锁。
锁的粒度
按锁作用的数据范围进行分类的话,锁可以分为行级锁
和表级锁
。
行级锁
:作用在数据行上,锁的粒度比较小。表级锁
:作用在整张数据表上,锁的粒度比较大。
锁的分类
为了实现读-读
之间不受影响,并且写-写
、读-写
之间能够相互阻塞,Mysql
使用了读写锁
的思路进行实现,具体来说就是分为了共享锁
和排它锁
:
共享锁(Shared Locks)
:简称S锁
,在事务要读取一条记录时,需要先获取该记录的S锁
。S锁
可以在同一时刻被多个事务同时持有。我们可以用select ...... lock in share mode;
的方式手工加上一把S锁
。排他锁(Exclusive Locks)
:简称X锁
,在事务要改动一条记录时,需要先获取该记录的X锁
。X锁
在同一时刻最多只能被一个事务持有。X锁
的加锁方式有两种,第一种是自动加锁,在对数据进行增删改的时候,都会默认加上一个X锁
。还有一种是手工加锁,我们用一个FOR UPDATE
给一行数据加上一个X锁
。
还需要注意的一点是,如果一个事务已经持有了某行记录的S锁
,另一个事务是无法为这行记录加上X锁
的,反之亦然。
除了共享锁(Shared Locks)
和排他锁(Exclusive Locks)
,Mysql
还有意向锁(Intention Locks)
。意向锁是由数据库自己维护的,一般来说,当我们给一行数据加上共享锁之前,数据库会自动在这张表上面加一个意向共享锁(IS锁)
;当我们给一行数据加上排他锁之前,数据库会自动在这张表上面加一个意向排他锁(IX锁)
。意向锁
可以认为是S锁
和X锁
在数据表上的标识,通过意向锁可以快速判断表中是否有记录被上锁,从而避免通过遍历的方式来查看表中有没有记录被上锁,提升加锁效率。例如,我们要加表级别的X锁
,这时候数据表里面如果存在行级别的X锁
或者S锁
的,加锁就会失败,此时直接根据意向锁
就能知道这张表是否有行级别的X锁
或者S锁
。
InnoDB中的表级锁
InnoDB
中的表级锁主要包括表级别的意向共享锁(IS锁)
和意向排他锁(IX锁)
以及自增锁(AUTO-INC锁)
。其中IS锁
和IX锁
在前面已经介绍过了,这里不再赘述,我们接下来重点了解一下AUTO-INC锁
。
大家都知道,如果我们给某列字段加了AUTO_INCREMENT
自增属性,插入的时候不需要为该字段指定值,系统会自动保证递增。系统实现这种自动给AUTO_INCREMENT
修饰的列递增赋值的原理主要是两个:
AUTO-INC锁
:在执行插入语句的时先加上表级别的AUTO-INC锁
,插入执行完成后立即释放锁。如果我们的插入语句在执行前无法确定具体要插入多少条记录,比如INSERT ... SELECT
这种插入语句,一般采用AUTO-INC锁
的方式。轻量级锁
:在插入语句生成AUTO_INCREMENT
值时先才获取这个轻量级锁
,然后在AUTO_INCREMENT
值生成之后就释放轻量级锁
。如果我们的插入语句在执行前就可以确定具体要插入多少条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。
“mysql默认根据实际场景自动选择加锁方式,当然也可以通过
”innodb_autoinc_lock_mode
强制指定只使用其中一种。
InnoDB中的行级锁
前面说过,通过MVCC
可以解决脏读
、不可重复读
、幻读
这些读一致性问题,但实际上这只是解决了普通select
语句的数据读取问题。事务利用MVCC
进行的读取操作称之为快照读
,所有普通的SELECT
语句在READ COMMITTED
、REPEATABLE READ
隔离级别下都算是快照读
。除了快照读
之外,还有一种是锁定读
,即在读取的时候给记录加锁,在锁定读
的情况下依然要解决脏读
、不可重复读
、幻读
的问题。由于都是在记录上加锁,这些锁都属于行级锁
。
InnoDB
的行锁,是通过锁住索引来实现的,如果加锁查询的时候没有使用过索引,会将整个聚簇索引都锁住,相当于锁表了。根据锁定范围的不同,行锁可以使用记录锁(Record Locks)
、间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
的方式实现。假设现在有一张表t
,主键是id
。我们插入了4行数据,主键值分别是 1、4、7、10。接下来我们就以聚簇索引为例,具体介绍三种形式的行锁。
记录锁(Record Locks) 所谓记录,就是指聚簇索引中真实存放的数据,比如上面的1、4、7、10都是记录。 显然,记录锁就是直接锁定某行记录。当我们使用唯一性的索引(包括唯一索引和聚簇索引)进行等值查询且精准匹配到一条记录时,此时就会直接将这条记录锁定。例如 select * from t where id =4 for update;
就会将id=4
的记录锁定。间隙锁(Gap Locks) 间隙指的是两个记录之间逻辑上尚未填入数据的部分,比如上述的(1,4)、(4,7)等。 同理,间隙锁就是锁定某些间隙区间的。当我们使用用等值查询或者范围查询,并且没有命中任何一个 record
,此时就会将对应的间隙区间锁定。例如select * from t where id =3 for update;
或者select * from t where id > 1 and id < 4 for update;
就会将(1,4)区间锁定。临键锁(Next-Key Locks) 临键指的是间隙加上它右边的记录组成的左开右闭区间。比如上述的(1,4]、(4,7]等。 临键锁就是记录锁(Record Locks)和间隙锁(Gap Locks)的结合,即除了锁住记录本身,还要再锁住索引之间的间隙。当我们使用范围查询,并且命中了部分 record
记录,此时锁住的就是临键区间。注意,临键锁锁住的区间会包含最后一个record的右边的临键区间。例如select * from t where id > 5 and id <= 7 for update;
会锁住(4,7]、(7,+∞)。mysql默认行锁类型就是临键锁(Next-Key Locks)
。当使用唯一性索引,等值查询匹配到一条记录的时候,临键锁(Next-Key Locks)会退化成记录锁;没有匹配到任何记录的时候,退化成间隙锁。
间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
都是用来解决幻读问题的,在已提交读(READ COMMITTED)
隔离级别下,间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
都会失效!
“原创不易,觉得文章写得不错的小伙伴,点个赞👍 鼓励一下吧~
”
墙裂推荐
【深度】互联网技术人的社群,点击了解!
关注公众号,回复“spring”有惊喜!!!
如果资源对你有帮助的话