InnoDB锁机制
什么是锁
锁是数据库系统区别于文件系统的一个关键特性,锁机制用于管理对共享资源的并发访问。例如,操作缓冲池中的LRU列表数据的删除,添加,移动。不同数据库和不同搜索引擎都可能有不同的锁机制,MyISAM 引擎的锁是表锁设计,并发读没有问题,但是并发写入可能就存在一定的问题。
InnoDB 存储引擎锁的实现和 Oracle 数据库非常类似,提供一致性的非锁定读和行级锁支持。行级锁没有相关额外的开销,可以同时得到并发性和一致性。
lock 与 latch 的区别
在 MySQL 中 lock 与 latch 都可以被称为”锁”,但是它们是不同的东西。
- latch 一般称为闩(shuan, 读一声)锁(轻量级的锁),它可以分为 mutex(互斥量) 和 rwlock(读写锁) ,它锁定的对象是内存中的共享资源,主要用来保证并发线程操作临界资源的正确性。
- lock 就是锁,它可以分为排他锁,共享锁,意向锁等,它锁定的对象是事务,主要用来锁定数据库中的对象,包括表,页,行等。
它们具体的区别可以用下表表示:
比较内容 | lock | latch |
---|---|---|
锁定对象 | 事务 | 线程 |
保护内容 | 数据库内存 | 内存数据结构 |
持续时间 | 整个事务过程 | 临界资源 |
模式 | 行锁,表锁,意向锁等 | 读写锁,互斥量 |
死锁检测 | 通过 waits-for graph, time out等机制检测与处理 | 无死锁检测机制。仅通过应用程序加锁的顺序保证无死锁的情况发生 |
存在于 | Lock Manager的哈希表中 | 每个数据结构对象中 |
锁的类型
行级锁
InnoDB 存储引擎支持如下两种类型的行级锁:
- 共享锁(S Lock) 允许事务读取一行数据
- 排他锁(X Lock) 允许事务删除或者更新一行数据
如果一个事务T1已经获得了记录r上的共享锁,另一个事务T2也可以获得记录r上的共享锁,这种情况称为锁兼容。但如果T1获取的是记录r上的排他锁,则T2获取不了记录r上的共享锁,这种情况称为锁不兼容。
共享锁和排他锁的兼容情况如下所示:
共享锁 | 排他锁 | |
---|---|---|
共享锁 | 兼容 | 不兼容 |
排他锁 | 不兼容 | 不兼容 |
注意: 共享锁和排他锁都是行锁,兼容指的是同一记录上的锁的兼容情况。
意向锁
InnoDB 存储引擎支持多粒度(multiple granularity locking)锁定,它允许表锁和行锁共存。为了支持在不同粒度上加锁,InnoDB 额外支持了一种表级锁类型,意向锁(Intention Locks)。
意向锁可以分为意向共享锁(Intention Shared Lock, IS)和意向排他锁(Intention eXclusive Lock, IX)。但它的锁定方式和共享锁和排他锁并不相同,意向锁上锁只是表示一种“意向”,并不会真的将对象锁住,让其他事物无法修改或访问。例如事物T1想要修改表test
中的行r1
,它会上两个锁:
- 在表
test
上意向排他锁 - 在行
r1
上排他锁
事物T1在test
表上上了意向排他锁,并不代表其他事物无法访问test
了,它上的锁只是表明一种意向,它将会在db
中的test
表中的某几行记录上上一个排他锁。
但是意向锁并不是完全和任何锁都兼容,在表级别上的意向锁也会和表级别的读锁(共享锁)和写锁(排他锁)冲突。所有表级别的锁的兼容性如下所示(其中S和X代表表级别的读锁和写锁):
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
共享锁(S) | 兼容 | 互斥 |
排他锁(X) | 互斥 | 互斥 |
一致性非锁定读
一致性非锁定读是指 InnoDB 存储引擎通过行多版本控制(multi version)的方式来读取当前执行时间数据库中行的数据。具体来说就是如果一个事务读取的行正在被锁定,那么它就会去读取这行数据之前的快照数据,而不会等待这行数据上的锁释放。这个读取流程如图1所示:
图1行的快照数据是通过undo段来实现的,而undo段用来回滚事务,所以快照数据本身没有额外的开销。此外,读取快照数据时不需要上锁的,因为没有事务会对快照数据进行更改。
MySQL 中并不是每种隔离级别都采用非一致性非锁定读的读取模式,而且就算是采用了一致性非锁定读,不同隔离级别的表现也不相同。在 READ COMMITTED 和 REPEATABLE READ 这两种隔离级别下,InnoDB存储引擎都使用一致性非锁定读。但是对于快照数据,READ COMMITTED 隔离模式中的事务读取的是当前行最新的快照数据,而 REPEATABLE READ 隔离模式中的事务读取的是事务开始时的行数据版本。
一致性锁定读
在 InnoDB 存储引擎中,select
语句默认采取的是一致性非锁定读的情况,但是有时候我们也有需求需要对某一行记录进行锁定再来读取,这就是一致性锁定读。
InnoDB 对于select
语句支持以下两种锁定读:
select ... for update
select ... lock in share mode
select ... for update
会对读取的记录加一个X锁,其他事务不能够再来为这些记录加锁。select ... lock in share mode
会对读取的记录加一个S锁,其它事务能够再为这些记录加一个S锁,但不能加X锁。
对于一致性非锁定读,即使行记录上加了X锁,它也是能够读取的,因为它读取的是行记录的快照数据,并没有读取行记录本身。
select ... for update
和select ... lock in share mode
这两个语句必须在一个事务中,当事务提交了,锁也就释放了。因此在使用这两条语句之前必须先执行begin
, start transaction
,或者执行set autocommit = 0
。
锁的阻塞分析
Infomation Schema 中关于事务和锁的表
从 InnoDB 1.0 开始,在 INFORMATION_SCHEMA
数据库下添加了表 INNODB_TRX
,INNODB_LOCKS
和 INNODB_LOCK_WAITS
。通过这三张表,用户可以更简单地监控当前事务并分析可能存在的锁问题。
INNODB_TRX 的表结构如下:
字段名 | 说明 |
---|---|
trx_id |
InnoDB 存储引擎内部唯一的事务ID |
trx_state |
当前事务的状态 |
trx_started |
事务的开始时间 |
trx_requested_lock_id |
等待事务的锁ID。如果 trx_state 为 LOCK WAIT,那么该值代表当前事务等待之前事务占用的锁资源ID,否则这个字段为NULL。 |
trx_wait_started |
事务等待开始的时间 |
trx_weight |
事务的权重,代表事务修改和锁住的行数。当死锁发生时,InnoDB 会选择权重值最小的事务进行回滚。 |
trx_mysql_thread_id |
MySQL 中的线程ID,SHOW PROCESSLIST 显示的结果 |
trx_query |
事务运行的 SQL 语句 |
INNODB_LOCK_WAITS
表结构说明
字段名 | 说明 |
---|---|
request_trx_id |
当前正在被阻塞的事务ID |
request_lock_id |
当前正在被阻塞的锁ID |
blocking_trx_id |
当前正在执行的事务ID |
blocking_lock_id |
当前正在阻塞的锁ID |
自增长与锁
MySQL 插入分类
插入类型 | 说明 |
---|---|
insert-like | insert-like 指所有的插入语句,如 insert,replace,insert … select, replace … select, load data等 |
simple inserts | simple inserts 指在插入前就能确定行数的语句,包括 insert, replace等。但是其不包括 insert … on duplicate key update 这类 SQL 语句 |
bulk inserts | bulk inserts 指插入前不能确定行数的 SQL 语句,例如 insert … select, replace … select, load data 等。 |
mixed-mode inserts | mixed-mode inserts 语句指插入的数据中有一部分是自增长的,也有一部分的主键是已经确定的,例如:insert into t(c1, c2) values (1, ‘a’), (NULL, ‘b’), (4, ‘c’)。注意: insert … on duplicate key update 这类语句也属于 mixed-mode inserts。 |
InnoDB 锁类型
AUTO-INC Locking
在 InnoDB 存储引擎的内存结构中,每个含有自增长列的表都有一个自增长计数器。当对含有自增长计数器的表进行插入时,这个计数器会被初始化,并通过以下语句来获得计数器的值:
select max(auto_inc_col) from t for update;
自增长计数器是一种特殊的表锁设计,因为在自增长计数器上的锁不会等到事务结束时才释放,而是在执行完插入语句后就被释放了。这种锁设计称为 AUTO-INC Locking。
轻量级互斥量锁
尽管 AUTO-INC Locking 在一定程度上提高了并发的插入效率,但还是存在一些性能上的问题,从 MySQL 5.1.22 版本开始,InnoDB 提供了一种轻量级互斥量的自增长实现方式,这种方式大大提高了自增长值插入的性能。并且从该版本开始,InnoDB 提供了一个innodb_autoinc_lock_mode
的参数来确定自增长锁的锁模式。
InnoDB 自增长锁模式
innodb_autoinc_lock_mode
共有三个值可以设置,即0,1,2。这三个值的具体功能如下:
innodb_autoinc_lock_mode | 说明 |
---|---|
0 | 这是在 MySQL 5.1.22 之前使用AUTO-INC Locking 的方式来实现主键的自增长 |
1 | 这是该参数的默认值。对于simple inserts ,该值会使用互斥量(mutex)去对内存中的计数器进行累加的操作。对于bulk inserts 还是会使用传统表锁的AUTO-INC Locking 的方式。在这种配置下,如果不考虑回滚操作,对于自增长列的键值还是连续的。这种方式下,statement-based 的 replication 还是能很好地工作的。 |
2 | 这种模式下,对于所有”INSERT-LIKE”自增长值的产生都是通过互斥量,而不是”AUTO-INC Locking”的方式。显然,这是性能最高的方式。但是这种模式在并发插入时,产生的自增长的值可能不是连续的。此外,在这种模式下,statement-based 的 replication 会出现问题。因此使用这种模式时需要使用 row-based 的 replication。 |
此外,MyISAM 和 InnoDB 不同,它是表锁设计,所以自增长不需要考虑并发插入的问题。
此外,在 InnoDB 存储引擎中,自增长的列必须是索引,而且必须是索引的第一个列,如果不是第一个列,MySQL 数据库会抛出异常。如下所示:
mysql root@localhost:hp> create table primary_table( -> a int auto_increment, -> b int, -> key(b, a) -> );
(1075, 'Incorrect table definition; there can be only one auto column and it must be defined as a key')
外键与锁
在InnoDB中,如果没有显示地对外键列添加索引,InnoDB存储引擎自动对其加一个索引。
对于外键值的插入或者更新,首先需要查询父表中的记录,即使用SELECT.....LOCK IN SHARE MODE一致性读的方式锁定附表记录。
行锁
行锁的三种算法
InnoDB 存储引擎有三种行锁的算法,其分别是:
- Record Lock: 单个行记录上的锁
- Gap Lock: 间隙锁,锁定一个范围,但不包含记录本身
- Next-Key 锁: Gap Lock + Record Lock,锁定一个范围,并且会锁定记录本身
Record Lock 总是会去锁住索引记录,如果 InnoDB 表在创建的时候没有设置任何主键索引,那么 Record Lock 会锁住隐式的主键。
加锁场景分析
- 主键索引
如果我们加锁的行上存在主键索引,那么就会在这个主键索引上添加一个 Record Lock。
- 辅助索引
如果我们加锁的行上存在辅助索引,那么我们就会在这行的辅助索引上添加 Next-Key Lock,并在这行之后的辅助索引上添加一个 Gap Lock
辅助索引上的 Next-Key Lock 和 Gap Lock 都是针对 Repeatable Read 隔离模式存在的,这两种锁都是为了防止幻读现象的发生。
- 唯一的辅助索引
这里有一个特殊情况,如果辅助索引是唯一索引的话,MySQL 会将 Next-Key Lock 降级为 Record Lock,只会锁定当前记录的辅助索引。
如果唯一索引由多个列组成的,而我们只锁定其中一个列的话,那么此时并不会进行锁降级,还会添加 Next-Key Lock 和 Gap Lock。
- Insert 语句
在 InnoDB 存储引擎中,对于 Insert 的操作,其会检查插入记录的下一条记录是否被锁定,若已经被锁定,则不允许查询。
介绍了上面三种锁的加锁场景后,我们接着来看一个例子,存在一个表z
,
-- 注意a列上有主键索引,b列上有辅助索引
Create Table | CREATE TABLE `z` ( `a` int(11) NOT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`a`), KEY `b` (`b`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
表z
中的数据如下所示:
+-----+-----+
| a | b |
|-----+-----|
| 1 | 1 |
| 3 | 1 |
| 5 | 3 |
| 7 | 6 |
| 10 | 8 |+-----+-----+
通过上面表z
中的数据,我们可以分析得出 Next-Key Lock 加锁的区间为:
(负无穷, 1]
(1, 3]
(3, 6]
(6, 8]
(8, 正无穷)
接着我们开启两个事务A和B,在事务A中执行如下的锁定语句:
-- 在事务A中执行
select * from z where b = 3 for update;
在执行完上述语句后,我们会在表z中添加上三个锁:
- 在主键列a的索引上添加一个 Record Lock,锁定住 a=5 的索引
- 在列b的辅助索引上添加一个 Next Key Lock,锁定住区间(1, 3]
- 在列b的辅助索引上添加一个 Gap Lock,锁定住区间(3, 6)
这里大家可能会疑惑,为什么在辅助索引上会加两个锁,这主要是为了防止事务A出现幻读,即事务A再次执行select * from z where b = 3
时出现了多条语句。
因为列b并不是唯一的,所以其他事务也有可能插入b=3
的记录到表z中。根据B+树索引的有序性,其他事务如果插入b=3
的记录,那么就会在辅助索引上原有的b=3
索引的前后区间中添加记录,如果我们把原有的b=3
索引的前后区间锁住,那么其他事务就无法添加b=3
的记录了,从而防止了幻读现象的出现。
死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种相互等待的现象。若无外力作用,事务都将无法推进下去。
在InnoDB存储引擎中,可以通过超时机制来解决死锁问题,但超时机制是按照FIFO的顺序来进行回滚的,有可能我们回滚的事务做的更新比较多,对其回滚所用时间比回滚另一个事务要多的多,此时超时机制就不太合适了。
因此除了超时机制,还会采用wait-for graph
的方式来进行死锁检测,这是一种更主动的死锁检测方式。
锁升级
锁升级(Lock Escalation)是指将当前锁的粒度降低。将行锁升级成页锁,将页锁升级成表锁。
在InnoDB中不存在页锁,也不存在锁升级的问题,因为1个行锁的开销与1 000 000个行锁的一样,都没有开销。