select ... for update 语句的分析

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 语句是常见的数据库悲观锁实现方式,但是由于当前读和间隙锁的部分特殊逻辑,可能与不加锁的查询语句语义不同,在生产环境下使用时需要结合相关场景。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值