摘要:从一次转账业务的诡异bug出发,深度剖析MySQL事务隔离级别与MVCC多版本并发控制机制。通过真实的SQL并发演示,展示脏读、不可重复读、幻读的实际场景,揭秘InnoDB如何通过隐藏字段、undo log版本链、Read View机制实现高并发下的数据一致性。配合图解、源码分析和Java模拟实现,让你彻底理解"为什么RR隔离级别能防止幻读"、"快照读和当前读的区别"等核心问题。
💥 翻车现场
周五下午5点,哈吉米正准备下班,突然被测试同学拉进了钉钉群。
测试同学:@哈吉米 转账功能有bug!用户余额对不上!
哈吉米:???怎么可能,我都加事务了啊!
测试同学:你自己看日志!
用户A初始余额:1000元
用户B初始余额:500元
操作步骤:
1. 事务1:A给B转账100元
2. 事务2:A给B转账200元
3. 同时执行
预期结果:
- A余额 = 1000 - 100 - 200 = 700
- B余额 = 500 + 100 + 200 = 800
实际结果:
- A余额 = 800(丢了100元!)
- B余额 = 800(对的)
哈吉米:"卧槽,钱去哪了?"
紧急回滚后,哈吉米打开代码:
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 查询转出方余额
Account from = accountMapper.selectById(fromUserId);
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 2. 扣减转出方余额
from.setBalance(from.getBalance().subtract(amount));
accountMapper.updateById(from);
// 3. 增加转入方余额
Account to = accountMapper.selectById(toUserId);
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(to);
}
哈吉米:"这代码有问题吗?明明加了 @Transactional 啊!"
晚上7点,南北绿豆和阿西噶阿西赶来了。
南北绿豆:"你这是典型的并发读写问题,事务隔离级别没设对!"
阿西噶阿西:"而且你肯定不知道MVCC是怎么工作的吧?"
哈吉米:"MVCC?那是啥?"
南北绿豆:"来,今晚给你讲透彻!"
🤔 事务的ACID到底是什么?
南北绿豆在白板上写下四个字母。
ACID的真实含义
A - Atomicity(原子性):要么全做,要么全不做
C - Consistency(一致性):数据的完整性约束不被破坏
I - Isolation(隔离性):并发事务互不干扰
D - Durability(持久性):提交后的数据不丢失
阿西噶阿西:"很多人只会背概念,但不知道实际怎么实现。"
南北绿豆:"我们用转账的例子逐个讲解。"
A - Atomicity(原子性)
-- 转账操作(原子性)
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_id = 1; -- 步骤1
UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 步骤2
COMMIT; -- 两步要么都成功,要么都失败
实现机制:undo log(回滚日志)
如果步骤2失败了:
1. MySQL读取undo log
2. 执行相反操作:balance = balance + 100(撤销步骤1)
3. 恢复到事务开始前的状态
哈吉米:"所以原子性靠undo log实现?"
南北绿豆:"对!undo log记录了每次修改的'反向操作',回滚时执行一遍就行。"
C - Consistency(一致性)
-- 一致性约束
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10, 2) NOT NULL CHECK (balance >= 0), -- 余额不能为负
CONSTRAINT total_balance CHECK (
(SELECT SUM(balance) FROM account) = 10000 -- 总金额恒定
)
);
实现机制:数据库约束 + 应用层逻辑
// 应用层保证一致性
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足"); // 保证余额不为负
}
阿西噶阿西:"一致性是目标,AID是手段。"
I - Isolation(隔离性)
南北绿豆:"这是今天的重点!隔离性决定了并发事务能看到什么数据。"
并发问题示例:
时间线:
事务A:读取余额=1000
事务B: 扣减100,余额=900,提交
事务A: 再次读取余额=?
问题:事务A两次读取,数据变了(不可重复读)
实现机制:MVCC(多版本并发控制)+ 锁
D - Durability(持久性)
-- 提交后即使断电,数据也不丢失
UPDATE account SET balance = 900 WHERE user_id = 1;
COMMIT; -- 提交后,数据持久化了
-- 此时断电重启,数据仍然是900
实现机制:redo log(重做日志)
提交流程:
1. 修改内存中的数据(buffer pool)
2. 写redo log到磁盘(顺序IO,很快)
3. 返回"提交成功"
4. 后台异步刷盘(将内存数据写入磁盘)
如果步骤4之前断电:
1. 重启后读取redo log
2. 重放日志,恢复数据
哈吉米:"所以持久性靠redo log?"
南北绿豆:"对!redo log保证提交的数据不丢,undo log保证未提交的数据能回滚。"
🔥 4种隔离级别与并发问题
阿西噶阿西:"现在进入正题:隔离性!"
并发事务的3大问题
南北绿豆:"先搞清楚并发会出现哪些问题。"
问题1️⃣:脏读(Dirty Read)
定义:读到了其他事务未提交的数据。
场景演示:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | START TRANSACTION; | |
| T3 | UPDATE account SET balance = 900 WHERE user_id = 1; | |
| T4 | SELECT balance FROM account WHERE user_id = 1; 结果:900(脏读!) | |
| T5 | ROLLBACK;(事务B回滚了) | |
| T6 | SELECT balance FROM account WHERE user_id = 1; 结果:1000(数据变回来了) |
问题:事务A读到了事务B未提交的900,但事务B最后回滚了,导致事务A读到了"脏数据"。
SQL实战演示:
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 设置为读未提交
START TRANSACTION;
-- 终端2(事务B)
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 1;
-- 注意:不提交
-- 终端1(事务A)
SELECT balance FROM account WHERE user_id = 1; -- 读到了900(脏读!)
-- 终端2(事务B)
ROLLBACK; -- 回滚
-- 终端1(事务A)
SELECT balance FROM account WHERE user_id = 1; -- 又变回1000了
哈吉米:"卧槽,这不是乱套了吗?"
南北绿豆:"所以读未提交隔离级别基本不用,脏读太危险!"
问题2️⃣:不可重复读(Non-Repeatable Read)
定义:同一个事务内,多次读取同一行数据,结果不一样。
场景演示:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT balance FROM account WHERE user_id = 1; 结果:1000 | |
| T3 | START TRANSACTION; | |
| T4 | UPDATE account SET balance = 900 WHERE user_id = 1; | |
| T5 | COMMIT; | |
| T6 | SELECT balance FROM account WHERE user_id = 1; 结果:900(不可重复读!) |
问题:事务A两次读取,数据变了(因为事务B修改并提交了)。
SQL实战演示:
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置为读已提交
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 1; -- 结果:1000
-- 终端2(事务B)
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 1;
COMMIT; -- 提交
-- 终端1(事务A)
SELECT balance FROM account WHERE user_id = 1; -- 结果:900(数据变了!)
阿西噶阿西:"这就是你转账bug的原因!读已提交隔离级别下,两次读取结果不一样!"
问题3️⃣:幻读(Phantom Read)
定义:同一个事务内,多次查询符合条件的行数,结果不一样。
场景演示:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT COUNT(*) FROM account WHERE balance > 500; 结果:3行 | |
| T3 | START TRANSACTION; | |
| T4 | INSERT INTO account VALUES (4, 1000); | |
| T5 | COMMIT; | |
| T6 | SELECT COUNT(*) FROM account WHERE balance > 500; 结果:4行(幻读!) |
问题:事务A两次查询,行数变了(因为事务B插入了新数据)。
不可重复读 vs 幻读的区别:
| 类型 | 针对对象 | 场景 |
|---|---|---|
| 不可重复读 | 已存在的行(UPDATE) | 读到了别人修改的数据 |
| 幻读 | 新增/删除的行(INSERT/DELETE) | 读到了别人插入/删除的数据 |
SQL实战演示:
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM account WHERE balance > 500; -- 结果:3行
-- 终端2(事务B)
START TRANSACTION;
INSERT INTO account (user_id, balance) VALUES (4, 1000);
COMMIT;
-- 终端1(事务A)
SELECT * FROM account WHERE balance > 500; -- 结果:4行(幻读!)
4种隔离级别对比
南北绿豆:"MySQL定义了4种隔离级别,解决不同程度的并发问题。"
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED 读未提交 | ❌ 会发生 | ❌ 会发生 | ❌ 会发生 | 不加锁 |
| READ COMMITTED 读已提交 | ✅ 解决 | ❌ 会发生 | ❌ 会发生 | MVCC(每次生成Read View) |
| REPEATABLE READ 可重复读(默认) | ✅ 解决 | ✅ 解决 | ✅ 解决* | MVCC(只生成一次Read View) + 间隙锁 |
| SERIALIZABLE 串行化 | ✅ 解决 | ✅ 解决 | ✅ 解决 | 加锁(退化成串行) |
注意:InnoDB的RR隔离级别通过Next-Key Lock(间隙锁+行锁)解决了幻读问题,这是MySQL的特色优化!
哈吉米:"为什么默认是RR(可重复读)?"
阿西噶阿西:"因为RR能解决大部分并发问题,性能又比SERIALIZABLE好很多。"
查看和设置隔离级别
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 或
SHOW VARIABLES LIKE 'transaction_isolation';
-- 设置全局隔离级别(重启后仍有效)
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 设置当前会话隔离级别
SET SESSION transaction_isolation = 'REPEATABLE-READ';
-- 设置下一个事务的隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
🎯 MVCC多版本并发控制原理
南北绿豆:"现在进入核心:MVCC是怎么实现的?"
哈吉米:"等等,MVCC是啥?"
阿西噶阿西:"Multi-Version Concurrency Control,多版本并发控制。核心思路是:每个事务看到的是数据的一个快照版本,不同事务看到不同版本,互不影响。"
MVCC的3大核心组件
1. 隐藏字段(每行数据都有)
2. undo log版本链(历史版本)
3. Read View(判断哪个版本可见)
组件1️⃣:隐藏字段
南北绿豆:"InnoDB给每一行数据都加了3个隐藏字段。"
-- 假设有这样一张表
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10, 2) NOT NULL
);
-- 实际存储的结构(InnoDB内部)
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10, 2) NOT NULL,
-- 以下是隐藏字段
DB_TRX_ID BIGINT, -- 最后修改这行数据的事务ID
DB_ROLL_PTR POINTER, -- 回滚指针,指向undo log中的上一个版本
DB_ROW_ID BIGINT -- 隐藏主键(如果表没有主键)
);
字段说明:
| 字段 | 含义 | 示例 |
|---|---|---|
| DB_TRX_ID | 事务ID,标识哪个事务最后修改了这行数据 | 100 |
| DB_ROLL_PTR | 回滚指针,指向undo log中的历史版本 | 0x7f8a3c |
| DB_ROW_ID | 隐藏主键(如果表没有主键则自动生成) | 1 |
哈吉米:"所以每一行数据都记录了'谁改的'和'改之前是啥'?"
南北绿豆:"对!这是MVCC的基础。"
组件2️⃣:undo log版本链
阿西噶阿西:"每次修改数据,旧版本会被保存到undo log,形成一条版本链。"
示例:
初始数据:
user_id = 1, balance = 1000, trx_id = 80
事务100:UPDATE account SET balance = 900 WHERE user_id = 1;
事务200:UPDATE account SET balance = 800 WHERE user_id = 1;
版本链结构:
最新版本(在数据页中)
┌─────────────────────────────────┐
│ user_id: 1 │
│ balance: 800 │
│ DB_TRX_ID: 200 │ ← 事务200修改的
│ DB_ROLL_PTR: ──────────────┐ │
└─────────────────────────────│───┘
↓
undo log版本2
┌─────────────────────────────────┐
│ user_id: 1 │
│ balance: 900 │
│ DB_TRX_ID: 100 │ ← 事务100修改的
│ DB_ROLL_PTR: ──────────────┐ │
└─────────────────────────────│───┘
↓
undo log版本1
┌─────────────────────────────────┐
│ user_id: 1 │
│ balance: 1000 │
│ DB_TRX_ID: 80 │ ← 最初的版本
│ DB_ROLL_PTR: NULL │
└─────────────────────────────────┘
南北绿豆:"这就是版本链!通过 DB_ROLL_PTR 指针,可以找到这行数据的所有历史版本。"
哈吉米:"所以MVCC的'多版本'就是这些历史版本?"
阿西噶阿西:"对!不同事务根据自己的Read View,选择看哪个版本。"
组件3️⃣:Read View(读视图)
南北绿豆:"Read View是MVCC的核心,它决定了当前事务能看到哪些版本的数据。"
Read View的结构
// Read View(简化版)
class ReadView {
long creator_trx_id; // 创建这个Read View的事务ID
List<Long> m_ids; // 生成Read View时,活跃的事务ID列表
long min_trx_id; // m_ids中的最小值
long max_trx_id; // 生成Read View时,下一个将要分配的事务ID
}
字段说明:
| 字段 | 含义 |
|---|---|
| creator_trx_id | 当前事务的ID |
| m_ids | 生成Read View时,哪些事务还在运行(未提交) |
| min_trx_id | m_ids中最小的事务ID |
| max_trx_id | 下一个将要分配的事务ID(当前最大事务ID + 1) |
示例:
时刻T1:
- 事务80:已提交
- 事务100:运行中
- 事务200:运行中
- 事务250:刚刚启动
事务250的Read View:
{
creator_trx_id: 250,
m_ids: [100, 200, 250],
min_trx_id: 100,
max_trx_id: 251
}
Read View的可见性判断算法
阿西噶阿西:"有了Read View,如何判断某个版本是否可见?"
算法(源码逻辑):
// InnoDB源码:read0read.cc
bool ReadView::changes_visible(trx_id_t id) const {
// 规则1:如果数据的trx_id等于当前事务ID,可见(自己修改的数据)
if (id == creator_trx_id) {
return true;
}
// 规则2:如果数据的trx_id < min_trx_id,可见
// (在生成Read View之前就已经提交的事务)
if (id < min_trx_id) {
return true;
}
// 规则3:如果数据的trx_id >= max_trx_id,不可见
// (在生成Read View之后才启动的事务)
if (id >= max_trx_id) {
return false;
}
// 规则4:如果min_trx_id <= trx_id < max_trx_id
// 需要判断trx_id是否在m_ids中
if (m_ids.contains(id)) {
return false; // 在活跃列表中,说明未提交,不可见
} else {
return true; // 不在活跃列表中,说明已提交,可见
}
}
判断流程图:
开始读取某一行数据
↓
读取这行的 trx_id
↓
┌─────────────────────────────┐
│ trx_id == creator_trx_id? │ ──YES→ 可见(自己改的)
└──────────NO──────────────────┘
↓
┌─────────────────────────────┐
│ trx_id < min_trx_id? │ ──YES→ 可见(早就提交了)
└──────────NO──────────────────┘
↓
┌─────────────────────────────┐
│ trx_id >= max_trx_id? │ ──YES→ 不可见(还没创建)
└──────────NO──────────────────┘
↓
┌─────────────────────────────┐
│ trx_id in m_ids? │ ──YES→ 不可见(未提交)
│ │ ──NO──→ 可见(已提交)
└─────────────────────────────┘
哈吉米:"如果当前版本不可见,怎么办?"
南北绿豆:"沿着版本链往前找,直到找到可见的版本!"
完整的查询流程
事务250要读取 user_id = 1 的数据
↓
读取最新版本:balance = 800, trx_id = 200
↓
判断可见性:trx_id=200 在 m_ids 中吗?
↓
在!说明事务200未提交,不可见
↓
沿着 DB_ROLL_PTR 找上一个版本
↓
读取版本2:balance = 900, trx_id = 100
↓
判断可见性:trx_id=100 在 m_ids 中吗?
↓
在!说明事务100未提交,不可见
↓
继续找上一个版本
↓
读取版本1:balance = 1000, trx_id = 80
↓
判断可见性:trx_id=80 < min_trx_id(100)
↓
可见!返回 balance = 1000
阿西噶阿西:"这就是MVCC的核心:通过版本链和Read View,不同事务看到不同版本的数据,避免加锁!"
RC vs RR:Read View生成时机的区别
南北绿豆:"RC(读已提交)和RR(可重复读)的区别,就在于Read View的生成时机!"
READ COMMITTED(读已提交)
特点:每次SELECT都生成一个新的Read View
时间线:
T1: 事务A启动
T2: 事务A执行 SELECT(生成Read View 1)
T3: 事务B提交
T4: 事务A再次 SELECT(生成Read View 2)← 新的Read View!
结果:两次SELECT结果不同(因为Read View 2能看到事务B的提交)
示例:
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 1; -- 结果:1000
-- Read View 1:m_ids = [100, 200]
-- 终端2(事务B = 200)
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 1;
COMMIT; -- 事务200提交了
-- 终端1(事务A)
SELECT balance FROM account WHERE user_id = 1; -- 结果:900(变了!)
-- Read View 2:m_ids = [100](事务200已不在活跃列表)
REPEATABLE READ(可重复读)
特点:整个事务只生成一次Read View(第一次SELECT时)
时间线:
T1: 事务A启动
T2: 事务A执行 SELECT(生成Read View)
T3: 事务B提交
T4: 事务A再次 SELECT(复用之前的Read View)← 还是同一个!
结果:两次SELECT结果相同(因为Read View没变)
示例:
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 1; -- 结果:1000
-- Read View:m_ids = [100, 200]
-- 终端2(事务B = 200)
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 1;
COMMIT; -- 事务200提交了
-- 终端1(事务A)
SELECT balance FROM account WHERE user_id = 1; -- 结果:仍然是1000!
-- 复用之前的Read View:m_ids = [100, 200]
-- trx_id=200 还在m_ids中,所以看不到事务200的修改
对比总结:
| 隔离级别 | Read View生成时机 | 可重复读 | 性能 |
|---|---|---|---|
| RC | 每次SELECT生成 | ❌ 不能 | 稍好(能读到最新提交) |
| RR | 事务第一次SELECT生成,之后复用 | ✅ 能 | 稍差(需要回溯版本链) |
哈吉米:"卧槽,恍然大悟!所以RR能保证可重复读,就是因为Read View不变!"
🔍 快照读 vs 当前读
阿西噶阿西:"还有个重点:并不是所有查询都走MVCC!"
快照读(Snapshot Read)
定义:读取的是数据的快照版本(历史版本),走MVCC,不加锁。
-- 这些都是快照读
SELECT * FROM account WHERE user_id = 1;
SELECT COUNT(*) FROM account;
SELECT balance FROM account WHERE balance > 500;
特点:
- ✅ 不加锁,并发性能高
- ✅ 读取的是快照版本,不是最新数据
- ✅ 适合大部分查询场景
当前读(Current Read)
定义:读取的是数据的最新版本,会加锁。
-- 这些都是当前读
SELECT * FROM account WHERE user_id = 1 FOR UPDATE; -- 加排他锁
SELECT * FROM account WHERE user_id = 1 LOCK IN SHARE MODE; -- 加共享锁
INSERT INTO account VALUES (2, 500); -- 加排他锁
UPDATE account SET balance = 900 WHERE user_id = 1; -- 加排他锁
DELETE FROM account WHERE user_id = 1; -- 加排他锁
特点:
- ❌ 会加锁,并发性能低
- ✅ 读取的是最新数据
- ✅ 适合需要修改数据的场景
为什么需要当前读?
场景:转账业务
START TRANSACTION;
-- 如果用快照读
SELECT balance FROM account WHERE user_id = 1; -- 读到的是快照:1000
-- 同时另一个事务扣了100
-- 当前余额其实是900了,但你不知道
-- 你基于1000去判断
IF balance >= 200 THEN
UPDATE account SET balance = balance - 200 WHERE user_id = 1;
-- 实际变成了700,但应该是余额不足!
END IF;
COMMIT;
问题:快照读导致判断基于旧数据,出现逻辑错误。
正确写法:用当前读
START TRANSACTION;
-- 用当前读(加锁)
SELECT balance FROM account WHERE user_id = 1 FOR UPDATE; -- 锁住这行
-- 这时读到的是最新数据:900
IF balance >= 200 THEN
UPDATE account SET balance = balance - 200 WHERE user_id = 1;
ELSE
-- 余额不足
END IF;
COMMIT; -- 释放锁
南北绿豆:"所以:查询用快照读,修改前用当前读!"
🚨 RR隔离级别如何解决幻读?
哈吉米:"前面说RR能解决幻读,具体怎么做的?"
阿西噶阿西:"靠Next-Key Lock(间隙锁+行锁)!"
幻读问题重现
-- 终端1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM account WHERE balance > 500; -- 结果:3行
-- 终端2(事务B)
START TRANSACTION;
INSERT INTO account (user_id, balance) VALUES (4, 1000);
COMMIT;
-- 终端1(事务A)
SELECT * FROM account WHERE balance > 500; -- 还是3行(快照读,没有幻读)
-- 但如果用当前读
SELECT * FROM account WHERE balance > 500 FOR UPDATE; -- 4行(幻读!)
问题:快照读不会幻读,但当前读会幻读!
Next-Key Lock(间隙锁)
定义:锁住记录之间的间隙,防止其他事务插入数据。
示例:
-- 表中有3条数据
user_id | balance
--------|--------
1 | 500
2 | 800
3 | 1000
-- 事务A执行
SELECT * FROM account WHERE balance > 500 FOR UPDATE;
加锁范围:
(-∞, 500] ← 不锁(不满足条件)
(500, 800] ← 锁住间隙 + 行锁user_id=2
(800, 1000] ← 锁住间隙 + 行锁user_id=3
(1000, +∞) ← 锁住间隙(防止插入更大的值)
效果:
-- 终端2(事务B)
INSERT INTO account (user_id, balance) VALUES (4, 1000);
-- 阻塞!因为1000在间隙(800, 1000]内,被锁住了
INSERT INTO account (user_id, balance) VALUES (5, 400);
-- 成功!因为400不在被锁的间隙内
南北绿豆:"间隙锁防止了插入,所以RR隔离级别下,当前读也不会幻读!"
锁的类型总结
| 锁类型 | 锁定范围 | 作用 |
|---|---|---|
| Record Lock | 锁定单行记录 | 防止其他事务修改这行 |
| Gap Lock | 锁定记录之间的间隙 | 防止其他事务插入 |
| Next-Key Lock | Record Lock + Gap Lock | 同时防止修改和插入 |
示例:
-- Record Lock(只锁行)
SELECT * FROM account WHERE user_id = 1 FOR UPDATE;
-- Next-Key Lock(锁行+间隙)
SELECT * FROM account WHERE balance > 500 FOR UPDATE;
🎯 完整案例:转账业务的正确写法
哈吉米:"现在我知道问题在哪了,怎么改?"
南北绿豆:"来,写个完整的正确版本。"
错误写法(原始版本)
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 查询余额(快照读)
Account from = accountMapper.selectById(fromUserId);
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 2. 扣减余额
from.setBalance(from.getBalance().subtract(amount));
accountMapper.updateById(from);
// 3. 增加余额
Account to = accountMapper.selectById(toUserId);
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(to);
}
问题:
- 快照读导致判断基于旧数据
- 并发时可能超扣
正确写法1:使用当前读(FOR UPDATE)
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 查询余额(当前读,加锁)
Account from = accountMapper.selectByIdForUpdate(fromUserId);
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 2. 扣减余额
from.setBalance(from.getBalance().subtract(amount));
accountMapper.updateById(from);
// 3. 增加余额(也要加锁,避免并发问题)
Account to = accountMapper.selectByIdForUpdate(toUserId);
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(to);
}
Mapper实现:
<select id="selectByIdForUpdate" resultType="Account">
SELECT * FROM account WHERE user_id = #{userId} FOR UPDATE
</select>
优点:
- ✅ 绝对不会超扣
- ✅ 数据强一致
缺点:
- ❌ 并发性能低(串行执行)
- ❌ 可能死锁(如果两个转账相反顺序加锁)
正确写法2:直接用UPDATE(推荐)
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 直接扣减余额(带余额检查)
int updated = accountMapper.decreaseBalance(fromUserId, amount);
if (updated == 0) {
throw new RuntimeException("余额不足或账户不存在");
}
// 2. 增加余额
accountMapper.increaseBalance(toUserId, amount);
}
Mapper实现:
<update id="decreaseBalance">
UPDATE account
SET balance = balance - #{amount}
WHERE user_id = #{userId}
AND balance >= #{amount} <!-- 关键:保证余额足够 -->
</update>
<update id="increaseBalance">
UPDATE account
SET balance = balance + #{amount}
WHERE user_id = #{userId}
</update>
优点:
- ✅ 性能好(UPDATE自动加行锁)
- ✅ 逻辑简洁
- ✅ 不会死锁(单条SQL原子执行)
缺点:
- ⚠️ 需要判断
updated返回值
正确写法3:乐观锁(版本号)
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
int retryCount = 0;
while (retryCount < 3) {
try {
// 1. 查询余额和版本号
Account from = accountMapper.selectById(fromUserId);
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 2. 乐观锁扣减余额
int updated = accountMapper.decreaseBalanceWithVersion(
fromUserId, amount, from.getVersion()
);
if (updated == 0) {
// 版本号不匹配,重试
retryCount++;
continue;
}
// 3. 增加余额
accountMapper.increaseBalance(toUserId, amount);
return; // 成功
} catch (Exception e) {
retryCount++;
}
}
throw new RuntimeException("转账失败,请重试");
}
Mapper实现:
<update id="decreaseBalanceWithVersion">
UPDATE account
SET balance = balance - #{amount},
version = version + 1
WHERE user_id = #{userId}
AND balance >= #{amount}
AND version = #{version} <!-- 乐观锁 -->
</update>
优点:
- ✅ 并发性能高(不阻塞)
- ✅ 无死锁风险
缺点:
- ❌ 失败率高(CAS冲突)
- ❌ 需要重试逻辑
三种方案对比
| 方案 | 并发性能 | 复杂度 | 适用场景 |
|---|---|---|---|
| FOR UPDATE | ⭐⭐ | 简单 | 并发不高的核心业务 |
| 直接UPDATE | ⭐⭐⭐⭐ | 简单 | 推荐!大部分场景 |
| 乐观锁 | ⭐⭐⭐⭐⭐ | 复杂 | 高并发、可接受重试 |
南北绿豆:"一般情况下,直接UPDATE就够了!简单高效。"
📊 真实案例:电商订单
阿西噶阿西:"来个更复杂的例子:创建订单 + 扣库存。"
场景
用户下单流程:
1. 检查库存
2. 扣减库存
3. 创建订单
4. 扣减用户余额
问题:并发下可能超卖
错误写法
@Transactional
public void createOrder(Long userId, Long productId, Integer num) {
// 1. 查询库存(快照读)
Stock stock = stockMapper.selectByProductId(productId);
if (stock.getNum() < num) {
throw new RuntimeException("库存不足");
}
// 2. 扣减库存
stock.setNum(stock.getNum() - num);
stockMapper.updateById(stock);
// 3. 创建订单
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setNum(num);
orderMapper.insert(order);
// 4. 扣减余额
accountMapper.decreaseBalance(userId, stock.getPrice().multiply(new BigDecimal(num)));
}
问题:并发时可能超卖(跟转账一样的问题)
正确写法
@Transactional(rollbackFor = Exception.class, isolation = Isolation.REPEATABLE_READ)
public void createOrder(Long userId, Long productId, Integer num) {
// 1. 直接扣减库存(带库存检查)
int updated = stockMapper.decreaseStock(productId, num);
if (updated == 0) {
throw new RuntimeException("库存不足");
}
// 2. 查询商品价格
Stock stock = stockMapper.selectByProductId(productId);
BigDecimal totalPrice = stock.getPrice().multiply(new BigDecimal(num));
// 3. 扣减余额
int updatedAccount = accountMapper.decreaseBalance(userId, totalPrice);
if (updatedAccount == 0) {
throw new RuntimeException("余额不足");
}
// 4. 创建订单
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setNum(num);
order.setTotalPrice(totalPrice);
orderMapper.insert(order);
}
关键SQL:
<update id="decreaseStock">
UPDATE stock
SET num = num - #{num}
WHERE product_id = #{productId}
AND num >= #{num} <!-- 关键:防止库存扣成负数 -->
</update>
优点:
- ✅ 绝对不会超卖
- ✅ 性能好
- ✅ 事务自动回滚(余额不足时,库存也会回滚)
🛠️ 如何选择隔离级别?
哈吉米:"实际项目中,该用哪个隔离级别?"
南北绿豆:"看业务需求和性能权衡。"
选择指南
| 隔离级别 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| READ UNCOMMITTED | ❌ 基本不用 | 性能最好 | 脏读,数据不可靠 |
| READ COMMITTED | 统计报表、日志查询、非核心业务 | 能读到最新提交,性能好 | 不可重复读 |
| REPEATABLE READ | 金融、电商、核心业务 | 可重复读,MySQL默认 | 性能稍差 |
| SERIALIZABLE | ❌ 基本不用 | 完全串行,最安全 | 性能极差 |
实际建议
1. 大部分场景:用默认的RR
# application.yml
spring:
datasource:
hikari:
transaction-isolation: TRANSACTION_REPEATABLE_READ # 默认
2. 统计报表:用RC
// 统计昨天的订单数
@Transactional(isolation = Isolation.READ_COMMITTED)
public Long countYesterdayOrders() {
return orderMapper.countByDate(LocalDate.now().minusDays(1));
}
3. 核心金融业务:用RR + 当前读
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 用当前读(FOR UPDATE)
Account from = accountMapper.selectByIdForUpdate(fromUserId);
// ...
}
4. 极端场景:手动加分布式锁
@Transactional
public void sensitiveOperation(Long userId) {
// 用Redis分布式锁
RLock lock = redissonClient.getLock("user:" + userId);
try {
lock.lock(10, TimeUnit.SECONDS);
// 业务逻辑
} finally {
lock.unlock();
}
}
🎓 思考题
题目1:这段代码有什么问题?
@Transactional
public void processOrder(Long orderId) {
Order order = orderMapper.selectById(orderId); // 快照读
if (order.getStatus() == 0) {
// 处理订单
processOrderLogic(order);
// 更新状态
order.setStatus(1);
orderMapper.updateById(order);
}
}
答案:
问题:并发时可能重复处理订单。
原因:
selectById是快照读,可能读到旧状态- 两个线程都读到
status=0,都会处理
正确写法:
@Transactional
public void processOrder(Long orderId) {
// 用当前读
Order order = orderMapper.selectByIdForUpdate(orderId);
if (order.getStatus() == 0) {
processOrderLogic(order);
order.setStatus(1);
orderMapper.updateById(order);
}
}
// 或者用乐观锁
UPDATE `order`
SET status = 1, version = version + 1
WHERE id = #{orderId}
AND status = 0
AND version = #{version};
题目2:为什么RC隔离级别主从延迟问题更严重?
答案:
RC每次SELECT都生成新的Read View,容易读到主从不一致的数据。
示例:
T1: 主库写入订单(order_id=100)
T2: 应用读从库(RC隔离级别)
- 第1次SELECT:生成Read View 1,读不到order_id=100(延迟)
T3: 主从同步完成
T4: 应用再次SELECT(同一个事务内)
- 第2次SELECT:生成Read View 2,读到了order_id=100
问题:同一个事务内,两次读取结果不一样!
RR隔离级别:
T1: 主库写入订单(order_id=100)
T2: 应用读从库(RR隔离级别)
- 第1次SELECT:生成Read View,读不到order_id=100
T3: 主从同步完成
T4: 应用再次SELECT(同一个事务内)
- 复用之前的Read View,仍然读不到order_id=100
结果:虽然有延迟,但至少在一个事务内是一致的
建议:读写分离场景下,优先用RR隔离级别。
题目3:手写一个MVCC模拟器
用Java模拟MVCC的版本链和Read View:
/**
* MVCC模拟器
*/
public class MVCCSimulator {
// 数据版本
static class DataVersion {
String value; // 数据值
long trxId; // 修改这个版本的事务ID
DataVersion prev; // 上一个版本(模拟roll_ptr)
public DataVersion(String value, long trxId, DataVersion prev) {
this.value = value;
this.trxId = trxId;
this.prev = prev;
}
}
// Read View
static class ReadView {
long creatorTrxId; // 当前事务ID
List<Long> activeIds; // 活跃事务列表
long minTrxId; // 最小活跃事务ID
long maxTrxId; // 下一个将要分配的事务ID
// 判断某个版本是否可见
public boolean isVisible(long trxId) {
// 规则1:自己修改的可见
if (trxId == creatorTrxId) {
return true;
}
// 规则2:在Read View生成前就提交的可见
if (trxId < minTrxId) {
return true;
}
// 规则3:在Read View生成后才启动的不可见
if (trxId >= maxTrxId) {
return false;
}
// 规则4:在活跃列表中的不可见
return !activeIds.contains(trxId);
}
}
/**
* 查找可见版本
*/
public static DataVersion findVisibleVersion(DataVersion latest, ReadView readView) {
DataVersion current = latest;
while (current != null) {
if (readView.isVisible(current.trxId)) {
return current; // 找到可见版本
}
current = current.prev; // 沿着版本链往前找
}
return null; // 没有可见版本
}
/**
* 测试
*/
public static void main(String[] args) {
// 构造版本链
DataVersion v1 = new DataVersion("balance=1000", 80, null);
DataVersion v2 = new DataVersion("balance=900", 100, v1);
DataVersion v3 = new DataVersion("balance=800", 200, v2);
// 构造Read View
ReadView readView = new ReadView();
readView.creatorTrxId = 250;
readView.activeIds = Arrays.asList(100L, 200L, 250L);
readView.minTrxId = 100;
readView.maxTrxId = 251;
// 查找可见版本
DataVersion visible = findVisibleVersion(v3, readView);
if (visible != null) {
System.out.println("可见版本:" + visible.value + ", trxId=" + visible.trxId);
// 输出:可见版本:balance=1000, trxId=80
} else {
System.out.println("没有可见版本");
}
}
}
输出:
可见版本:balance=1000, trxId=80
解释:
- v3(trxId=200):在activeIds中,不可见
- v2(trxId=100):在activeIds中,不可见
- v1(trxId=80):80 < minTrxId(100),可见!
🎉 结束语
晚上10点,三人终于把事务隔离级别和MVCC讲透了。
哈吉米:"原来我的bug是因为用了快照读,并发时判断基于旧数据!"
南北绿豆:"记住:查询用快照读,修改前用当前读或直接UPDATE!"
阿西噶阿西:"还有,MVCC的核心是:通过版本链和Read View,让不同事务看到不同版本的数据,避免加锁。"
哈吉米:"RC和RR的区别就是Read View生成时机:RC每次生成,RR只生成一次!"
南北绿豆:"对!下周咱们聊聊死锁?我昨天又把测试库锁死了……"
哈吉米:"卧槽,又是你!"
MVCC记忆口诀:
隐藏字段记事务,版本链条连历史
Read View判可见,RC每次RR一次
快照读取走MVCC,当前读取要加锁
修改之前先上锁,FOR UPDATE保安全
希望这篇文章能帮你彻底搞懂事务隔离级别和MVCC!记住:理解原理比死记硬背重要,实战演练比纸上谈兵有用!
收藏+点赞,面试不慌张!💪