1. 前言
本文将介绍select … for update语句相关知识,以及对可能导致的死锁、影响、相关代码进行分析。
如无特殊说明,本文涉及的MySQL版本为8.0.22,事务隔离级别为RR,数据库引擎为INNODB。
2. 实验数据准备
创建一个测试表t_a:
CREATE TABLE `test_a` (
`id` int NOT NULL,
`a` int NOT NULL,
`b` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
插入测试数据:
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('5', '5', '5');
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('10', '10', '10');
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('15', '15', '15');
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('20', '20', '20');
2. 当前读与一致性读
通常的在RR下,开启一个事务时,会记录下当前系统的活跃事务数组和max_trx_id全局变量;并将活跃事务数组中的trx_id最小值置为低水位,max_trx_id加1置为高水位。同时利用每一行记录的隐藏列db_trx_id和db_roll_pointer确定实际返回的数据。
- 对于db_trx_id小于低水位的row,系统认为该版本对于当前事务是可见的;
- 对于db_trx_id大于等于高水位的row,系统认为该版本对于当前事务是不可见的;
- 对于db_trx_id大于等于低水位且小于高水位的row,如果db_trx_id在活跃事务数组中,则系统判定为不可见;否则对当前事务时可见。
如果判断为不可见,系统将借助另一个隐藏列回滚指针db_roll_pointer,找到undo日志中上一个版本的位置,并检查该行数据的db_trx_id;如此循环,直到找到可见的db_trx_id。
2.1 实验1
打开两个命令行,按时间线操作,即使session B查询时session A已经提交,但查询结果仍为a=10。
2.2 实验2
打开两个命令行,按时间线操作,session B在session A提交后开始事务,但查询结果为a=11。
2.3 实验3
在session B中增加了一条的select … for update语句,要求查询时加上X锁,返回结果变成了a=11。
使用select … lock in share mode 和 select … for update语句时,读取操作为当前读,直接读取该行最新值,不再基于undo log回滚数据,并对读取记录加S锁或X锁,阻塞其他事务的修改操作。
3. 间隙锁、插入意向锁和死锁
当select … for update 按主键等值查询,且能够查到记录时,逻辑较为明确;但如果查询对象不存在,引擎会对扫描过的间隙上锁,可能导致死锁。
3.1 实验4
首先关闭死锁检测
SET GLOBAL innodb_deadlock_detect = off;
session A和session B由于死锁,都阻塞直到超时,并抛出如下异常:
ERROR: 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
3.2 实验5
然后让我们打开死锁检测后,按实验4的步骤重新跑一遍。
SET GLOBAL innodb_deadlock_detect = on;
这一次,死锁检测发现死锁,session A执行成功,session B被引擎回滚。
使用命令show engine innodb status; 查看引擎死锁状态,
------------------------
LATEST DETECTED DEADLOCK
------------------------
2021-01-30 10:36:45 0x700004c8d000
*** (1) TRANSACTION:
TRANSACTION 2112, ACTIVE 20 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 12, OS thread handle 123145392267264, query id 480 localhost 127.0.0.1 root update
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('7', '7', '7')
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `test`.`test_a` trx id 2112 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000083d; asc =;;
2: len 7; hex 02000000890237; asc 7;;
3: len 4; hex 8000000a; asc ;;
4: len 4; hex 8000000a; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `test`.`test_a` trx id 2112 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000083d; asc =;;
2: len 7; hex 02000000890237; asc 7;;
3: len 4; hex 8000000a; asc ;;
4: len 4; hex 8000000a; asc ;;
*** (2) TRANSACTION:
TRANSACTION 2113, ACTIVE 13 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 14, OS thread handle 123145392570368, query id 481 localhost 127.0.0.1 root update
INSERT INTO `test`.`test_a` (`id`, `a`, `b`) VALUES ('7', '7', '7')
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `test`.`test_a` trx id 2113 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000083d; asc =;;
2: len 7; hex 02000000890237; asc 7;;
3: len 4; hex 8000000a; asc ;;
4: len 4; hex 8000000a; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `test`.`test_a` trx id 2113 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000083d; asc =;;
2: len 7; hex 02000000890237; asc 7;;
3: len 4; hex 8000000a; asc ;;
4: len 4; hex 8000000a; asc ;;
*** WE ROLL BACK TRANSACTION (2)
果然发生了死锁,相关信息如下:
- index PRIMARY of table
test
.test_a
,表示主键索引 - lock_mode X locks gap before rec insert intention waiting,表示插入意向锁,gap表示间隙锁而非行锁
- n_fields 5; compact format; info bits 0及之后的5行,表示gap锁之后的记录。从这条信息也可看出,间隙锁的范围其实就是由间隙右边的记录定义的。
0: len 4; hex 8000000a; // id=10
1: len 6; hex 00000000083d; // trx_id,事务id
2: len 7; hex 02000000890237; // roll_pointer,回滚指针
3: len 4; hex 8000000a; // a=10
4: len 4; hex 8000000a; // b=10
由于间隙锁之间是不冲突的,session A和session B的select … for update语句都能拿到(5,10)这个间隙锁。
但是insert这一动作,与session A和session B所持有的间隙锁都冲突了。
因此,当session A执行insert语句时,需要等待session B释放(5,10)间隙锁;而session B执行insert语句时,又需要等待session A释放(5,10)间隙锁;死锁产生了。
引擎在解决死锁时通常有两种方案:
- 超时等待,如果关闭innodb_deadlock_detect,session会在innodb_lock_wait_timeout后超时,innodb_lock_wait_timeout默认50s。
- 死锁检测,如果打开innodb_deadlock_detect,引擎会消耗一定的CPU算力进行死锁检测,发现死锁后,主动回滚部分事务,让另一部分事务得以正确执行。
4. 源码分析
4.1 间隙锁
获取锁时判断是否等待的逻辑如下:
UNIV_INLINE
bool lock_rec_has_to_wait(
const trx_t *trx, // 新锁事务id
ulint type_mode, // 锁类型,S锁、X锁、间隙锁、行锁、插入意向锁
const lock_t *lock2,
bool lock_is_on_supremum) // 是否对supremum上锁,supremum是innodb为了简化代码设置的虚拟行记录,可以理解为无穷大
{
ut_ad(trx && lock2);
ut_ad(lock_get_type_low(lock2) == LOCK_REC);
const bool is_hp = trx_is_high_priority(trx);
if (trx != lock2->trx &&
!lock_mode_compatible(static_cast<lock_mode>(LOCK_MODE_MASK & type_mode),
lock_get_mode(lock2))) {
// 检查事务优先级,如果是高优任务且lock2是等待状态,则无需wait
if (is_hp && lock2->is_waiting() && !trx_is_high_priority(lock2->trx)) {
return (false);
}
// 如果是申请间隙锁或右侧记录为supremum,且非插入意向锁,则无需等待
if ((lock_is_on_supremum || (type_mode & LOCK_GAP)) &&
!(type_mode & LOCK_INSERT_INTENTION)) {
return (false);
}
// 如果不是申请插入意向锁,且lock2为行锁锁,则无需等待
if (!(type_mode & LOCK_INSERT_INTENTION) && lock_rec_get_gap(lock2)) {
return (false);
}
// 如果申请的是间隙锁,且lock2为记录锁,则无需等待
if ((type_mode & LOCK_GAP) && lock_rec_get_rec_not_gap(lock2)) {
return (false);
}
// 略去无关代码
return (true);
}
return (false);
}
4.2 死锁检测
死锁检测逻辑如下:
static void lock_wait_find_and_handle_deadlocks(
const ut::vector<waiting_trx_info_t> &infos, // 所有wait事务
const ut::vector<int> &outgoing, // infos[id].trx等待infos[outgoing[id]].trx
ut::vector<trx_schedule_weight_t> &new_weights) { // 事务权重,发现死锁后,按权重判断回滚哪个事务
ut_ad(infos.size() == new_weights.size());
ut_ad(infos.size() == outgoing.size());
ut_ad(infos.size() < std::numeric_limits<uint>::max());
const auto n = static_cast<uint>(infos.size());
ut_ad(n < static_cast<uint>(std::numeric_limits<int>::max()));
ut::vector<uint> cycle_ids;
cycle_ids.clear();
// 状态保存容器
ut::vector<uint> colors;
colors.clear();
colors.resize(n, 0);
uint current_color = 0;
// 遍历全部wait事务
// 下面的代码就是个DFS,看起来像个leetcode medium题 :)
for (uint start = 0; start < n; ++start) {
if (colors[start] != 0) {
// 如果已经遍历过,则跳过
continue;
}
++current_color;
for (int id = start; 0 <= id; id = outgoing[id]) {
ut_ad(id != outgoing[id]);
if (colors[id] == 0) {
// 记录循环次数
colors[id] = current_color;
continue;
}
if (colors[id] == current_color) {
// DFS时发现了一个循环,判断为死锁
lock_wait_extract_cycle_ids(cycle_ids, id, outgoing);
if (lock_wait_check_candidate_cycle(cycle_ids, infos, new_weights)) {
MONITOR_INC(MONITOR_DEADLOCK);
} else {
MONITOR_INC(MONITOR_DEADLOCK_FALSE_POSITIVES);
}
}
break;
}
}
MONITOR_INC(MONITOR_DEADLOCK_ROUNDS);
MONITOR_SET(MONITOR_LOCK_THREADS_WAITING, n);
}
5. 总结
select … for update 语句是常见的数据库悲观锁实现方式,但是由于当前读和间隙锁的部分特殊逻辑,可能与不加锁的查询语句语义不同,在生产环境下使用时需要结合相关场景。