MySQL InnoDB 锁机制全解:行锁 / 表锁 / 间隙锁 / 临键锁底层逻辑与死锁避坑指南

0 阅读21分钟

引言

高并发业务场景下,90%的MySQL性能瓶颈、服务阻塞、数据不一致乃至线上故障,都源于对InnoDB锁机制的认知偏差。很多开发者只知道“更新要加行锁”,却不懂锁的本质是加在索引上;只听过间隙锁,却不知道它是线上死锁的头号元凶。


一、InnoDB锁机制的核心前置知识

所有锁规则都建立在两个核心基础之上:事务隔离级别、InnoDB索引组织表结构,脱离这两个前提谈锁,必然会出现认知错误。

1.1 事务隔离级别与锁的强关联

MySQL InnoDB支持4种标准SQL事务隔离级别,不同级别下的锁行为完全不同,本文所有规则默认基于MySQL默认的可重复读(RR) 隔离级别,特殊场景会单独标注。

隔离级别锁行为核心特征解决的问题未解决的问题
读未提交(RU)几乎无锁,写操作仅加行级排他锁,提交前即可被其他事务读取脏读、不可重复读、幻读
读已提交(RC)仅存在记录行锁,间隙锁完全关闭,语句执行完后会释放不匹配行的锁脏读不可重复读、幻读
可重复读(RR)行锁+间隙锁+临键锁完整生效,锁持续到事务结束脏读、不可重复读、幻读无(InnoDB通过临键锁完全解决幻读)
串行化所有查询自动加共享锁,所有写操作自动加排他锁,全表级锁控制所有问题并发性能极差,生产环境几乎不使用

核心结论:RC级别下不会出现间隙锁,锁范围更小,并发性能更高,也是互联网业务的首选隔离级别;RR级别通过间隙锁解决幻读,但也带来了更多的锁冲突和死锁风险。

1.2 InnoDB索引组织表的核心特性

InnoDB的锁,永远是加在索引上的,而非物理数据行上,这是理解所有行锁规则的核心前提。

  • InnoDB采用索引组织表结构,数据本身就是按主键索引组织的B+树;
  • 聚簇索引(主键索引):叶子节点存储完整的行数据,主键值有序排列;
  • 二级索引(普通索引/唯一索引):叶子节点仅存储对应的主键值,索引列值有序排列;
  • 核心规则:如果SQL没有命中任何索引,InnoDB无法定位到具体行,会对全表所有记录加锁,直接退化为表级锁,高并发场景下会瞬间导致服务雪崩。

1.3 锁的兼容性核心规则

InnoDB的锁分为共享锁(S锁)和排他锁(X锁)两大基础类型,兼容性规则是所有锁冲突判断的核心:

  • 共享锁(S锁):又称读锁,多个事务可以同时持有同一行的S锁,互不阻塞;
  • 排他锁(X锁):又称写锁,同一时间只有一个事务能持有某行的X锁,与所有锁都互斥;
  • 意向锁:表级锁,分为意向共享锁(IS)和意向排他锁(IX),用于快速判断表级锁与行级锁的冲突,无需逐行检查。

完整兼容性矩阵如下:

锁类型X锁IX锁S锁IS锁
X锁互斥互斥互斥互斥
IX锁互斥兼容互斥兼容
S锁互斥互斥兼容兼容
IS锁互斥兼容兼容兼容

二、InnoDB表级锁全解析

InnoDB的表级锁分为三类:意向锁、元数据锁(MDL锁)、手动表锁,其中意向锁和MDL锁是InnoDB自动加的,也是线上最容易踩坑的表级锁。

2.1 意向锁(Intention Lock)

底层逻辑

意向锁是表级锁,核心作用是解决行锁与表锁的冲突检测效率问题。如果没有意向锁,当一个事务对表内某行加了行锁,另一个事务要加表锁时,需要逐行检查是否有行锁冲突,性能极差;有了意向锁后,只需检查表级意向锁是否兼容,即可快速判断冲突。

