Innodb存储引擎之锁
一、概述
数据库需要尽可能的提高并发访问效率,还要能确保每个用户能以一致的方式读取和修改数据,根据此问题诞生了锁机制。
锁是数据库系统区别于文件系统的一个非常重要的特性,它用于管理对共享资源的并发访问,保证各个用户访问数据一致和完整。
Innodb 提供一致性的非锁定读,支持行级锁。
二、lock 与 latch
-
latch 是轻量级的锁,它要求锁定时间必须非常短,latch锁若持续时间长将严重影响性能,Innodb中它一般用来保证并发线程操作临界资源的正确性;latch 锁定的对象是线程,它保护的是内存中的临界资源。
-
lock 的对象是事务,它一般用来锁定数据库中的对象,比如之前文章中提到的 表、页、行等,lock有死锁机制;它保护的是数据库中的内容,它存在于整个事务过程中仅在发生事务回滚或者事务提交的时候进行释放,它也是本文的主角。
三、Innodb存储引擎中的锁
锁
Innodb支持如下两种标准的行级锁
-
共享锁(S Lock),允许事务读行数据,多个事务可以共享该锁,称为锁兼容。
-
排他锁(X Lock),允许事务更新或删除行数据,只允许一个事务获得排他锁,必须等持有该锁的事务释放后其它的事务才能获取该锁。
仅S 锁跟 S锁 兼容,X 锁跟任意锁不兼容。
Innodb支持多粒度的锁,这种锁定允许事务在行级上的锁和表级上的锁同时存在。
为了支持不同粒度的锁,Innodb支持一种额外的锁定方式,称之为意向锁,它将数据对象分为多个层次,意向锁的意思是希望再更细的粒度上加锁。
下图为数据库的对象层次:
如上的树形结构中,如果要对树底层的行记录加锁,那么必须依次对它所在的数据库、表、页上加一个意向锁。意向锁是表级别的锁,它预示着即将被请求的锁的类型。因为Innodb支持的是行级别的锁,所以意向锁不会阻塞除全表扫描<表级别非意向锁>以外的任何请求。
下图为事务之间表级意向锁和行级锁的兼容情况:
-
意向共享锁 (IS Lock),事务想要获取表中的某几行数据的共享锁。
-
意向排它锁 (IX Lock),事务想要获得表中某几行的排它锁。
事务T1 对表A的编号为k的行,加一个X锁,那么需要先对表和所在页加意向锁,从兼容情况可以得知,任意的意向锁之间相互兼容,最后判断编号为k的行记录上是否存在任意锁,如果不存在,T1对其上X锁成功,否则等待。
可以通过information_schema架构下的三张表简单的分析当前事务可能存在的锁问题
-
INNODB_TRX 当前存在的所有事务,trx_requested_lock_id 表示等待事务的锁Id,若没有则为 null
-
INNODB_LOCKS 记录所有的锁,以及与之相关的事务id、锁类型、需要上锁的资源等,通过它可以查看每张表上的上锁情况。
-
INNODB_LOCL_WAITS 它可以很直观的显示当前等待中的事务,表中记录了:requested_trx_id申请锁资源的事务ID、requested_lock_id申请的锁ID(别的事务占有,所以导致等待)、blocking_trx_id阻塞的事务(当前占有资源的事务)、blocking_lock_id阻塞的锁ID(占有的锁ID,从如下截图可以看出其与requested_lock_id一致);
下图为我模拟的事务等待场景,17270事务占有了锁,事务17272必须等待。
一致性非锁定读
一致性非锁定读是Innodb通过对行记录的多版本控制方式来实现的,它依赖于undo<它用来支持事务的回滚>段来实现。所谓一致性非锁定读,即使需要读取的行上存在X锁,读取的请求也不会进行等待,而是去读取,通过undo段实现的,行记录的一个快照。它不会等待,所以称为 “非锁定”,因为undo段上的快照数据是不会被事务修改的,所以不需要上锁。
另外由于undo段上的行记录的快照可能不止一个,所以这种多版本控制称为行多版本技术,它带来的并发控制称为:多版本并发控制(MVCC);
Innodb在一些事务级别下支持一致性非锁定读,此外即使是在这些事务隔离级别下,对于一致性非锁定读也有着差异;Innodb中,READ COMMITED 和 REPEATABLE READ 事务隔离级别下支持一致性非锁定读。
-
READ COMMITED 事务隔离级别下,行多版本控制默认读取最新一行。
-
REPEATABLE READ 事务隔离级别下,行多版本控制默认读取该事务开始时的行版本数据<该版本之前或者之后都可能存在行记录别的版本>;也就是说当前事务未提交前,不论该行如何改变,当前事务读取到的都是当前事务开始那一刻的行数据。
一致性锁定读
Innodb只是显式的对数据库操作进行加锁以确保数据一致,它支持如下两种形式的一致性锁定读。
-
select c1,c2 from table_1 where xx = xx for update
-
select c1,c2 from table_1 where xx = xx lock in share mode
for update - 它对读取的行加一个X锁。
lock in share mode - 他对读取的行加一个S锁。
通过锁兼容性,我们很清楚能对它们做什么,不能做什么。
自增长与锁
自增长相信大家都很熟,它在主键上的应用非常广泛;在较低版本的MySQL中,自增长通过表锁实现,这样做的缺点是并发性能较差,因为它是表锁的设计,所以当多个事务插入时,别的事务只能等待执行的事务提交后才能继续。
先来了解一下SQL插入语句类型:
-
insert-like 它泛指所有类型的插入语句。
-
simple inserts 它指再插入前就能明确知道插入的数据行数(或者说能明确知道需要执行多少次自增)。
-
bulk inserts 这类sql插入前无法得知需要插入多少条数据,例如:
# table_a 和 table_b 拥有相同的表结构,sql语句执行前我们不知道有多少条数据会被插入table_a insert into table_a select * from table_b where name like 'prefix%';
-
mixed-mode inserts 这类SQL中既有自增长的,又有确定的,例如:
insert into table_t (c1,c2) values (1,false), (2,true),(null, false),(null, true);
从MySQL5.1.22版本开始,Innodb存储引擎引入了一种轻量级的互斥量(同一时刻只能有一个线程能访问它,相较于表锁它需要付出的代价小了太多)来实现自增长;此版本开始Innodb提供了一个参数:innodb_autoinc_lock_mode 来控制自增长的模式。
-
当 innodb_autoinc_lock_mode = 0 表示使用低版本的MySQL中的表锁方式实现自增长。
-
当 innodb_autoinc_lock_mode = 1,知道行数的 simple inserts 插入会使用互斥量实现自增长,不知道行数的插入使用表锁方式。需要注意的是,当有事务使用表锁方式自增长时,使用互斥量自增长的事务必须等待。
-
当 innodb_autoinc_lock_mode = 2,对所有插入都使用互斥量,毫无疑问这是性能最优的选择,但是需要注意如下的问题:
1.但是正因为是并发的,所以插入的值可能不是连续自增长的(可能部分事务发生回滚,但是已经无法将子增量退回, 因为使用互斥量,事务间是没有发生阻塞的); 2.已知binlog在 statement 模式下,记录的是执行的SQL;row模式记录的是每一行的变化;当所有插入都使用互斥量时, statement模式的binlog无法保证在从库上执行后也能得到同样的主键,所以当所有sql都通过互斥量自增长时, 主从复制必须使用row模式记录每一行的变化,而不是记录执行的SQL。
外键与锁
与Oracle不同的是,Innodb建立外键时,如果用户没有主动建立索引,它会自动在该列上建一个索引。
当需要对表上的外键值进行更新或者插入时,会通过一致性锁定读的方式去查父表该外键是否存在于父表上:
- select * from parent_table where foreign_column_name = xx lock in share mode
如果存在一个行记录,会对该行记录加一个S锁,在对子表操作事务提交前,父表上该行不能被任何事务上X锁。
从而保证了外键约束的功能。
四、锁的算法
锁的算法
-
Record Lock 单个行记录上的锁,它总是用于锁定索引上的一个记录。
-
Gap Lock 间隙锁,锁定一个范围,但是不包含记录本身,它的作用是阻止多个事务将记录插入到同一个范围内导致幻读问题。
-
Next-Key Lock 它锁定的不是单个值而是一个范围,且包含它自身,** Innodb 通过它解决大名鼎鼎的 “幻读问题” **。它除了锁定包含该值的范围外,还会锁定该值的下一个值所在的区间。
重点讲一下Next-Key Lock,假如一个索引上有如下值:-100,-30,22,56,那么可以被锁定的范围是:
- ( -∞,-100 ]
- ( -100,-30 ]
- ( -30,22 ]
- ( 22,56 ]
- ( 56,+∞ )
假如需要对值56 上一个锁,那么区间:( 22,56 ]、( 56,+∞ ) 会被锁住。
此外,当上述索引为唯一索引时,锁会降级为行锁,此时还是对行记录 56 上锁,此时被上锁的就是56这个行本身。【例外情况是,当唯一索引由多个列组成,如果事务中只操作其中的部分列时,那么此时的查询类型是range而不是point查询,所以依然需要使用 Next-Key Lock】。
当存在多个索引时,会对聚集索引上的Next-Key Lock 降级为 Record Lock 而对于非唯一的辅助索引则会严格按照Next-Key Lock 上锁,
Phantom Problem 幻读问题
- Phantom Problem 幻读问题指的是在同一个事务下,同一个SQL两次查询可能得到不同的查询结果。
下面来进行一个模拟,表r中有三行数据,其中 列a 上存在一个聚集索引。
测试1,使用read committed 事务隔离级别:
表中原始数据如下
事务A执行如下语句不提交事务
set transaction isolation level read committed;
start transaction;
select * from r where a >= 10 for update;
此时开启事务B,执行如下语句,插入成功
回到事务A,再次执行相同的查询语句,发现查询结果多了一列:12,很明显它违反了事务的隔离性,因为当前事务能看到别的事务的执行结果,这就是Phantom Problem 问题。
测试2,使用 repeatable read 事务隔离级别
表中数据同样为:10、13、15
事务A执行如下语句不提交事务,由于隔离级别不同,此时关于 行10 的锁不再降级为行锁
set transaction isolation level repeatable read;
start transaction;
select * from r where a >= 10 for update;
事务B 执行如下插入,此时发现,插入任意 大于10 的值都被阻塞。
这时任意可能导致事务A中SQL select * from r where a >= 10 for update; 查询结果变更的SQL都无法成功插入。
- 此外测试过程中还发现了一些特殊现象,当被锁定的值是边界的时候可能不一定完全遵循上述规则。
repeatable read 事务隔离级别下,并不一定完全是Next-Key Lock,它锁定的范围是根据SQL条件来决定的。例如:表中a有如下值:10,20,30,40,50
-
a >= 20 锁定 20到正无穷。
-
a = 20 时实际上只对行 20 加了一个行锁。
五、锁的问题
脏读
所谓脏读,是指事务读到了别的事务未提交的数据,被当前事务读到,在事务隔离级别为:read uncommitted 下会出现,它违反了事务隔离性。
在mysql 主从模式下,slave节点可以设置为:read uncommitted 因为slave只负责查询,并不会有需要修改数据的事务由slave去完成。
不可重复读
指同一个事务中,对相同资源的多次读取可能得到不同的结果,这是因为需要读取的数据被别的事务修改了,同样这也违反了事务的隔离性。在 read commited 事务隔离级别下会出现该问题。
它读到的都是已经提交的数据<非锁定读,否则别的事务不可能更改目标资源>,但是当前事务未提交前,目标资源还可能被别的事务读取并修改。
丢失更新
该问题与事务隔离级别无关,它是逻辑上的丢失,实际应用中非常多这样的使用场景,我们需要对某个行进行更新,但是我们不是直接update 去更新,而是先查询,然后在现有指的基础上修改。
-
select 得到余额,展示到页面上
-
用户操余额进行update 操作更新数据
在并发环境下,如果多个用户执行上述操作就会出现问题。
针对此种情况,需要在读取的时候就对数据加一个 X 锁 (select … for update ),这样就可以保证该数据不会别别的事务读取并修改了。
六、阻塞
阻塞发生的原因是由锁兼容问题导致的,例如事务A在行 K 上加了一个X 锁,其它任意事务访问行 K 的行为都会被阻塞,如此可以保证事务可以并发且正确的运行。
需要注意的是,一旦事务中发生了异常,必须决定回滚还是提交。
七、死锁
死锁的概念很简单:两个事务互相等待对方互斥占用的资源,或者多个事务间互斥资源的请求形成了回路。
事务死锁判断机制名为:wait-for graph,它是一种主动死锁检测机制,如果它发现某个sql 可能导致死锁会抛出异常。它的基本概念可以参考 JVM 的 GC Root机制判断循环依赖,当然它们具体实现肯定各不相同。
八、锁升级
锁升级指,锁从细粒度升级未粗粒度,多个行锁可能升级为页锁,多个页锁也可能升级为表锁。
- 单独的SQL中在一个对象上持有锁的数量超过了阈值,这时会自动进行锁升级,默认值为5000.
- 锁资源占用的内存超过了激活内存的40%时会发生锁升级。