引言
高并发业务场景下,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写锁,写锁与所有读锁、写锁都互斥。
线上核心坑点复现
sql
-- 表结构初始化
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(32) NOT 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锁)
触发场景
- 手动执行
SELECT ... LOCK IN SHARE MODE语句,会对命中的索引记录加S锁; - 外键约束校验时,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锁)
触发场景
- 所有DML写语句(INSERT/UPDATE/DELETE),都会对命中的索引记录加X锁;
- 手动执行
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:无索引的更新,退化为表锁
diff
-- 注意:此时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会直接退化为行锁,缩小锁范围,提升并发性能。
示例
bash
-- 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会锁住查询值所在的间隙,防止其他事务插入这条不存在的记录,避免幻读。
示例
bash
-- 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会加完整的临键锁,锁住查询值所在的区间和下一个间隙。
示例
sql
-- 给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:所有范围查询,无论索引类型,都会触发临键锁,锁住整个查询范围的所有区间
范围查询会遍历整个查询范围的索引,因此会对范围内的所有临键锁区间加锁,防止范围内插入新记录,避免幻读。
示例
ini
-- user表主键id=1、2、3、5
| 事务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 死锁的四个必要条件
只有四个条件同时满足时,才会发生死锁,只要破坏其中任意一个,就能避免死锁:
- 互斥条件:锁只能被一个事务持有,其他事务必须等待;
- 占有且等待:事务已经持有至少一个锁,又申请新的锁被阻塞,不释放已持有的锁;
- 不可剥夺:锁只能由持有事务主动提交/回滚释放,不能被其他事务强行剥夺;
- 循环等待:多个事务形成循环等待锁的链路,每个事务都在等待下一个事务持有的锁。
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:间隙锁导致的死锁(最隐蔽)
这是线上最难排查的死锁场景,根源在于间隙锁之间互相兼容,但插入意向锁与间隙锁互斥,形成循环等待。
示例
ini
-- user表age字段有普通索引,现有数据age=20、25、30
| 事务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:二级索引与主键索引加锁顺序相反
二级索引的更新会先锁二级索引,再锁主键索引;而主键索引的更新会先锁主键索引,再锁二级索引,二者加锁顺序相反,极易形成死锁。
示例
bash
-- 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 死锁的快速排查方法
- 查看最近一次死锁日志 :执行
SHOW ENGINE INNODB STATUS;,在输出的LATEST DETECTED DEADLOCK部分,会完整记录死锁的两个事务、持有的锁、申请的锁、执行的SQL; - 死锁日志持久化 :执行
SET GLOBAL innodb_print_all_deadlocks = ON;,所有死锁日志都会写入MySQL的错误日志,方便后续排查; - 核心分析点 :重点关注两个事务的
WAITING FOR THIS LOCK TO BE GRANTED和HELD 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
<?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 实体类定义
kotlin
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接口定义
less
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 业务层实现
kotlin
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 控制层实现
kotlin
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)
ruby
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配置类
kotlin
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 项目启动类
typescript
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的锁机制,本质上是平衡数据一致性与并发性能的一套规则体系。绝大多数锁冲突、死锁问题的根源,都源于对"锁永远加在索引上"这一核心本质的认知缺失,以及对事务粒度、加锁顺序的不合理设计。 对于业务开发而言,无需死记硬背所有锁规则,但必须牢牢掌握三个核心原则:
- 无索引不执行更新操作,从根源避免锁全表的灾难性风险;
- 全业务统一加锁顺序,直接破坏死锁必备的循环等待条件;
- 严格控制事务粒度,缩短锁的持有时间,是提升数据库并发性能的核心。