Java事务与MySQL事务的关系及MVCC通俗解析

一、写在前面

说实话,刚开始工作那会儿,我对事务的理解就四个字:加个注解

java 复制代码
@Transactional
public void transfer() { ... }

"加了 @Transactional,数据就安全了!"------这是我当时唯一的认知。至于为什么安全?不知道。Java事务和MySQL事务啥关系?不清楚。MVCC只知道叫多版本并发控制,具体细节不太清楚!!

后来线上出了一次事故:两个线程同时查同一条记录,一个改了余额,另一个读到的还是旧值。leader 让我排查,我对着日志看了半天,一脸懵。

从那之后,我才下决心把这些底层的东西彻底搞明白。今天这篇文章,就是我的踩坑笔记,希望能帮到和我当年一样迷糊的同学。

咱们先理清一个最基础的问题:Java 事务和 MySQL 事务,到底谁管谁?


二、Java事务 vs MySQL事务:谁是老板?

2.1 一个生活中的类比

去过餐厅吧?咱们把这件事放到餐厅里看:

TypeScript 复制代码
Spring 事务管理器  ≈ 餐厅经理 
MySQL InnoDB      ≈ 后厨团队 
连接池            ≈ 后厨的灶台

经理(Spring 事务管理器)不炒菜,但他决定:

  • 什么时候开火(开启事务)

  • 什么时候出菜上桌(提交 commit)

  • 哪桌的菜做砸了,倒掉重来(回滚 rollback)

真正的切菜、颠勺、摆盘,全是后厨(MySQL InnoDB)在干。

2.2 @Transactional 到底做了什么?

咱们直接看伪代码,一目了然:

java 复制代码
// Spring 事务管理器做了这些事(简化版)
Connection conn = dataSource.getConnection();   // 从连接池借一个连接
conn.setAutoCommit(false);                      // 关闭自动提交 → 事务开始!
try {
    yourBusinessMethod();                       // 执行你写的业务代码
    conn.commit();                              // 一切顺利 → 提交
} catch (Exception e) {
    conn.rollback();                            // 出岔子了 → 回滚
} finally {
    conn.setAutoCommit(true);                   // 还原设置
    connectionPool.returnConnection(conn);      // 把连接还回池子里
}

看出来了吧?Spring 做的所有事情,本质就是操作了一个 java.sql.Connection 对象。 它没有缓存数据,没有写日志,没有加锁------这些活儿全是 MySQL 干的。

2.3 对比表一目了然

Java/Spring 事务 MySQL 事务
角色 指挥官,负责协调 执行者,真正干活
干了啥 借连接、关autoCommit、调commit/rollback 写Undo/Redo Log、加行锁、改数据页、刷盘
数据存在哪 啥也没存,Java端不缓存任何数据 Undo Log、Redo Log、Buffer Pool、数据文件
ACID谁保证 不保证,它只负责"喊口令" 100%由MySQL保证

💡 核心结论:Java 事务是"协调者",MySQL 事务是"执行者"。离开了 MySQL,Spring 的 @Transactional 啥也保证不了。

2.4 一个最容易踩的坑

没有 @Transactional 的时候,MySQL 就没有事务了吗?

不是。 MySQL 默认 autoCommit = true,每条 SQL 自己就是个事务,执行完立刻自动提交。

java 复制代码
// ❌ 没加 @Transactional,灾难场景
accountMapper.deduct(fromId, 100);   // 扣款 → 立刻提交了
// ---- 此时服务器崩了 ----
accountMapper.add(toId, 100);        // 没执行到
​
// 结果:钱扣了,但对方没收到。数据不一致!
// ✅ 加了 @Transactional
@Transactional
public void transfer() {
    accountMapper.deduct(fromId, 100);   // 没提交,在事务中
    accountMapper.add(toId, 100);        // 没提交,在事务中
}
// 正常结束 → 两条一起提交
// 中间崩了 → 两条一起回滚
// 数据始终一致 ✅

所以 @Transactional 的价值不是"提供了事务",而是把多条 SQL 绑进同一个事务里


三、重头戏来了:MVCC 到底是什么鬼?

好,铺垫完了。接下来是本文的重头戏------MVCC(Multi-Version Concurrency Control,多版本并发控制)

这个东西,我第一次看官方文档的时候,差点怀疑自己的智商。什么隐藏字段、什么Undo Log链、什么ReadView快照......每个字都认识,连一起就不认识了。

后来我想明白了一个道理:不是你笨,是文档写得太干。 咱们换个方式来理解。

3.1 先说 MVCC 要解决的问题

