Skip to content

两个事务同时改数据,凭什么不互相干扰?—— 事务与 MVCC ​

属于 S1 MySQL 深入 · 深入篇第二篇 上一篇:存储引擎与 B+ 树 下一篇:索引与 SQL 优化

想象一个转账场景:账户 A 给账户 B 转 100 块,要"从 A 扣 100、给 B 加 100"两步。如果执行到一半,另一个事务也来查 A 的余额,看到了"已经扣了 100 但 B 还没加"的中间状态,业务就错了。数据库要解决的核心问题,就是让并发执行的多个事务看起来像是"串行执行"的一样,互不干扰——这就是"隔离性"。

先说清楚目标。事务有四个特性(ACID):原子性、一致性、隔离性、持久性。这四个目标不是凭空实现的,InnoDB 用三样东西来支撑它们:undo log 管回滚(原子性),redo log 管崩溃不丢(持久性),锁 + MVCC 管并发互不干扰(隔离性)。上一篇讲过 redo 了,这一篇聚焦"隔离"这件事。

隔离到什么程度:四种隔离级别 ​

两个事务并发,可能出三种乱子:

  • 脏读:读到了别人还没提交的数据(人家可能回滚,你读到的是废数据)。
  • 不可重复读:同一事务内两次读同一行,值变了(别人 UPDATE 提交了)。
  • 幻读:同一事务内两次范围查询,行数变了(别人 INSERT 提交了)。

注意区分后两者:不可重复读是"一行内容变了",幻读是"多出来一行",一个对应 UPDATE,一个对应 INSERT。

针对这些乱子,SQL 标准定义了四种隔离级别,从松到严:

隔离级别脏读不可重复读幻读
读未提交有有有
读已提交(RC)无有有
可重复读(RR,MySQL 默认)无无部分解决
串行化无无无

隔离越严,并发性能越差,所以数据库要在"正确"和"快"之间取舍。MySQL 默认选了 RR,并额外用锁把幻读也堵得差不多。

不靠锁,怎么读到一致快照:MVCC ​

一个朴素的想法是:要隔离,加锁不就行了?读的时候加锁,别人就改不了。但这样读写会互相阻塞,吞吐很差。

MySQL 的思路更聪明:让读操作根本不用加锁,而是去读"历史版本"。这就是 MVCC(多版本并发控制)。

实现上,InnoDB 的每一行数据都藏着两个隐藏列:DB_TRX_ID(最后修改这行的事务 ID)和 DB_ROLL_PTR(指向上一个版本的指针)。每次修改不覆盖原数据,而是写一个新版本,用指针把新旧版本串成一条版本链:

text
当前版本(trx_id=100)
    │ roll_ptr
    ▼
上一版本(trx_id=90)
    │ roll_ptr
    ▼
更早版本(trx_id=80)

读的时候,事务手里有一个 Read View,它记录"此刻哪些事务还没提交"。拿着它去版本链上找:哪个版本的修改者已经提交了,就读哪个版本。这样一来,读操作全程无锁,却能读到一份一致的历史快照。

关键的区别在 Read View 的生成时机:

  • RC(读已提交):每次查询都新建 Read View → 能看到别的事务新提交的数据 → 所以会"不可重复读"。
  • RR(可重复读):第一次查询时生成,之后整个事务复用 → 永远看同一份快照 → 所以"可重复读"。

一句话记住:RC 每次读都刷新视角,RR 从头到尾戴同一副眼镜。

MVCC 有边界:快照读 vs 当前读 ​

到这里你可能想问:MVCC 这么完美,RR 下是不是就没幻读了?不完全是。

普通 SELECT 走的是 MVCC,读历史快照,不加锁,这叫快照读,它在 RR 下确实看不到新插入的行,天然免疫幻读。但 UPDATE、DELETE、SELECT ... FOR UPDATE 这种操作,必须对"最新数据"动手,这叫当前读,它会加锁。当前读锁住的只是已经存在的行,堵不住别人"往间隙里插新行"——于是 RR 下,当前读仍可能幻读。

InnoDB 的补丁就是 next-key lock(临键锁):它不只锁记录本身,还锁住记录前面的"间隙",让 INSERT 进不来。所以 RR 的幻读是"快照读靠 MVCC 解决,当前读靠 next-key lock 解决",两条腿走路。

锁的代价:死锁 ​

锁带来正确性,也带来代价——死锁。两个事务各自拿着对方想要的锁,互相等待,形成环。InnoDB 用 wait-for graph(等待图) 检测这种环,发现死锁后回滚其中一个事务(通常挑回滚成本小的),并在日志里报 Deadlock found。

工程上的经验是:按固定顺序访问资源、事务尽量短、更新时命中索引缩小锁范围,能显著减少死锁。


串起来 ​

回到开头的转账:两个事务同时操作,InnoDB 用隔离级别划定"能忍多大干扰",用 MVCC 让读操作无锁地读到一致快照(RC/RR 的差别在 Read View 时机),当前读靠 next-key lock 补上幻读的漏洞,而死锁是这套锁机制的代价,靠等待图检测 + 回滚一方来兜底。

下一篇进入索引与 SQL 优化:索引建了就一定快吗?为什么有些索引建了还是全表扫描,EXPLAIN 到底在看什么?

持续学习,持续构建。