背景
锁
写在前面
本篇摘录自,数据库内核月报 - 2017 / 12《MySQL · 引擎特性 · Innodb 锁子系统浅析》
link
record_lock_type 对于 LOCK_TABLE 类型来说都是空的,对于 LOCK_REC 目前值有:
#define LOCK_WAIT 256 /* 表示正在等待锁 */
#define LOCK_ORDINARY 0 /* 表示 next-key lock ,锁住记录本身和记录之前的 gap*/
#define LOCK_GAP 512 /* 表示锁住记录之前 gap(不锁记录本身) */
#define LOCK_REC_NOT_GAP 1024 /* 表示锁住记录本身,不锁记录前面的 gap */
#define LOCK_INSERT_INTENTION 2048 /* 插入意向锁 */
#define LOCK_CONV_BY_OTHER 4096 /* 表示锁是由其它事务创建的(比如隐式锁转换) */
使用位操作来设置和判断是否设置了对应的值。
加锁分析
对于行数据的加锁是由函数 lock_rec_lock 完成,简单点来看,主要的参数是 mode(锁类型),block(包含该行的 buffer 数据页),heap_no(具体哪一行)。就可以确定加什么样的锁,以及在哪一行加。对于 mode 的值,来源于查询的逻辑,索引和二级索引的定义,隔离级别等等。
lock fast
lock_rec_lock 首先走 lock_rec_lock_fast 逻辑,判断能否快速完成加锁。如果对应 block 上面一个锁都没有( lock_rec_get_first_on_page(block)==NULL ),那么就创建一个锁( lock_rec_create ),返回加锁成功。如果 block 上已经存在锁,满足下面代码的逻辑就返回 LOCK_REC_FAIL, 快速加锁失败。
if (lock_rec_get_next_on_page(lock) /* 页上是否只有一个锁 */
|| lock->trx != trx /* 拥有锁的事务不是当前事务 */
|| lock->type_mode != (mode | LOCK_REC)/* 已有锁和要加的锁模式是否相同 */
|| lock_rec_get_n_bits(lock) <= heap_no) { /* 已有锁的 n_bits 是否满足 heap_no */
status = LOCK_REC_FAIL;
}else if (!impl) {
/* If the nth bit of the record lock is already set
then we do not set a new lock bit, otherwise we do
set */
if (!lock_rec_get_nth_bit(lock, heap_no)) {
lock_rec_set_nth_bit(lock, heap_no);
status = LOCK_REC_SUCCESS_CREATED;
}
如果上述条件都为 false,说明:
page 上只有一个锁
拥有该锁的事务是当前事务
锁模式相同
n_bits 也足够描述大小为 heap_no 的行
那么只需要设置一下 bitmap 就可以了(impl 表示加隐式锁,其实也就是不加锁)。
注:上述函数 lock_rec_get_first_on_page(block) 是从全局 Lock_sys->hash 中拿到第一个锁的,也就是 Hash 桶的第一个 node。
lock slow
lock fast 逻辑失败后就会走 lock slow 逻辑,也就是上述 lock fast 判断的
四个条件中有一个或多个为 true的时候。
lock slow 首先判断当前事务上是否已经加了同等级或者更强级别的锁,函数 lock_rec_has_expl,循环取出对应行上的所有锁,它们要满足以下几个条件,就认为行上有更强的锁。
1.基本锁类型更强,就比如加了 LOCK_X 就不必要加 LOCK_S 了。lock_mode 基本锁类型之间的强弱关系使用 lock_strength_matrix 判断(lock_mode_stronger_or_eq)
static const byte lock_strength_matrix[5][5] = {
/** IS IX S X AI */
/* IS */ { TRUE, FALSE, FALSE, FALSE, FALSE},
/* IX */ { TRUE, TRUE, FALSE, FALSE, FALSE},
/* S */ { TRUE, FALSE, TRUE, FALSE, FALSE},
/* X */ { TRUE, TRUE, TRUE, TRUE, TRUE},
/* AI */ { FALSE, FALSE, FALSE, FALSE, TRUE}
};
2.不是插入意向锁。
3.没有等待,LOCK_WAIT 位为0
4.LOCK_REC_NOT_GAP 位为0。(没有这个标记默认就是 NEXT KEY LOCK,锁住行前面的gap) 或者 要加锁的 LOCK_REC_NOT_GAP 位为 1 或者 当前行为 PAGE_HEAD_NO_SUPREMUM, 表示上界。
5.LOCK_GAP 位为0 或者 要加锁的 LOCK_GAP 为 1 或者 当前行为 PAGE_HEAD_NO_SUPREMUM, 表示上界。
如果没有更强级别的锁,就要进行锁冲突判断,如果有锁冲突就需要入队列等待,并且还要进行死锁检测。冲突判断调用函数 lock_rec_other_has_conflicting,循环的拿出对应行上的每一个锁,调用 lock_rec_has_to_wait 进行冲突判断。
以下描述 “锁” 表示循环拿出的每个锁,“当前锁” 表示要加的锁。
如果锁和当前锁是相同的事务,返回 false,不需要等待。
如果锁和当前锁的基本锁类型兼容,返回 false,不需要等待。兼容性根据锁的兼容矩阵判断。兼容矩阵:
static const byte lock_compatibility_matrix[5][5] = {
/** IS IX S X AI */
/* IS */ { TRUE, TRUE, TRUE, FALSE, TRUE},
/* IX */ { TRUE, TRUE, FALSE, FALSE, TRUE},
/* S */ { TRUE, FALSE, TRUE, FALSE, FALSE},
/* X */ { FALSE, FALSE, FALSE, FALSE, FALSE},
/* AI */ { TRUE, TRUE, FALSE, FALSE, FALSE}
};
如果上述两条都不满足,不是相同的事务,基本锁类型也不兼容,那么满足下面任意一条,同样返回false,不需要等待,否则返回 true,需要等待。
如果当前锁锁住的是 supremum 或者 LOCK_GAP 为 1 并且 LOCK_INSERT_INTENTION 为 0。因为不带 LOCK_INSERT_INTENTION 的 GAP 锁不需要等待任何东西,不同的用户可以在 gap 上持有冲突的锁。
如果当前锁 LOCK_INSERT_INTENTION 为 0 并且锁是 LOCK_GAP 为 1。因为行锁(LOCK_ORDINARY LOCK_REC_NOT_GAP)不需要等待一个 gap 锁。
如果当前锁 LOCK_GAP 为 1,锁 LOCK_REC_NOT_GAP 为 1。同样的,因为 gap 锁没有必要等待一个 LOCK_REC_NOT_GAP 锁。
如果锁 LOCK_INSERT_INTENTION 为 1。此处是最后一步,说明之前的条件都不满足,源码中备注描述如下:
No lock request needs to wait for an insertintention
lock to be removed. This is ok since our rules allow conflicting
locks on gaps. This eliminates a spurious deadlock
caused by a next-key lock waiting for an insert
intention lock; when the insert intention lock was
granted, the insert deadlocked on the waiting next-key
lock. Also, insert intention locks
do not disturb eachother.
举个简单的例子,如果一行数据上已经加了 LOCK_S | LOCK_REC_NOT_GAP, 再尝试去加 LOCK_X | LOCK_GAP,LOCK_S 和 LOCK_X 本身是冲突的,但是满足上述第 3 个条件,返回 FALSE,不需要等待。
如果行数据上没有更强级别的锁,也没有冲突的锁,并且加的不是隐式锁,就调用 lock_rec_add_to_queue。核心思想是复用锁对象,如果要加锁的行数据上当前没有其它锁等待,并且行所在的数据页上有相似的锁对象(lock_rec_find_similar_on_page)就可以直接设置对应行的 bitmap 位,表示加锁成功。如果有其它锁等待,就重新创建一个锁对象。
死锁检测
死锁检测的入口函数是 lock_deadlock_check_and_resolve,算法是深度优先搜索,如果在搜索过程中发现有环,就说明发生了死锁,为了避免死锁检测开销过大,如果搜索深度超过了 200(LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK)也同样认为发生了死锁。
MySQL 5.7 之后增加了更多面向对象的代码结构,但是实际算法并没有改变。
稍早版本的时候,Innodb 使用的是递归方式搜索,为了减少栈空间的开销,改为使用入栈的方式。
两个辅助数据结构:
/** Deadlock check context. */
struct lock_deadlock_ctx_t {
const trx_t* start; /*!< Joining transaction that is
requesting a lock in an incompatible
mode */
const lock_t* wait_lock; /*!< Lock that trx wants */
ib_uint64_t mark_start; /*!< Value of lock_mark_count at
the start of the deadlock check. */
ulint depth; /*!< Stack depth */
ulint cost; /*!< Calculation steps thus far */
ibool too_deep; /*!< TRUE if search was too deep and
was aborted */
};
/** DFS visited node information used during deadlock checking. */
struct lock_stack_t {
const lock_t* lock; /*!< Current lock */
const lock_t* wait_lock; /*!< Waiting for lock */
ulint heap_no; /*!< heap number if rec lock */
};
lock_stack_t 就是辅助的栈结构,使用一个 lock_stack_t 类型的数组来作为数据栈,初始化在创建 Lock_sys 的时候,大小为 LOCK_STACK_SIZE, 实际上是 srv_max_n_thread,最大的线程数。
lock_deadlock_ctx_t 中的 start 始终保持不变,是第一个请求锁的事务,如果深度搜索过程中锁对应的事务等于 start,那么就说明产生了环,发生死锁。wait_lock 表示搜索中的事务等待的锁。
举个简单的例子:
有三个事务 A,B,C 已经获得了三行数据 1,2,3 上的 X 锁。现在事务 A 去拿数据 2 的 X 锁,阻塞等待。同样事务 B 也去拿数据 3 的 X 锁,同样阻塞等待。当事务 C 尝试去拿 数据 1 的 X 锁时,发生死锁。看下此时的死锁检测流程:
1.ctx 中的 start 初始化为 C,wait_lock 初始化为 X1(数据1上的X锁)
2.根据 wait_lock=X1,调用函数 lock_get_first_lock 拿到加在数据 1 上的第一个锁 lock。在例子中就是事务 A 已经获得的 X1 锁。
3.然后判断 lock 对应的事务(A)是否也在等待其它锁:lock->trx->lock.que_state == TRX_QUE_LOCK_WAIT。当前事务 A 确实在等待 X2 锁。所以为 true,把当前的 lock 入栈(lock_dead_lock_push)。
4.ctx 中的 wait_lock 更新为 lock->trx->lock.wait_lock, 也就是 X1 锁的持有者事务 A 所等待的锁 X2。
5.同步骤 2 ,根据 wait_lock=X2, 拿到加在数据 2 上的第一个锁赋值给 lock,也就是事务 B 持有的 X2 锁。完成一次循环。
6.再次进入循环,lock 对应的事务(B)同样在等待其它锁,所以把当前的 lock 入栈。
7.ctx 中的 wait_lock 更新为 lock->trx->lock.wait_lock, 也就是 X2 锁持有者事务 B 所等待的锁 X3。
8.同步骤 2,根据 wait_lock=X3, 拿到加在数据 3 上的第一个锁赋值给 lock,也就是事务 C 持有的 X3 锁。完成一次循环。
9.再次进入循环,此时 lock->trx = C = ctx->start。死锁形成。
上述例子较为简单,没有涉及到一行数据上有多个锁,也没有出栈操作,一次深度遍历就找到了死锁,实际情况会复杂点,其它分支可以参看源码理解。
show variables like 'innodb_deadlock_detect';#死锁检查开关
victim 选择
当发生死锁后,会选择一个代价较少的事务进行回滚操作,选择函数:lock_deadlock_select_victim(ctx)。Innodb 中的 victim 选择比较粗暴,不论死锁链条有多长,只会在 ctx->start 和 ctx->wait_lock->trx 二者中选择其一。对应上述例子,就是在事务 B 和事务 C 中选择。
具体的权重比较函数是 trx_weight_ge, 如果一个事务修改了不支持事务的表,那么认为它的权重较高,否则认为 undo log 数加持有的锁数之和较大的权重较高。
死锁信息分析
当发生死锁之后,会调用 lock_deadlock_notify 写入死锁信息,SHOW ENGINE INNODB STATUS 语句可以看到最近一次发生的死锁信息,因为死锁信息是默认写到 temp 目录的临时文件中,每次发生死锁都会覆盖写。如果打开 innodb_print_all_deadlocks可以把历史所有的死锁信息打印到 errlog 中。
关于打印出来的内容具体含义有文章已经讲的比较清楚了:mysql lover 和 percona。其中推荐 percona 的文章,其实发生死锁后想找出原因的话,只有死锁信息是不够的,因为 1.只显示最近两条事务的信息 2.只显示事务最近执行的一条语句。如文中推荐的做法,配合 general log 和 binlog 进行排查。
link
SHOW ENGINE INNODB STATUS 语句可以看到最近一次发生的死锁信息
配合 general log 和 binlog 进行排查。
锁等待以及唤醒
锁的等待以及唤醒实际上是线程的等待和唤醒,调用函数 lock_wait_suspend_thread 挂起当前线程,配合 OS_EVENT 机制,实现唤醒和锁超时等功能
Test Case 实践
在完成一个锁相关 patch 的时候发现 test case 中比较诡异的点,在执行应该产生死锁的语句时,不是每次都会产生死锁,也会发生锁超时的情况。以死锁检测中描述的例子,test case 如下:
--disable_warnings
DROP TABLE IF EXISTS t1;
--enable_warnings
create table t1(a int primary key, b int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9);
# ************* 3 Transactions cause deadlock. **************** #
# Hold locks
connection con1;
begin;
select * from t1 where a=1 for update;
connection con2;
begin;
select * from t1 where a=2 for update;
connection con3;
begin;
select * from t1 where a=3 for update;
# Request locks as a circle
connection con1;
insert into t1 values(11,11);
send select * from t1 where a=2 for update;
connection con2;
insert into t1 values(12,12);
send select * from t1 where a=3 for update;
connection con3;
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;
其中插入语句是为了产生 undo log,控制那一个事务会被选为 victim。上述 test case 预期产生死锁的语句有时会报锁超时,也就是没有正确发生死锁。起初以为是 victim 选择算法的原因,后来才发现是因为 send 语句,它只保证语句发出去,并不保证执行完毕,所以在最后一条 select 语句执行的时候也许前面的语句还没执行完,无法产生死锁。
使用 wait condition 语句等待下就没问题了:
let $wait_condition=
SELECT COUNT(*) = 2 FROM information_schema.innodb_trx
WHERE trx_operation_state = 'starting index read' AND
trx_state = 'LOCK WAIT';
--source include/wait_condition.inc
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;
总结
Innodb 的锁系统实际上是封装了一层逻辑,和行本身数据一点关系也没有,了解之前以为会像文件锁一样,锁的粒度越小,维护起来越复杂,所以开头提到的 Berkeley DB 才只有页锁,了解之后很迷惑为什么不支持行锁… 区分一下 Innodb 同步机制使用的锁和本文介绍的锁是不同的,可以参考这篇月报, 有一个最不可思议的死锁问题,就是这两种锁之间切换导致的。锁系统作为事务中一个重要模块,需要配合其它模块,对于事务系统可以参考本期月报这篇文章。
MySQL · 引擎特性 · InnoDB 同步机制; MySQL · 引擎特性 · InnoDB 事务系统
本文说明,主要技术内容来自互联网技术大佬的分享,还有一些自我的加工(仅仅起到注释说明的作用)。如有相关疑问,请留言,将确认之后,执行侵权必删