Skip to content

前言

MySQL 的锁机制十分复杂,对其没有一个整体的认知会越来越乱。

共享锁和排他锁

InnoDB 实现了标准的行级锁,其中有两种类型;

  • 共享锁(Shared Lock)又称之为 S 锁,允许持有锁的事务读取行;
  • 排他锁(Exclusive Lock)又称之为 X 锁,允许持有锁的事务更新删除行;

SS 不冲突,SX XX 会冲突。

意向锁

InnoDB 支持多粒度锁,允许行锁与表锁共存。意向锁(Intention Lock)是表级锁,表示将要对表中的行是用哪种锁(S OR X),同样意向锁也有两种类型,意向共享锁(Intention Shared Lock)和意向排他锁(Intention Exclusive Lock)。

意向锁规定事务在请求 S 锁前,要先获得 IS 锁,同理请求 X 锁前,要先获得 IX 锁。

以下是各种类型锁之间的兼容性表格;

共享锁(S)排他锁(X)意向共享锁(IS)意向排他锁(IX)
共享锁(S)×x
排他锁(X)xxxx
意向共享锁(IS)x
意向排他锁(IX)xx

意向锁不会阻止除全表请求之外的任何内容(例如,LOCK TABLES ... WRITE)。意向锁在SHOW ENGINE INNODB输出类似以下内容:

TABLE LOCK table `test`.`t` trx id 10080 lock mode IX

记录锁

记录锁(Record Lock)是索引记录上的锁。例如SELECT * FROM t WHERE t.id = 2023 FOR UPDATE;会给 id 为 2023 索引记录加锁。及时表未定义聚集索引,InnoDB 也会创建一个隐藏的聚集索引并锁定该记录。

记录锁在SHOW ENGINE INNODB输出类似以下内容:

RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10078 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0

间隙锁

间隙锁(Gap Lock)是对索引记录之间间隙的锁定,包括第一个记录之前的间隙和最后一个记录之后的间隙。间隙锁是性能与并发之间的权衡,只有在某些事务隔离级别下生效。

仍以SELECT * FROM t WHERE t.id = 2023 FOR UPDATE;为例,如果 id 建立唯一索引不会涉及到间隙锁,未建立索引或是非唯一索引则会锁定该记录前面的间隙。

间隙锁可以共存,唯一目的就是防止其他事务插入行到间隙中,可以通过设置事务隔离级别为READ COMMITTED就可以禁用间隙锁。

临键锁

临键锁(Next-Key Lock)是记录锁和间隙锁的组合。假设非唯一索引包含值 1994 2023,则会有以下几个临键锁区间;

  • (-∞, 1994]
  • (1994, 2023]
  • (2023, +∞)

临键锁在REPEATABLE READ事务隔离级别下运行,这也是 InnoDB 的默认事务隔离级别。临键锁能有效避免幻读问题。

临键锁在SHOW ENGINE INNODB输出类似以下内容:

RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10080 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

插入意向锁

插入意向锁(Insert Intention Lock)也是一种间隙锁,在 INSERT 执行前进行设置。多个事务能同时持有相同间隙的插入意向锁,只要实际插入的位置不相同,则事务间不会被阻塞等待。

不过插入意向锁与普通的间隙锁冲突,当插入的间隙已被设置间隙锁,申请插入意向锁会被阻塞。

插入意向锁在SHOW ENGINE INNODB输出类似以下内容:

RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0

自增锁

自增所(AUTO-INC Lock)是一个特殊的表级锁,在 INSERT 涉及到 AUTO_INCREMENT 列的情况下设置。最简单的情况,一个事务正在执行插入操作,其他任何事务必须等待该事务提交后才能执行。

实践巩固

以下是一个查询当前事务持有锁信息的 SQL,用来观察加锁流程;

sql
SELECT
	OBJECT_SCHEMA,-- 数据库
	OBJECT_NAME,-- 表
	ENGINE_TRANSACTION_ID,-- 事务ID
	INDEX_NAME,-- 索引名
	LOCK_TYPE,-- 锁类型 TABLE/RECORD
	LOCK_MODE,-- 锁模式 S/X/IS/IX/GAP/REC_NOT_GAP/INSERT_INTENTION
	LOCK_STATUS,-- 锁状态 GRANTED/WAITING
	LOCK_DATA -- 实际加锁记录
FROM
	`performance_schema`.data_locks;

