本文同时发表在https://github.com/zhangyachen/zhangyachen.github.io/issues/53
lock与latch
在数据库中,lock与latch都可以成为锁,但两者有截然不同的含义。
latch 一般称为闩锁(轻量级的锁) 因为其要求锁定的时间非常短,若持续时间长,则应用性能非常差,在InnoDB存储引擎中,latch有可以分为mutex(互斥锁)和rwlock(读写锁)其目的用来保证并发线程操作临界资源的正确性,并且没有死锁检测的机制。
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同),此外lock正如大多数数据库中一样,是有死锁机制的。
意向锁
将锁定的对象分为多个层次,意味着事务希望在更细粒度上进行加锁。
比如如果对一个数据对象加IS锁,表示它的子结点有意向加 S锁。例如,要对某个元组加 S锁,则要首先对关系和数据库加 IS锁。
数据库的层级结构如下:
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实是不会阻塞除全表扫以外的任何请求。意向锁只是说明该事务有意向对子节点加某个锁。
意向锁与行级锁的兼容性如下:
作用
引进意向锁是为了提高封锁子系统的效率。该封锁子系统支持多种封锁粒度。原因是:在多粒度封锁方法中一个数据对象可能以两种方式加锁 ― 显式封锁和隐式封锁。因此系统在对某一数据对象加锁时不仅要检查该数据对象上有无(显式和隐式)封锁与之冲突,还要检查其所有上级结点和所有下级结点,看申请的封锁是否与这些结点上的(显式和隐式)封锁冲突,显然,这样的检查方法效率很低。为此引进了意向锁。
一致性非锁定读
一致性非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过多版本控制(multi versionning)的方式来读取当前执行时间数据库中行的数据,如果读取的行正在执行DELETE或UPDATE操作,这是读取操作不会因此等待行上锁的释放。相反的,InnoDB会去读取行的一个快照数据。
上面展示了InnoDB存储引擎一致性的非锁定读。之所以称为非锁定读,因为不需要等待访问的行上X锁的释放。
但是在不同的事务隔离级别下,读取的方式不同,并不是每个事务隔离级别下都是采用非锁定的一致性读,此外,即使使用非锁定的一致性读,但是对于快照数据的定义也各不相同。
在事务隔离级别RC和RR下,InnoDB存储引擎引擎使用非锁定的一致性读。然而,对于快照数据的定义却不相同。在RC事务隔离级别下,对于快照数据,非一致性读总是被锁定行的最新一份快照数据。而在RR事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。即:RC级别再其他事务提交之后即能看到数据的最新版本。RR级别总是读取事务开始时的行数据,即使其他事务提交了也是如此。(不做演示了)。
锁的算法
行锁的三种算法
- Reord Lock:单行记录上的锁
- Gap Lock:间隙锁,锁定一个范围,但是不包含记录本身
- Next-Key Lock:Gap Lock + Reord Lock,锁定一个范围,并且锁定记录本身
Next-Key Lock是结合Gap Lock和Record Lockd 一种锁定算法,在Nex-Key Lock算法下,InnoDB对于行的查询都是采用这种锁定算法,例如一个索引有10 11 13 20这四个值,那么该索引可能被Next-Key Locking的区间为:
(-∞,10]
(10,11]
(11,13]
(13,20]
(20,+∞)
当查询的索引含有唯一属性时,InnoDB会对Next-Key Lock进行优化,降级为Reord Lock,即仅锁住索引本身,而不是范围。
表t共有1 2 5 三个值,在会话A中首先对a=5进行了X锁定。而由于a是主键且唯一,因此锁定的仅是5这个值,而不是(2,5)这个范围,这样在会话B中插入4不会阻塞,可以立即插入并返回,及锁定由Next-Key Lock算法降级为了Record Lock,从而提高应用的并发性。
正如前面介绍,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;
表z的列b是辅助索引,若在会话A中执行下面的SQL语句:
>SELECT * FROM z WHERE b=3 FOR UPDATE;
很明显,这是SQL语句通过索引b进行查询,因此使用传统的Next-Key Locking技术加锁,并且由于有两个索引,其需要分别进行锁定。对于聚集索引,其仅对列a等于5的索引加Record Lock(b=3对应的列中a的值),而对于辅助索引,其加上了Next-Key Lock,锁定的范围是(1,3)特别需要注意的是,InnoDB存储引擎还会对辅助索引的下一个键值加上gap lock,即还有一个辅助索引范围为(3,6)的锁。因此在会话B中运行下面语句会被阻塞:
>SELECT * FROM z WHERE a=5 LOCK IN SHARE MODE;
>INSERT INTO z SELECT 4,2;
>INSERT INTO z SELECT 6,5;
用户可以通过以下两种方式来显式的关闭Gap Lock:
- 将事务的隔离级别设成RC
- 将参数innodb_locks_unsafe_for_binlog 设置为1
但需要牢记,上述设置破坏了事务的隔离性,并且对于replication,可能会导致主从不一致。此外,从性能上看,RC也不会优于默认的事务隔离级别RR。
需要再次提醒,对于唯一键值的锁定,Next-Key Lock降级为Record Lock仅仅存在于查询所有的唯一索引列。若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么查询其实是range类型查询,而不是point类型查询,故InnoDB存储引擎依然使用Next-Key Lock进行锁定。
注意:通过主键或则唯一索引来锁定不存在的值,也会产生GAP锁定。
以下示例中id为主键。
mysql> select * from a;
+----+------------+
| id | data |
+----+------------+
| 1 | 1 |
| 2 | 0 |
| 3 | 3 |
| 4 | 4 |
| 5 | 0 |
| 6 | 3 |
| 7 | 4294967295 |
+----+------------+
7 rows in set (0.04 sec)
Time | 用户1 | 用户2 |
---|---|---|
1 | BEGIN | |
2 | mysql> select * from a where id=10 for update; Empty set (0.03 sec) | |
3 | insert into a(id,data) values(8,10); #等待 |
锁问题
脏读
读到其他事务未提交的数据,生产环境不会使用,不解释了。
不可重复读
读到其他事务已经提交的数据。RC隔离环境会出现此问题。
但是,一般来说,不可重复读是可以接收的,因为其读到的是已经提交的数据,本身不会带来很大的问题,因此很多数据库产商将其事务隔离级别默认设置成RC,在这种隔离级别下允许不可重复读的现象发生。
在InnoDB存储引擎中,通过Next-Key Lock算法来避免不可重复读的问题,在MySQL官方文档将不可重复读的问题定义为Phantom Problem,即幻像问题。在Next-Key Lock算法下,对于索引的扫描,不仅是锁住扫描的索引,还是锁住这些索引覆盖的范围gap,因此这个范围内的插入都是不允许的,这样就避免了另外的事务在这个范围内插入数据导致不可重复读的问题。因此InnoDB存储引擎的默认事务隔离级别是RR,采用Next-Key Lock算法,避免不可重复读的现象。
丢失更新
一个事务的更新操作会被另一个事务的更新操作锁覆盖,从而导致数据的不一致。
事务T1将行记录r更新为v1,但是事务T1并未提交
与此同时,事务T2将行记录r更新为v2,事务T2未提交
事务T1提交
事务T2提交
但是在当前数据库的任何隔离级别下,都不会导致数据库理论上的丢失更新问题。这是因为,即使是READ UNCOMMITTED的事务隔离级别,对于行的DML操作,需要对行或者其他粗粒度级别的对象加锁,因此在上述步骤B中,事务T2并不能对行记录r进行更新操作,其余被阻塞,直到事务T1提交。
虽然数据库能阻止丢失更新问题的产生,但是在生产应用中还有另一个逻辑意义的丢失更新问题,而导致该问题并不是因为数据库本身的问题。实际上,在所有多用户计算机系统环境下都有可能产生这个问题。简单来说,出现下面的情况,就会发生丢失更新。
事务T1查询一行数据,放入本地内存,并显示给一个终端用户User1
事务T2也查询该行数据,并将取得的数据显示给终端用户User2
User1修改这行的记录,更新数据库并提交
User2修改这行的记录,更新数据库并提交
我们以12306为例:如果用户要购买票,应用程序应该为先查询此时的余票,如果余票>0,用户再进行购买。
查询余票数量 select 余票 into tickets
if(tickets > 0){
数据库进行余票数量更新 update 票数 = tickets - 1
}
两个用户1与2同时登录12306进行购买,即触发了两个事务T1与T2。此时T1与T2查询到的余票数量是一样的,比如都是1,T1与T2进行update,所以用户1与2都买票成功。但是一个问题是:买之前余票都是1,但是有两个用户买票成功。
要避免丢失更新发生,需要将事务在这种情况下操作变成串行化,而不是并行操作。
Time | 用户1 | 用户2 |
---|---|---|
1 | BEGIN | |
2 | select ticket into @ticket from xx where id=xx for update(相当于应用程序中有变量存储余票数量) | |
3 | select ticket into @ticket from xx where id=xx for update #等待中... | |
4 | update xx set ticket=@ticket-1 where id=xx | |
5 | commit | |
6 | update xx set ticket=@ticket-1 where id=xx | |
7 | commit |
阻塞
阻塞不是一件坏事,是为了保证事务可以并发并且正常的运行。
在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来控制等待的时间(默认50秒),innodb_rollback_on_timeout用来设定是否在等待超时时对进行中的事务进行回滚操作(默认为OFF,代表不会滚)。参数innodb_lock_wait_timeout是动态的,可以在运行中调整。
>set @@innodb_lock_wait_timeout=60;
而innodb_rollback_on_timeout是静态的,不可在启动时进行修改。
mysql> set @@innodb_rollback_on_timeout=on;
ERROR 1238 (HY000): Variable ‘innodb_rollback_on_timeout‘ is a read only variable
当发生超时,MySQL会抛出一个1205的错误。
>BEGIN
>SELECT * FROM t WHERE a=1 FOR UPDATE;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
需要牢记,在默认情况下,InnoDB存储引擎不会由于超时而引发回滚,其实InnoDB存储引擎在大部分情况下都不会对异常进行回滚,如在一个会话中添加了如下语句:
会话A:
>SELECT * FROM t;
>BEGIN;
>SELECT * FROM t WHERE a<4 FOR UPDATE;
在会话A中开启一个事务,在Next-key Lock算法下锁定小于4的所有记录(其实也锁定了4这个记录本身)在另一个会话B中执行以下语句
>BEGIN;
>INSERT INTO t SELECT 5;
>INSERT INTO t SELECT 3;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
可以看到,在会话B中插入记录5是可以的,但是插入记录为3的时候,因为会话A中Next-Key Lock算法的关系,需要等待会话A中事务释放这个资源,所以等待后产生超时,但是在超时后用户再进行SELECT操作时发现,5这个记录依然存在。
这是因为会话B中事务虽然抛出异常,但是既没有进行COMMIT操作,也没有进行ROLLBACK。而这是非常危险的状态,因此用户必须判断是否需要COMMIT还是ROLLBACK,之后在进行下一步操作。
死锁
概念
指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种互相等待的现象。
解决办法
最简单的一种方法是超时,当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务回滚,另一个等待的事务就能继续运行了,在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来设置超时时间。
超时机制虽然简单,但是其仅通过超时后对事务进行回滚的方式处理,或者说其是根据FIFO的顺序选择回滚对象,但若超时的事务所占权重比较大,如事务操作更新了很多行,占用了较多的undo log,这是采用FIFO方式,就显得不合适了,因为回滚这个事务的时间相对另一个事务所占用的时间可能会更多。
除了超时机制,当前的数据库还采用wait-for graph(等待图)的方式来进行死锁检测,较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也是采用这种方式。wait-for graph要求数据库保存以下两种信息:
- 锁的信息链表
- 事务等待链表
我们可以看出一共有4个事务。
row1行被t2占用的x锁,t1在等待t2释放x锁。
row2行被t1、t4同时占用s锁,t2在等待t1、t4释放s锁,同理t3在等待t1、t4、t2。
所以wait-for-graph图如下:
如图,可以发现回路(t1,t2)因此存在死锁。通过上述介绍,可以发现wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说,InnoDB存储引擎会选择回滚undo量最小的事务。
死锁示例
Time | 会话A | 会话B |
---|---|---|
1 | BEGIN | |
2 | select * from t where id=1 for update | begin |
3 | select * from t where id=2 for update | |
4 | select * from t where id=2 for update #等待 | |
5 | select * from t where id=1 for update ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
在上述操作中,会话B中的事务会抛出1213的错误,即表示发生死锁,死锁的原因是会话A和会话B的资源在互相等待。大多数死锁InnoDB存储引擎本身可以侦测到,不需要人为干预,但是在上述例子中,在会话B中的事务抛出死锁异常后,会话A中马上得到了记录为2的这个资源,这其实是因为会话B中的事务发生了回滚,否则会话A中的事务是不可能得到该资源的,InnoDB存储引擎是不会回滚大部分的错误异常,但是死锁除外。发生死锁后,InnoDB存储引擎会马上回滚一个事务,这点需要注意,因此如果在应用程序中捕获1213这个错误,其实并不需要对其进行回滚。
此外还存在另一种死锁,即当前事务持有了待插入记录的下一个记录的X锁,但是在等待队列中存在一个S锁的请求,则可能发生死锁,来看一个例子:
Time | 会话A | 会话B |
---|---|---|
1 | BEGIN | |
2 | begin | |
3 | select * from t where a=4 for update | |
4 | select * from t where a<=4 lock in share mode | |
5 | insert into t values(3); ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
A获得a=4的x锁,B在a=1,2,3上加上s锁后,等待A释放x锁。在事件5中A想要插入a=3,需要等待B释放a=3的s锁,死锁就此发生。
锁升级
指将当前锁的粒度降低。
比如说,数据库可以把一个表的1000个行级锁升级为一个页锁,或者将页锁升级为表锁,因为这样可以避免锁的开销。
SQL Server在以下情况下可能发生锁的升级:
- 由一句单独的SQL语句在一个对象上持有的锁的数量超过了阈值,默认为5000。如果是不同的对象,不会发生锁升级。
- 锁资源占用的内存超过了激活内存的40%时就会发生锁升级。
但是锁升级会带来并发性的降低。
InnoDB存储引擎不存在锁升级的问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的位图的方式。因此不管一个事务锁住页的一个记录还是多个记录,其开销通常是一致的。
参考资料:《MySQL技术内幕-InnoDB存储引擎》
http://baike.baidu.com/link?url=ZYqMGtJ-urHQzkfER91GmTkIZfgmNobdHLk2rsrI4e11EmQIdnrp2HAMxvp7jVt9srKg6dnXahWt4MBCrtXu3q