数据库的锁机制用于管理对共享数据的并发访问。InnoDB存储引擎的锁实现和Oracle数据库很类似,提供了一致性的非锁定读、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。
在了解InnoDB锁机制之前,我们先要关注lock
和latch
的区别。它们二者都可以称之为锁,但使用场景有着很多区别:
latch
要求锁定的时间必须很短,如果锁定的时间很长,则会严重影响性能。在InnoDB中,latch
分为mutex
(互斥锁)和rwlock
(读写锁),目的是保证线程并发操作线程共享资源的正确性,通常没有死锁检测。lock
是针对事务设计的,它锁定的是数据库中的对象,如表、页、行。并且一般lock
的对象仅在事务commit
或者rollback
后进行释放,并且lock
有死锁检测机制。
lock | latch | |
---|---|---|
对象 | 事务 | 线程 |
保护 | 数据库数据 | 数据库进程中的数据结构正确性 |
模式 | 行锁、表锁、意向锁、页面锁 | 读写锁、互斥锁 |
死锁检测 | waits-for graph、超时机制检测 | 没有死锁检测机制 |
1 锁的使用
1.1 锁的类型
InnoDB实现了两种行级锁:
- 共享锁(S锁),允许事务读取一行数据
- 排他锁(X锁),允许事务删除或更新一行数据
如果一个事务在获得一个锁情况下,另一个事务同样也可以获得这个锁,我们称这种情况为锁兼容(Lock Compatible
),当加锁时发生锁不兼容的情况时,就需要等待该锁释放。下面是S锁和X锁的锁兼容情况:
X锁 | S锁 | |
---|---|---|
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
可以看出S锁和X锁的兼容机制很像读写锁(S锁看成读锁,X锁看成写锁)。
InnoDB支持多粒度锁定,它允许行锁和表锁同时存在,为了支持不同粒度上的加锁操作,InnoDB支持一种额外的加锁方式,称之为意向锁(Intention Lock
),设计意向锁的目的是提高对行级锁加锁的效率,为什么说提高了效率呢?
回顾一下事务中加表锁的方法:
LOCK TABLE t READ; #给表t加上读锁
LOCK TABLE t WRITE; #给表t加上写锁
加行级锁的方法:
select id from t where id = 1 for update #给id为1的行加上X锁,注意条件字段需要是索引字段,否则会加上表锁
select id from t where id = 1 lock in share mode #给id为1的行加上S锁
考虑一下这个场景:如果事务A给表t
中id
字段为1
的行加上了X锁,之后事务B尝试给表t
加上表级写锁,那么事务B如何判断表t
中有没有行加上了写锁呢?我们不可能遍历表t
中每一行的加锁情况来判断是否可以加行级写锁,那样做效率太低。为了解决这个问题,意向锁诞生了。
意向锁将锁定的对象分为多个层次,意向锁的意向指的是这个事务意向(希望)在更细的粒度上加锁。意向锁的具体操作是首先对粗粒度的对象加锁,然后对细粒度的对象加锁:例如需要对一个行加上X锁,那么需要按照数据库->表->页这种顺序加IX锁,然后再对该行加上X锁。这样,事务B在加表级写锁前只需要判断这个表有没有加上IX锁就可以了。
InnoDB的意向锁均为表级锁,设计目的是为了在一个事务中揭示下一行将被请求的锁类型:
- 意向共享锁(IS锁),事务需要获得一张表中某几行的共享锁(S锁)
- 意向排他锁(IX锁),事务想要获得一张表中某几行的排他锁(X锁)
意向锁不会阻塞除了全表扫描以外的请求,意向锁和行级锁的兼容性如下:
IS | IX | S | X | |
---|---|---|---|---|
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
1.2 一致性非锁定读
一致性非锁定读(consistent nonlocking read
)是指InnoDB存储引擎通过多版本控制的方式来读取当前数据库中的行数据。例如:当一个事务需要读取一个行时,如果此时有其它事务正在对该行执行DELETE
或UPDATE
操作,那么读取操作不会因此等待X锁的释放,相反该事务会去读取一个行的“快照数据”。也就是说,在一致性非锁定读操作中,即使一个行加了X锁,也不会影响其它事务读取改行。
下面示例印证了非锁定读的效果:
Transaction A | Transaction B
begin; | begin;
select * from employees where emp_no = 10001 for update; |
update employees set first_name = 'Chris' where emp_no = 10001|
| select * from employees where emp_no = 10001 #不会阻塞,并且first_name是事务A开始前的数据
commit; | commit;
快照数据是指该行之前版本的数据,存储在事务日志undo log
中,undo log
也可以用于事务回滚,所以读取这个快照数据并没有因此而产生额外开销,相反还极大地提高了数据库的并发性能。
需要注意的是,一致性非锁定读只在事务隔离级别为READ COMMITTED
和REPEATABLE READ
下被使用。在READ COMMITTED
级别下,一致性非锁定读操作会读取最新的快照数据(副作用就是会发生不可重复读)。而在REPEATABLE READ
下,事务会读取该事务开始时的快照数据(不会发生不可重复读)。
1.3 一致性锁定读
某些情况下,用户需要显式地对数据库读取操作进行枷锁来保证数据逻辑的一致性,这就要求数据库支持显式的加锁语句,也就是之前提到的SELECT ... FOR UPDATE
和SELECT ... LOCK IN SHARE MODE
。
在一致性锁定读操作中,如果事务A对一个行执行了SELECT ... FOR UPDATE
操作,然后事务B对该行执行SELECT ... LOCK IN SHARE MODE
操作,那么事务B会被阻塞直到事务A提交。
1.4 自增列与锁
自增长的插入操作分为以下几类:
insert-like
:指所有的插入语句,如INSERT
、REPLACE
、INSERT ... SELECT
、REPLACE ... SELECT
等simple inserts
:指能在插入前就能确定插入行数的语句,包括INSERT
、REPLACE
,需注意不包含INSERT ... ON DUPLICATE KEY UPDATE
bulk inserts
:指在插入前不能确定插入行数的语句,如INSERT ... SELECT
、REPLACE ... SELECT
、LOAD DATA
mixed-mode inserts
:指插入中有一部分的值是自增长的,有一部分是确定的。
目前InnoDB有多种自增长实现机制,可通过参数innodb_autoinc_lock_mode
来控制自增长实现机制,默认为1,该参数的含义如下:
- 值为0。MySQL 5.1之前的自增长实现模式,该模式通过表锁的
AUTO-INC Locking
方式实现自增列。具体操作是首先执行以下SQL语句获得自增长计数器的值:
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;
然后对上述语句返回的值加上1赋给自增长列。为了提高性能,这个X锁在完成对自增长值插入的SQL语句后立刻释放,而不会等到事务完成后再释放。
- 值为1。对于
simple inserts
,该值会用mutex
对自增计数器进行累加操作。对于bulk inserts
,还是使用传统表锁的AUTO-INC Locking
方式。 - 值为2。所有
insert-like
都采用mutex
方式,性能最高,但是在并发插入时,可能会产生自增值不是连续的情况。
2 锁算法
InnoDB有三种行锁的算法,分别是:
Record Lock
:单个行记录上锁Gap Lock
:间隙锁,锁定一个范围但并不包含记录本身Next-Key Lock
:锁定一个范围并且包含记录本身。
Record Lock
总是会锁定索引记录,如果表没有显式设定主键,那么会使用隐式的6字节主键来锁定
Next-Key Lock
是结合了Gap Lock
和Record Lock
的一种锁定算法,InnoDB对于行的查询都是采用这种锁定算法。例如一个索引有10、11、13和20四个值,那么锁定的区间可能有:
(
−
∞
,
10
]
(-\infty, 10]
(−∞,10] 、
(
10
,
11
]
(10,11]
(10,11]、
(
11
,
13
]
(11,13]
(11,13]、
(
13
,
20
]
(13,20]
(13,20]、
(
20
,
+
∞
)
(20, +\infty)
(20,+∞),采用Next-Key Lock
的锁定技术成为Next-Key Locking
,其目的是为了解决幻读(InnoDB在REPEATABLE READ
隔离级别下就可以做到不发生幻读)。
当查询的索引具有唯一性(例如主键)时,InnoDB会对Next-Key Lock
进行优化,将其降级为Record Lock
,也就是仅仅锁定索引本身,而非范围。如果是辅助非唯一索引,情况就不一样了,例如:
CREATE TABLE t (a INT, b INT, PRIMARY KEY(a), KEY(b))
INSERT INTO t SELECT 1, 1
INSERT INTO t SELECT 3, 1
INSERT INTO t SELECT 5, 3
INSERT INTO t SELECT 7, 6
INSERT INTO t SELECT 10, 8
列a是主键,列b是辅助索引。在执行SELECT * FROM t WHERE b=3 FOR UPDATE
语句时,会使用Next-Key Locking
技术加锁,锁定的范围是
(
1
,
3
)
(1,3)
(1,3)和
(
3
,
6
)
(3,6)
(3,6),因为InnoDB还会对辅助索引的下一个键值加上Gap Lock
。此时如果执行SELECT * FROM t WHERE a = 5 LOCK IN SHARE MODE
或者INSERT INTO t SELECT 4, 2
等,都会因此而发生阻塞。Gap Lock
作用就是阻止多个事务将记录插入到同一范围内而发生幻读问题。
关闭Gap Lock
有两种方式:
- 事务隔离级别设置到
READ COMMITTED
innodb_locks_unsafe_for_binlog
设置为1
在上述配置下,除了外键约束和唯一性检查需要Gap Lock
,其它情况均会使用Record Lock
进行锁定。
3 死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而产生的一种互相等待的现象。如果产生了死锁并且外界不加干涉,事务将无法推进下去。死锁不会发生在串行执行事务的情况下,只存在于并发执行事务的情况,例如:
事务A | 事务B |
---|---|
BEGIN | |
SELECT * FROM t WHERE id = 1 FOR UPDATE | BEGIN |
SELECT * FROM t WHERE id = 2 FOR UPDATE | |
SELECT * FROM t WHERE id = 2 FOR UPDATE | |
SELECT * FROM t WHERE id = 1 FOR UPDATE ERROR 1213 (40001): Deadlock found... |
上述示例就发生了死锁:事务A持有id=1的行锁,并等待id=2的行锁释放;事务B持有id=2的行锁,并等待id=1的行锁释放。
解决死锁的一种方式是超时检测,即当两个事务互相等待并且等待时间超过一定阈值时,其中一个事务就会进行回滚,另外一个事务就可以继续执行。可通过参数innodb_lock_wait_timeout
来设置这个等待时间。超时检测的缺点就是会影响数据库的并发性,是一种被动的检测机制。
除了超时机制,InnoDB还采用了wait-for graph
(等待图)的方式来检测死锁,这是一种主动的检测死锁的机制。等待图要求数据库保留两种信息:
- 锁的信息链表
- 事务等待链表
若上述链表存在一个环,就代表存在死锁。在等待图中,事务为图的顶点,边可以定义为一个事务等待另外一个事务所占用的资源。
参考资料
《MySQL技术内幕(InnoDB存储引擎)》