其中 LOCK_MODE 再来复习下;

  • S 是共享锁;X 是排他锁,在REPEATABLE READ事务隔离级别下为临键锁;
  • IS 是意向共享锁,IX 是意向排他锁,事务获取实际锁之前必须先获取意向锁;
  • GAP 间隙锁,锁定某个索引记录前的间隙;
  • REC_NOT_GAP 记录锁,也就是常说的行锁;
  • INSERT_INTENTION 插入意向锁

MySQL 版本为 8.0.32,事务隔离级别为REPEATABLE READ,假设有一张数据表,结构即内容如下;

SQL
CREATE TABLE `employees` (
  `id` int NOT NULL,
  `name` varchar(255) DEFAULT NULL,
  `salary` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_name_salary` (`name`,`salary`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO employees VALUE (2019, 'sasa', 3000);
INSERT INTO employees VALUE (2021, 'taotao', 5000);

事务(1)执行以下SELECT * FROM employees where name = 'taotao' FOR UPDATE;语句,观察事务加锁信息;

ENGINE_TRANSACTION_IDINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
8663NullTABLEIXGRANTEDNull
8663idx_name_salaryRECORDXGRANTEDsupremum pseudo-record
8663idx_name_salaryRECORDXGRANTED'taotao', 5000, 2019
8663PRIMARYRECORDX,REC_NOT_GAPGRANTED2019

该事务一共持有四把锁;

  • 对 employees 表的意向排他锁 IX;
  • 对 idx_name_salary 索引的 'taotao',5000,2019 记录的临键锁;
  • 对 idx_name_salary 索引的无限大 supermum pseudo-record 记录的临键锁;
  • 对 PRIMARY 索引中关联的 id=2019 记录的行锁;

事务(2)执行以下INSERT INTO employees VALUE (2020, 'songsong', 8000);语句,观察事务加锁信息;

ENGINE_TRANSACTION_IDINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
8665NullTABLEIXGRANTEDNull
8665idx_name_salaryRECORDX,GAP,INSERT_INTENTIONWAITING'taotao', 5000, 2019
8663NullTABLEIXGRANTEDNull
8663idx_name_salaryRECORDXGRANTEDsupremum pseudo-record
8663idx_name_salaryRECORDXGRANTED'taotao', 5000, 2019
8663PRIMARYRECORDX,REC_NOT_GAPGRANTED2019

可以清楚看到事务(2)在获取插入意向锁时被阻塞,因为插入 idx_name_salary 索引的位置在sasataotao之间,但是taotao已经被事务(1)加了临键锁,前面的间隙也被锁定。

死锁排查

saveOrUpdate

MyBatis-Plus 会提供 saveOrUpdate 这个方法,重点注意这个方法在高并发场景非常容易产生死锁问题。

java
    /**
     * TableId 注解存在更新记录,否插入一条记录
     *
     * @param entity 实体对象
     * @return boolean
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean saveOrUpdate(T entity) {
        if (null != entity) {
            Class<?> cls = entity.getClass();
            TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
            Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!");
            String keyProperty = tableInfo.getKeyProperty();
            Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!");
            Object idVal = ReflectionKit.getMethodValue(cls, entity, tableInfo.getKeyProperty());
            return StringUtils.checkValNull(idVal) || Objects.isNull(getById((Serializable) idVal)) ? save(entity) : updateById(entity);
        }
        return false;
    }

    /**
     * <p>
     * 根据updateWrapper尝试更新,否继续执行saveOrUpdate(T)方法
     * 此次修改主要是减少了此项业务代码的代码量(存在性验证之后的saveOrUpdate操作)
     * </p>
     *
     * @param entity 实体对象
     */
    default boolean saveOrUpdate(T entity, Wrapper<T> updateWrapper) {
        return update(entity, updateWrapper) || saveOrUpdate(entity);
    }

可以看到如果是用saveOrUpdate(T entity, Wrapper<T> updateWrapper)这个方法,会先执行 update 失败再进行 save 处理。

仍以上面employees表为例子,事务(1)、事务(2)需要分别新增或更新(2022,'songsong',6000)和(2023,'kunkun',8000)两条记录,是用saveOrUpdate方法会先后执行下面的 update 语句;

SQL
-- 事务(1)
UPDATE employees SET name = 'songsong' and salary = 6000 where id = 2022;

-- 事务(2)
UPDATE employees SET name = 'kunkun' and salary = 8000 where id = 2023;

由于不存在 id 是 2022 和 2023 的记录,事务(1)和事务(2)都会更新失败,继而执行后续的 save 操作。先看看这两个事务的持有锁信息;

ENGINE_TRANSACTION_IDINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
8701NullTABLEIXGRANTEDNull
8701PRIMARYRECORDXGRANTEDsupremum pseudo-record
8700NullTABLEIXGRANTEDNull
8700PRIMARYRECORDXGRANTEDsupremum pseudo-record

注意:加在正无穷大上的间隙锁和临键锁日志都是 lock_mode X,彼此不会冲突。

然后再分别执行 insert 操作,此时便产生了死锁;

SQL
-- 事务(1)
INSERT INTO employees VALUE (2022, 'songsong', 6000);

-- 事务(2)
INSERT INTO employees VALUE (2023, 'kunkun', 8000);

执行SHOW ENGINE INNODB STATUS;查看死锁日志;

所以在生产环境中,慎用 saveOrUpdate 方法。

INSERT INTO ... ON DUPLICATE KEY UPDATE

在 MySQL 5.7.21 版本,INSERT INTO ... ON DUPLICATE KEY UPDATE 操作非主键唯一索引会插入一个间隙锁极易导致死锁问题。

假设有 t 表,结构和内容如下;

sql
CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `no` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_no` (`no`) USING BTREE
) ENGINE=InnoDB;

