做后端这几年MySQL事务是被问得最多、也最容易说出半吊子答案的一个话题。面试官问“事务隔离级别”几乎人人都能背出四个级别但一问“可重复读到底怎么实现的为什么MySQL默认用可重复读”很多人就卡住了。再往深一点问“redo log和binlog有什么区别”“Spring事务为什么有时候会静默失效”能讲清楚的人就更少了。这篇文章不是教科书复读而是把我自己踩过的坑、翻过的源码、总结出的实操经验一次性讲清楚适合刚入行想进阶的开发者也适合准备面试、写业务代码时需要理解事务原理的朋友。内容会从底层原理一直延伸到线上排查和分布式事务尽量做到每一步都有据可循。1. 事务到底在解决什么问题1.1 从一次“转账失败”说起先想一个最简单的场景A账户给B账户转账1000元逻辑上需要两步——从A扣1000给B加1000。如果第一步执行完、第二步执行前数据库崩了会发生什么如果没有事务A的钱少了1000B的钱没变这笔账永远对不上。这就是事务存在的根本理由把多个操作打包成一个不可分割的单元要么全部成功要么全部失败不允许出现中间状态。换成技术语言就是ACID四个特性——原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。很多初学者把ACID当八股文背其实每个特性都有对应的底层机制原子性靠undo log实现出错了能回滚持久性靠redo log实现宕机了能恢复隔离性靠锁和MVCC实现并发操作互不干扰一致性是最终目标前三者共同保证数据从一种合法状态流转到另一种合法状态。1.2 被神话的ACID落到MySQL里是什么这里要强调一个容易混淆的点ACID不是MySQL独有的概念但也不是所有存储引擎都支持。MySQL的MyISAM引擎就不支持事务InnoDB才是默认的事务引擎。这个区别在面试中经常出现实际开发中选错表引擎会带来灾难性后果。用InnoDB时事务通过START TRANSACTION开启COMMIT提交ROLLBACK回滚。但底层真正干活的是日志系统、锁系统和版本链机制。理解事务原理本质上是理解这三套机制如何配合。还有一个常见误区认为事务只是“对账用的”。实际上事务直接影响到并发下的数据正确性。比如电商秒杀两个请求同时扣同一个库存没有事务隔离机制会出现超卖。事务不是锦上添花是高并发场景的保命底线。2. 事务的底层四件套redo log、undo log、锁与MVCC2.1 redo log让已提交的事务不丢先思考一个问题一个事务提交后MySQL要保证数据不丢最简单的办法是每次提交都把内存中的修改刷到磁盘数据文件。但这样做性能极差——随机磁盘IO是毫秒级每次提交都刷盘TPS根本上不去。InnoDB的解法是引入redo log重做日志。事务修改数据页时先把修改记录写到redo log buffer再按策略刷到磁盘上的redo log文件。这个日志是顺序写入性能比随机刷数据页高得多。等系统空闲或数据页需要落盘时再把内存中的脏页刷到数据文件。如果MySQL崩溃重启后通过redo log重放恢复已提交事务的修改。这里有一个关键参数innodb_flush_log_at_trx_commit它决定redo log什么时候刷盘取值刷盘时机安全性性能1每次事务提交都刷盘默认值最高最多丢失0条记录最慢0每秒刷一次崩溃时可能丢失最近1秒内的事务最快2每次提交写入OS缓存每秒刷盘操作系统崩溃时可能丢1秒数据MySQL崩溃不丢较快我在实际项目中除了对性能极其敏感且能容忍丢失最近少量数据的场景一律保持默认值1。金融、订单类业务如果随意调成0一旦宕机就是事故。数据安全优先性能其次这是事务场景的铁律。2.2 undo log让未提交的事务能回滚redo log负责“向前恢复”undo log负责“向后回滚”。事务执行过程中每修改一行数据InnoDB都会生成对应的undo log记录修改前的值。举个例子把id1的账户余额从500改成300undo log里会记录“修改前是500”。事务回滚时根据undo log把值改回500事务提交后undo log也不能立即删除因为MVCC还需要它来构建更早版本的快照。undo log还有个容易被忽视的作用它被记录在系统表空间和undo表空间中如果事务特别大、修改的行特别多undo会变得很大影响查询性能。这就是“大事务要拆小”的底层原因之一。2.3 锁与隔离级别的实现原理MySQL有四种隔离级别从宽松到严格依次是读未提交、读已提交、可重复读、串行化。它们对数据正确性的影响差异非常大隔离级别脏读不可重复读幻读实现方式读未提交可能可能可能读不加锁读已提交不可能可能可能读用快照每次生成新ReadView可重复读默认不可能不可能可能但InnoDB通过间隙锁解决读用快照首次生成ReadView串行化不可能不可能不可能所有读都加锁重点说说可重复读为什么是MySQL默认级别。这跟主从复制有关MySQL的binlog日志在早期版本只支持语句级别复制如果使用读已提交会出现主库和从库数据不一致的问题。举例来说一个事务里先DELETE再INSERT同一条记录语句级复制可能导致从库先插入后删除。可重复读的间隙锁机制能避免这类问题从机制上保证主从一致。MVCC多版本并发控制是读已提交和可重复读的核心。每一行数据隐藏了两个字段trx_id最近修改事务ID和roll_pointer指向上一个版本的指针。多个版本通过undo log串成一条版本链。查询时InnoDB根据ReadView判断哪些版本对当前事务可见。读已提交每次SELECT都生成新的ReadView所以能看到其他事务新提交的数据但可能前后两次读到不同值可重复读只在第一次SELECT生成ReadView后续复用同一份视图所以读到的一致。这里有个高频面试点当前读与快照读。普通SELECT是快照读走MVCC不加锁SELECT ... FOR UPDATE、DELETE、UPDATE是当前读走最新版本并加锁。在可重复读级别下快照读不会出现幻读但当前读会。InnoDB通过next-key lock记录锁间隙锁锁住扫描范围防止新记录插入才算真正解决了幻读问题。面试回答时把这两个维度讲清楚基本就过关了。3. 实操一个事务从开启到提交的完整过程3.1 事务的三种开启方式与选择建议在MySQL命令行或客户端工具中开启事务的方式有三种-- 方式一显式开启 START TRANSACTION; UPDATE t_account SET balance balance - 1000 WHERE id 1; UPDATE t_account SET balance balance 1000 WHERE id 2; COMMIT; -- 方式二关闭自动提交会话级 SET autocommit 0; -- 之后所有SQL都处于事务中直到手动COMMIT或ROLLBACK -- 方式三通过JDBC/SqlSession编程式控制 -- Java代码中用 connection.setAutoCommit(false)结束时 commit/rollback我的建议是尽量使用显式START TRANSACTION避免长期打开autocommit0。原因很现实autocommit0时如果开发者忘记COMMIT事务会一直挂着连接池里的连接被长期占用最终拖垮数据库连接数。线上很多“连接数爆满”的事故就是长事务积累出来的。3.2 那些会导致隐式提交的“坑”这是事务实操中我最想强调的部分。MySQL中有些SQL执行后会隐式提交当前事务也就是你前面做的所有操作立即生效无法回滚。常见的有DDL语句CREATE TABLE、ALTER TABLE、DROP TABLE等管理语句SET autocommit 1、LOCK TABLES、UNLOCK TABLES部分GRANT、REVOKE语句。注意事务里混入DDL是极其危险的操作。比如你先更新了业务数据再执行一个ALTER TABLE加索引前面的更新会自动提交后面如果出错回滚也不会把前面的更新撤销。我在一次版本变更中遇到过这种情况导致线上数据残缺只能通过备份恢复。从那以后我给自己定了一条规矩事务中绝不出现DDL所有表结构变更单独执行、单独审查。另一个隐蔽的坑是SELECT ... FOR UPDATE之后忘记COMMIT或者程序抛异常但没走回滚逻辑。这会导致行锁一直持有其他线程更新同一行时无限阻塞。排查时先看information_schema.innodb_trx和performance_schema.data_locks能找到长时间未提交的事务。3.3 结合代码演示订单与库存的扣减逻辑用一个电商场景演示完整事务操作用户下单后创建订单记录同时扣减库存。这两步必须原子执行。START TRANSACTION; -- 1. 创建订单省略订单明细 INSERT INTO t_order (order_no, user_id, goods_id, amount, status) VALUES (20240615001, 1001, 88, 299.00, 0); -- 2. 扣减库存只允许库存充足时扣减防止超卖 UPDATE t_goods_stock SET stock stock - 1, version version 1 WHERE goods_id 88 AND stock 0; -- 3. 检查受影响行数如果为0说明库存不足必须回滚 SELECT ROW_COUNT(); COMMIT;这里最关键的技巧是用库存大于0作为更新条件配合当前读加锁从机制上避免超卖。如果仔细分析过UPDATE ... WHERE stock 0的加锁行为会发现它同时加了记录锁和间隙锁两个并发事务同时扣减同一商品库存时后者会等待前者的锁释放然后在最新版本上执行条件判断。这比“先查后改”的编程方式安全得多。对应的Java代码Spring MyBatis大概是这样的Transactional(rollbackFor Exception.class) public void createOrderAndDeductStock(OrderDTO dto) { // 1. 插入订单 orderMapper.insert(buildOrder(dto)); // 2. 扣减库存返回受影响行数 int affectedRows goodsStockMapper.deductStock(dto.getGoodsId(), dto.getQuantity()); if (affectedRows 0) { throw new BizException(库存不足); } }注意Transactional的rollbackFor一定要设置为Exception.class。默认只对RuntimeException回滚如果业务代码抛出的是自定义受检异常事务不会自动回滚数据就停留在中间状态。这个细节在Spring面试题里出现频率极高也是实际开发中很容易踩的坑。4. 事务失效与性能问题排查实录4.1 Spring事务失效的六种经典场景Spring事务用起来方便但失效场景特别多。我把这些年见过的案例整理成一份速查表每一条都是线上真实遇到过的失效场景原因解决方案方法内部自调用类内部直接调用不走代理Transactional不生效注入自身代理对象或拆分到另一个Bean非public方法Spring AOP只拦截public方法改成public事务方法必须有外部调用入口异常被吞掉catch异常后不抛出事务不感知记录日志后继续抛出或手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()rollbackFor未配置抛受检异常时不触发回滚显式Transactional(rollbackFor Exception.class)数据库引擎不支持MyISAM表不参与事务统一使用InnoDB检查表引擎事务方法在子线程中调用Spring事务绑定当前线程新线程拿不到上下文使用其他同步方案避免在子线程开启新事务这里面第1条“自调用”最难排查因为代码看起来完全正常。Spring事务基于动态代理实现事务逻辑被织入代理对象。同一个类里的方法直接互调调用的是原始对象代理逻辑不会执行。有一次排查一个“明明加了Transactional却不回滚”的案例最后发现是方法链内部直接调用了同类的方法。这种问题用肉眼很难发现建议在代码评审阶段就特别关注。4.2 大事务为什么不建议使用把很多操作放在一个事务里听起来很省事实际是性能杀手。大事务会导致锁持有时间长并发冲突概率急剧上升undo log膨胀回滚耗时极长甚至拖垮整个实例主从复制延迟从库要等待大事务整体的binlog回放完成连接池连接被长时间占用应用线程被阻塞。举个我经历过的例子某个批量导入功能把几千条数据放在一个事务里逐行插入。线上跑了几分钟后数据库的History list length飙升到几百万主从延迟超过十分钟。后来把事务拆成每100条提交一次延迟立刻降下来负载也恢复正常。拆分事务时要注意业务语义。对强一致要求高的场景不能盲目拆比如资金转账必须一个事务。但像批量导入、定时任务这种中间结果可接受的场景就应该拆小。拆事务的原则是只保证必要的原子性不为用不上的原子性付出性能代价。4.3 长事务定位与解决如果怀疑系统中有长事务可以通过下面几条SQL快速定位-- 查看当前运行中的事务 SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_query FROM information_schema.innodb_trx; -- 查看事务锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看持有锁的连接对应SQL配合processlist SELECT * FROM performance_schema.data_locks;定位到长事务后先确认当前连接在干什么是程序忘了提交还是事务里执行了慢SQL又或是锁等待导致的事务悬挂。线上处理时不要直接KILL进程除非确认是异常事务。如果是正常业务但耗时太长说明SQL或事务边界需要优化比如减少事务中SELECT范围、在事务外预查数据等。5. 从单机事务到分布式事务延伸思路5.1 为什么会有分布式事务单库单表时事务在同一个数据库内就能完成。但互联网业务发展到一定规模订单、支付、库存往往分布在不同的微服务中甚至在不同的数据库实例上。比如前面讲的订单与库存案例在微服务架构下订单服务写订单库库存服务写库存库本地事务无法保证跨库的原子性。这时就出现了分布式事务的经典难题怎么让多个数据源的操作保持最终一致常见的方案有可靠消息最终一致性、TCCTry-Confirm-Cancel、两阶段提交协议2PC、三阶段提交协议3PC等。核心思路都是用一个协调者统一管理多个参与者的提交或回滚。5.2 常用方案对比方案核心思路优点缺点适用场景2PC协调者先让所有参与者做准备全部成功后才能提交否则全局回滚强一致实现简单协调者单点性能受限于最慢参与者事务阻塞时间长并发量低、强一致要求高的内部系统TCC每个操作拆成Try-Confirm-Cancel三步业务层自行实现无需数据库支持XA灵活性高实现复杂每个操作都要写三段逻辑高并发业务如支付、积分可靠消息最终一致性本地事务消息表生产者保证消息发出消费者保证消息处理最终一致吞吐量高存在短暂不一致窗口需要补偿机制高频、可容忍短暂不一致的订单通知类场景Seata AT模式框架自动生成快照和回滚日志业务无侵入对业务代码侵入小像使用本地事务性能损耗较大依赖Seata服务端中小规模微服务统一技术栈团队我在工作中用过Seata AT模式和可靠消息方案。一个很真实的体会是分布式事务方案没有银弹。2PC虽然强一致但性能天花板太低TCC实现成本高但能精确控制并发和补偿消息最终一致性吞吐最好但需要业务容忍短时不一致。选择时先想清楚业务到底能不能容忍最终一致而不是一上来就追求“所有数据库全局一致性”。关于Seata AT模式有一个值得提的底层细节它会在业务SQL执行前后生成undo快照事务提交时异步删除回滚时利用快照恢复。这种设计对业务代码侵入小但会额外增加SQL执行开销。线上高并发场景要关注Seata分支事务的注册耗时和全局锁等待时间必要时对热点数据做预热或改用TCC为热点资源提供更细的控制。5.3 分布式事务的最终一致性补偿无论选择哪种方案最终一致性的前提是业务要具备可补偿性。以订单与库存为例订单创建成功、库存扣减失败时需要回退订单状态订单支付超时需要尝试取消订单并释放库存。失败重试、对账、人工处理通道都是分布式事务不可忽略的组成部分。我习惯为每个涉及分布式事务的核心状态机画一张状态流转表标明每个状态既能转去哪、失败后怎么补然后对这些异常分支编写单独的补偿逻辑。线上稳定性提升很大一部分来自这些“看不到的补丁代码”而不是那几行主流程逻辑。在实际维护中我还会记录事务号或业务单号到日志中方便对账定位。分布式事务坑深没有日志和链路追踪辅助出了问题就像大海捞针排查起来极为困难。6. 面试与开发中的高频追问6.1 回答事务原理类问题的正确姿势面试问事务原理最忌讳直接背八股文“ACID四个特性……”。有经验的面试官想听的是层级递进的逻辑第一层——事务的语义原子提交、失败回滚。 第二层——为什么能原子提交undo log支持回滚redo log支持崩溃恢复。 第三层——并发下如何隔离MVCC快照读、锁的当前读。 第四层——为什么MySQL默认可重复读配合主从复制保证一致性。 第五层——实战中什么情况会失效Spring代理失效、隐式提交、引擎不支持等。把这条线讲顺了面试官基本能判断“这人是真的用过事务”。每层展开一两句话就够了关键是逻辑连贯。比如说到redo log时顺带提一下innodb_flush_log_at_trx_commit1的作用再补充一句“线上我一般不改这个参数”面试官会觉得接地气。6.2 最容易翻车的三个细节问题以下三个问题是我见过的面试翻车重灾区一是“commit之后数据一定已经写到磁盘了吗”答案是否定的。redo log保证的是崩溃可恢复但如果磁盘本身损坏一切日志都救不了。所以备份和双机热备才这么重要。二是“可重复读能完全避免幻读吗”正确答案是快照读能当前读不能InnoDB用next-key lock辅助解决当前读下的幻读。一字之差天壤之别回答时一定要把快照读和当前读分开讲。三是“binlog和redo log有什么区别”redo log是InnoDB存储引擎层的物理日志循环写记录页的修改binlog是MySQL Server层的逻辑日志追加式记录SQL语句或行数据变化用于主从复制和数据恢复。两者配合时还有两阶段提交机制先写redo logprepare状态再写binlog最后把redo log改成commit状态保证两份日志在崩溃时仍然一致。这个机制我在排查主从数据不一致时反复理解过理解了它才算真正串起MySQL日志体系。7. 把事务原理落到日常开发中的几点体会最后分享几个我这几年的实操感悟都是拿线上事故换来的。第一事务要短平快。凡是能在事务里少执行的语句尽量放到事务外。事务开始前先查询、事务结束后再处理耗时操作这样锁持有时间最短隔离级别带来的开销也最低。原则很简单把事务当作一种“只保留必要等待”的资源。第二配置Transactional时永远显式指定rollbackFor。哪怕你觉得团队约定好了一律抛出RuntimeException等某天有人抛了一个受检异常你就知道这个默认值有多坑了。第三不管用什么框架都要理解背后的代理机制。Spring事务失效问题排查难度极高耗费的时间远超写代码的时间。团队内最好约定事务方法放在Service层不跨类调用保持自调用链路的简单。第四线上多观察information_schema.innodb_trx和慢查询日志。事务性能问题很少直接报错大多是隐性地拉高延迟、降低TPS。有监控指标在手才能及时发现长事务、锁等待这些隐患。事务原理这块内容值得反复咀嚼。第一遍看是概念第二遍看是机制第三遍看是方法论。真正用起来之后你写出来的SQL会和以前不太一样——每一条UPDATE都会本能地想想锁范围每一次事务提交都会算算持锁时间这大概就是“进阶”的感觉。