触发场景

  • 事务要对表内某行加S锁前,会先对整个表加IS意向共享锁;
  • 事务要对表内某行加X锁前,会先对整个表加IX意向排他锁。

核心规则

  • 意向锁之间完全兼容,多个事务可以同时对同一个表加IX/IS锁,不会互相阻塞;
  • 意向锁仅与表级的S/X锁互斥,与行级锁无冲突;
  • 意向锁是InnoDB自动维护的,无需用户手动干预,所有正常的DML语句都会自动触发。

2.2 元数据锁(MDL Lock)

底层逻辑

MDL锁的核心作用是保护表的元数据(表结构),避免DML和DDL操作并发执行导致的数据不一致。只要访问一张表,就一定会加MDL锁,这是线上改表导致整个库雪崩的核心元凶。

触发场景

  • 所有DML语句(select/insert/update/delete)会加MDL读锁,读锁之间互相兼容;
  • 所有DDL语句(alter table/drop table等)会加MDL写锁,写锁与所有读锁、写锁都互斥。

线上核心坑点复现

-- 表结构初始化
CREATE TABLE `user` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` VARCHAR(32NOT NULL COMMENT '姓名',
  `age` INT NOT NULL COMMENT '年龄',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
INSERT INTO user (name,age) VALUES ('张三',20),('李四',25),('王五',30);
事务1(会话1)事务2(会话2)事务3(会话3)
BEGIN;
SELECT * FROM user WHERE id=1;
(事务未提交,持有MDL读锁)
ALTER TABLE user ADD COLUMN phone VARCHAR(11);
(申请MDL写锁,被事务1的读锁阻塞,进入等待)
SELECT * FROM user WHERE id=2;
(申请MDL读锁,被前面等待的MDL写锁阻塞)

这个场景下,只要事务1不提交,后续所有对user表的查询都会被阻塞,瞬间打满数据库连接,导致整个服务不可用。

2.3 手动表锁

手动表锁通过LOCK TABLES ... READ/WRITE语句触发,会完全覆盖InnoDB的行锁机制,强制使用表级锁,严重破坏并发性能。除了极少数特殊运维场景,生产环境绝对禁止使用手动表锁。


三、InnoDB行级锁核心原理与触发场景

行级锁是InnoDB支持高并发的核心能力,它只针对具体的索引记录加锁,不同行的锁互不影响,并发性能远高于表级锁。

3.1 行锁的底层本质

行锁的全称是记录锁(Record Lock) ,它锁住的是索引上的一条具体记录,而非物理数据行。

  • 如果SQL命中主键索引,行锁直接加在主键索引的对应记录上;
  • 如果SQL命中二级索引,行锁先加在二级索引的对应记录上,再加在对应主键索引的记录上;
  • 如果SQL没有命中任何索引,InnoDB无法定位具体记录,会对全表所有索引记录加锁,直接退化为表锁。

3.2 共享锁(S锁)

触发场景

  1. 手动执行SELECT ... LOCK IN SHARE MODE语句,会对命中的索引记录加S锁;
  2. 外键约束校验时,InnoDB会自动对关联表的对应记录加S锁,避免关联数据被删除。

核心规则

  • S锁与S锁兼容,多个事务可以同时对同一行加S锁,互不阻塞;
  • S锁与X锁、IX锁互斥,只要有事务持有某行的S锁,其他事务就无法对该行加X锁,反之亦然。

示例

事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE id=1 LOCK IN SHARE MODE;
(持有id=1的S锁)BEGIN;
SELECT * FROM user WHERE id=1 LOCK IN SHARE MODE;
(S锁兼容,执行成功,无阻塞)
UPDATE user SET age=21 WHERE id=1;
(申请X锁,与S锁互斥,进入阻塞)

3.3 排他锁(X锁)

触发场景

  1. 所有DML写语句(INSERT/UPDATE/DELETE),都会对命中的索引记录加X锁;
  2. 手动执行SELECT ... FOR UPDATE语句,会对命中的索引记录加X锁。

核心规则

  • X锁与所有类型的锁都互斥,同一时间只有一个事务能持有某行的X锁;
  • X锁会持续到事务提交或回滚时才释放,而非SQL执行完就释放。

核心示例1:命中索引的行锁,无阻塞并发

事务1(会话1)事务2(会话2)
BEGIN;
UPDATE user SET age=21 WHERE id=1;
(持有id=1的X锁)BEGIN;
UPDATE user SET age=26 WHERE id=2;
(命中id=2的主键索引,加X锁,与id=1的锁无冲突,执行成功)
COMMIT;COMMIT;

核心示例2:无索引的更新,退化为表锁

-- 注意:此时user表的name字段无索引
事务1(会话1)事务2(会话2)
BEGIN;
UPDATE user SET age=21 WHERE name='张三';
(无索引,退化为表级X锁)BEGIN;
UPDATE user SET age=26 WHERE name='李四';
(申请表级X锁,互斥,进入阻塞)
COMMIT;(阻塞解除,执行成功)

这个示例是线上最常见的性能问题之一:一个无索引的更新语句,直接锁全表,导致整个表的所有写操作都被阻塞。


四、间隙锁(Gap Lock)与临键锁(Next-Key Lock):解决幻读的核心

间隙锁和临键锁是InnoDB RR隔离级别独有的锁机制,也是绝大多数开发者最容易误解的部分,更是线上隐蔽死锁的头号元凶。

4.1 幻读的本质

很多人会把幻读和不可重复读混淆,二者的核心区别如下:

  • 不可重复读:同一个事务内,两次查询同一行,数据内容被修改,侧重数据更新
  • 幻读:同一个事务内,两次执行相同的范围查询,第二次查询返回了第一次没有的行,侧重行数变化

InnoDB在RC级别下无法解决幻读,而在RR级别下,通过临键锁完全解决了幻读问题。

4.2 临键锁的底层逻辑

临键锁(Next-Key Lock)是InnoDB RR级别解决幻读的核心方案,它由记录锁(行锁)+ 间隙锁(Gap Lock) 组成,锁住的是一个左开右闭的索引区间。

InnoDB会按照索引的有序排列,将索引划分为多个连续的临键锁区间,比如主键索引有记录1、5、10、15,那么临键锁区间划分如下:

  • 记录锁:锁住区间右侧闭区间的索引记录;
  • 间隙锁:锁住区间左侧开区间的间隙,防止其他事务在间隙中插入新记录;
  • 核心特性:间隙锁之间是互相兼容的,多个事务可以同时持有同一个间隙的间隙锁,不会互相阻塞;但间隙锁与插入意向锁互斥,会阻塞插入操作。

4.3 临键锁的触发规则与退化场景

临键锁的触发规则与索引类型、查询条件、记录是否存在强相关,核心规则如下,所有规则均经过MySQL 8.0官方验证:

规则1:等值查询命中唯一索引存在的记录,临键锁退化为记录锁,不会加间隙锁

唯一索引的等值查询可以唯一确定一条记录,不会出现幻读,因此InnoDB会直接退化为行锁,缩小锁范围,提升并发性能。

示例
-- user表主键id为唯一索引,现有记录id=1、2、3
事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE id=2 FOR UPDATE;
(仅对id=2加记录锁,无间隙锁)BEGIN;
INSERT INTO user (name,age) VALUES ('赵六',28);
(无间隙锁,插入成功,无阻塞)
UPDATE user SET age=31 WHERE id=3;
(仅id=3的记录锁,无冲突,执行成功)
COMMIT;COMMIT;

规则2:等值查询命中唯一索引不存在的记录,触发间隙锁

唯一索引的等值查询没有命中记录时,InnoDB会锁住查询值所在的间隙,防止其他事务插入这条不存在的记录,避免幻读。

示例
-- user表主键id为唯一索引,现有记录id=1、2、3,最大id=3
事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE id=4 FOR UPDATE;
(id=4不存在,加间隙锁(3, +∞))BEGIN;
INSERT INTO user (name,age) VALUES ('赵六',28);
(新记录id=5,落在(3, +∞)间隙,被阻塞)
COMMIT;(阻塞解除,插入成功)

规则3:等值查询命中普通二级索引,无论记录是否存在,都会触发临键锁,不会退化

普通二级索引不保证唯一性,即使等值查询命中了存在的记录,也可能有其他相同值的记录插入,因此InnoDB会加完整的临键锁,锁住查询值所在的区间和下一个间隙。

示例
-- 给user表的age字段添加普通二级索引
ALTER TABLE user ADD INDEX idx_age (age);
-- 现有数据:id=1 age=20、id=2 age=25、id=3 age=30
事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE age=25 FOR UPDATE;
(加临键锁(20,25] + 间隙锁(25,30),整体锁住(20,30)区间)BEGIN;
INSERT INTO user (name,age) VALUES ('赵六',22);
(age=22落在(20,30)区间,被阻塞)
INSERT INTO user (name,age) VALUES ('钱七',28);
(age=28落在(20,30)区间,被阻塞)
UPDATE user SET name='张三新' WHERE age=20;
(age=20不在锁区间,执行成功)
COMMIT;(阻塞解除,插入成功)

规则4:所有范围查询,无论索引类型,都会触发临键锁,锁住整个查询范围的所有区间

范围查询会遍历整个查询范围的索引,因此会对范围内的所有临键锁区间加锁,防止范围内插入新记录,避免幻读。

示例
-- user表主键id=1235
事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE id BETWEEN 2 AND 4 FOR UPDATE;
(加临键锁(1,2]、(2,3]、(3,5],整体锁住(1,5]区间)BEGIN;
INSERT INTO user (name,age) VALUES ('赵六',28);
(新记录id=4,落在(3,5]区间,被阻塞)
COMMIT;(阻塞解除,插入成功)

五、典型死锁场景复现与底层分析

死锁的本质是多个事务互相持有对方需要的锁,形成循环等待,谁都无法继续执行。要解决死锁,首先要理解死锁的四个必要条件,以及InnoDB中最常见的死锁场景。

5.1 死锁的四个必要条件

只有四个条件同时满足时,才会发生死锁,只要破坏其中任意一个,就能避免死锁:

  1. 互斥条件:锁只能被一个事务持有,其他事务必须等待;
  2. 占有且等待:事务已经持有至少一个锁,又申请新的锁被阻塞,不释放已持有的锁;
  3. 不可剥夺:锁只能由持有事务主动提交/回滚释放,不能被其他事务强行剥夺;
  4. 循环等待:多个事务形成循环等待锁的链路,每个事务都在等待下一个事务持有的锁。

5.2 场景1:不同事务加锁顺序相反(最常见)

这是线上最普遍的死锁场景,多个事务对相同的行,以相反的顺序加锁,形成循环等待。

示例

事务1(会话1)事务2(会话2)
BEGIN;
UPDATE user SET age=21 WHERE id=1;
(持有id=1的X锁)BEGIN;
UPDATE user SET age=26 WHERE id=2;
(持有id=2的X锁)
UPDATE user SET age=22 WHERE id=2;
(申请id=2的X锁,被事务2阻塞)
UPDATE user SET age=27 WHERE id=1;
(申请id=1的X锁,循环等待,触发死锁)
(事务被回滚,死锁解除)(执行成功)

底层分析

四个死锁条件全部满足:

  • 互斥:X锁只能被一个事务持有;
  • 占有且等待:两个事务都持有锁,又申请新的锁被阻塞;
  • 不可剥夺:锁只能主动释放;
  • 循环等待:事务1等待事务2的id=2锁,事务2等待事务1的id=1锁。

5.3 场景2:间隙锁导致的死锁(最隐蔽)

这是线上最难排查的死锁场景,根源在于间隙锁之间互相兼容,但插入意向锁与间隙锁互斥,形成循环等待。

示例

-- user表age字段有普通索引,现有数据age=202530
事务1(会话1)事务2(会话2)
BEGIN;
SELECT * FROM user WHERE age=22 FOR UPDATE;
(age=22不存在,加间隙锁(20,25))BEGIN;
SELECT * FROM user WHERE age=23 FOR UPDATE;
(age=23不存在,加间隙锁(20,25),间隙锁兼容,无阻塞)
INSERT INTO user (name,age) VALUES ('赵六',22);
(申请插入意向锁,被事务2的间隙锁阻塞)
INSERT INTO user (name,age) VALUES ('钱七',23);
(申请插入意向锁,被事务1的间隙锁阻塞,循环等待,触发死锁)
(事务被回滚,死锁解除)(执行成功)

底层分析

这个场景的隐蔽性在于,两个事务的查询语句完全不会互相阻塞,直到执行插入语句时才会触发死锁,很多开发者根本想不到是前面的查询语句导致的。

5.4 场景3:二级索引与主键索引加锁顺序相反

二级索引的更新会先锁二级索引,再锁主键索引;而主键索引的更新会先锁主键索引,再锁二级索引,二者加锁顺序相反,极易形成死锁。

示例

-- user表age字段有普通索引,现有数据:id=1 age=25、id=2 age=20
事务1(会话1)事务2(会话2)
BEGIN;
UPDATE user SET name='A' WHERE age=20;
(加锁顺序:idx_age age=20 → 主键id=2)BEGIN;
UPDATE user SET name='B' WHERE id=1;
(加锁顺序:主键id=1 → idx_age age=25)
UPDATE user SET name='A1' WHERE id=1;
(申请主键id=1的锁,被事务2阻塞)
UPDATE user SET name='B1' WHERE age=25;
(申请idx_age age=25的锁,被事务1阻塞,循环等待,触发死锁)

5.5 死锁的快速排查方法

  1. 查看最近一次死锁日志:执行SHOW ENGINE INNODB STATUS;,在输出的LATEST DETECTED DEADLOCK部分,会完整记录死锁的两个事务、持有的锁、申请的锁、执行的SQL;
  2. 死锁日志持久化:执行SET GLOBAL innodb_print_all_deadlocks = ON;,所有死锁日志都会写入MySQL的错误日志,方便后续排查;
  3. 核心分析点:重点关注两个事务的WAITING FOR THIS LOCK TO BE GRANTEDHELD LOCK部分,直接定位加锁顺序冲突的根源。

六、生产环境锁问题避坑最佳实践

6.1 索引设计层面

  • 所有DML语句必须命中索引,绝对禁止无索引的UPDATE/DELETE语句,避免退化为表锁;
  • 优先使用唯一索引做等值查询,减少普通索引带来的间隙锁风险;
  • 避免在更新频繁的字段上建立二级索引,减少加锁的范围和冲突概率;
  • 索引区分度低于30%的字段不要建立索引,避免索引失效导致全表扫描锁表。

6.2 事务设计层面

  • 控制事务粒度,大事务拆分为小事务,缩短锁的持有时间,减少锁冲突的概率;
  • 绝对禁止在事务内执行RPC调用、HTTP接口调用、本地IO等耗时操作,避免锁持有时间过长;
  • 统一所有业务的加锁顺序,比如按主键ID从小到大的顺序加锁,从根源上破坏循环等待条件;
  • 事务内的更新操作,尽量放在事务的末尾,缩短锁的持有时间。

6.3 SQL编写层面

  • 避免使用大范围的范围查询,缩小锁的覆盖范围,减少间隙锁的影响区间;
  • 业务允许的情况下,优先使用RC隔离级别,关闭间隙锁,大幅降低死锁概率,同时减少锁的持有时间;
  • 禁止使用SELECT * FOR UPDATE不加WHERE条件的语句,直接锁全表;
  • 避免在WHERE条件中使用函数、隐式类型转换,导致索引失效,退化为表锁。

6.4 死锁防控层面

  • 保持innodb_deadlock_detect=ON(默认开启),InnoDB会自动检测死锁,回滚代价最小的事务,解除死锁;
  • 超高并发场景下,若死锁检测导致CPU使用率过高,可关闭死锁检测,配合innodb_lock_wait_timeout设置合理的超时时间(默认50s,建议调整为3-5s);
  • 定期监控死锁日志,提前发现业务代码中的加锁问题,避免故障扩大。

6.5 MDL锁避坑

  • 绝对禁止在业务高峰执行DDL表结构变更操作;
  • 执行DDL前,先查看是否有长事务持有MDL读锁,避免阻塞后续所有查询;
  • 使用Online DDL工具执行表结构变更,减少MDL写锁的持有时间,避免长时间阻塞。

七、Java实现

7.1 核心依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-transaction</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.7</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.5.0</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.32</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.52</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>33.1.0-jre</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

7.2 实体类定义

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serial;
import java.io.Serializable;

/**
 * 用户实体类
 * @author ken
 */
@Data
@TableName("user")
@Schema(description = "用户实体")
public class User implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID", example = "1")
    private Long id;

    @Schema(description = "姓名", example = "张三")
    private String name;

    @Schema(description = "年龄", example = "20")
    private Integer age;
}

7.3 Mapper接口定义

package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

import java.util.List;

/**
 * 用户Mapper接口
 * @author ken
 */
public interface UserMapper extends BaseMapper<User> {

    /**
     * 排他锁查询用户列表
     * @param minId 最小ID
     * @param maxId 最大ID
     * @return 用户列表
     */
    @Select("SELECT * FROM user WHERE id BETWEEN #{minId} AND #{maxId} FOR UPDATE")
    List<User> selectListForUpdate(@Param("minId") Long minId, @Param("maxId") Long maxId);
}

7.4 业务层实现

package com.jam.demo.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.google.common.collect.Lists;
import com.jam.demo.entity.User;
import com.jam.demo.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.util.List;

/**
 * 用户业务服务
 * @author ken
 */
@Slf4j
@Service
public class UserService {

    private final UserMapper userMapper;

    private final TransactionTemplate transactionTemplate;

    public UserService(UserMapper userMapper, TransactionTemplate transactionTemplate) {
        this.userMapper = userMapper;
        this.transactionTemplate = transactionTemplate;
    }

    /**
     * 按顺序更新用户信息,避免死锁
     * @param user1 第一个用户更新信息
     * @param user2 第二个用户更新信息
     * @return 更新结果
     */
    public Boolean updateUserWithRightOrder(User user1, User user2) {
        return transactionTemplate.execute(new TransactionCallback<Boolean>() {
            @Override
            public Boolean doInTransaction(TransactionStatus status) {
                try {
                    // 统一按ID从小到大的顺序加锁,破坏循环等待条件
                    List<Long> idList = Lists.newArrayList(user1.getId(), user2.getId());
                    idList.sort(Long::compareTo);

                    // 先加锁小ID的记录
                    for (Long id : idList) {
                        User lockUser = userMapper.selectById(id);
                        if (ObjectUtils.isEmpty(lockUser)) {
                            log.warn("用户不存在,id:{}", id);
                            status.setRollbackOnly();
                            return Boolean.FALSE;
                        }
                    }

                    // 执行更新操作
                    if (StringUtils.hasText(user1.getName())) {
                        User updateUser1 = new User();
                        updateUser1.setId(user1.getId());
                        updateUser1.setName(user1.getName());
                        userMapper.updateById(updateUser1);
                    }

                    if (!ObjectUtils.isEmpty(user2.getAge())) {
                        User updateUser2 = new User();
                        updateUser2.setId(user2.getId());
                        updateUser2.setAge(user2.getAge());
                        userMapper.updateById(updateUser2);
                    }

                    log.info("用户信息更新成功,id1:{}, id2:{}", user1.getId(), user2.getId());
                    return Boolean.TRUE;
                } catch (Exception e) {
                    log.error("用户信息更新失败", e);
                    status.setRollbackOnly();
                    return Boolean.FALSE;
                }
            }
        });
    }

    /**
     * 范围查询加锁,避免幻读
     * @param minId 最小ID
     * @param maxId 最大ID
     * @return 用户列表
     */
    public List<User> queryUserWithRangeLock(Long minId, Long maxId) {
        return transactionTemplate.execute(new TransactionCallback<List<User>>() {
            @Override
            public List<User> doInTransaction(TransactionStatus status) {
                try {
                    if (ObjectUtils.isEmpty(minId) || ObjectUtils.isEmpty(maxId)) {
                        log.warn("查询参数为空");
                        return Lists.newArrayList();
                    }

                    List<User> userList = userMapper.selectListForUpdate(minId, maxId);
                    if (CollectionUtils.isEmpty(userList)) {
                        log.info("查询范围内无用户数据,minId:{}, maxId:{}", minId, maxId);
                        return Lists.newArrayList();
                    }

                    log.info("范围查询用户成功,数量:{}", userList.size());
                    return userList;
                } catch (Exception e) {
                    log.error("范围查询用户失败", e);
                    status.setRollbackOnly();
                    return Lists.newArrayList();
                }
            }
        });
    }
}

7.5 控制层实现

package com.jam.demo.controller;

import com.jam.demo.entity.User;
import com.jam.demo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 用户控制器
 * @author ken
 */
@RestController
@RequestMapping("/user")
@Tag(name = "用户管理", description = "用户相关操作接口")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    /**
     * 按顺序更新用户信息
     * @param user1 第一个用户信息
     * @param user2 第二个用户信息
     * @return 更新结果
     */
    @PostMapping("/update/order")
    @Operation(summary = "按顺序更新用户信息", description = "统一加锁顺序,避免死锁")
    public ResponseEntity<Boolean> updateUserWithOrder(@RequestBody User user1, @RequestBody User user2) {
        Boolean result = userService.updateUserWithRightOrder(user1, user2);
        return ResponseEntity.ok(result);
    }

    /**
     * 范围查询加锁用户列表
     * @param minId 最小ID
     * @param maxId 最大ID
     * @return 用户列表
     */
    @GetMapping("/query/range")
    @Operation(summary = "范围查询加锁用户列表", description = "范围查询加锁,避免幻读")
    public ResponseEntity<List<User>> queryUserWithRange(@RequestParam Long minId, @RequestParam Long maxId) {
        List<User> userList = userService.queryUserWithRangeLock(minId, maxId);
        return ResponseEntity.ok(userList);
    }
}

7.6 核心配置文件(application.yml)

server:
  port: 8080
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  transaction:
    default-timeout: 5
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  type-aliases-package: com.jam.demo.entity
  configuration:
    map-underscore-to-camel-case: true
    cache-enabled: false
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
springdoc:
  swagger-ui:
    path: /swagger-ui.html
    enabled: true
  api-docs:
    enabled: true
    path: /v3/api-docs

7.7 MyBatisPlus配置类

package com.jam.demo.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * MyBatisPlus配置类
 * @author ken
 */
@Configuration
@MapperScan("com.jam.demo.mapper")
@EnableTransactionManagement
public class MybatisPlusConfig {

    /**
     * 配置MyBatisPlus拦截器
     * @return 拦截器实例
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

7.8 项目启动类

package com.jam.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 项目启动类
 * @author ken
 */
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

八、写在最后

InnoDB的锁机制,本质上是平衡数据一致性与并发性能的一套规则体系。绝大多数锁冲突、死锁问题的根源,都源于对“锁永远加在索引上”这一核心本质的认知缺失,以及对事务粒度、加锁顺序的不合理设计。 对于业务开发而言,无需死记硬背所有锁规则,但必须牢牢掌握三个核心原则:

  1. 无索引不执行更新操作,从根源避免锁全表的灾难性风险;
  2. 全业务统一加锁顺序,直接破坏死锁必备的循环等待条件;
  3. 严格控制事务粒度,缩短锁的持有时间,是提升数据库并发性能的核心。