说实话这个问题我被问过不止一次。每次有人来问我 MySQL 事务是怎么回事我都发现大家普遍停留在「ACID 四个特性」这个层面背得挺溜但真要问你 MySQL 底层是怎么实现原子性的怎么保证崩了数据不丢怎么做到多个事务并发跑还互不干扰——很多人就开始含糊了。这篇文章我就把这块彻底说清楚。不搞那些花里胡哨的直接从底层机制讲起生产上遇到过的坑也会顺带提一嘴。先说说事务是什么事务这个概念说白了就是把一组操作捆绑成一个整体要么全部成功要么全部失败不允许中间状态存在。举个最经典的例子转账。A 给 B 转 500 块数据库层面是两步操作A 的账户减 500B 的账户加 500。这两步必须同时成功或者同时失败不然 A 扣了钱 B 没收到或者 B 收到了 A 没扣这都是灾难性的数据错误。这就是事务要解决的核心问题。MySQL 里事务主要是 InnoDB 引擎实现的MyISAM 不支持事务这个要先知道。ACID 大家都背过原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。但背概念没用我们要知道 MySQL 是用什么技术手段实现这四个特性的。原子性→ 靠undo log持久性→ 靠redo log隔离性→ 靠锁 MVCC一致性→ 是上面三个共同作用的结果下面一个一个展开说。Undo Log原子性的保障undo log 翻译过来叫回滚日志。它的核心思想很简单在你修改数据之前先把原来的数据记下来万一事务失败了就拿这个日志把数据恢复回去。你执行了一条INSERTundo log 里就记一条DELETE你执行了UPDATEundo log 里就记一条把数据改回去的UPDATE你执行了DELETEundo log 里就记一条INSERT。这样当事务需要回滚的时候MySQL 就把 undo log 里的操作反向执行一遍数据就回到了事务开始之前的状态。有一点要注意undo log 一定是优先于数据修改落盘的这个顺序不能乱。如果数据先改了undo log 还没写这时候崩了你连回滚的依据都没有了。实际上 undo log 不只是用来回滚它还承担着 MVCC 的职责后面会说到。Redo Log持久性的保障redo log 这块是我觉得 MySQL 设计里最精妙的地方之一。先说问题背景。MySQL 的数据最终是存在磁盘上的但读写操作都是在内存里的 Buffer Pool 里进行的不是每次改完数据都立刻写磁盘。这样做是为了性能磁盘随机 IO 太慢了。但这就带来了一个风险数据在内存里改了还没来得及刷到磁盘MySQL 突然崩了数据就丢了。怎么解决redo log 就是答案。redo log 记录的是数据页的物理修改每次事务提交的时候不需要立刻把数据页刷到磁盘但必须先把 redo log 写到磁盘。redo log 是顺序写的顺序写磁盘的速度比随机写快很多这个性能差距在机械硬盘时代尤其明显。这个机制有个专业名字叫WALWrite-Ahead Logging意思就是先写日志再写数据。MySQL 崩溃重启之后会扫描 redo log把已经提交但还没来得及刷盘的数据重新应用一遍数据就恢复了。这就是为什么事务一旦提交就算服务器崩了数据也不会丢。redo log 有个重要的参数innodb_flush_log_at_trx_commit这个参数控制 redo log 的刷盘策略设置为1每次事务提交都强制刷盘最安全但性能最差设置为2提交时写到操作系统的缓存每秒刷一次盘折中方案设置为0每秒刷一次盘性能最好但崩溃可能丢 1 秒数据生产环境一般金融类业务设置 1对数据安全性要求没那么高的业务可以设置 2。设置 0 风险比较大不建议。-- 查看当前配置SHOWVARIABLESLIKEinnodb_flush_log_at_trx_commit;-- 查看 redo log 相关配置SHOWVARIABLESLIKEinnodb_log%;MVCC隔离性的核心机制这块是整个事务机制里最复杂的也是面试最爱考的。MVCC 全称 Multi-Version Concurrency Control多版本并发控制。它解决的核心问题是怎么让读操作不加锁同时还能保证数据的隔离性传统的做法是读写都加锁读的时候其他人不能写写的时候其他人不能读这样数据是安全了但并发性能很差。MVCC 的思路是给数据维护多个版本不同的事务看到不同版本的数据读操作基本不需要加锁。InnoDB 是怎么实现多版本的InnoDB 在每行数据上隐式地加了几个字段DB_TRX_ID最后一次修改这行数据的事务 IDDB_ROLL_PTR指向 undo log 的指针通过这个可以找到这行数据的历史版本DB_ROW_ID隐式的行 ID每次事务修改一行数据不会直接覆盖原来的数据而是创建一个新版本旧版本通过DB_ROLL_PTR串起来形成一个版本链。举个例子一行数据初始值age 20事务 AID100把它改成了 25事务 BID101又把它改成了 30这行数据就有了三个版本通过版本链串联在一起。Read View 是什么光有版本链还不够还需要一个机制来决定当前事务应该看哪个版本的数据这就是 Read View读视图的作用。Read View 里记录了几个关键信息当前活跃的事务 ID 列表m_ids最小活跃事务 IDmin_trx_id下一个待分配的事务 IDmax_trx_id创建这个 Read View 的事务 ID判断一个数据版本是否对当前事务可见规则大概是这样如果这个版本的DB_TRX_ID小于min_trx_id说明这个版本是在 Read View 创建之前就已经提交的可见如果DB_TRX_ID大于等于max_trx_id说明这个版本是在 Read View 创建之后才开始的事务改的不可见如果在这两者之间就看DB_TRX_ID是不是在活跃事务列表里在的话说明这个事务还没提交不可见不在的话说明已经提交了可见如果当前版本不可见就顺着版本链往前找直到找到一个可见的版本。隔离级别和 MVCC 的关系MySQL 有四个隔离级别读未提交、读已提交、可重复读、串行化。读未提交基本不用 MVCC直接读最新版本啥都不管脏读问题很严重生产上几乎不用。读已提交Read Committed每次执行 SELECT 都重新创建一个 Read View所以每次都能读到其他事务最新提交的数据。这个级别会有不可重复读的问题——同一个事务里两次查询同一行数据结果可能不一样因为中间有其他事务提交了修改。可重复读Repeatable Read这是 MySQL 的默认隔离级别。它在事务第一次执行 SELECT 的时候创建 Read View整个事务期间都复用这个 Read View所以不管其他事务怎么改你每次查到的都是一样的数据。这就是可重复读的含义。来看个具体的场景-- 事务 A 开始STARTTRANSACTION;SELECTageFROMuserWHEREid1;-- 读到 age 20-- 此时事务 B 把 age 改成了 25 并提交-- UPDATE user SET age 25 WHERE id 1; COMMIT;-- 事务 A 再次查询SELECTageFROMuserWHEREid1;-- 在 RR 级别下仍然读到 age 20COMMIT;在可重复读级别下事务 A 两次查询结果是一样的事务 B 的修改对 A 不可见因为 A 的 Read View 是在 B 提交之前创建的。串行化Serializable所有事务串行执行完全不存在并发问题但性能最差基本只在极端场景下用。幻读问题和间隙锁说到这里要提一个经典问题MVCC 能解决幻读吗答案是不能完全解决。幻读是指同一个事务内两次范围查询第二次查到了第一次没有的记录通常是其他事务插入了新数据。MVCC 通过 Read View 可以解决快照读普通 SELECT的幻读问题但如果你用的是当前读SELECT ... FOR UPDATE或者UPDATE、DELETE那就不走 MVCC 了是直接读最新数据这时候幻读就可能出现。InnoDB 解决这个问题用的是间隙锁Gap Lock和Next-Key Lock。Next-Key Lock 行锁 间隙锁它不只锁住符合条件的行还会锁住这些行之间的间隙防止其他事务在这个范围内插入新数据。-- 这条语句会加 Next-Key LockSELECT*FROMuserWHEREageBETWEEN20AND30FORUPDATE;-- 锁住了 age 在 20-30 范围内的所有行以及这个范围内的间隙-- 其他事务无法在这个范围内插入新记录这就是为什么 MySQL 在可重复读级别下能基本解决幻读问题但要注意这是在当前读的场景下靠锁来保证的不是靠 MVCC。事务提交和崩溃恢复的完整流程把前面说的串起来看看一个事务从开始到提交底层到底发生了什么事务开始分配事务 ID执行 SQL 操作修改 Buffer Pool 里的内存数据每次修改先写 undo log记录原始数据用于回滚和 MVCC再把修改记录到 redo log buffer事务提交时把 redo log buffer 里的内容刷到磁盘这步完成事务就算持久化了释放事务持有的锁后台线程异步把 Buffer Pool 里的脏页刷到磁盘数据文件崩溃恢复时扫描 redo log找到已提交但没有刷盘的事务重新应用找到未提交的事务用 undo log 回滚这个设计保证了数据既不会因为崩溃而丢失已提交的数据也不会因为崩溃而保留未提交的数据。生产上几个要注意的坑长事务是大忌。事务越长持有锁的时间就越长其他事务等待的时间就越长系统并发能力就越差。而且长事务会导致 undo log 不能被清理因为可能还有其他事务需要读历史版本undo log 会一直膨胀磁盘空间会被大量占用。我之前处理过一个案例一个业务同学写了个数据迁移脚本在一个事务里处理了几十万条数据跑了将近一个小时这期间 undo log 撑到了几十 GB把磁盘快打满了差点影响整个数据库服务。-- 查看当前活跃事务找出长事务SELECT*FROMinformation_schema.INNODB_TRXORDERBYtrx_started;-- 看看有没有跑了很久的事务SELECTtrx_id,trx_started,trx_state,trx_queryFROMinformation_schema.INNODB_TRXWHERETIMESTAMPDIFF(SECOND,trx_started,NOW())60;不要在事务里做外部调用。比如在事务里调用第三方接口、发消息队列这些操作耗时不可控会导致事务时间变长锁持有时间变长。正确的做法是先提交事务再做外部调用或者用异步的方式处理。隔离级别不是越高越好。串行化虽然安全但并发性能极差。大多数业务用可重复读就够了某些读多写少且对一致性要求不那么严格的场景用读已提交性能更好。autocommit 要注意。MySQL 默认开启自动提交每条 SQL 都是一个独立的事务。如果你用BEGIN或者START TRANSACTION开启了事务记得要COMMIT或者ROLLBACK不然事务会一直挂着。总结MySQL 事务的实现核心就是三个东西undo log、redo log、MVCC。undo log 保证了原子性事务失败可以回滚同时也是 MVCC 多版本数据的存储基础。redo log 保证了持久性通过 WAL 机制事务提交后数据不会因为崩溃而丢失。MVCC 加上锁机制保证了隔离性让并发事务之间互不干扰同时尽量减少锁的使用提升并发性能。这几个机制不是独立运作的它们相互配合共同构成了 InnoDB 事务体系。理解了这些再去看各种隔离级别的行为、幻读的成因、长事务的危害就都能说清楚了。下次面试官再问你 MySQL 事务是怎么实现的希望你不只是背 ACID而是能说出 undo log 怎么保证原子性redo log 为什么能保证持久性MVCC 的 Read View 是怎么判断版本可见性的。这才是真正理解了事务机制。如果觉得这篇文章对你有帮助欢迎点赞转发让更多人看到。我会持续分享生产环境中的运维实战经验MySQL 优化、故障排查、架构设计这些内容后续都会出关注不迷路。个人博客躬行笔记