MySQL之8---锁、MVCC和RV
锁
锁:对 “某种范围(行和表)” 的数据上 “某种锁”
作用:保护并发访问资源。在事务ACID过程中,“锁”和“隔离级别”一起来实现“I”隔离性和"C" 一致性 (redo也有参与).
-- 查看锁状态
SELECT * FROM sys.innodb_lock_waitsG
-- 持有和请求的数据锁,8.0后
SELECT * FROM performance_schema.data_locks;
-- 查看事务的状态
SELECT * FROM information_schema.innodb_trx;
保护资源分类
-
latch
(闩锁):rwlock、mutex,主要保护内存资源 -
MDL
: Metadata_lock,元数据(DDL操作),全局表锁 -
table_lock
:表级别- Lock table t1 read ;
- mysqldump、XBK(PBK):备份非InnoDB数据时,触发FTWRL全局锁表(Global)。
- 行锁升级为表锁。
AUTO-INC Locks
:自增锁,发生涉及AUTO_INCREMENT列的事务性插入操作时产生。
-
Row Lock
:InnoDB 默认锁粒度,加锁方式都是在索引加锁的。-
Record Lock
:记录锁,在聚簇索引锁定。- RC级别只有 Record Lock。
- 现象:某一事务操作一行数据时,锁定所用行,另一事务再操作这一行时,会夯住50s后退出事务。
- 仅仅锁住索引记录的一行,在单条索引记录上加锁。
- 锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁。
-
GAP Lock
:间隙锁,在辅助索引间隙加锁。- RR级别存在,可以防止幻读,保证索引间的不会被插入数据。
- 现象:某一事务范围操作数据时,锁定要操作的数据之间的范围,另一事务再操作这个范围之间的数据时,会夯住50s后退出事务。
- 仅仅锁住一个索引区间。( , )
- 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录本身。
-
Next-Key Lock
:下一键锁,- GAP+Record。RR级别存在。( , ]
-
select … for update
- 当查询的索引含有唯一属性时,降级为Record Lock,即仅锁住索引本身,不是范围。
- Next-Key Lock在不同的场景中会退化
场景 退化成的锁类型 使用unique index精确匹配(=),且记录存在 Record Lock 使用unique index精确匹配(=),且记录不存在 GAP Lock 使用unique index范围匹配(<和>) GAP+Record,[ , ) -
Insert Intention Locks
:插入意向锁,是一种GAP Lock
,不是意向锁,在insert操作时产生。-
在多事务同时写入不同数据,至同一索引间隙时,并不需要等待其他事务完成,不会发生锁等待。
- 例如:一个记录索引包含键值4和7,不同的事务分别插入5和6。每个事务都会产生一个加在4-7之间的插入意向锁,获取在插入行上的排它锁。但是不会被互相锁住,因为数据行并不冲突。
-
插入意向锁不会阻止任何锁,对于插入的记录会持有一个记录锁。
- 例如:test表存在若干数据的数据,先开始一个事务A,插入一条n=5的数据;
insert into test (n) values(5);
- 如果此时事务B执行查询 ,会申请
GAP Lock
(4, ∞),申请成功后,被事务A的x锁阻塞,直到x锁被释放。
select * from test where n > 4 for update;
-
-
兼容性
-
是否兼容 | 当事务A上了:Gap | Insert Intention | Record | Next-Key |
---|---|---|---|---|
事务B能否上:Gap | 是 | 是 | 是 | 是 |
Insert Intention | 否 | 是 | 是 | 否 |
Record | 是 | 是 | 否 | 否 |
Next-Key | 是 | 是 | 否 | 否 |
功能性上分类
-
共享锁 Shared Locks(S锁)读锁。
- 兼容性:加了S锁的记录,允许其他事务再加S锁,不允许其他事务再加X锁
- 加锁方式:
select … lock in share mode
- 当事务同时增加共享锁时候,事务的更新必须等待先执行的事务 commit 后才行,如果同时并发太大可能很容易造成死锁
-
排他锁 Exclusive Locks(X锁)写锁。
- 兼容性:加了X锁的记录,不允许其他事务再加S锁或者X锁
- 加锁方式:
select … for update
-
意向锁 Intention Locks,意向锁相互兼容,表级锁
- 表明“某个事务正在某些行持有了锁、或该事务准备去持有锁”
- 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。
- 例如:事务A修改user表的记录r,会给记录r上一把行级的排他锁(X),同时会给user表上一把意向排他锁(IX),这时事务B要给user表上一个表级的排他锁就会被阻塞。意向锁通过这种方式实现了行锁和表锁共存且满足事务隔离性的要求。
1)意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
2)意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁
q1:为什么意向锁是表级锁呢?
当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁);
(1)如果意向锁是行锁,则需要遍历每一行数据去确认;
(2)如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。
q2:意向锁怎么支持表锁和行锁并存?
(1)首先明确并存的概念是指数据库同时支持表、行锁,而不是任何情况都支持一个表中同时有一个事务A持有行锁、又有一个事务B持有表锁,因为表一旦被上了一个表级的写锁,肯定不能再上一个行级的锁。
(2)如果事务A对某一行上锁,其他事务就不可能修改这一行。这与“事务B锁住整个表就能修改表中的任意一行”形成了冲突。所以,没有意向锁的时候,让行锁与表锁共存,就会带来很多问题。于是有了意向锁的出现,如q1的答案中,数据库不需要在检查每一行数据是否有锁,而是直接判断一次意向锁是否存在即可,能提升很多性能。
- 意向锁和共享锁、排他锁的兼容关系。
是否兼容 | 当事务A上了:IS | IX | S | X |
---|---|---|---|---|
事务B能否上:IS | 是 | 是 | 是 | 否 |
IX | 是 | 是 | 否 | 否 |
S | 是 | 否 | 是 | 否 |
X | 否 | 否 | 否 | 否 |
意向锁相互兼容,因为IX、IS只是表明申请更低层次级别元素(比如 page、记录)的X、S操作。
因为上了表级S锁后,不允许其他事务再加X锁,所以表级S锁和X、IX锁不兼容。
上了表级X锁后,会修改数据,所以表级X锁和 IS、IX、S、X(即使是行排他锁,因为表级锁定的行肯定包括行级速订的行,所以表级X和IX、行级X)不兼容。
注意:上了行级X锁后,行级X锁不会因为有别的事务上了IX而堵塞,一个mysql是允许多个行级X锁同时存在的,只要他们不是针对相同的数据行。
锁的选择
-
如果更新条件没有走索引,例如执行
update test set name=“hello” where name=“world”;
,此时会进行全表扫描,扫表的时候,要阻止其他任何的更新操作,所以上升为表锁。 -
如果更新条件为索引字段,但是并非唯一索引(包括主键索引),例如执行
update test set name=“hello” where code=9;
,此时更新会使用Next-Key Lock。原因:- 首先要保证在符合条件的记录上加上排他锁,会锁定当前非唯一索引和对应的主键索引的值;
- 还要保证锁定的区间不能插入新的数据。
- 如果更新条件为唯一索引,则使用Record Lock(记录锁)。
MVCC
MVCC(Multi-Version Concurrent Control):多版本并发控制协议
目标是在保证数据一致性的前提下,提高数据库高并发场景下的吞吐性能。
不同的事务在并发过程中,SELECT
操作可以不加锁而是通过 MVCC
机制读取指定的版本历史记录,并通过一些手段保证保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。
例如两个事务 A
和 B
按照如下顺序进行更新和读取操作
在事务 A
提交前后,事务 B
读取到的 x
的值是什么呢?
答案是:事务 B
在不同的隔离级别下,读取到的值不一样。
- 如果事务
B
的隔离级别是读未提交(RU),那么两次读取均读取到x
的最新值,即20
。 - 如果事务
B
的隔离级别是读已提交(RC),那么第一次读取到旧值10
,第二次因为事务A
已经提交,则读取到新值 20。 - 如果事务
B
的隔离级别是可重复读或者串行(RR,S),则两次均读到旧值10
,不论事务A
是否已经提交。
可见在不同的隔离级别下,数据库通过 MVCC
和隔离级别,让事务之间并行操作遵循了某种规则,来保证单个事务内前后数据的一致性。
特点
- 应对高并发事务,
MVCC
比单纯的加行锁更有效, 开销更小 MVCC
在读已提交(Read Committed)
和可重复读(Repeatable Read)
隔离级别下起作用MVCC
既可以基于乐观锁又可以基于悲观锁来实现
悲观锁
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
因为悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响了程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是对长事务而言,这样的开销往往无法承受。所以与悲观锁相对的,我们有了乐观锁。
乐观锁
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
两种常见实现方式:版本号机制和CAS 算法的自旋锁
版本号机制
一般是在数据表中加上版本号字段 version
,表示数据被修改的次数。当数据被修改时,这个字段值会加1。
例如:假设帐户信息表中有一个 version 字段,当前值为 1 ,而当前帐户余额( balance )为 100 。
- 操作员 A 此时准备将其读出( version=1 ),并从其帐户余额中扣除 50( 100-50 );
- 操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 20 ( 100-20 );
- 操作员 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),提交到数据库完成更新;
- 操作员 B 完成了操作,也将版本号加1( version=2 )试图向数据库提交数据( balance=80 ),但此时比对数据库记录版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。
因此,操作员 B 的提交被驳回。这样,就避免了操作员 B 用基于 version=1 的旧数据修改,最终造成覆盖操作员 A 操作结果的可能。
CAS 算法
即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个 native 原子操作)。一般情况下,这是一个自旋操作,即不断的重试。
InnoDB MVCC 实现原理
InnoDB MVCC 采用乐观锁的版本号机制,和回滚指针组织成的 Undo Log
链提供的“快照”机制,实现非锁定读取。
每个事务操作都要经历的两个阶段:
- 读操作:乐观锁。没有锁
- RV:Read View(可读视图)
- 在RC级别下,事务中可以立即读取到其他事务 commit 过的 readview
- 在RR级别下,事务中从第一次查询开始,生成一个一致性 readview,直到事务结束。
- 写操作:悲观锁。X锁,行锁,谁先操作某个数据行,就会持有<这行>的(X)锁.
每一行记录都有两个隐藏列:TRX_ID
、ROLL_PTR
(如果没有主键,则还会多一个隐藏的单调自增主键列GEN_CLUST_INDEX
)。另外,每条记录的头信息(record header
)里都有一个专门的 bit
(deleted_flag
)来表示当前记录是否已经被删除。
TRX_ID
保存了行的创建时间(system version number
),ROLL_PTR
保存行的过期时间(或删除时间)。
每开始一个新的事务,系统版本号都会自动递增。
事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
组织 Undo Log 链
在多个事务并行操作某行数据的情况下,不同事务对该行数据的 DML 操作会产生多个版本(global consitence snapshot
),然后通过回滚指针组织成一条 Undo Log
链,一直伴随着事务生命周期结束。
UPDATE
事务 A
对值 x
进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务 ID
为 100
,事务 A
的 ID
为 200
,该行的隐藏主键为 1
。
事务 A
的操作过程为:
- 对
DB_ROW_ID = 1
的这行记录加排他锁 - 把该行原本的值拷贝到
undo log
中,DB_TRX_ID
和DB_ROLL_PTR
都不动 - 修改该行的值这时产生一个新版本,更新
DATA_TRX_ID
为修改记录的事务ID
,将DATA_ROLL_PTR
指向刚刚拷贝到undo log
链中的旧版本记录,这样就能通过DB_ROLL_PTR
找到这条记录的历史版本。如果对同一行记录执行连续的UPDATE
,Undo Log
会组成一个链表,遍历这个链表可以看到这条记录的变迁 - 记录
redo log
,包括undo log
中的修改
INSERT
产生一条新纪录,它的 DATA_TRX_ID
为当前插入记录的事务 ID
;
DELETE
可看成是一种特殊的 UPDATE
,其实是软删,真正执行删除操作会在 commit
时,DATA_TRX_ID
则记录下删除该记录的事务 ID
。
RR隔离级别下,MVCC实现
-
SELECT
InnoDB会根据以下两个条件检查每行记录:
- InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
只有符合上述两个条件的记录,才能返回作为查询结果
-
INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号。
-
DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除标识。
-
UPDATE
InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作
初始化测试环境:
create table mvcctest(
id int primary key auto_increment,
name varchar(20));
insert into mvcctest values(NULL,'mi');
insert into mvcctest values(NULL,'kong');
commit;
假设系统初始事务ID为1;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | undefined |
transaction 1:
start transaction;
select * from mvcctest; //(1)
select * from mvcctest; //(2)
commit
SELECT
假设当执行事务1的过程中,准备执行语句(2)时,开始执行事务2:
transaction 2:
start transaction;
insert into mvcctest values(NULL,'qu');
commit;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | undefined |
3 | qu | 3 | undefined |
事务2执行完毕,开始执行事务1的语句(2),由于事务1只能查询创建时间小于等于2的,所以事务2新增的记录在事务1中是查不出来的,这就通过乐观锁的方式避免了幻读的产生。
UPDATE
假设当执行事务1的过程中,准备执行语句(2)时,开始执行事务3:
transaction session 3:
start transaction;
update mvcctest set name = 'fan' where id = 2;
commit;
InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间。
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | 4 |
2 | fan | 4 | undefined |
事务3执行完毕,开始执行事务1语句(2),由于事务1只能查询创建时间小于等于2的,所以事务修改的记录在事务1中是查不出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的。
DELETE
假设当执行事务1的过程中,准备执行语句(2)时,开始执行事务4:
transaction session 4:
start transaction;
delete from mvcctest where id = 2;
commit;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | 5 |
事务4执行完毕,开始执行事务1语句(2),由于事务1只能查询创建时间小于等于2、并且过期时间大于等于2,所以id=2的记录在事务1语句(2)中,也是可以查出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的。
ReadView
在 RU
隔离级别下,直接读取版本的最新记录就 OK,对于 SERIALIZABLE
隔离级别,则是通过加锁互斥来访问数据,因此不需要 MVCC
的帮助。因此 MVCC
运行在 RC
和 RR
这两个隔离级别下,当 InnoDB
隔离级别设置为二者其一时,在 SELECT
数据时就会用到版本链。
核心问题是版本链中哪些版本对当前事务可见?
InnoDB
为了解决这个问题,设计了 ReadView
(可读视图)的概念。
RR 下的 ReadView 生成
在 RR
隔离级别下,每个事务 touch first read
时(本质上就是执行第一个 SELECT
语句时,后续所有的 SELECT
都是复用这个 ReadView
,其它 update
, delete
, insert
语句和一致性读 snapshot
的建立没有关系),会将当前系统中的所有的活跃事务拷贝到一个列表生成ReadView
。
下图中事务 A
第一条 SELECT
语句在事务 B
更新数据前,因此生成的 ReadView
在事务 A
过程中不发生变化,即使事务 B
在事务 A
之前提交,但是事务 A
第二条查询语句依旧无法读到事务 B
的修改。
下图中,事务 A
的第一条 SELECT
语句在事务 B
的修改提交之后,因此可以读到事务 B
的修改。但是注意,如果事务 A
的第一条 SELECT
语句查询时,事务 B
还未提交,那么事务 A
也查不到事务 B
的修改。
RC 下的 ReadView 生成
在 RC
隔离级别下,每个 SELECT
语句开始时,都会重新将当前系统中的所有的活跃事务拷贝到一个列表生成 ReadView
。二者的区别就在于生成 ReadView
的时间点不同,一个是事务之后第一个 SELECT
语句开始、一个是事务中每条 SELECT
语句开始。
ReadView
中是当前活跃的事务 ID
列表,称之为 m_ids
,其中最小值为 up_limit_id
,最大值为 low_limit_id
,事务 ID
是事务开启时 InnoDB
分配的,其大小决定了事务开启的先后顺序,因此我们可以通过 ID
的大小关系来决定版本记录的可见性,具体判断流程如下:
- 如果被访问版本的
trx_id
小于m_ids
中的最小值up_limit_id
,说明生成该版本的事务在ReadView
生成前就已经提交了,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
大于m_ids
列表中的最大值low_limit_id
,说明生成该版本的事务在生成ReadView
后才生成,所以该版本不可以被当前事务访问。需要根据Undo Log
链找到前一个版本,然后根据该版本的 DB_TRX_ID 重新判断可见性。 - 如果被访问版本的
trx_id
属性值在m_ids
列表中最大值和最小值之间(包含),那就需要判断一下trx_id
的值是不是在m_ids
列表中。如果在,说明创建ReadView
时生成该版本所属事务还是活跃的,因此该版本不可以被访问,需要查找 Undo Log 链得到上一个版本,然后根据该版本的DB_TRX_ID
再从头计算一次可见性;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。 - 此时经过一系列判断我们已经得到了这条记录相对
ReadView
来说的可见结果。此时,如果这条记录的delete_flag
为true
,说明这条记录已被删除,不返回。否则说明此记录可以安全返回给客户端。
RC 下的 MVCC 判断流程
我们现在回看刚刚的查询过程,为什么事务 B
在 RC
隔离级别下,两次查询的 x
值不同。RC
下 ReadView
是在语句粒度上生成的。
当事务 A
未提交时,事务 B
进行查询,假设事务 B
的事务 ID
为 300
,此时生成 ReadView
的 m_ids
为 [200,300]
,而最新版本的 trx_id
为 200
,处于 m_ids
中,则该版本记录不可被访问,查询版本链得到上一条记录的 trx_id
为 100
,小于 m_ids
的最小值 200
,因此可以被访问,此时事务 B
就查询到值 10
而非 20
。
待事务 A
提交之后,事务 B
进行查询,此时生成的 ReadView
的 m_ids
为 [300],而最新的版本记录中 trx_id
为 200
,小于 m_ids
的最小值 300
,因此可以被访问到,此时事务 B
就查询到 20
。
RR 下的 MVCC 判断流程
如果在 RR
隔离级别下,为什么事务 B
前后两次均查询到 10
呢?
RR
下生成 ReadView
是在事务开始时,m_ids 为 [200,300],后面不发生变化,因此即使事务 A
提交了,trx_id
为 200
的记录依旧处于 m_ids
中,不能被访问,只能访问版本链中的记录 10
。
一个争论点
其实并非所有的情况都能套用 MVCC
读的判断流程,特别是针对在事务进行过程中,另一个事务已经提交修改的情况下,这时不论是 RC
还是 RR
,直接套用 MVCC
判断都会有问题,例如 RC
下:
事务 A
的 trx_id = 200
,事务 B
的 trx_id = 300
,且事务 B
修改了数据之后在事务 A
之前提交,此时 RC
下事务 A
读到的数据为事务 B
修改后的值,这是很显然的。下面我们套用下 MVCC
的判断流程,考虑到事务 A
第二次 SELECT
时,m_ids
应该为 [200],此时该行数据最新的版本 DATA_TRX_ID = 300
比 200
大,照理应该不能被访问,但实际上事务 A
选取了这条记录返回。
这里其实应该结合 RC
的本质来看,RC
的本质就是事务中每一条 SELECT
语句均可以看到其他已提交事务对数据的修改,那么只要该事物已经提交其结果就是可见的,与这两个事务开始的先后顺序无关,不完全适用于 MVCC 读。
RR
级别下还是用之前那张图:
这张图的流程中,事务 B
的 trx_id = 300
比事务 A
200
小,且事务 B
先于事务 A
提交,按照 MVCC
的判断流程,事务 A
生成的 ReadView
为 [200],最新版本的行记录 DATA_TRX_ID = 300
比 200
大,照理不能访问到,但是事务 A
实际上读到了事务 B
已经提交的修改。这里还是结合 RR
本质进行解释,RR
的本质是从第一个 SELECT
语句生成 ReadView
开始,任何已经提交过的事务的修改均可见。
总结
RC
、RR
两种隔离级别的事务在执行普通的读操作时,通过访问版本链的方法,使得事务间的读写操作得以并发执行,从而提升系统性能。
RC
、RR
这两个隔离级别的一个很大不同就是生成 ReadView
的时间点不同。
RC
在每一次 SELECT
语句前都会生成一个 ReadView
,事务期间会更新,因此在其他事务提交前后所得到的 m_ids
列表可能发生变化,使得先前不可见的版本后续又突然可见了。
而 RR
只在事务的第一个 SELECT
语句时生成一个 ReadView
,事务操作期间不更新。