事务隔离级别与MVCC原理:为什么我的钱凭空消失了?

摘要:从一次转账业务的诡异bug出发,深度剖析MySQL事务隔离级别与MVCC多版本并发控制机制。通过真实的SQL并发演示,展示脏读、不可重复读、幻读的实际场景,揭秘InnoDB如何通过隐藏字段、undo log版本链、Read View机制实现高并发下的数据一致性。配合图解、源码分析和Java模拟实现,让你彻底理解"为什么RR隔离级别能防止幻读"、"快照读和当前读的区别"等核心问题。


💥 翻车现场

周五下午5点,哈吉米正准备下班,突然被测试同学拉进了钉钉群。

测试同学:@哈吉米 转账功能有bug!用户余额对不上!
哈吉米:???怎么可能,我都加事务了啊!
测试同学:你自己看日志!

用户A初始余额:1000元
用户B初始余额:500元

操作步骤:
1. 事务1AB转账1002. 事务2AB转账2003. 同时执行

预期结果:
- 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
T1START TRANSACTION;
T2START TRANSACTION;
T3UPDATE account SET balance = 900 WHERE user_id = 1;
T4SELECT balance FROM account WHERE user_id = 1;
结果:900(脏读!)
T5ROLLBACK;(事务B回滚了)
T6SELECT 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
T1START TRANSACTION;
T2SELECT balance FROM account WHERE user_id = 1;
结果:1000
T3START TRANSACTION;
T4UPDATE account SET balance = 900 WHERE user_id = 1;
T5COMMIT;
T6SELECT 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
T1START TRANSACTION;
T2SELECT COUNT(*) FROM account WHERE balance > 500;
结果:3行
T3START TRANSACTION;
T4INSERT INTO account VALUES (4, 1000);
T5COMMIT;
T6SELECT 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_idm_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 LockRecord 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. 快照读导致判断基于旧数据
  2. 并发时可能超扣

正确写法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);
    }
}

答案

问题:并发时可能重复处理订单。

原因

  1. selectById 是快照读,可能读到旧状态
  2. 两个线程都读到 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=100T2: 应用读从库(RC隔离级别)
    - 第1SELECT:生成Read View 1,读不到order_id=100(延迟)
T3: 主从同步完成
T4: 应用再次SELECT(同一个事务内)
    - 第2SELECT:生成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!记住:理解原理比死记硬背重要,实战演练比纸上谈兵有用

收藏+点赞,面试不慌张!💪