事务基础回顾:从ACID到MVCC,那些年我们踩过的坑

各位同学,咱们今天聊点基础但极其重要的东西——事务。你可能觉得这玩意儿太简单了,不就是ACID嘛。但说实话,我在金融系统里摸爬滚打这么多年,发现很多线上事故的根源,恰恰是这些“基础”没吃透。

好,咱们开始。今天的内容,我打算这么展开:先聊聊本地事务的ACID到底在说什么,然后看看MySQL的事务隔离级别怎么选,接着深入MVCC的原理,最后说说事务并发到底会带来哪些问题。

1. ACID:不只是四个字母

ACID这四个字母,背下来很容易。但你真的理解它们在实际系统中意味着什么吗?

特性 含义 我踩过的坑
原子性(Atomicity) 事务要么全做,要么全不做 曾经有个同事在转账逻辑里,扣款成功但加款失败,结果钱凭空消失了……
一致性(Consistency) 事务前后,数据约束不被破坏 账户余额不能为负,这个约束如果没在事务里保证,后果你懂的
隔离性(Isolation) 并发事务互不干扰 这个最头疼,后面细说
持久性(Durability) 事务提交后,数据永久保存 别以为刷盘就万事大吉,主从延迟了解一下

我个人习惯把ACID理解成一句话:事务就是让数据库从一个一致状态,变到另一个一致状态,中间不管出什么幺蛾子,都不能留下烂摊子。

核心要点:原子性是手段,一致性是目的,隔离性是并发时的保障,持久性是最后的兜底。

2. MySQL事务隔离级别:选错了真要命

MySQL的InnoDB引擎支持四种隔离级别。我建议你记住这张表:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED ✅ 可能 ✅ 可能 ✅ 可能
READ COMMITTED ❌ 不会 ✅ 可能 ✅ 可能
REPEATABLE READ ❌ 不会 ❌ 不会 ✅ 可能(InnoDB通过间隙锁解决)
SERIALIZABLE ❌ 不会 ❌ 不会 ❌ 不会

嗯,这里要注意。MySQL默认是REPEATABLE READ,但很多互联网公司会改成READ COMMITTED。为什么?

我在项目中遇到过这样一个场景:一个统计报表查询跑了5秒,期间有新的订单插入。如果用REPEATABLE READ,这个查询看到的是事务开始时的快照,新订单看不到。但业务方说“我要看到最新的数据啊”。最后我们改成了READ COMMITTED,每次查询都读最新已提交的数据。

我的建议:金融交易类系统,如果对一致性要求极高,用REPEATABLE READ甚至SERIALIZABLE。但要做好性能下降的心理准备。如果追求吞吐量,READ COMMITTED通常够用。

3. MVCC原理与快照读:InnoDB的杀手锏

MVCC,全称多版本并发控制。说白了,就是每个事务看到的数据版本可能不一样

它是怎么实现的?核心靠三个东西:

  • 隐藏字段:每行数据都有DB_TRX_ID(事务ID)、DB_ROLL_PTR(回滚指针)
  • Undo Log:记录数据的历史版本
  • Read View:事务启动时生成的一个“快照”,决定你能看到哪些版本

我画了一张图,帮你理解MVCC的核心逻辑:

MVCC 快照读核心流程 事务A(TRX_ID=100) 事务B(TRX_ID=200) 数据行 DB_TRX_ID=150 Undo Log 版本链 v1: TRX_ID=50 → v2: TRX_ID=100 → v3: TRX_ID=150 (回滚指针串联) 生成Read View 生成Read View 回滚指针 图例说明: • 每个事务启动时生成Read View,记录当前活跃事务列表 • 读取数据时,通过DB_TRX_ID和Read View判断哪个版本可见

快照读,就是普通的SELECT语句(不加FOR UPDATE或LOCK IN SHARE MODE)。它读的是事务开始时的快照,不会加锁。所以性能很好。

但要注意:快照读不是万能的。如果你需要“当前读”(比如SELECT ... FOR UPDATE),MVCC就帮不了你了,得靠锁。

我曾经踩过的坑:在一个高并发扣库存的场景里,我用了快照读来判断库存是否足够。结果两个事务同时读到库存为1,都以为够扣,最后库存变成了-1。嗯,从那以后,扣库存我必用SELECT ... FOR UPDATE。

4. 事务并发带来的问题:四个经典场景

事务并发,说白了就是多个事务同时操作同一份数据。这时候问题就来了:

  1. 脏读:读到别人还没提交的数据。万一人家回滚了,你读到的就是“脏”的。
  2. 不可重复读:同一个事务里,两次读同一行数据,结果不一样。因为中间被别的事务修改并提交了。
  3. 幻读:同一个事务里,两次查询同一范围的数据,结果行数不一样。因为中间有别的事务插入了新行。
  4. 丢失更新:两个事务同时读同一行数据,各自修改后提交,后提交的覆盖了先提交的。

你想想看,在交易系统里,这些问题哪个不是致命的?

举个例子:一个账户余额1000元。事务A要扣500,事务B要扣600。如果发生丢失更新,可能两个事务都读到1000,各自扣完提交,最后余额变成500或400,但实际应该扣1100,余额应为-100(如果允许透支的话)。

解决方案:

  • 脏读 → 隔离级别至少READ COMMITTED
  • 不可重复读 → 隔离级别至少REPEATABLE READ
  • 幻读 → REPEATABLE READ + 间隙锁,或SERIALIZABLE
  • 丢失更新 → 乐观锁(版本号)或悲观锁(SELECT ... FOR UPDATE)

好了,今天的基础就聊到这儿。这些概念虽然基础,但它们是后面所有高级话题的基石。下一章我们会深入分布式事务,到时候你会发现,今天讲的这些本地事务知识,全都能用上。


专注资料整理