拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 判断,压测一上就超卖。正确做法是直接用原子 UPDATE:

UPDATE 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 Logging(WAL)策略:先写 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 插入 id=50 的记录时,需要获得间隙锁,而事务 A 持有 1-100 范围的间隙锁,于是 B 等待。如果事务 A 后面还要插入一条记录,需要 B 的锁,就形成死锁。

排查死锁时,使用SHOW ENGINE INNODB STATUS查看最近一次死锁信息,里面会明确告诉你两个事务各自持有和等待哪些锁。死锁不等于 bug,业务里偶尔发生是正常的,关键是让事务能快速回滚重试,而不是永久阻塞。

4. Spring 事务实战:注解怎么用才不踩坑

日常开发多数用@Transactional,但很多人只知其然,不知道这个注解里藏着多少坑。

4.1 @Transactional 的失效场景,逐一排查

第一个大坑是自调用导致事务失效。同一个类里,方法 A 调用方法 B,B 上有@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 的半消息机制保证本地事务和消息发送的原子性。
  • TCC(Try-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 判断”更安全,也减少一次查询和一次锁竞争。

我个人在实际操作中最深的一条教训是:事务本身不解决业务逻辑问题,它只是保证并发下的数据一致性底线。真正决定事务好不好的,是你把什么东西放进了事务、用什么条件约束数据。把这一层想清楚,所有事务相关的面试题和线上问题,你都会有清晰的解题思路。

返回列表