6. 锁
开发多用户、数据库驱动的应用时,最大的难点是:一方面要最大程度的利用数据库的并发访问,一方面还要确保每个用户能以一致的方式读取和修改数据,为此有了锁的机制。
6.1 什么是锁
锁机制用于管理对共享资源的并发访问。InnoDB除了会在行级别上对表数据上锁,也会在数据库内部其他多个地方上锁,从而允许对多种不同资源提供并发访问。例如,操作缓冲池中的LRU列表,删除、添加移动LRU列表上的元素等。
InnoDB 锁的实现和 Oracle数据库非常类似,提供一致性的非锁定读、行级锁支持,行级锁没有相关的额外开销,并可以得到并发性和一致性。
6.2 lock 与 latch(闩)
latch 一般称为闩锁(轻量级),因为其要求锁定的时间非常短,若持续时间长,性能会很差。在 InnoDB中,latch 又可以分为 mutex 和rwlock (读写锁)。目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检验。
lock 一般锁定的是数据库中的对象,如表、页、行。并且lock的对象仅在事务 commit 或 rollback后释放。
lock | latch | |
对象 | 事务 | 线程 |
保护 | 数据库内存 | 内存数据结构 |
持续时间 | 整个事务过程 | 临界资源 |
模式 | 行锁、表锁、意向锁 | 读写锁、互斥锁 |
死锁 | 通过 wait for graph、time out 机制进行死锁检测与处理 | 无死锁检测与处理机制,仅通过应用程序加锁的顺序保证无死锁的发生 |
存在于 | lock Manager的哈希表中 | 每个数据结构的对象中 |
6.3 InnoDB存储引擎中的锁
6.3.1 锁的类型
InnoDB 实现了以下两种标准的行级锁:
- 共享锁,S Lock,允许事务读一行数据
- 排它锁, X Lock,允许事务删除或更新一行数据
S和X 锁都是行锁,兼容是指对同一行记录的兼容性情况。
InnoDB 支持多粒度锁定,这种锁定支持行级别的锁和表级别的锁同时存在,即意向锁,意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。
如果需要对页上的记录r加X锁,需要分别对数据库A、表、页上意向锁IX,最后对r上X锁。若其中任意一个部分导致等待,那么该操作需要等待粗粒度的锁完成。InnoDB支持意向锁比较简单,其意向锁就是表级别的锁,主要是为了在一个事务中揭示下一行将被请求的锁类型。
- 意向共享锁(IS Lock),事务想要获得表中某几行的共享锁
- 意向排它锁(IX Lock),事务想要获得表中某几行的排它锁
InnoDB支持的是行级别的锁,因此意向锁不会阻塞全表扫描以外的任何请求。
IS | IX | S | X | |
IS | 兼容 | 箭容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
6.3.2 一致性非锁定读
一致性的非锁定读(consistent nonlocking read)是指 InnoDB 通过多版本并发控制(multi versioning Concurrency control)的方式来读取当前执行时间数据库中行的数据。如果此时,读取的行正在执行 delete 或 update,这时读取操作不会因此等待行上的锁释放,相反,InnoDB会去读取行的一个快照数据(该实现是通过 undo 段来完成),而undo段是用来在事务中回滚数据,因此没有额外开销,此外,读取快照数据是不需要上锁的,因为没有事务需要对历史数据进行修改。
一致性的非锁定读,是InnoDB的默认读取方式(Repeatable隔离级别),即读取不会占用和等待表上的锁。
一个行记录不止一个快照数据,一般称这种技术为行多版本并发控制技术,在事务隔离级别为 READ COMMITED 和 REPEATABLE READ下,InnoDB使用的是非锁定的一致性读。
在 READ COMMITED事务隔离级别下,对于快照数据总是读取被锁定行的最新一份的快照数据,从数据库理论的角度看,违反了ACID中的隔离性I。而 REPEATABLE READ 读取的是事务开始时的行数据版本。
时间 | 会话A | 会话B |
1 | BEGIN | |
2 | select * from t where id=1 | |
3 | BEGIN | |
4 |
update t set id=3 where id=1 此时,事务没有提交,加X锁,会话A的查询5,使用MVCC查询结果 |
|
5 | select * from t where id=1 | |
6 | COMMIT; | |
7 | select * from t where id=1 | |
8 | COMMIT; |
在 READ COMMITED 隔离级别下,1,5 返回id=1的数据,7返回null,因为会话B提交后,id=1的数据被更新为3,此时的数据是最新的。而在REPEATABLE READ 下,1,5,7 总是返回id=1 的数据,因为总是读取事务开始前的数据。
6.3.3 一致性锁定读
某些情况下,用户需要显式的对数据库读取操作进行加锁以保证数据的一致性。而这要求数据库支持加锁语句,即使是对于select 的查询语句。InnoDB对于 select语句支持两种一致性的锁定读操作:
- select …… for update
- select …… lock in share mode
select …… for update 对读取的行加X锁,其他事务不能再对该行加任何锁。select …… share in mode 对读取的行记录加S锁,其他事务可以向被锁定的行加S锁,但不能加X锁,X锁只能阻塞。
select …… for update 和 select …… share in mode必须在事务中,事务提交,锁释放。
6.3.4 自增长与锁
在 InnoDB的内存结构中,对每个含有自增长值的表都有一个自增长计数器。执行如下的语句得到计数器的值:
select MAX(auto_inc_col) from t for update;
插入操作会根据这个自增长的计数器值加1 赋予自增长列。这个实现方式被称为 AUTO-INC Locking、这种锁其实采用一种特殊的表锁机制,为了提高插入性能,锁不是在事务完成后释放,而是完成对自增长值的插入的SQL语句后立即释放。
虽然 AUTO-INC Locking在一定程度上提高了并发性,但对于有自增长值的列的并发插入性能较差,事务必须等待前一个插入的完成(虽然不需要等待事务的完成),其次,对于 Insert…… select 的大数据量的插入会影响插入的性能,因为另一个事务会被阻塞。
MySQL 5.1.22 版本开始,InnoDB提供了一种轻量级互斥量的自增长的实现机制。从该版本开始,InnoDB提供了一个参数 innodb_autoinc_lock_mode来控制自增长的模式,该参数默认为1.
innodb_autoinc_lock_mode | 说明 |
0 | MySQL5.1.22版本之前的实现方式,即通过 AUTO-INC lock方式,因为有了新的自增长方式,所以该值不应该是用户的首选项 |
1 |
默认值,对于”simple inserts“,该值会使用互斥量对内存中的计数器进行累加的操作。对于”bulk inserts“,还是使用传统的 AUTO-INC locking方式。在这种配置下,如果不考虑回滚操作,对于自增值的增长还是连续的。并且在这种方式想,statement-based 方式的replication还是能很好的工作。 注:如果已经使用 AUTO-INC locking方式,而这时需要再进行”simple inserts“操作时,还是需要等待”AUTO-INC locking“的释放 |
2 | 在该模式下,对所有的”insert-likes“自增长值的产生都是通过互斥量,而不是 AUTO-INC locking,显然,这是性能最高的方式,然而,会带来一些问题,因为并发的存在,在每次插入时,自增长的值可能不是连续的,此外,重要的是,基于 Statement-based Replication会有问题。因此,使用这个模式,任何时候都应该使用 row-base replication,这样才能保证最大的并发及 replication主从数据的一致性。 |
插入类型 | 说明 |
insert-like | 所有的插入语句,包括 insert、replace、insert……select、replace……select、load data等 |
simple inserts | 能在插入前就确定插入行数的语句,包括insert,replace等,不包含 insert …… on duplicate key update这类SQL |
bulk inserts | 在插入前不能确定插入行数的语,如 insert……select,replace……select、load data等 |
mixed-mode inserts | 插入中有一部分的值是自增长的,一部分是可以确定的,如insert into t (e1,e2) values (1,'a'),(NULL,'b'),(3,'c'),也可以是 insert …… on duplicate key update这类SQL |
另外,在 InnoDB中,自增长的列必须是索引,同时是索引的第一个列,否则MySQL会抛出异常。
6.3.5 外键和锁
外键主要用于引用完整性的约束检查,在InnoDB中,对于一个外键列,如果没有显示的对这个列添加索引,InnoDB会自动对其加索引,避免表锁。
对于外键的插入或更新,需要先查询父表,但是对于父表的查询不是使用一致性非锁定读(MVCC),因为这样会发生数据不一致的问题,使用的是 select ……lock in share mode,主动为付表加S锁,如果这时父表上已有X锁,则阻塞。
时间 | 会话A | 会话B |
1 | BEGIN | |
2 | delete from parent where id=5 | |
3 | BEGIN | |
4 |
insert into child select 2,5 #第二列是外键,执行时被阻塞waiting |
6.4 锁的算法
6.4.1 行锁的三种算法
InnoDB 有三种行锁算法,分别是
- Record Lock:单个记录上的锁
- Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
- Next-Key Lock:Gap Lock + Record Lock,锁定一个范围,并锁定记录本身
Record Lock 总是锁住索引记录,如果 InnoDB在建立时没有设置任何一个索引,那么 InnoDB会使用隐士的主键来锁定。
InnoDB对于行的查询都是采用 Next-Key Lock,其目的是为了解决幻读 Phantom Problem,是谓词锁的一种改进。
当查询的索引含有唯一属性时,InnoDB 会对Next-Key Lock优化,降级为 Record Key,以此提高应用的并发性。
如前面介绍的。next-key lock 降级为record lock是在查询的列是唯一索引的情况下,若是辅助索引,则情况不同:
create table z (a int, b int , Primary key(a), key(b)) insert into z select 1,1; insert into z select 3,1; insert into z select 5,3; insert into z select 7,6; insert into z select 10,8;
其中b是辅助索引,此时执行 select * from z where b=3 for update;
此时,sql 语句通过索引列b进行查询,因此其使用传统的 next-key lock 进行加锁,并且由于有两个索引,其需要分别进行锁定。对于聚集索引,仅对列a=5的索引加record lock。而辅助索引,加的是next-key lock,锁定的是(1,3)范围,特别注意的是,InnoDB还会对辅助索引下一个键值加上 gap lock,即还有一个(3,6)范围的锁。因此,一下SQL都会被阻塞:
select * from z where a=5 lock in share mode; insert into z select 4,2; insert into z select 6,5;
从上面可以看出,gap lock的作用是为了阻止多个事务将记录插入到同一个范围内,而这会导致幻读问题的产生。用户可以通过以下两种方式来关闭 Gap Lock:
- 将事务隔离级别改为 READ COMMITED
- 将参数 innodb_locks_unsafe_for_binlog设为1
在上述的配置下,除了外键约束和唯一性检查依然需要的Gap Lock,其余情况仅使用 Record Lock进行锁定,需要牢记的是,上述配置破坏了事务的隔离性,并且对 replication可能会导致不一致。且从性能上看,READ COMMITED也不会优于默认的 READ REPEATABLE;
在 InnoDB中,对Insert的操作,其会检查插入记录的下一条记录是否被锁定,若已锁定,则不允许查询。对于上面的例子,会话A已经锁定了表中b=3的记录,即已锁定了(1,3)的范围,这时如果在其他会话中进行如下的插入同样会导致阻塞
insert into z select 2,2;
因为检测到辅助索引列b上插入2的记录时,发现3已经被索引,而将插入修改为如下值,可以立即执行:
insert into z select 2,0;
最后,对于唯一键值的锁定,next-key lock降级为record ke仅存在于查询所有的唯一索引列。若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的一个,那么查询其实是range类型,而不是point查询,故InnoDB依然采用 next-key lock进行锁定。
6.4.2 解决 Phantom Problem
幻读指的是在同一个事务下,连续执行两次相同的SQL语句可能返回不一样的结果,第二次的SQL语句可能会返回之前不存在的行。
InnoDB采用 next-key lock 的算法解决了 Phantom Problem,对 select * from t where id > 2 for update,锁住的不单是5这个值,而是对(2,+∞)这个范围加了X锁。因此,对这个范围的插入是不允许的,从而避免幻读。
时间 | 会话A | 会话B |
1 |
set session tx_isolation = 'READ-COMMITED' |
|
2 | BEGIN | |
3 |
select * from t where a>2 for update; ***********1 row ************* a:4 |
|
4 | BEGIN | |
5 | insert into t select 4 | |
6 | COMMIT; | |
7 |
select * from t where a>2 for update; ***********1 row ************* a:4 ***********2 row ************* a:5 |
REPEATABLE READ 采用的是 next-key locking加锁。而 READCOMMITED 采用的是 record lock .
此外,用户可以通过 InnoDB的 next-key lock在应用层面实现唯一性的检查:
select * from table where col=xxx lock in share mode; if not found any row : #unique for insert value insert into table values(……);
如果用户通过一个索引查询一个值,并对该行加上了S lock,那么即使查询的值不存在,其锁定的也是一个范围,因此若没有返回任何行,那么新插入的值一定是唯一的。
那,如果在第一步select lock in share mode时,有多个事务并发操作,那么这种唯一性检查是否会有问题,其实不会,因为会发生死锁。只有一个事务会成功,其他的事务会抛出死锁错误。
6.5 锁问题
6.5.1 脏读
脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另一个事务未提交的数据,则显然违反了数据库的隔离性。
脏读指的是在不同事务下,当前事务可以读到另外事务未提交的数据,即脏数据。
时间 | 会话A | 会话B |
1 |
set @@tx_isolation = 'read-ncommited' |
|
2 |
set @@tx_isolation = 'read-ncommited' |
|
3 |
BEGIN |
|
4 |
select * from t ; **********1 row ************* a:1 |
|
5 | insert into t select 2; | |
6 |
select * from t ; **********1 row ************* a:1 **********2 row ************* a:2 |
脏读发生条件是需要事务的隔离级别为 read uncommited;目前大部分数据库至少设置为 read COMMITED;
6.5.2 不可重复读
不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的是已提交的数据,但是违反了事务一致性的要求。
时间 | 会话A | 会话B |
1 |
et @@tx_isolation = 'read-commited' |
|
2 |
et @@tx_isolation = 'read-commited' |
|
3 | BEGIN | BEGIN |
4 |
select * from t ; **********1 row ************* a:1 |
|
5 | insert into t select 2; | |
6 | COMMITED | |
7 |
select * from t ; **********1 row ************* a:1 **********2 row ************* a:2 |
一般来说,不可重复读是可接受的,因为读到的是已提交的数据,本身没有带来很大问题。在 InnoDB中使用 next-key lock避免不可重复读问题,即 幻读(Phantom Problem)。在 Next-Key lock算法下,对索引的扫描,不仅是锁住扫描到的索引,还有这些索引覆盖的范围,因此在这个范围内插入是不允许的。这样则避免了另外的事务在这个范围内的插入导致不可重读的问题。
6.5.3 丢失更新
丢失更新就是一个事务的更新操作被另一个事务的更新操作覆盖,从而导致数据不一致。
- 事务1将行记录r更新为v1,但是事务未提交
- 事务2将行记录r更新为v2,事务未提交
- 事务1提交
- 事务2提交
当前数据库的任何隔离级别下,以上情况都不会导致数据库理论意义上的丢失更新问题,因为,对于行的DML操作,需要对行货其他粗粒度级别的对象加锁,步骤2,事务2并不能对记录进行更新,被阻塞,直到事务1提交。
但在生产应用中,还有一个逻辑意义的丢失更新问题,而导致该问题的不是因为数据库本身的问题,简单来说,下面情况会发生丢失更新:
- 事务T1查询一行数据,放入本地内存,返回给User1
- 事务T2查询一行数据,放入本地内存,返回给User2
- User1修改后,更新数据库提交
- User2修改后,更新数据库提交
显然,这个过程中,User1的修改操作”丢失“了。在银行操作中,尤为恐怖。要避免丢失,需要让事务串行化。
时间 | 会话A | 会话B |
1 | BEGIN | |
2 |
select cash into @cash from account where user=pUser for update #加X锁 |
|
3 |
select cash into @cash from account where user=pUser for update #等待,直到m提交后,锁释放 |
|
…… | …… | |
m |
update account set cash = @cash - 9000 where user = pUser |
|
m+1 | commit | |
m+2 |
update account set cash = @cash -1 where user = pUser |
|
m+3 | commit |
6.6 阻塞
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。
阻塞并不是一件坏事,其是为了确保事务可以并发且正常地运行。
需要牢记的是,默认情况下,InnoDB存储引擎不会回滚超时引发的错误异常,其实,InnoDB在大部分情况下都不会对异常进行回滚。
时间 | 会话A | 会话B |
1 |
select * from t; **********3 row ************* a:1 a:2 a:4 |
|
2 | BEGIN | |
3 |
select * from t where a < 4 for update; **********2 row ************* a:1 a:2 #对(2,4)上X锁 |
|
4 | BEGIN | |
5 | insert into t select 5; | |
6 |
insert into t select 3; #等待超时,需要确定超时后,5的插入是否需要回滚或提交,否则这是十分危险的状态 |
6.7 死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种相互等待的现象。解决死锁的办法:
- 超时回滚
- wait-for graph 等待图的方式进行死锁检测,采用深度有限的算法,选择回滚undo量最小的事务。
死锁的概率与以下因素有关:
- 事务的数量n,数量越多死锁概率越大
- 事务操作的数量r,数量越多,死锁概率越大
- 操作数据的集合R,越小,死锁概率越大
6.8 锁升级
锁升级就是将当前锁的粒度降低,例如把行锁升级为页锁,把页锁升级为表锁。
InnoDB不存在锁升级,因为其不是根据每个记录来产生行锁的。相反,其根据每个事务访问的每个页对锁进行管理,采用的是位图的方式。因此不管一个事务锁住页中的一个记录还是多个记录,开销都是一样的。