想象一个场景:你和同事同时在看同一份 Excel 表格(数据库里的同一行数据)。

复制代码
你在看:张三的余额 = 1000 元
同事在改:张三的余额改成 500 元(还没保存)

问题来了:你看到的应该是 1000 还是 500?

如果让你看到 500(同事还没提交的中间状态),那就是脏读 。 如果同事改完保存了,你再查一次发现变了,同一事务里两次读结果不一样,那就是不可重复读

这两种情况都很头疼。那怎么办?

最暴力的方案:加锁 。你要读?等着,等我改完你再读。这样数据确实一致了,但并发性能直接归零------所有人排队等锁,这数据库还用不用了?

MVCC 的思路完全不同:不加锁,给你看"快照"。

3.2 一个比喻:照相馆的快照

把 MVCC 想象成一家照相馆

你在某一个瞬间(事务开始的那一刻),照相馆数据库里的所有数据拍了一张合照。之后不管别人怎么修改数据,你看到的永远是那张照片上的样子。

  • 张三余额 = 1000?照片上就是 1000。

  • 同事把余额改成 500 了?不好意思,我只看照片,照片上是 1000。

  • 同事改完又改成 800 了?照片上还是 1000。

你的视角被"定格"在了拍照那一刻。 这就保证了可重复读 ------同一个事务里,不管读几次,读到的都一样。

这就是 MVCC 的核心思想:读不加锁,读到的是某个历史版本(快照),而不是当前实时值。

划重点:MVCC 让"读"和"写"互不阻塞。 你读你的快照,我改我的数据,大家各干各的,谁也不等谁。并发性能直接起飞。

3.3 MVCC 三件套:隐藏字段 + Undo Log + ReadView

光知道"快照"还不够,咱们得搞清楚:MySQL 是怎么实现这个快照机制的?

这就涉及到 MVCC 的三个核心组件。别怕,咱们一个一个来。


① 隐藏字段:每行数据自带的"身份证"

MySQL 的 InnoDB 引擎在你建的表之外,偷偷给每一行数据加了几个隐藏字段(你看不到,但它们一直在):

TypeScript 复制代码
┌─────────────────────────────────────────────────────────┐
│                    你看到的字段                          │
│  id    │  name    │  balance                            │
├─────────────────────────────────────────────────────────┤
│                 InnoDB 偷偷加的隐藏字段                  │
│  DB_TRX_ID  │  DB_ROLL_PTR  │  DB_ROW_ID                │
└─────────────────────────────────────────────────────────┘
隐藏字段 大白话解释
DB_TRX_ID 最近一次修改这行数据的事务ID。就像快递上贴的"最后一个经手人"标签
DB_ROLL_PTR 回滚指针,指向这行数据的"上一个版本"存在哪里。就像快递上贴的"上一个经手人地址"
DB_ROW_ID 隐藏主键(没有主键时自动生成,先不管它)

DB_TRX_ID 告诉你"谁最后动过这行数据",DB_ROLL_PTR 告诉你"想找上一个版本去哪儿找"。


② Undo Log:数据的"版本链"

每次修改一行数据,InnoDB 不会直接覆盖旧值,而是把旧值存到一个叫 Undo Log (回滚日志)的地方。然后通过 DB_ROLL_PTR 这个指针,把新旧版本串成一条链。

举个例子,假设张三的余额被改了三次:

TypeScript 复制代码
当前数据(最新版本):
┌──────────────────────────────────────────────────┐
│ name: 张三 | balance: 800 | TRX_ID: 300          │
│ ROLL_PTR ──→ 指向上一个版本                       │
└──────────────┬───────────────────────────────────┘
               │
               ▼
Undo Log 中的版本:
┌──────────────────────────────────────────────────┐
│ name: 张三 | balance: 500 | TRX_ID: 200          │
│ ROLL_PTR ──→ 指向更早的版本                       │
└──────────────┬───────────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────────┐
│ name: 张三 | balance: 1000 | TRX_ID: 100         │
│ ROLL_PTR ──→ NULL(最早版本了)                   │
└──────────────────────────────────────────────────┘

这条链就叫版本链。InnoDB 要找历史版本,就沿着这条链往下找就行了。

这就是为什么回滚可以很快------不用改磁盘上的数据文件,顺着链找到旧值恢复就行


③ ReadView:决定"你能看哪个版本"的规则

好,现在版本链有了,问题来了:我的事务应该看这条链上的哪个版本?

这就靠 ReadView (可以理解为一个**"快照清单"**)来决定了。

