1. 先搞清楚:到底为什么要关注 MySQL 的锁和事务
我记得刚接触 MySQL 那会儿,最直观的困惑就是:明明一个 UPDATE 语句执行得挺快,为什么在某个业务接口里就卡住了?为什么两个事务同时改同一条数据,后提交的人会把先提交的人的更新覆盖掉?为什么偶尔会冒出“Deadlock found when trying to get lock; Try restarting transaction”这种报错?
如果你也有类似的疑问,那这篇文章就是给你写的。MySQL 的锁和事务,是整个数据库并发控制的基石。往小了说,它决定了你的 UPDATE、DELETE、INSERT 能不能按预期生效;往大了说,它直接影响系统的吞吐量、数据一致性和线上稳定性。无论是刚入门的学生、转行做后端的开发,还是负责数据库维护的 DBA,搞清楚锁与事务的底层机制,都是绕不开的一课。
我写这篇指南的定位很明确:面向新手,但不停留在“会用”层面。我会先讲事务的几种隔离级别,再讲 InnoDB 下锁的分类、加锁规则、死锁的排查思路,最后补充几个高频面试题和实际生产里常见的坑。内容尽量往深讲,但会用生活化的例子帮你理解,保证你读完之后,再遇到锁等待或死锁,至少知道从哪下手查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事务的隔离级别,决定了你会遇到哪种“坑”
2.1 没有隔离级别会怎样
先做个思维实验。假设有一个账户表,里面有 A 和 B 两个人各 100 元。现在 A 要给 B 转账 50 元,操作可以拆成两步:
- A 的余额减 50;
- B 的余额加 50。
如果数据库不提供任何事务隔离机制,那么在 A 减完钱、B 还没加钱的时候,另一个会话读到了 A 的余额是 50 元。这个中间态就是脏数据,读到的行为就叫脏读。更麻烦的是,如果转账第二步失败后回滚了,那 A 的钱虽然减了但又恢复了,可别的事务已经基于“A 只有 50 元”做了后续操作,数据就乱了。
所以数据库设计了事务机制,强制要求“要么全成功,要么全回滚”,这就是事务的原子性(Atomicity)。除了原子性,还需要一致性(Consistency)、隔离性(Isolation)和持久性(Durability),合称 ACID。其中隔离性的实现,就依赖锁和 MVCC(多版本并发控制)。
2.2 四种隔离级别:读未提交、读已提交、可重复读、串行化
SQL 标准定义了四种事务隔离级别,MySQL InnoDB 默认使用的是可重复读(REPEATABLE READ)。这四种级别,从宽松到严格,分别是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 避免 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 避免 | 避免 | 可能(InnoDB 通过间隙锁基本避免) |
| SERIALIZABLE(串行化) | 避免 | 避免 | 避免 |
为了理解这张表,我打个比方。你把一个房间当成一张数据表,房间里的抽屉代表行记录。
- 读未提交:一个人进了房间,刚打开抽屉还没决定要不要拿东西,外面的人就能看到抽屉里的东西被翻动的样子。这相当于一个事务还没提交,另一个事务就看到了它改了但没落地的数据。
- 读已提交:一个人必须等抽屉关上(事务提交)之后,外面的人才能看到里面的最终状态。但问题是,如果这个人一会儿开一次抽屉,每次开的时间不同,外面的人每次看到的内容可能都不一样。这就是不可重复读。
- 可重复读:第一次打开抽屉时,拍张照片(生成快照)。之后不管抽屉在现实中被别人改成什么样,你看到的都是照片里的内容。InnoDB 的可重复读就是靠快照实现的,事务内多次读取结果一致。
- 串行化:所有事务排队进房间,一个出来另一个才能进去。代价是并发能力急剧下降,基本等于把所有读写串行化了。
2.3 InnoDB 的“可重复读”和标准定义不一样
需要特别提一句:SQL 标准里的 REPEATABLE READ 并不能完全防住幻读,但在 InnoDB 里,默认的 REPEATABLE READ 配合间隙锁(Gap Lock)和MVCC,其实已经把幻读的问题处理掉了。
所谓幻读,是指一个事务内执行两次相同的查询,第二次查询多出了一些之前不存在的行。比如第一次查订单表 where status=1,返回 5 条;另一个事务插入了 1 条 status=1 的订单并提交;你再查一次,结果变成 6 条。这 6 条里多出来的那条,就像“幻觉”一样。
InnoDB 在处理幻读时用了两套机制:
- 对于普通的快照读(SELECT),通过 MVCC 的快照机制,保证事务内读取的一致视图不变;
- 对于当前读(SELECT ... FOR UPDATE、UPDATE、DELETE),通过临键锁(Next-Key Lock)锁住范围区间,阻止其他事务在区间内插入新记录。
理解了这一点,就明白为什么业界普遍推荐使用默认的 REPEATABLE READ,而不是把隔离级别调到 READ COMMITTED 了。
3. 锁的分类与加锁逻辑,InnoDB 锁的底牌
3.1 从两个维度拆解锁:粒度与模式
先弄清“锁”到底锁的是什么。InnoDB 的锁,按粒度可以分为表级锁和行级锁;按模式可以分为共享锁(S 锁)和排他锁(X 锁)。它们之间的兼容关系很简单:
- 共享锁之间互相兼容:多个事务可以同时读同一行;
- 共享锁与排他锁互斥:一个人要写,其他人连读都不行;
- 排他锁之间互斥:一个人要写,其他人也必须等。
这种设计很像图书馆的座位:你可以和别人共用一个阅读桌(共享锁兼容),但不能一边有人占着座位一边又有人要在同一位置放包(排他锁不兼容)。表锁就好比把整个自习室的大门锁上,行锁则只锁某个座位。
3.2 行锁到底锁的什么
新手最容易误解的是“行锁锁住一行记录”。实际上,InnoDB 的行锁是基于索引实现的。也就是说,如果一条 UPDATE 语句走了二级索引,InnoDB 会先锁二级索引记录,再回表锁主键索引记录。如果一条语句没有走索引,那么 InnoDB 为了锁住目标行,只可能全表扫描,逐行给记录加锁,代价极大。
这意味着什么呢?我见过很多线上事故,都是因为 UPDATE 或 DELETE 语句的条件字段没建索引,结果一执行就把整张表锁住了。看起来是行锁,实际上退化成类似表锁的效果。对着一张千万级的大表,一个不带索引的 WHERE 条件,可能直接拖垮所有业务。
3.3 间隙锁与临键锁,为什么它们总在后台
继续往下挖,InnoDB 在 REPEATABLE READ 隔离级别下,还有一种非常独特的锁,叫间隙锁。间隙锁锁的不是某一条记录,而是“记录与记录之间的空隙”。比如一张表有主键 1、3、5、7 四条记录,那么间隙就有 (1,3)、(3,5)、(5,7) 以及 (7,正无穷)。当你执行 SELECT * FROM t WHERE id BETWEEN 2 AND 4 FOR UPDATE 时,InnoDB 除了锁定 id=3 这一行,还会把 (1,3) 和 (3,5) 这两个间隙锁住,防止其他事务往这个范围内插入 id=2、4 这类记录,从而阻止幻读。
当间隙锁和行锁合在一起时,就形成了临键锁(Next-Key Lock)。临键锁锁住的是一个左开右闭区间,比如 (1,3]。它既锁住了记录 3 本身,也锁住了 1 到 3 之间的空隙。大部分情况下,InnoDB 对范围内的查询加的都是临键锁,这也是很多死锁发生的原因。
注意,在 READ COMMITTED 隔离级别下,InnoDB 会禁用间隙锁。这也是有些团队为了降低死锁概率,主动把隔离级别调成 READ COMMITTED 的原因。但代价是幻读问题回归,需要业务层自己兜底。
4. MVCC:可重复读背后的“时间旅行”
4.1 版本链与 Read View
讲完锁,必须讲 MVCC。因为锁的机制解决了写写冲突,但对读写并发并不友好。假设一个事务正在 UPDATE 一条记录,另一个事务恰好要 SELECT 这条记录,按照严格加锁的思路,读操作应该阻塞等待写事务提交。但这样会严重影响并发性能。
InnoDB 的做法是:每次更新记录时,并不是直接覆盖旧值,而是生成一个新版本,旧版本保留在 undo log 里。每个版本都有事务编号,形成一条版本链。快照读的时候,通过 Read View(读视图)判断哪个版本对当前事务可见。
还是用照片来类比。可重复读隔离级别下,事务第一次执行 SELECT 时,InnoDB 会拍一张“快照”,确定哪些版本可见、哪些版本不可见。之后整个事务期间,就算别人提交了新数据,你看到的仍然是第一次拍下的快照视图。READ COMMITTED 则是每执行一条 SELECT 就拍一次新快照,所以能看到其他事务新提交的数据。
4.2 快照读 vs 当前读
这就延伸出另一个关键概念:快照读与当前读。
- 普通的
SELECT语句,不加任何锁,走的是 MVCC 快照读,不会阻塞其他事务,也不会被其他事务阻塞; SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都属于当前读,读取的是记录的最新版本,并且会对记录加锁。
这个区分极其重要。很多人以为可重复读下,事务内所有读都不会看到新数据,但如果你在一个事务里先 SELECT 再 UPDATE,UPDATE 操作会读取最新版本并加锁,可能覆盖掉之前快照读的结果。这类“慢 SQL 为什么产生死锁”的案例,追根溯源都是在快照读和当前读之间反复横跳导致的。
4.3 MVCC 与事务隔离级别的对应关系
MVCC 并不是独立存在的机制,它和事务隔离级别深度绑定:
- READ UNCOMMITTED 不生成 Read View,直接读最新版本,所以脏读;
- READ COMMITTED 每一条 SELECT 创建新的 Read View;
- REPEATABLE READ 第一个 SELECT 创建 Read View,之后复用;
- SERIALIZABLE 不依赖 MVCC,直接通过加锁实现串行化。
我刚学会这套概念时,总感觉 MVCC 很抽象。后来把它理解成“数据库给每条记录维护了一份修改历史,Read View 就是你在某个时间点建立的查询视角”,瞬间就通透多了。实际排查数据不一致问题时,如果能判断某个查询是快照读还是当前读,往往能快速定位原因。
5. 实操排查死锁与锁等待,现场解决思路
5.1 查看当前锁等待与事务状态
当你遇到“Lock wait timeout exceeded; try restarting transaction”这种报错时,第一步不是重启应用,而是查一下当前数据库里有哪些事务正在跑、在等哪把锁。
MySQL 5.7 及之前版本,可以查 information_schema 库里的三张表:
sql复制-- 查看当前所有事务
SELECT * FROM information_schema.innodb_trx\G;
-- 查看当前持有的锁
SELECT * FROM information_schema.innodb_locks\G;
-- 查看锁等待关系
SELECT * FROM information_schema.innodb_lock_waits\G;
MySQL 8.0 之后,innodb_locks 和 innodb_lock_waits 被移到了 performance_schema 下,名称也变了:
sql复制SELECT * FROM performance_schema.data_locks\G;
SELECT * FROM performance_schema.data_lock_waits\G;
实际排障时,我最常用的是一条关联查询,把事务 ID、用户、连接来源、锁等待状态和执行的 SQL 一次性拉出来:
sql复制SELECT
trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING'
ORDER BY trx_started ASC;
然后通过 trx_mysql_thread_id 去 SHOW PROCESSLIST 里找到那个连接正在执行的 SQL。这套组合拳,基本能定位到“哪个事务持锁不释放”。
5.2 死锁日志怎么看
死锁发生的时候,MySQL 会自动选择一个事务回滚,另一个事务继续执行。同时错误日志里会记录详细的死锁现场。
在 MySQL 8.0 里,用以下命令可以直接查看最近一次死锁日志:
sql复制SHOW ENGINE INNODB STATUS\G;
重点看 LATEST DETECTED DEADLOCK 这一段。里面会列出两个事务的编号、状态、持有的锁、等待的锁,以及正在执行的 SQL。常见的死锁场景有两种:
- 两个事务对相同的资源按不同顺序加锁。比如事务 A 先锁 id=1 再锁 id=2,事务 B 先锁 id=2 再锁 id=1,两边互相等对方释放,形成循环等待。
- 间隙锁导致的死锁。比如事务 A 在范围查询时锁了间隙,事务 B 要插入一条记录到该间隙,同时事务 A 又试图获取 B 已持有的某个锁,也会形成互相等待。
日志里最有用的是 WAITING FOR THIS LOCK TO BE GRANTED 部分,它会明确指出当前事务等待的是哪把锁,锁在哪个表哪个索引的哪个记录上。只要能看懂这一行,死锁原因就基本清楚了。
5.3 几个常见的高危 SQL 模式
排查中了这么多锁问题,我总结出几类高危 SQL 模式,新手特别容易踩:
| 场景 | 为什么危险 | 建议 |
|---|---|---|
| UPDATE 条件字段无索引 | 全表扫描逐行加锁,锁范围扩大 | 给 WHERE 条件字段建索引,先按主键或唯一键定位 |
| 大范围 UPDATE 或 DELETE | 锁大量行,阻塞时间长,容易引发死锁 | 拆分批次执行,每次 LIMIT 限制行数 |
| 长事务里执行大量写操作 | 事务一直不提交,锁持续持有 | 保持事务短小,及时提交 |
| 多表操作顺序不一致 | 不同事务按不同顺序加锁,循环等待 | 在业务中规定固定加锁顺序 |
| SELECT ... FOR UPDATE 后忘记释放 | 手动开启事务后未 commit,锁一直挂着 | 使用事务时务必保证提交和回滚成对出现 |
有一次,我在测试环境模拟高并发抢购,就遇到了典型的行锁升级死锁。日志里显示事务 A 拿到了主键 id=100 的锁,事务 B 拿到了主键 id=101 的锁,紧接着 A 需要 id=101,B 需要 id=100,死锁直接形成。后来我把处理顺序改成统一按资源 ID 升序处理,问题就不再出现了。
6. 结合高频场景,深入聊聊锁和事务的联动优化
6.1 如何减少锁等待与死锁
想减少锁等待和死锁,没有银弹,但有几个方向是经过验证的:
- 让事务尽量短:不要在事务里做 RPC 调用、外部 API 请求、批量大计算,只保留必要的数据库操作。一个常见反例是把发消息、发邮件放在事务内部,每次执行好几秒,锁长时间不释放。
- 按固定顺序访问资源:如果多个事务都要同时更新订单和库存,那代码层面统一先更新订单再更新库存,就能避免双方互相持有对方需要的锁。
- 选用合适的隔离级别:如果业务允许,考虑在非核心场景使用 READ COMMITTED,减少间隙锁引发的死锁。
- 控制并发度:秒杀场景下,引入 redis 分布式锁或消息队列把请求串行化,避免大量事务同时冲击数据库。
- 监控长事务:把
trx_started超过 1 秒的事务设置为预警,及时提醒业务侧排查。
6.2 一个真实优化场景:UPDATE 与 INSERT 互锁
实际工作里,我被问得最多的一个问题是:“为什么我查一条不存在的记录,再用 INSERT 插入,会死锁?”这个问题的根源,就是对“锁空白记录”的逻辑不熟。
假设表里有主键 id 分别为 1 和 5 的两条记录。事务 A 执行:
sql复制SELECT * FROM t WHERE id = 3 FOR UPDATE;
这条查询不会命中任何记录,但在 REPEATABLE READ 模式下,InnoDB 会在主键索引的间隙 (1,5) 上加一个间隙锁。此时事务 B 想插入 id=3 的记录,会被阻塞。如果事务 B 自身也持有某些与事务 A 相关的锁,就可能引发死锁。
处理这类问题的思路通常有两种:
- 业务上避免在事务内先查后插同一条“不存在”的数据,可以先 INSERT,如果主键冲突再走 UPDATE 逻辑;
- 对可能高并发插入的接口,先用分布式锁在应用层控制并发,降低数据库层的锁竞争。
6.3 事务隔离级别与锁的关系速查
| 隔离级别 | 行的读取方式 | 加锁特点 | 适用场景 |
|---|---|---|---|
| READ UNCOMMITTED | 直接读最新版本 | 无 | 几乎不用,除非对一致性要求极低 |
| READ COMMITTED | 每语句生成新 Read View | 只有行锁,无间隙锁 | 对一致性要求不高但追求并发 |
| REPEATABLE READ | 事务内复用首个 Read View | 行锁 + 间隙锁 + 临键锁 | MySQL 默认,大多数业务场景 |
| SERIALIZABLE | 全部加锁 | 读写互斥,并发极低 | 强一致但并发极低,慎用 |
这套速查表是我自己总结的,面试和排障时都很有用。它能把隔离级别、锁类型和业务场景一次性串起来,不会让你在概念之间来回跳。
7. 延伸场景:分布式事务与事务失效问题
7.1 Redis 分布式锁,解决的是数据库锁解决不了的问题
很多做业务开发的读者,其实已经接触过 Redisson 或自定义的 Redis 分布式锁了。那它和 MySQL 的行锁、表锁是什么关系?
MySQL 的锁解决的是“多个事务在同一个数据库实例里操作同一行数据”的并发问题。但当你做了微服务拆分,订单服务在 A 实例、库存服务在 B 实例,各自连的数据库也不一样,MySQL 的锁根本管不到这种跨库跨服务的场景。这时候就需要一把全局的、所有服务都能看到的锁,Redis 分布式锁就是一种常见实现。
Redis 分布式锁的核心思路很简单:加锁时执行 SET key value NX EX timeout,保证同一时刻只有一个客户端能成功;释放锁时用 Lua 脚本先校验 value 再 DEL,避免误删别人的锁;为了防止持有锁的进程崩溃导致死锁,还要设置超时时间。它的粒度是“全局逻辑”,MySQL 锁的粒度是“单库数据行”,两者不是替代关系,而是配合关系——分布式锁负责上层业务的并发控制,行锁负责底层数据库的一致性。
7.2 Spring @Transactional 事务失效的那些坑
最后一个高频热搜词是“springboot 事务失效场景”。这里简单提几个最常见的失效原因,新手排查时逐一对照:
- 方法被 private 修饰,且被同类内部调用,事务不会生效;
- 异常被 try-catch 吞掉,事务感知不到异常,不会回滚;
- 方法不是通过 Spring 代理对象调用的,自调用绕过了代理;
- 数据库引擎是 MyISAM 而不是 InnoDB,不支持事务;
- 事务传播行为设置成了 NOT_SUPPORTED,会挂起当前事务。
我印象最深的是有个同事排查了很久的“数据没回滚”问题,最后发现是 catch 异常后把日志打出来就直接 return 了,事务压根没收到异常信号。这个案例让我一直记得一句话:事务回滚靠异常,异常处理别乱吞。锁和事务的底层机制是数据库层面的硬道理,但上层框架的坑也同样能让你翻车,两者都得留意。
8. 我的几点建议
从“知道锁和事务”到“能排查线上锁问题”,中间隔着大量的实际操作。如果让我给你一条学习路径,我会建议按这个顺序动手验证:
- 开两个 MySQL 终端,手动开启事务,分别执行 UPDATE,观察另一个会话的阻塞等待;
- 尝试 SELECT ... FOR UPDATE 和普通 SELECT 在并发场景下的行为差异;
- 故意构造一个死锁场景(比如两个事务互相更新对方锁定的记录),然后看
SHOW ENGINE INNODB STATUS的死锁日志; - 用 EXPLAIN 检查自己的 UPDATE/DELETE 语句是否走索引,确认锁的范围。
这套方法论看起来简单,但胜过你读十篇理论文章。只有亲手把锁等超时、死锁、间隙锁这些问题都折腾一遍,你对 MySQL 的并发控制才算真正有了体感。后续我会再写一篇深入 InnoDB 锁实现的拆解文章,如果你在实践中碰到的具体现象和这里对不上,也欢迎带着现场日志来找我讨论。
