资讯中心

MySQL事务实战:从ACID到隔离级别、锁与Spring事务避坑

📅 2026/9/29 16:37:00
MySQL事务实战:从ACID到隔离级别、锁与Spring事务避坑
一提到 MySQL 事务很多同学第一反应就是背 ACID然后面试官再追一句“具体解决了什么问题”人就卡住了。我当年阿里一面就栽在这题上后来复盘才发现事务不是用来背的是用来解决真实业务一致性问题的。花了一周时间把事务相关的实战场景、底层原理、踩坑记录重新梳理了一遍这篇就把“为什么用事务”这件事讲透并且给你 4 个可以直接套进简历和面试回答里的业务场景。1. 事务的本质不是“能用”而是“必须用”很多人对事务的理解停留在“多条 SQL 要么都成功要么都失败”这个说法没错但太浅了。真正的问题是没有事务的时候业务到底会坏成什么样1.1 没有事务的世界是混乱的先看一个最简单的转账场景A 给 B 转 100 元代码里至少两条 SQL——扣 A 的余额加 B 的余额。如果这两条 SQL 之间数据库突然宕机了会发生什么A 的钱扣了B 的钱没到。钱凭空消失了。这不是什么极端情况硬件故障、网络闪断、进程被杀任意一个都能触发。再比如库存扣减用户下单时先检查库存够不够再扣减库存再创建订单。这三步如果中间失败了可能出现库存扣了但订单没创建或者订单创建了但库存没扣。前者导致用户白付款后者导致超卖。这就是事务存在的根本原因它把一组操作变成一个不可分割的原子单元。这组操作要么全部提交成功要么全部回滚数据库里永远不会留下“做了一半”的中间状态。1.2 事务的边界就是业务一致性的边界面试官问“事务具体解决了什么问题”本质上是在考察你能不能识别出业务里的“一致性边界”。什么叫一致性边界就是一组数据之间必须维持某种业务规则。比如账户余额和交易流水必须对得上库存和订单必须对得上主表和从表之间外键关系不能断。这些“必须对得上”的规则就是事务要保护的边界。事务把边界内的所有操作绑定在一起保证要么全部生效要么全部消失。这不是 MySQL 的独门绝技而是所有关系型数据库的底线能力。理解了这一点你才能回答“为什么不用 Redis 存订单 MySQL 存库存”这类问题——因为 Redis 根本没有事务的强一致保证而业务需要。2. 四个必须用事务的经典场景直接套我整理了几个高频业务场景每个都是面试官爱问的也是日常开发真正会用到的。你可以直接用它们的思路回答“事务解决了什么问题”。2.1 场景一下单扣库存超卖的防线电商下单是最经典的事务场景。伪代码大概是这样的BEGIN; SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 业务校验stock 0 UPDATE product SET stock stock - 1 WHERE id 1; INSERT INTO order (user_id, product_id, amount) VALUES (1001, 1, 99.00); COMMIT;这里事务解决的三个具体问题第一防止超卖。如果两个请求同时读到库存为 1都通过了校验都执行了扣减库存就变成 -1 了。加了FOR UPDATE行锁后第二个事务必须等第一个事务提交后才能读库存读到的是 0然后直接返回“库存不足”。第二保证订单和库存数量一致。如果先创建订单、后扣库存扣库存失败时订单已经落库了用户会看到“有订单但没有库存”的怪事。事务让这两步要么都成功要么都失败后台对账永远不用操心“订单数 vs 库存扣减数”对不上。第三失败可回滚。当订单金额校验失败、或者支付回调超时导致订单需要取消时直接ROLLBACK就能把库存加回来不需要写“补偿逻辑”。实际注意点库存扣减不能用SELECT stock THEN UPDATE的“先查后改”方式除非你查询时加了锁否则一定会遇到并发问题。我在项目里见过很多同学写SELECT stock FROM ...不带FOR UPDATE然后做 if 判断压测一上就超卖。正确做法是直接用原子 UPDATEUPDATE product SET stock stock - 1 WHERE id 1 AND stock 0;影响行数为 1 表示扣减成功为 0 表示库存不足。这种方式不需要显式加行锁性能更好也更安全。2.2 场景二转账与资金流水一分钱都不能差转账和账户余额变动是所有金融类系统的命根子。事务在这里解决的不仅是“钱不能丢”还包括“流水不能缺”。BEGIN; UPDATE account SET balance balance - 100 WHERE id A; UPDATE account SET balance balance 100 WHERE id B; INSERT INTO transfer_log (from_id, to_id, amount, status) VALUES (A, B, 100, SUCCESS); COMMIT;事务在这里的核心价值是资金操作与流水记录的同生共死。如果只更新余额不记录流水一旦后续对账发现金额不平你根本无法追溯。如果先记流水再改余额流水可能和实际余额不一致。另一个容易被忽略的点是金额操作必须使用balance balance - 100这种相对更新不能先 SELECT 出来再 UPDATE 绝对值。原因和库存一样并发下两个请求读到同一个余额各自计算后写回就会丢更新。同时要小心余额不能为负数UPDATE account SET balance balance - 100 WHERE id A AND balance 100;影响行数为 0 就说明余额不足这就利用数据库的行锁保证了“余额检查 扣减”是原子的。2.3 场景三并发抢购/限量发放防止身份与数量冲突抢购、优惠券限量领取、活动名额抢占这些场景的特点是同一时刻大量并发请求争抢有限资源。假设一个活动只发放 100 张优惠券用户最多领 1 张。代码逻辑BEGIN; -- 检查用户是否已领取 SELECT COUNT(*) FROM coupon_user WHERE user_id 1001 AND activity_id 888; -- 检查券是否还有剩余 SELECT remaining FROM coupon_activity WHERE id 888 FOR UPDATE; -- 扣减剩余数量 UPDATE coupon_activity SET remaining remaining - 1 WHERE id 888; -- 插入用户领取记录 INSERT INTO coupon_user (user_id, activity_id) VALUES (1001, 888); COMMIT;事务在这里解决的是“唯一性 数量双重约束”的并发一致性问题。如果两个事务同时查到用户没领过、同时插入领取记录就会有人重复领。解决重复领取有两种方式数据库唯一索引兜底给(user_id, activity_id)建唯一索引第二次插入直接报 Duplicate Entry。唯一索引 事务配合在一个事务里先查询再插入配合唯一索引的报错回滚让重复领取的操作回滚掉避免数量被重复扣减。这里要注意依赖“先查后插”在并发下是不可靠的唯一索引才是最后防线事务负责把数量扣减和领取记录绑在一起。2.4 场景四跨表多级写操作主从数据不分离业务里经常有一张主表 多张从表的场景比如创建订单要写订单主表、订单明细表、订单状态流转表、消息推送表。BEGIN; INSERT INTO order_main (order_no, user_id, amount) VALUES (NO-2025-001, 1001, 99.00); INSERT INTO order_detail (order_no, product_id, price, qty) VALUES (NO-2025-001, 1, 99.00, 1); INSERT INTO order_status_log (order_no, status, remark) VALUES (NO-2025-001, CREATED, 下单成功); INSERT INTO notify_task (biz_id, content, status) VALUES (NO-2025-001, 您的订单已创建, PENDING); COMMIT;这个场景的事务价值在于多张表的逻辑关系不可分割。如果订单主表写入成功明细表插入失败你会得到一张没有商品的订单如果状态流转表没写入后续订单追踪坏了如果消息表没写入用户收不到通知客服得手工查。事务把这几张表的写入变成一个整体任何一步失败所有表都不会留下脏数据。实际开发中主从表不一致是最难排查的数据问题之一因为看起来每条数据都像真的但对账就是不平。多表联写时遵守一条原则主表和它的所有从表必须在同一个事务里完成写操作。3. 事务的底层原理隔离级别、锁与日志的配合光知道场景不够面试官还会问原理。你要能从底层解释“事务为什么能实现这些保证”核心是三件事隔离级别控制并发可见性锁控制资源竞争日志控制持久化与回滚。3.1 隔离级别怎么选决定了并发能力MySQL 的 InnoDB 引擎默认隔离级别是Repeatable Read可重复读这也是大家背得最熟的。四种隔离级别对应不同的一致性强度隔离级别脏读不可重复读幻读并发能力Read Uncommitted可能可能可能最高Read Committed避免可能可能高Repeatable Read避免避免可能InnoDB已解决中Serializable避免避免避免最低脏读是事务读到了其他事务未提交的数据如果对方回滚你读到的就是假数据。不可重复读是同一事务里两次读同一行结果不一样因为别的事务提交了修改。幻读是同一事务里两次范围查询行数不一样因为别的事务插入或删除了行。InnoDB 在 Repeatable Read 下通过 MVCC多版本并发控制解决了快照读的幻读问题又通过间隙锁解决了当前读的幻读问题。所以默认级别下脏读、不可重复读、幻读都能被避免同时保持较高的并发。注意一个常见误区不是隔离级别越高越好。Serializable 最安全但所有读写都会串行化并发掉到零。你要根据业务容忍度选级别大部分系统用默认的 RR 就够了读多写少的报表系统甚至可以降到 Read Committed 提升并发。3.2 undo log 和 redo log事务的双保险事务能回滚靠的是undo log事务能持久化靠的是redo log。undo log记录的是“修改前的数据”。事务执行 UPDATE 时先把旧值写入 undo log如果事务回滚就用 undo log 把数据恢复原样。这也是 MVCC 实现的核心——不同事务能看到数据的不同版本底层就是靠 undo log 链。redo log记录的是“修改后的数据”用于崩溃恢复。InnoDB 采用 Write-Ahead LoggingWAL策略先写 redo log再刷数据页。如果数据库在事务提交后、数据落盘前崩溃了重启后可以用 redo log 把已提交的事务恢复出来。这两套日志配合回答了面试官爱问的问题“事务提交了但数据库宕机数据会丢吗”答案是不会因为 redo log 里已经有记录。这同时也是 MySQL 主从复制的基础备库通过同步主库的 binlog 来恢复数据。3.3 锁的粒度与选择影响事务性能的关键InnoDB 的锁分两种共享锁S Lock读锁和排他锁X Lock写锁。读写互斥写写互斥读读共存。行锁是 InnoDB 的默认锁定粒度它还有一个特殊情况间隙锁Gap Lock。在 RR 隔离级别下当你用范围条件查询时InnoDB 不仅锁住命中行还会锁住索引之间的间隙防止其他事务插入新行导致幻读。这里有一个反直觉的知识点即使你的 WHERE 条件没有命中任何行只要走了索引范围扫描也会触发间隙锁把整个范围锁死。一个常见的死锁场景是-- 事务A SELECT * FROM product WHERE id BETWEEN 1 AND 100 FOR UPDATE; -- 事务B INSERT INTO product (id, name) VALUES (50, new);事务 B 插入 id50 的记录时需要获得间隙锁而事务 A 持有 1-100 范围的间隙锁于是 B 等待。如果事务 A 后面还要插入一条记录需要 B 的锁就形成死锁。排查死锁时使用SHOW ENGINE INNODB STATUS查看最近一次死锁信息里面会明确告诉你两个事务各自持有和等待哪些锁。死锁不等于 bug业务里偶尔发生是正常的关键是让事务能快速回滚重试而不是永久阻塞。4. Spring 事务实战注解怎么用才不踩坑日常开发多数用Transactional但很多人只知其然不知道这个注解里藏着多少坑。4.1 Transactional 的失效场景逐一排查第一个大坑是自调用导致事务失效。同一个类里方法 A 调用方法 BB 上有Transactional但事务不会生效。原因是 Spring 事务基于 AOP 动态代理代理对象才能拦截方法并开启事务。自调用是 this.methodB()走的是原始对象不是代理对象事务拦截器根本感知不到。// 错误写法 Service public class OrderService { public void createOrder() { this.deductStock(); // 自调用事务失效 } Transactional public void deductStock() { // 这个事务不会生效 } } // 正确写法1注入自身代理 Autowired private OrderService self; public void createOrder() { self.deductStock(); } // 正确写法2拆到另一个Service第二个大坑是Transactional 只捕获 RuntimeException 和 Error不捕获 checked 异常。如果你的业务方法显式抛出 Exception事务默认不会回滚。解决方法是加 rollbackFor 参数Transactional(rollbackFor Exception.class) public void doSomething() throws Exception { // 业务逻辑 }第三个坑是事务方法必须被 public 修饰。Spring 官方文档明确说明代理只能拦截 public 方法protected、private 上的 Transactional 不会生效。4.2 事务传播行为怎么选Transactional的 propagation 属性定义了事务的传播行为常用的几个传播行为效果使用场景REQUIRED默认如果已有事务则加入没有则新建大多数业务REQUIRES_NEW总是新建事务挂起外层事务独立记录日志NESTED基于 Savepoint 的嵌套事务内层失败只回滚自己批量处理时局部回滚NOT_SUPPORTED以非事务方式执行大查询避免占用连接我踩过最深的一个坑外层事务调用了内层REQUIRES_NEW的方法内层事务提交了但外层之后抛异常回滚内层已经提交的数据不会被回滚。这在一些“先发消息后改状态”的业务里会让数据不一致。使用 REQUIRES_NEW 前一定要确认这个内层操作你希望它独立于外层事务成功吗4.3 大事务是性能杀手所谓大事务就是执行时间长、操作数据量大的事务。比如在一个事务里循环 10 万条数据做更新整个过程占用连接、持有锁其他事务全部等待连接池被打满数据库负载飙升。我见过一个事故某系统凌晨跑批一个事务处理 50 万条数据运行 40 分钟直接把数据库连接池耗尽线上业务全部超时。解决思路是批处理按批次提交每 500 条一个事务事务里不要写远程调用、发消息等耗时操作事务里尽量只做必要的 SQL能挪到事务外的都挪出去。那题“mysql 的事务日志已满”本质上也是大事务的变种。事务日志通常是 undo log 或 redo log被一个长事务占满数据库无法继续分配日志空间会直接报错。遇到这种情况先杀掉或回滚大事务再检查日志文件的增长速度。5. 事务常见问题与排查技巧实录5.1 锁等待超时与死锁怎么区分和应对很多同学遇到Lock wait timeout exceeded默认 50 秒和Deadlock found会慌。这两个完全不同锁等待是事务 A 持锁事务 B 等待A 没结束B 等到超时。通常原因一个事务执行太久没提交另一个事务要修改同一行。排查思路先查information_schema.innodb_trx看看有哪些长事务没提交再查sys.innodb_lock_waits定位具体的阻塞链。死锁是两个事务互相持有对方需要的锁数据库检测到死锁后会让其中一个回滚另一个继续。死锁通常发生在多行更新顺序不一致的场景。比如事务 A 先更新订单再更新库存事务 B 先更新库存再更新订单在并发下就可能死锁。解决办法一是统一多行更新的顺序按主键或固定字段排序后再更新二是让事务尽量短减少锁持有时间三是在业务层捕获死锁异常后重试。5.2 怎么定位一个事务里到底干了什么线上排查事务问题最实用的方式开启 general log 或者用 Performance Schema 查看当前执行的语句-- 查看当前所有事务 SELECT * FROM information_schema.innodb_trx; -- 查看锁等待情况 SELECT * FROM sys.innodb_lock_waits; -- 查看正在执行的SQL SELECT * FROM performance_schema.events_statements_current;这三个查询组合起来基本能回答“谁持有锁”“谁在等锁”“卡了多久”。我在实际排查中80% 的事务问题是长事务和慢 SQL 叠加导致的用这三条语句就能快速定位。5.3 分布式事务别动不动就上面试官很容易追问分布式事务。你要清楚分布式事务不是在本地事务之外另搞一套而是把多个数据源的操作纳入同一个一致性协议。常见的方案有两阶段提交2PC性能差协调者单点实际生产用得少。本地消息表在自己库里建消息表业务操作和消息写入同事务由异步任务投递消息。这是最实用的方案成本低可靠性高。事务消息如 RocketMQ通过 MQ 的半消息机制保证本地事务和消息发送的原子性。TCCTry-Confirm-Cancel性能好、侵入性强适合高并发场景。我的建议是优先考虑能否把数据源合并到同一个库比如把订单和库存放同一个 MySQL 实例用本地事务解决实在拆不开再考虑本地消息表方案它是投入产出比最高的。我在一个小项目里就踩过分布式事务的坑刚开始微服务拆得很细订单服务和库存服务各用一个库订单创建时要调库存服务扣减两边的数据根本没法用本地事务保证一致。后来把两个表合并到同一个库用本地事务 消息表就解决了成本和维护难度都大幅下降。6. 实操总结事务工具包与避坑清单最后把日常开发最有用的一组实操要点集中列一下方便你直接抄作业。6.1 事务编码自查清单[ ] 事务方法必须是 public且不能自调用[ ]Transactional指定rollbackFor Exception.class别用默认值[ ] 事务里的 SQL 不允许远程调用、IO 操作、大循环[ ] 写操作必须用相对更新SET balance balance - 100不要先查后改[ ] 唯一性约束靠数据库唯一索引兜底别依赖业务代码判断[ ] 多行更新统一顺序避免死锁[ ] 批处理按批次提交每批次不要超过 500~1000 条[ ] 事务提交后立即释放连接尽快归还连接池。6.2 面试回答模板如果面试官问“为什么用 MySQL 事务具体解决了什么问题”你可以这样组织回答“事务解决的核心问题是一组业务操作要么全部生效要么全部回滚。具体到场景上第一是订单和库存的一致性防止超卖和半截数据第二是资金操作和流水的绑定防止账实不符第三是并发场景下的资源竞争通过锁和隔离级别保证操作互不干扰第四是多表联写的主从一致性。底层靠 undo log 实现回滚redo log 实现持久化MVCC 和锁实现并发控制隔离级别则是在一致性和性能之间做权衡。”这一套回答既有场景、又有原理、还带权衡比单纯背 ACID 强得多。6.3 小技巧事务里如何优雅处理“业务校验失败”实际开发里事务里经常要抽“余额是否足够”“库存是否充足”这样的校验。我的建议是让数据库来兜底业务规则。比如库存扣减用stock 0条件余额扣减用balance 100条件。条件不满足时 UPDATE 影响行数为 0业务层判断后抛出异常触发回滚。这样做比“先 SELECT 再 if 判断”更安全也减少一次查询和一次锁竞争。我个人在实际操作中最深的一条教训是事务本身不解决业务逻辑问题它只是保证并发下的数据一致性底线。真正决定事务好不好的是你把什么东西放进了事务、用什么条件约束数据。把这一层想清楚所有事务相关的面试题和线上问题你都会有清晰的解题思路。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案