ReadView 本质上是事务在某个时刻生成的一张清单,记录了当时数据库里所有活跃(还没提交)的事务ID:

TypeScript 复制代码
ReadView {
    m_ids: [200, 300]        ← 生成快照时,还没提交的事务ID列表
    min_trx_id: 200          ← 这些事务里最小的ID
    max_trx_id: 301          ← 下一个将要分配的事务ID
    creator_trx_id: 400      ← 我自己的事务ID
}

当你要读某一行数据时,InnoDB 会拿这行的 DB_TRX_ID 和 ReadView 里的规则做比较,来判断这个版本对你是否可见

TypeScript 复制代码
判断逻辑(简化版):

1. DB_TRX_ID == creator_trx_id?
   → 这是我自己改的,当然看得到 ✅

2. DB_TRX_ID < min_trx_id?
   → 这个版本在我快照之前就提交了,看得到 ✅

3. DB_TRX_ID >= max_trx_id?
   → 这个版本是快照之后才出现的,看不到 ❌
   → 沿着版本链往前找更老的版本

4. DB_TRX_ID 在 m_ids 列表中?
   → 这个事务在我拍快照时还没提交,看不到 ❌
   → 沿着版本链往前找

5. DB_TRX_ID 不在 m_ids 列表中?
   → 这个事务在我拍快照前已经提交了,看得到 ✅

🎯 用人话说就是:我只看在我拍照之前就已经"修好图"的版本,那些正在P图中的(未提交的)、或者在我拍完照之后才开始P的,我一律不看。

3.4 另一个比喻:Git 的版本控制

如果你觉得上面的"照相馆"还不够直观,咱们再换个 Git 的视角:

TypeScript 复制代码
你的 Git 仓库(数据库):
  commit 3:  balance = 800   (TRX_ID: 300)  ← HEAD(当前版本)
  commit 2:  balance = 500   (TRX_ID: 200)
  commit 1:  balance = 1000  (TRX_ID: 100)

你的事务在 commit 2 的时候"git checkout"了

  → 不管后面有没有 commit 3,你的 HEAD 一直指着 500
  → 别人推了 commit 4 也不影响你
  → 你永远看到的是 balance = 500

ReadView 就像 git log,告诉你哪些 commit 已经 push 了(已提交),哪些还在别人的本地分支(未提交)。你只看已经 push 的。


四、实战演练:RR 隔离级别下 MVCC 是怎么工作的?

说了这么多理论,咱们来个真实的 SQL 场景,走一遍 MVCC 的判断过程。

MySQL 默认隔离级别是 可重复读(Repeatable Read, RR),这也是 MVCC 大显身手的场景。

场景:读写并发

TypeScript 复制代码
-- 初始状态:张三的 balance = 1000
-- 假设该行的 DB_TRX_ID = 100(事务100已经提交了)
​
-- 时刻T1:事务A(ID=200)开始
BEGIN;  -- 事务A 生成 ReadView: m_ids=[200] (此时只有自己是活跃的)
​
-- 时刻T2:事务B(ID=300)开始并修改数据【这个时刻还没提交】
BEGIN;
UPDATE account SET balance = 500 WHERE name = '张三';
-- 此时张三那行:DB_TRX_ID = 300, balance = 500
-- 旧版本(1000)通过 ROLL_PTR 存在 Undo Log 里
​
-- 时刻T3:事务A 再次读张三的余额【事务B更改完还没提交】
SELECT balance FROM account WHERE name = '张三';
-- 结果是什么?1000 还是 500?

走一遍 MVCC 判断:

TypeScript 复制代码
事务A 的 ReadView: { m_ids: [200], min_trx_id: 200, max_trx_id: 201 }
​
当前最新版本的 DB_TRX_ID = 300
​
判断:
  300 >= max_trx_id(201)?  → YES!
  → 这个版本是快照之后才产生的,对事务A不可见 ❌
  → 沿着版本链往前找
  → 找到 Undo Log 中的旧版本:DB_TRX_ID = 100, balance = 1000
​
  100 < min_trx_id(200)?  → YES!
  → 这个版本在快照之前就提交了,可见 ✅
​
最终结果:事务A 读到 balance = 1000 ✅

事务B 改了数据但还没提交,事务A 完全不受影响,读到的还是旧值。这就是防止了脏读和不可重复读。

如果事务B提交了呢?

TypeScript 复制代码
-- 时刻T4:事务B 提交
COMMIT;  -- 事务300 提交了
​
-- 时刻T5:事务A 再次读
SELECT balance FROM account WHERE name = '张三';
-- 结果是什么?

结果还是 1000!

