事务是数据库操作中再熟悉不过的功能,日常开发中我们随手写下BEGIN、COMMIT就完成了一次事务提交。但被问到原子性到底靠什么保证、可重复读为什么能防住幻读、MVCC又是在哪一层起作用时,不少人就开始含糊了。这篇文章挑选几个事务中既有意思又容易被忽视的概念,逐一拆解它们的实现原理和实际影响。

原子性:靠日志回滚,而不是靠魔法
很多人以为原子性是数据库天生自带的能力,实际上它的实现依赖一整套日志机制。以InnoDB为例,每当事务修改数据时,引擎并不是直接覆盖磁盘上的旧值,而是先写undo log,把修改前的旧版本记录下来,再执行修改。一旦事务需要回滚,数据库就沿着undo log反向执行,把数据恢复到事务开始之前的状态。
这也解释了一个常见的性能现象:如果一个事务内部修改了海量数据,回滚的代价会非常高,因为每一条修改都要逆操作一遍。MySQL官方文档明确提到,回滚一个大事务的时间可能比执行它更长。所以在业务设计上,尽量把大事务拆成小事务,既是减少锁持有的手段,也是降低回滚成本的策略。
与undo log配套的还有redo log,它记录的是物理层面的修改,用于保证持久性。两者配合,一个负责向前恢复(崩溃后重做已提交的修改),一个负责向后回滚(撤销未提交的修改),共同撑起了ACID中的A和D。
隔离级别:脏读、不可重复读、幻读到底差在哪
隔离级别是事务中最容易混淆的部分,核心是三种读异常。脏读指一个事务读到了另一个事务尚未提交的数据,如果对方回滚,读到的就是从未真实存在过的值。不可重复读指同一个事务内,两次读取同一行数据结果不同,因为别的事务在这期间提交了修改。幻读则是两次执行相同的范围查询,返回的行数不一样,因为别的事务插入或删除了符合条件的记录。
可以用一个简单的实验来观察差异。假设表accounts中有一行id为1的记录,在READ COMMITTED隔离级别下执行:
-- 事务A BEGIN; SELECT balance FROM accounts WHERE id = 1; -- 返回100 -- 事务B(并发执行) BEGIN; UPDATE accounts SET balance = 200 WHERE id = 1; COMMIT; -- 事务A再次读取 SELECT balance FROM accounts WHERE id = 1; -- 返回200,发生不可重复读
如果把隔离级别切换为REPEATABLE READ,第二次读取依然会得到100,因为InnoDB会在事务第一次快照读时建立一个一致性视图,后续的普通SELECT都基于这个视图。但需要注意的是,当前读(比如SELECT ... FOR UPDATE、UPDATE、DELETE)永远读取最新版本,不受快照约束,这也是很多幻读问题的来源。
MySQL默认的REPEATABLE READ通过临键锁(Next-Key Lock)在当前读时锁住记录及其间隙,能在很大程度上防止幻读。而PostgreSQL的REPEATABLE READ虽然名字相同,实际上实现的是可串行化快照隔离,行为细节存在差异,跨数据库迁移时要特别留意。
MVCC:不加锁的并发读是怎么做到的
MVCC(多版本并发控制)是现代数据库几乎标配的机制,它的核心思想是:修改不覆盖旧数据,而是产生新版本,读操作根据自己的视图选择合适的版本,从而实现读写互不阻塞。在InnoDB中,每行记录都隐藏了两个列:DB_TRX_ID表示最后一次修改该行的事务ID,DB_ROLL_PTR指向undo log中的旧版本,通过这个指针可以把整条版本链串起来。
当事务执行快照读时,它会根据Read View中的活跃事务列表判断某个版本是否可见:如果版本的DB_TRX_ID小于Read View中最小的事务ID,说明修改它的事务早已提交,可见;如果大于最大事务ID,说明是未来事务的修改,不可见;落在区间内则要细查活跃列表。不可见就顺着版本链往前找,直到找到第一个可见版本。这套判断逻辑完全不需要加锁,读操作因此可以获得极高的并发度。
MVCC虽然强大,但代价隐藏在undo log的膨胀上。如果一个长事务迟迟不提交,它持有的Read View会阻止旧版本被清理,版本链越拖越长,普通查询也需要遍历更多版本才能找到可见数据,性能随之下降。这也是长事务被公认为数据库性能杀手之一的根本原因。
长事务:看不见的资源黑洞
长事务的问题远不止锁竞争。它会让undo log无法回收,占用大量回滚段空间;会让主从复制的延迟加剧,因为大事务在binlog中是整体传输的;还会拉长备份时间窗口,某些备份工具必须依赖一致性快照点。线上排查时,可以通过information_schema.innodb_trx表查看当前运行的事务及已经执行的时间:
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds,
trx_rows_modified
FROM information_schema.innodb_trx
ORDER BY run_seconds DESC;
对于运行时间异常的事务,结合performance_schema或数据库慢日志可以定位到具体SQL与业务代码位置。治理思路上,一是设置innodb_rollback_on_timeout和合理的语句超时参数,二是从代码层面避免在事务中混入RPC调用、消息发送等耗时操作,三是控制单次事务的数据规模,必要时改用批量分片提交。
总结
事务的几个核心概念环环相扣:undo log支撑原子性与MVCC,Read View决定隔离级别下读到的数据版本,版本链的清理又依赖事务的及时提交。理解这些机制之间的因果关系,遇到锁等待、性能抖动、幻读争议等问题时,才能从原理出发做出准确判断,而不是盲目地加锁或调整隔离级别。日常开发中养成小事务、快提交的习惯,比任何事后调优都更有效。