INSERT INTO t VALUE (10, 10);
INSERT INTO t VALUE (20, 20);

有三个事务并发,执行顺序和结果如下表格;

事务(1)事务(2)事务(3)
begin;begin;begin;
insert into t value (11, 11) on duplicate key update id = id;
(Query OK, 1 row affected)
insert into t value (12, 12) on duplicate key update id = id;
(阻塞)
insert into t value (13, 13) on duplicate key update id = id;
(阻塞)
commit;
(Query OK, 1 row affected)(Deadlock)

show engine innodb status;观察死锁日志;

事务(2)和事务(3)都持有(10, 20)之间的间隙锁,并且同时在等待插入意向锁由于事务(1)持有该间隙锁。当事务(1)提交后事务(2)和事务(3)都需要获取插入意向锁,但由于对方持有间隙锁从而造成死锁。

  • 5.7.26 版本就没有这个间隙锁不会存在这种问题;
  • 仅 insert into 也不会存在出现这种问题;
  • 操作主键 ID 也不会有这个问题;

并发 DELETE

在 MySQL 5.7.21 版本,并发 DELETE 非主键唯一索引会插入一个间隙锁极易导致死锁问题。

假设有 t 表,结构和内容如下;

sql
CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `no` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_no` (`no`) USING BTREE
) ENGINE=InnoDB;

INSERT INTO t VALUE (10, 10);
INSERT INTO t VALUE (20, 20);

有三个事务并发执行 DELETE 语句;

sql
-- 事务1
DELETE FROM t WHERE no = 10;
-- 事务2
DELETE FROM t WHERE no = 10;
-- 事务3
DELETE FROM t WHERE no = 10;

竟然会发生死锁??分析死锁的两个事务情况;

  • 事务 7706 等待获取 Record Lock
  • 事务 7708 持有 Record Lock
  • 事务 7708 等待获取 Next-Key Lock

为避免事务 7706 发生饥饿现象,事务 7708 需要等待事务 7706 获取行锁成功后才能获取 Next-Key Lock,由于互相等待从而造成死锁。

回顾下 MySQL 执行 DELETE 语句只是将对应记录 MARK DELETE 而不是物理删除,对唯一索引记录加锁需要分成三种情况;

  1. 记录存在,加 Record Lock;
  2. 记录存在但 MARK DELETE,加 Next-Key Lock;
  3. 记录不存在,加 Gap Lock;

再来看看三个事务逻辑执行情况;

事务(1)事务(2)事务(3)
对记录加 Record Lock
Record Lock 阻塞Record Lock 阻塞
MARK DELETE
事务提交
获取 Record Lock 成功
事务 restart 申请获取 Next-Key Lock
死锁

根据事务(3)执行 DELETE 的时机在 MARK DELETE 前后,出现死锁的日志也分为等待 Record Lock 和等待 Next-Key Lock。

8.x 也不出出现...

参考

基于 MIT 许可发布