因为事务A的 ReadView 是在 T1 时刻生成的 。在 RR 隔离级别下,整个事务生命周期内只生成一次 ReadView,不会因为别人提交了就更新快照。

所以就算事务B已经提交了,300 依然不在事务A的 m_ids 里,但它依然 >= max_trx_id,依然不可见。

这就是"可重复读"的秘密:ReadView 在事务第一次读的时候生成,之后一直复用同一个,所以每次读的结果都一样。

对比:RC 隔离级别呢?

如果隔离级别是读已提交(Read Committed, RC),情况就不一样了:

RC 的区别:每次 SELECT 都会重新生成一个新的 ReadView。

TypeScript 复制代码
所以在时刻T5,事务A 重新生成 ReadView:{ m_ids: [200], min_trx_id: 200, max_trx_id: 301 }
DB_TRX_ID = 300
300 < max_trx_id(301)? → YES
300 在 m_ids 中? → NO(300已经提交了)
→ 可见 ✅
​
结果:balance = 500(读到了事务B提交后的新值)

同一个场景,RR 读到 1000,RC 读到 500。 区别就在于 ReadView 什么时候生成。

隔离级别 ReadView 生成时机 效果
RC(读已提交) 每次 SELECT 都重新生成 能读到其他事务已提交的最新数据
RR(可重复读) 事务中第一次 SELECT 时生成,之后复用 整个事务看到的数据始终一致

五、一张图总结 MVCC 的工作流程

TypeScript 复制代码
事务A 发起 SELECT
       │
       ▼
┌─────────────────┐
│ 有 ReadView 了吗?│
└────┬───────┬────┘
     │ NO    │ YES
     ▼       ▼
  生成新     复用已有
  ReadView   ReadView
     │       │
     └──┬────┘
        ▼
  读取当前行数据,获取 DB_TRX_ID
        │
        ▼
  用 ReadView 的规则判断:
  这个版本对我可见吗?
        │
   ┌────┴────┐
   │         │
  YES        NO
   │         │
   ▼         ▼
  返回这个   沿版本链往前
  版本的值   找上一个版本
              │
              ▼
          继续判断,直到找到
          可见的版本为止

六、最后唠两句

回顾一下咱们今天聊的东西:

  1. Java 事务是"嘴",MySQL 事务是"手"。 Spring 借连接、喊口令(commit/rollback),MySQL 负责写日志、加锁、改数据,分工很明确。

  2. MVCC 的本质就是"快照读"。 事务开始时拍一张照片,之后不管外面怎么风吹雨打,你只看照片上的数据。

  3. MVCC 三件套: 隐藏字段是"标签",Undo Log 是"相册"(版本链),ReadView 是"取景框"(决定你看到哪个版本)。

  4. RR vs RC 的区别: RR 拍一次照用到老,RC 每次 SELECT 都重新拍。

其实 MVCC 还有很多细节没展开------比如当前读 vs 快照读的区别、间隙锁(Gap Lock)、以及什么情况下 RR 也会出现幻读......但一篇文章塞太多容易消化不了,咱们下次再聊。

MVCC 进阶:快照读 vs 当前读、幻读与 Next-Key Lock​​​​​​​


如果这篇文章对你有帮助,点个赞收藏一下,别让它在收藏夹里吃灰就行!!!

相关推荐
放弃 治疗1 小时前
Windows 11系统 最新 Launch4j 安装与使用教程:从 JAR 到 EXE 的完整打包指南
java·jar
火星校尉1 小时前
一场数据基建与消费场景的跨界实验
java·前端·数据库·python·php
寻道码路1 小时前
LangChain4j Java AI 应用开发实战(二十六):多模型集成策略 —— OpenAI、DeepSeek、阿里百炼混合使用
java·开发语言·人工智能·ai
面朝大海,春不暖,花不开2 小时前
BPF与eBPF简介:核心概念与观测工具概览
开发语言·php·ebpf·bpf·性能观测
ch.ju2 小时前
Java Programming Chapter 4——Static code block
java·开发语言
risc1234562 小时前
Lucene80DocValuesConsumer 五种类型源码阅读顺序
java·服务器·前端
弹简特2 小时前
【Java项目-企悦抽】04-项目演示+项目源码+AI赋能整理接口文档
java·开发语言
郝学胜-神的一滴2 小时前
Qt 高级编程 034:深耕QWidget底层内核—彻底吃透无边框窗口设计核心原理
开发语言·c++·qt·程序人生·软件开发·用户界面
爱喝热水的呀哈喽2 小时前
hypermesh两个网格参数解析
服务器·数据库·mysql