MySQL 事务与锁机制实战:从隔离级别到死锁排查

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。

sequenceDiagram participant T1 as 事务1 participant DB as MySQL participant T2 as 事务2 T1->>DB: SELECT stock → 100 (快照读, 无锁) T2->>DB: SELECT stock → 100 (快照读, 无锁) T1->>DB: UPDATE stock = 99 (当前读, 加锁) T2->>DB: UPDATE stock = 99 (等待T1释放锁...) T1->>DB: COMMIT T2->>DB: UPDATE stock = 99 (T1提交后继续 → 还是99!) Note over T1,T2: 库存本应是98,实际是99 👎

要彻底理解为什么会出这个问题、以及怎么正确解决,你需要掌握三个核心概念:隔离级别 (怎么读)、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 做两件事:

  1. 旧版本 写入 Undo Log,DB_ROLL_PTR 指向它
  2. 把当前行的 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        |  ← 记录锁

🎯 关键发现:非唯一索引的加锁远比唯一索引"重":

  1. 二级索引上,每个匹配行加 Next-Key Lock
  2. 还额外在下一个不同的索引值 上加 Gap Lock(这里是 (2, 20),即 status=2 的第一个 key),防止其他事务 INSERT 新的 status=1 行
  3. 主键索引上,对实际数据行加 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

死锁日志的阅读方法:

  1. 先看 HOLDS THE LOCK(S)------每个事务持有什么锁
  2. 再看 WAITING FOR THIS LOCK TO BE GRANTED------每个事务在等什么
  3. 画出等待图: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 避免死锁的实践

  1. 固定加锁顺序:所有事务以相同顺序访问资源(如都按 id 升序更新),这是最有效的方法
  2. 缩短事务:事务越小,持锁时间越短,死锁概率越低
  3. 使用唯一索引:唯一索引等值命中退化为 Record Lock,锁范围更小
  4. RC 替代 RR:如果业务不需要 RR 的可重复读特性,RC 没有间隙锁,死锁概率大幅降低
  5. 死锁重试 :在应用层捕获 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 做了两件事:

  1. 当前读:读取最新的已提交数据,不是快照
  2. 加排他锁 :阻止其他事务同时读(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

相关推荐
zhangxingchao1 小时前
AI应用开发九:从 Hermes Agent 学长期记忆
前端·人工智能·后端
默默敲代码的徐哥儿2 小时前
八股文整理——后端
java·开发语言·spring boot·后端·学习
自由的裙子2 小时前
ASP.NET路由模型解析
后端·asp.net
k↑2 小时前
SpringCloud + React19 集成Scalar的API文档
后端·spring·spring cloud
Mandy的名字被占用了2 小时前
Dart 与 Flutter 快速入门指南
后端·flutter·dart
货拉拉技术2 小时前
基于eBPF的主机安全探索与实践
后端
梨子同志2 小时前
Spring Security
后端
名字还没想好☜2 小时前
Spring Boot 全局异常处理:@ControllerAdvice 实战
java·spring boot·后端·spring·异常处理
还是鼠鼠3 小时前
AI掘金头条新闻系统 (Toutiao News)-缓存相关推荐新闻
后端·python·mysql·fastapi·web