MySQL 事务与锁机制实战:从隔离级别到死锁排查
环境:MySQL 8.4.7 | 所有隔离级别、锁行为、死锁结果均来自本地实际运行
引言
促销活动上线第一天,客服群里炸了------用户反馈"明明提示下单成功,库存却变成负数"。
排查代码发现,扣库存的逻辑是:
java
// 伪代码:一个经典的"看似没问题"的扣库存
@Transactional
public void deductStock(String skuCode) {
int stock = mapper.selectStock(skuCode); // 查库存 → 100
if (stock > 0) {
mapper.updateStock(skuCode, stock - 1); // 扣库存 → 99
}
}
@Transactional 也加了,WHERE 条件也用到了索引。问题出在哪?
问题出在一个关键认知上:事务 ≠ 锁 。InnoDB 默认的 REPEATABLE READ 隔离级别下,SELECT 是快照读 ------它读到的是事务开始时的数据版本,不会加锁。两个事务同时读到 stock=100,都判定 "> 0",都执行 UPDATE stock=99,库存就变成了 -1。
要彻底理解为什么会出这个问题、以及怎么正确解决,你需要掌握三个核心概念:隔离级别 (怎么读)、MVCC (为什么这么读)、锁机制(怎么写不冲突)。这篇文章就从实际运行出发,逐一验证。
测试环境
所有测试基于 Docker + MySQL 8.4.7,你可以完全复现。
bash
# 1. 启动 MySQL
docker run -d --name mysql_txn \
-e MYSQL_ROOT_PASSWORD=123456 \
-p 3306:3306 \
mysql:8.4.7
# 2. 连接 MySQL(需要两个终端窗口,分别作为 Session A 和 Session B)
docker exec -it mysql_txn mysql -uroot -p123456
# 3. 初始化测试数据
sql
-- 建库建表
DROP DATABASE IF EXISTS txn_test;
CREATE DATABASE txn_test CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE txn_test;
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
INDEX idx_name (name)
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_user_status (user_id, status)
) ENGINE=InnoDB;
-- 初始数据
INSERT INTO account (id, name, balance) VALUES
(1, 'Alice', 1000.00),
(2, 'Bob', 1000.00),
(3, 'Charlie', 500.00);
INSERT INTO orders (id, order_no, user_id, amount, status) VALUES
(10, 'ORD001', 1, 99.00, 1),
(20, 'ORD002', 1, 150.00, 2),
(30, 'ORD003', 2, 200.00, 1),
(40, 'ORD004', 3, 50.00, 0);
一、四种隔离级别实战验证
1.1 隔离级别速览
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现机制 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ | ❌ | ❌ | 直接读最新版本,不加锁 |
| READ COMMITTED | ✅ | ❌ | ❌ | 每次 SELECT 创建新 ReadView |
| REPEATABLE READ(默认) | ✅ | ✅ | ⚠️ 快照读解决,当前读不行 | 事务首次 SELECT 创建 ReadView |
| SERIALIZABLE | ✅ | ✅ | ✅ | 读加共享锁(S Lock) |
🎯 下面每个级别都用实际运行来验证,不带猜测。
1.2 脏读复现(READ UNCOMMITTED)
脏读 = 读到其他事务未提交的修改。
操作方式:打开两个终端,分别执行 Session A 和 Session B 的 SQL。注意执行顺序和时机。
Session B(先执行):修改但不提交
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE account SET balance = 500.00 WHERE id = 1;
-- ⚠️ 不要 COMMIT!此时去执行 Session A
Session A(在 Session B 未提交期间执行):
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT id, name, balance FROM account WHERE id = 1;
-- 结果:balance = 500.00 ← 读到了未提交的脏数据!
实际运行结果:
bash
Session A (READ UNCOMMITTED): reading balance...
| id | name | balance |
|----|-------|---------|
| 1 | Alice | 500.00 | ← 脏数据!
Session B(随后回滚):
sql
ROLLBACK;
-- Session B 回滚后,真实 balance 应该是 1000
最终验证:
sql
SELECT id, name, balance FROM account WHERE id = 1;
-- 结果:balance = 1000.00 ← 500 从未真正存在过
🎯 结论:READ UNCOMMITTED 下,Session A 读到了 Session B 未提交的 balance=500,而 Session B 随后回滚了------那 500 从未"真正"存在过。这就是脏读。
1.3 不可重复读复现(READ COMMITTED)
不可重复读 = 同一事务内两次读取同一行,得到不同的值。
Session A(长事务,读取两次):
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT id, name, balance FROM account WHERE id = 1;
-- 第1次:balance = 1000.00
Session B(在 Session A 两次读取之间提交修改):
sql
START TRANSACTION;
UPDATE account SET balance = 600.00 WHERE id = 1;
COMMIT;
-- Session B 已提交!
Session A(继续读取):
sql
-- 还在同一个事务中
SELECT id, name, balance FROM account WHERE id = 1;
-- 第2次:balance = 600.00 ← 同一事务内,读到的值变了!
COMMIT;
实际运行结果:
bash
Session A (READ COMMITTED) - 第1次读取
| id | name | balance |
|----|-------|---------|
| 1 | Alice | 1000.00 |
Session A (READ COMMITTED) - 第2次读取(同一事务内)
| id | name | balance |
|----|-------|---------|
| 1 | Alice | 600.00 | ← 变了!同一事务内两次读到不同值
🎯 结论 :READ COMMITTED 下,Session B 提交后,Session A 在同一事务内就能看到变化。这意味着一个事务内两次相同的 SELECT 可能返回不同结果------"不可重复读"。
1.4 可重复读验证(REPEATABLE READ ------ MySQL 默认)
同样的操作流程,改用 REPEATABLE READ:
Session A:
sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT id, name, balance FROM account WHERE id = 1;
-- 第1次:balance = 600.00
Session B(提交修改):
sql
START TRANSACTION;
UPDATE account SET balance = 700.00 WHERE id = 1;
COMMIT;
Session A(继续读取):
sql
SELECT id, name, balance FROM account WHERE id = 1;
-- 第2次:balance = 600.00 ← 不变!保持第一次读到的值
COMMIT;
实际运行结果:
bash
Session A (REPEATABLE READ) - 第1次读取
| id | name | balance |
|----|-------|---------|
| 1 | Alice | 600.00 |
Session A (REPEATABLE READ) - 第2次读取(同一事务内)
| id | name | balance |
|----|-------|---------|
| 1 | Alice | 600.00 | ← 没变!
🎯 结论:REPEATABLE READ 下,事务 A 第一次 SELECT 时创建 ReadView 快照,后续所有 SELECT 都从这个快照读。Session B 提交了也没用------事务 A 的"时光机"还在事务开始那一刻。
1.5 RR 下幻读的"漏洞":快照读 vs 当前读
REPEATABLE READ 解决了不可重复读,但幻读呢?答案是:快照读可以防,当前读防不住。
Session A(快照读 + 当前读):
sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM orders ORDER BY id;
-- 快照读:4 行(id: 10, 20, 30, 40)
实际输出:
lua
| id | order_no | user_id | status |
|----|----------|---------|--------|
| 10 | ORD001 | 1 | 1 |
| 20 | ORD002 | 1 | 2 |
| 30 | ORD003 | 2 | 1 |
| 40 | ORD004 | 3 | 0 |
Session B(插入并提交):
sql
START TRANSACTION;
INSERT INTO orders (id, order_no, user_id, amount, status)
VALUES (50, 'ORD005', 1, 300.00, 1);
COMMIT;
-- 新行 id=50,status=1
Session A(继续------快照读 vs 当前读):
sql
-- 快照读:仍然看到旧快照
SELECT * FROM orders ORDER BY id;
-- 结果:还是 4 行!(id: 10, 20, 30, 40) ✅ 快照读防住了
-- 当前读:SELECT ... FOR UPDATE 会读取最新已提交数据
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- 结果:3 行!(id: 10, 30, 50) ← id=50 出现了!这就是幻读
实际运行结果:
bash
Session A (RR) - 快照读 again (same ReadView)
| id | order_no | user_id | status |
|----|----------|---------|--------|
| 10 | ORD001 | 1 | 1 |
| 20 | ORD002 | 1 | 2 |
| 30 | ORD003 | 2 | 1 |
| 40 | ORD004 | 3 | 0 | ← 还是 4 行,新插入的 id=50 不可见
Session A (RR) - 当前读: SELECT ... FOR UPDATE
| id | order_no | user_id | status |
|----|----------|---------|--------|
| 10 | ORD001 | 1 | 1 |
| 30 | ORD003 | 2 | 1 |
| 50 | ORD005 | 1 | 1 | ← id=50 出现了!幻读!
🎯 关键区分:
- 快照读(普通 SELECT):走 MVCC,读的是事务开始时的快照 → 防幻读 ✅
- 当前读 (
SELECT ... FOR UPDATE/UPDATE/DELETE/INSERT):读最新已提交版本,并加锁 → 可能幻读 ⚠️这也解释了开篇的扣库存 bug:
SELECT stock是快照读不加锁,两个事务看到同一个 stock=100,都执行 UPDATE,库存就扣错了。解决方式是用SELECT ... FOR UPDATE替代普通 SELECT,让读操作也加锁。
1.6 SERIALIZABLE:读也加锁
SERIALIZABLE 是最严格的隔离级别------普通 SELECT 也会自动加共享锁。
验证:SERIALIZABLE 下 SELECT 的锁
sql
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM orders WHERE id = 10;
-- 不需要 FOR UPDATE,InnoDB 自动加了共享锁
通过 performance_schema.data_locks 观测锁情况:
lua
| LOCK_TYPE | LOCK_MODE | LOCK_DATA |
|-----------|---------------|-----------|
| RECORD | S,REC_NOT_GAP | 10 | ← 共享记录锁!
🎯
S,REC_NOT_GAP= Shared Record Lock,一个纯 SELECT 就加了共享锁。在 SERIALIZABLE 下,读操作会阻塞其他事务的写操作,真正做到串行化执行。
并发写被阻塞验证:
sql
-- Session A (SERIALIZABLE):
START TRANSACTION;
SELECT * FROM account WHERE id = 1; -- 持有 id=1 的共享锁
-- 不提交,去执行 Session B...
-- Session B:
START TRANSACTION;
UPDATE account SET balance = 999 WHERE id = 1;
-- ⏳ 阻塞!等待 Session A 释放共享锁
-- ERROR 1205: Lock wait timeout exceeded
二、MVCC:为什么 RR 能"可重复读"
2.1 隐藏列与版本链
InnoDB 的每行数据都有两个隐藏列:
| 隐藏列 | 大小 | 含义 |
|---|---|---|
DB_TRX_ID |
6 字节 | 最近一次修改这行数据的事务 ID |
DB_ROLL_PTR |
7 字节 | 回滚指针,指向 Undo Log 中该行的上一个版本 |
DB_ROW_ID |
6 字节 | 行 ID(仅当表没有显式主键时存在) |
每次 UPDATE 一行数据,InnoDB 做两件事:
- 把旧版本 写入 Undo Log,
DB_ROLL_PTR指向它 - 把当前行的
DB_TRX_ID更新为当前事务 ID
多次更新就形成了一条版本链:
ini
当前行(DB_TRX_ID=1003)
│ balance = 400.00
│ DB_ROLL_PTR ──→ Undo Log: (DB_TRX_ID=1002, balance=300.00)
│ DB_ROLL_PTR ──→ Undo Log: (DB_TRX_ID=1001, balance=200.00)
│ DB_ROLL_PTR ──→ Undo Log: (DB_TRX_ID=1000, balance=100.00)
2.2 ReadView:决定"能看到哪个版本"
一个事务读取数据时,不会遍历整条版本链------而是通过 ReadView 做可见性判断。
ReadView 的核心字段:
| 字段 | 含义 |
|---|---|
m_ids |
创建 ReadView 时,系统中活跃事务(尚未提交)的 ID 列表 |
min_trx_id |
m_ids 中的最小值 |
max_trx_id |
创建 ReadView 时,系统下一个将分配的事务 ID |
creator_trx_id |
创建这个 ReadView 的事务自己 |
可见性判断逻辑(伪代码):
java
boolean isVisible(long rowTrxId, ReadView view) {
if (rowTrxId == view.creatorTrxId) return true; // ① 自己改的 → 可见
if (rowTrxId < view.minTrxId) return true; // ② 修改者在快照前已提交 → 可见
if (rowTrxId >= view.maxTrxId) return false; // ③ 修改者在快照后才开始 → 不可见
if (view.m_ids.contains(rowTrxId)) return false; // ④ 修改者还在活跃 → 不可见
return true; // ⑤ 修改者已提交(不在活跃列表且 < max)
}
如果判断为"不可见",就顺着
DB_ROLL_PTR指针往 Undo Log 回溯,直到找到一个"可见"的版本。
2.3 RC vs RR:ReadView 创建时机的差异
RC 和 RR 的唯一区别在于 ReadView 什么时候创建:
| 隔离级别 | ReadView 创建时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每次 SELECT 都创建新的 ReadView | 能看到其他事务已提交的修改 → 不可重复读 |
| REPEATABLE READ | 事务第一次 SELECT 时创建,整个事务复用 | 事务期间始终看到同一个快照 → 可重复读 |
用实际数据来演示:
sql
-- 假设时间线:
-- T1: 事务 A 开始
-- T2: 事务 A 第一次 SELECT(创建 ReadView,min_trx_id=100, max_trx_id=105)
-- T3: 事务 B UPDATE + COMMIT(trx_id=102)
-- T4: 事务 A 第二次 SELECT
-- RC 下:T4 时创建新的 ReadView(min_trx_id=104, max_trx_id=106)
-- → trx_id=102 < min_trx_id=104 → 可见 → 读到新值
-- RR 下:T4 时复用 T2 的 ReadView(min_trx_id=100, max_trx_id=105)
-- → trx_id=102 在 100~105 之间且不在活跃列表 → 已提交 → 可见
-- 等等... 102 在 T2 时是活跃的,在 m_ids 中 → 不可见 → 读到旧值 ✅
🎯 核心记法:RC 每次读都刷新视图,RR 事务开始就定视图。
三、锁机制实战观测
MVCC 解决了读-写并发 (读不加锁,写不阻塞读),但写-写并发 仍然需要锁。这一节通过 performance_schema.data_locks 真实观测 InnoDB 的加锁行为。
3.1 三种行锁速览
scss
索引记录: 10 20 30 40
间隙: (-∞,10) (10,20) (20,30) (30,40) (40,+∞)
Record Lock → 锁住一条索引记录(如 id=20)
Gap Lock → 锁住索引记录之间的间隙(如 (20,30)),防止 INSERT
Next-Key Lock → Record Lock + 前面的 Gap Lock(锁住 (10,20])
3.2 实战观测:三种锁的实际形态
通过 performance_schema.data_locks 可以直接看到每条锁的类型和范围。
① 记录锁(Record Lock)------ 唯一索引等值命中
sql
START TRANSACTION;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
观测锁:
sql
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'txn_test' AND OBJECT_NAME = 'orders';
实际结果:
sql
| LOCK_TYPE | LOCK_MODE | LOCK_DATA |
|-----------|----------------|-----------|
| TABLE | IX | NULL | ← 表级意向排他锁
| RECORD | X,REC_NOT_GAP | 10 | ← 记录锁!只锁 id=10
🎯
X,REC_NOT_GAP= eXclusive Record Lock, NOT GAP → 只锁这条记录本身,不锁间隙。因为唯一索引精确命中,不存在幻读的可能。
② 间隙锁(Gap Lock)------ 唯一索引等值未命中
sql
START TRANSACTION;
SELECT * FROM orders WHERE id = 25 FOR UPDATE; -- id=25 不存在!
实际结果:
scss
| LOCK_TYPE | LOCK_MODE | LOCK_DATA |
|-----------|-----------|-----------|
| RECORD | X,GAP | 30 | ← 间隙锁!锁住 (20, 30)
🎯
X,GAP= eXclusive Gap Lock。LOCK_DATA=30表示间隙的上界是 id=30。实际锁住的范围是 (20, 30)------即 id=20 之后、id=30 之前的所有"空隙"。任何 INSERT id=21~29 都会被阻塞。
验证间隙锁真的会阻塞 INSERT:
sql
-- Session A: 持有间隙锁
START TRANSACTION;
SELECT * FROM orders WHERE id = 25 FOR UPDATE;
-- 间隙 (20, 30) 被锁住
-- Session B: 尝试插入到间隙中
START TRANSACTION;
INSERT INTO orders (id, order_no, user_id, amount, status)
VALUES (25, 'ORD025', 2, 100.00, 0);
-- ⏳ 阻塞!ERROR 1205: Lock wait timeout exceeded
③ 临键锁(Next-Key Lock)------ 范围查询
sql
START TRANSACTION;
SELECT * FROM orders WHERE id >= 20 AND id < 40 FOR UPDATE;
实际结果:
ini
| LOCK_MODE | LOCK_DATA |
|----------------|-----------|
| X,REC_NOT_GAP | 20 | ← 记录锁:精确锁住 id=20
| X | 30 | ← Next-Key Lock: 锁住 (20, 30]
| X,GAP | 40 | ← 间隙锁:锁住 (30, 40)
三条锁的解读:
ini
id=20: X,REC_NOT_GAP → 只锁记录 20
id=30: X (Next-Key) → 锁住 (20, 30] = 记录30 + 间隙(20,30)
id=40: X,GAP → 锁住 (30, 40) 的间隙
合起来:锁住了 (20, 40) 整个区间!
🎯 InnoDB 对范围查询的加锁策略:扫描到的每一行都加 Next-Key Lock,最后再加一个 Gap Lock 在范围外的"下一个记录"上。这就是 RR 级别防幻读的核心机制。
④ 非唯一索引(普通索引)的加锁行为
sql
START TRANSACTION;
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- status=1 的行:id=10, id=30, id=50(还有之前测试插入的)
实际结果:
vbnet
-- 二级索引 idx_status 上的锁:
| LOCK_MODE | LOCK_DATA |
|-----------|-----------|
| X | 1, 10 | ← Next-Key Lock
| X | 1, 30 | ← Next-Key Lock
| X | 1, 50 | ← Next-Key Lock
| X,GAP | 2, 20 | ← 间隙锁!锁住下一个不同的 status 值之前
-- 主键索引上的锁:
| LOCK_MODE | LOCK_DATA |
|----------------|-----------|
| X,REC_NOT_GAP | 10 | ← 记录锁
| X,REC_NOT_GAP | 30 | ← 记录锁
| X,REC_NOT_GAP | 50 | ← 记录锁
🎯 关键发现:非唯一索引的加锁远比唯一索引"重":
- 二级索引上,每个匹配行加 Next-Key Lock
- 还额外在下一个不同的索引值 上加 Gap Lock(这里是
(2, 20),即 status=2 的第一个 key),防止其他事务 INSERT 新的 status=1 行- 主键索引上,对实际数据行加 Record Lock
这就是为什么唯一索引的性能优于普通索引------锁的范围更小,并发度更高。
3.3 加锁规则总结
| 查询类型 | 索引类型 | 是否命中 | RR 下加什么锁 |
|---|---|---|---|
| 等值查询 | 唯一索引 | ✅ 命中 | Record Lock(X,REC_NOT_GAP) |
| 等值查询 | 唯一索引 | ❌ 未命中 | Gap Lock(X,GAP) |
| 等值查询 | 普通索引 | 任意 | Next-Key Lock × N + 下一个 Gap Lock |
| 范围查询 | 任意 | - | 扫描到的行 Next-Key Lock + 范围后一个 Gap Lock |
💡 记忆口诀 :等值命中退记录,等值不命中退间隙,范围普通统统临键锁。
四、死锁复现与排查
4.1 经典死锁:交叉更新
最常见的死锁模式:两个事务以不同顺序更新相同行。
bash
事务 A: UPDATE id=1 → UPDATE id=2
事务 B: UPDATE id=2 → UPDATE id=1
↑_________________↓
互相等待
复现步骤:
sql
-- 准备:确保余额是初始值
UPDATE account SET balance = 1000.00 WHERE id IN (1, 2);
Session A(先锁 id=1,再锁 id=2):
sql
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- ✅ 拿到 id=1 的 X 锁
-- 暂停 3 秒,给 Session B 时间去锁 id=2...
-- (真实场景中不需要 SLEEP,这里只是为了控制时序)
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- ⏳ 等待 Session B 释放 id=2 的锁...
Session B(先锁 id=2,再锁 id=1):
sql
START TRANSACTION;
UPDATE account SET balance = balance - 200 WHERE id = 2;
-- ✅ 拿到 id=2 的 X 锁
UPDATE account SET balance = balance + 200 WHERE id = 1;
-- ⏳ 等待 Session A 释放 id=1 的锁...
-- 💀 InnoDB 检测到死锁 → ERROR 1213: Deadlock found
实际运行结果:
vbnet
Session B:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
4.2 解读死锁日志
SHOW ENGINE INNODB STATUS 中的 LATEST DETECTED DEADLOCK 段记录了死锁详情:
perl
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION: ← 事务1
TRANSACTION 16717, ACTIVE 2 sec
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), 2 row lock(s)
*** (1) HOLDS THE LOCK(S): ← 事务1 持有什么锁
RECORD LOCKS ... index PRIMARY of table `txn_test`.`account`
lock_mode X locks rec but not gap ← X 记录锁
Record lock, heap no 3 PHYSICAL RECORD: ... ← 这是 id=2 (Bob)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: ← 事务1 在等什么
RECORD LOCKS ... index PRIMARY of table `txn_test`.`account`
lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: ... ← 这是 id=1 (Alice)
*** (2) TRANSACTION: ← 事务2
...
*** (2) HOLDS THE LOCK(S):
Record lock, heap no 2 ... ← 持有 id=1
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
Record lock, heap no 3 ... ← 等待 id=2
*** WE ROLL BACK TRANSACTION (2) ← InnoDB 选择回滚事务2
死锁日志的阅读方法:
- 先看
HOLDS THE LOCK(S)------每个事务持有什么锁 - 再看
WAITING FOR THIS LOCK TO BE GRANTED------每个事务在等什么 - 画出等待图:T1 持有 A 等 B,T2 持有 B 等 A → 死锁
4.3 死锁的排查命令
| 命令 | 用途 |
|---|---|
SHOW ENGINE INNODB STATUS\G |
查看最近一次死锁详情 |
SELECT * FROM performance_schema.data_locks |
查看当前所有锁 |
SELECT * FROM performance_schema.data_lock_waits |
查看当前锁等待关系 |
SELECT * FROM information_schema.INNODB_TRX |
查看当前活跃事务 |
SET GLOBAL innodb_deadlock_detect = ON/OFF |
开关死锁检测(默认 ON) |
SET SESSION innodb_lock_wait_timeout = N |
锁等待超时秒数(默认 50s) |
4.4 避免死锁的实践
- 固定加锁顺序:所有事务以相同顺序访问资源(如都按 id 升序更新),这是最有效的方法
- 缩短事务:事务越小,持锁时间越短,死锁概率越低
- 使用唯一索引:唯一索引等值命中退化为 Record Lock,锁范围更小
- RC 替代 RR:如果业务不需要 RR 的可重复读特性,RC 没有间隙锁,死锁概率大幅降低
- 死锁重试 :在应用层捕获
DeadlockLoserDataAccessException(Spring 框架已封装),重试事务
java
// Spring 中死锁重试的标准写法
@Retryable(
value = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 按 id 升序更新,避免死锁
if (fromId < toId) {
accountMapper.decrease(fromId, amount);
accountMapper.increase(toId, amount);
} else {
accountMapper.increase(toId, amount);
accountMapper.decrease(fromId, amount);
}
}
五、隔离级别选型指南
| 隔离级别 | 并发问题 | 适用场景 |
|---|---|---|
| READ UNCOMMITTED | 脏读 + 不可重复读 + 幻读 | 几乎不用,除非是只读报表且允许脏数据 |
| READ COMMITTED | 不可重复读 + 幻读 | 互联网高并发场景主流选择(无间隙锁,死锁少) |
| REPEATABLE READ | 幻读(当前读) | MySQL 默认,金融/账务场景(需要一致性快照) |
| SERIALIZABLE | 无 | 极少使用,并发性能太差 |
🎯 很多人认为 RR "更安全所以更好",但 RR 的间隙锁在高并发下容易导致死锁。如果你的业务不需要可重复读的快照语义,RC + 行锁比 RR + 间隙锁更实用。
总结
核心概念一张图
yaml
┌──────────────────────────────────────────────────────────┐
│ MySQL 事务与并发控制 │
├────────────────┬────────────────┬────────────────────────┤
│ 隔离级别 │ MVCC │ 锁机制 │
│ (怎么读) │ (为什么这么读) │ (怎么写不冲突) │
├────────────────┼────────────────┼────────────────────────┤
│ RU: 脏读 ❌ │ 隐藏列: │ Record Lock: 锁行 │
│ RC: 不可重复读❌ │ DB_TRX_ID │ Gap Lock: 锁间隙 │
│ RR: 幻读⚠️ │ DB_ROLL_PTR │ Next-Key Lock: 锁行+隙 │
│ SE: 全解决✅ │ │ │
│ │ ReadView: │ 观测: │
│ │ 可见性判断 │ performance_schema │
│ │ │ .data_locks │
│ │ RC vs RR: │ │
│ │ 创建时机不同 │ 死锁: │
│ │ │ 交叉等待 → 回滚代价小的 │
└────────────────┴────────────────┴────────────────────────┘
速查表
| 问题 | 根因 | 解决方案 |
|---|---|---|
| 扣库存超卖 | 快照读不加锁,两个事务读到相同库存 | SELECT ... FOR UPDATE 当前读加锁 |
| 同一行两次读到不同值 | RC 级别每次 SELECT 新建 ReadView | 改用 RR 或加 FOR UPDATE |
| INSERT 被莫名其妙阻塞 | 间隙锁!有事务锁住了那个间隙 | 检查 data_locks,缩短持锁事务 |
| 偶发死锁 | 事务以不同顺序访问资源 | 统一加锁顺序 + 死锁重试 |
| 范围查询后出现幻读 | 当前读(UPDATE/DELETE)穿透快照 | RR + Next-Key Lock 天然防护 |
| 死锁日志看不懂 | 不熟悉 HOLDS vs WAITING 的阅读顺序 |
先画等待图,再看谁被回滚 |
| 间隙锁导致死锁频发 | RR 默认间隙锁在高并发下容易冲突 | 评估是否可以降级到 RC |
关于前面扣库存 bug 的正确修复
回到开篇的扣库存问题,正确的修复只需要一行改动:
java
// ❌ 错误:快照读,不加锁
int stock = mapper.selectStock(skuCode);
// ✅ 正确:当前读,加排他锁
int stock = mapper.selectStockForUpdate(skuCode);
对应 SQL:
sql
-- MyBatis Mapper:
SELECT stock FROM inventory
WHERE product_code = #{skuCode}
FOR UPDATE; -- ← 这一行是关键!
FOR UPDATE 做了两件事:
- 当前读:读取最新的已提交数据,不是快照
- 加排他锁 :阻止其他事务同时读(
FOR UPDATE)或写这行
这样两个并发事务就无法同时拿到 stock=100 了------第二个事务的 SELECT ... FOR UPDATE 会等待第一个事务释放锁。
如何复现
本文所有 SQL 和锁观测结果均来自 MySQL 8.4.7 实际运行。
bash
# 1. 启动 MySQL
docker run -d --name mysql_txn \
-e MYSQL_ROOT_PASSWORD=123456 \
-p 3306:3306 \
mysql:8.4.7
# 2. 打开两个终端,都连接 MySQL
docker exec -it mysql_txn mysql -uroot -p123456
# 3. 在两个终端中分别执行文章中各节的 SQL
# 注意:隔离级别测试和死锁测试需要两个终端配合时序
sql
-- ============================================================
-- MySQL 事务与锁机制实战 - 配套建表脚本
-- 环境:MySQL 8.4.x | 字符集:utf8mb4
-- ============================================================
-- 1. 建库建表
DROP DATABASE IF EXISTS txn_test;
CREATE DATABASE txn_test CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE txn_test;
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
INDEX idx_name (name)
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_user_status (user_id, status)
) ENGINE=InnoDB;
-- 2. 初始数据
INSERT INTO account (id, name, balance) VALUES
(1, 'Alice', 1000.00),
(2, 'Bob', 1000.00),
(3, 'Charlie', 500.00);
INSERT INTO orders (id, order_no, user_id, amount, status) VALUES
(10, 'ORD001', 1, 99.00, 1),
(20, 'ORD002', 1, 150.00, 2),
(30, 'ORD003', 2, 200.00, 1),
(40, 'ORD004', 3, 50.00, 0);
-- 3. 设置隔离级别(在各自 Session 中执行)
-- SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 4. 观测锁(在持有锁的 Session 之外执行)
-- SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
-- LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
-- FROM performance_schema.data_locks
-- WHERE OBJECT_SCHEMA = 'txn_test';
-- 5. 死锁检测
-- SHOW ENGINE INNODB STATUS\G