
Java 后端事务从 Spring 代理到幂等、状态机和补偿开头很多人复习事务时会先背几个词ACID 传播级别 隔离级别 脏读 幻读 分布式事务这些当然要会但如果只停在概念面试里很容易被追问打散。更接近真实业务的理解应该是一次业务操作 ├── 中间失败怎么办 - 事务 ├── 同时有多个请求怎么办 - 锁 / 条件更新 ├── 重复请求怎么办 - 幂等 ├── 状态能不能这样变 - 状态机 ├── 跨服务怎么办 - 分布式事务 / MQ / Outbox └── 半成功后怎么办 - 补偿也就是说事务不是孤立知识点。它经常和锁、幂等、状态机、消息和补偿一起出现。一、事务到底解决什么问题事务最核心的作用是保证一组数据库操作要么全部成功要么全部失败。比如一个下单动作创建订单 扣减库存 生成支付记录如果订单创建成功库存扣减失败系统状态就不一致了。所以这些数据库操作通常要放在一个本地事务里。数据库本身提供事务能力begin;insertintoorders(...);updatestocksetquantityquantity-1wheresku_id?;insertintopayment_record(...);commit;Spring 的Transactional不是替代数据库事务而是帮我们管理事务边界避免每个方法都手写begin、commit和rollback。二、ACID 不要只背定义事务有四个经典特性ACID。特性含义例子Atomicity 原子性一个事务里的操作不可分割转账时 A 扣钱和 B 加钱必须一起成功Consistency 一致性事务前后满足业务规则转账前后总金额不变Isolation 隔离性并发事务之间不能产生不可接受的互相干扰两人同时买最后一件商品不能都扣成功Durability 持久性提交后数据持久保存支付提交后服务重启订单仍是已支付面试里比较好的回答方式是事务是一组数据库操作的执行单元保证这些操作满足 ACID。比如下单时订单、库存、支付记录必须保持一致如果中间失败需要整体回滚。三、Spring 事务是怎么实现的Spring 声明式事务的关键是AOP 代理 事务拦截器 数据库事务管理器一次正常调用大概是Controller - Spring 代理对象 - 开启或加入事务 - 调用真实 Service 方法 - 正常返回提交 - 抛出异常按规则回滚可以理解成下面这个伪代码publicObjectinvoke(){beginOrJoinTransaction();try{Objectresulttarget.method();commit();returnresult;}catch(Throwableex){if(shouldRollback(ex)){rollback();}else{commit();}throwex;}}所以Transactional的关键不是“标了就一定生效”而是调用必须经过 Spring 代理。四、Transactional常见失效场景1. 同类内部调用ServicepublicclassOrderService{publicvoidcreateOrder(){saveOrder();}TransactionalpublicvoidsaveOrder(){// insert order}}createOrder()里调用saveOrder()本质是this.saveOrder()没有经过 Spring 代理所以事务逻辑可能不会生效。常见解决方式是拆成两个 Service让调用经过 Spring Bean。2. 异常被吃掉TransactionalpublicvoidcreateOrder(){saveOrder();try{deductStock();}catch(Exceptione){log.error(deduct stock failed,e);}}异常被捕获后方法正常返回Spring 会认为可以提交事务。关键失败不能只打印日志要继续抛出异常或者显式标记回滚。3. 异常类型不匹配Spring 默认对RuntimeException和Error回滚。对于 checked exception如果业务上也要求回滚需要显式配置Transactional(rollbackForException.class)4. 新线程和远程调用Spring 事务上下文通常绑定在线程上。新开的线程不会自动继承外层事务。远程接口、MQ、WebSocket、第三方支付也不属于本地数据库事务。它们需要幂等、消息、重试和补偿设计。五、事务传播级别传播级别解决的问题是一个事务方法调用另一个事务方法时内层方法应该加入外层事务、新开事务、非事务执行还是要求必须有/没有事务Spring 一共有 7 种传播级别传播级别有外部事务时无外部事务时常见用途REQUIRED加入当前事务新建事务默认最常用REQUIRES_NEW挂起外部事务新建事务新建事务独立日志、失败记录NESTED在外部事务内建保存点类似REQUIRED局部回滚SUPPORTS加入当前事务非事务执行可复用查询NOT_SUPPORTED挂起外部事务非事务执行非事务执行非事务长操作MANDATORY加入当前事务抛异常强制由上层提供事务NEVER抛异常非事务执行明确禁止事务1.REQUIRED默认级别有事务就加入没有事务就创建。适合订单创建这类必须一起成功失败的业务。2.REQUIRES_NEW不管外部有没有事务都新建一个独立事务如果外部有事务先挂起外部事务。典型场景订单事务 T1 - 保存订单 - 挂起 T1 - 日志事务 T2 提交 - 恢复 T1 - T1 后续失败回滚这样即使订单失败日志也可能保留下来。但不要滥用。比如订单和库存应该一起成功失败如果把库存扣减写成独立REQUIRES_NEW可能出现库存已扣 订单回滚3.NESTEDNESTED是保存点不是完全独立事务。外层事务 T1 - 保存订单 - 建立 savepoint - 发优惠券失败 - 回滚到 savepoint - 订单继续如果外层事务最终回滚内层保存点里的内容也会跟着回滚。六、事务隔离级别隔离级别解决并发事务之间的可见性问题。三个常见并发现象问题含义脏读读到别人未提交的数据不可重复读同一事务两次读同一行结果不同幻读同一事务两次范围查询记录集合不同四种隔离级别隔离级别说明READ UNCOMMITTED读未提交可能脏读READ COMMITTED读已提交避免脏读REPEATABLE READ可重复读MySQL InnoDB 默认SERIALIZABLE串行化隔离最强并发最低MySQL InnoDB 里要特别注意普通 SELECT - 快照读依赖 MVCC UPDATE / DELETE / SELECT ... FOR UPDATE - 当前读读取最新数据并加锁所以不要简单说“可重复读解决一切幻读”。更严谨的说法是InnoDB 在REPEATABLE READ下通过 MVCC 提供一致性快照对锁定读和更新操作会结合行锁、间隙锁和 next-key lock 控制并发。七、幂等重复执行也不能重复产生副作用幂等的核心是同一个业务请求执行一次和执行多次最终对系统的业务影响一致。重复请求不只来自用户重复点击还可能来自网络超时重试。RPC 重试。支付平台重复回调。MQ 至少一次投递。定时任务重跑。补偿任务重复执行。1. HTTP 方法的幂等性按常见 REST 语义方法常见含义是否天然幂等GET查询是PUT整体替换或确定性更新通常是DELETE删除资源从最终状态看通常是POST创建或提交动作通常不是PATCH部分更新不天然是取决于设计注意这只是语义约定。真正是否幂等还要看服务端怎么实现。2. 常见幂等方案唯一业务号 唯一索引比如支付流水号createuniqueindexuk_payment_noonpayment_record(payment_no);第一次插入成功重复插入违反唯一约束。重要业务要尽量把幂等兜底放在数据库层。状态机 条件更新支付回调不要无条件更新updateorderssetstatusPAIDwhereid?andstatusWAIT_PAY;第一次回调影响 1 行重复回调影响 0 行。幂等表MQ 消费常用message_consume_record ├── message_id ├── business_id ├── consume_status └── consume_time先记录消息是否处理过再执行业务。业务执行和消费记录最好放在一个本地事务里。Token 令牌适合短周期防重复提交获取 token - 提交时携带 token - 服务端校验并删除 token乐观锁updateapprovalsetstatusPASSED,versionversion1whereid?andversion?andstatusWAIT_AUDIT;更新失败说明数据已经被别人改过。分布式锁分布式锁解决的是“同一时间只允许一个请求进入”。它不是完整幂等。锁过期后重复请求仍然可能再次进入。所以重要业务还要有唯一键、状态条件更新或幂等表兜底。八、状态机控制业务能不能这样变化很多业务系统本质上都是状态流转。订单WAIT_PAY - PAID - COMPLETED审批WAIT_AUDIT - PASSED / REJECTED任务WAITING - RUNNING - SUCCESS / FAILED状态机解决的是当前状态是否允许迁移到目标状态。它和幂等经常结合在一起updateorderssetstatusPAIDwhereid?andstatusWAIT_PAY;这条 SQL 同时表达只有 WAIT_PAY 才能变成 PAID 重复 PAID 不再执行 并发更新只能有一个成功九、事务、锁、幂等的关系这三个东西经常被混在一起。1. 事务解决一次请求内部多张表操作是否一起提交或回滚。2. 锁解决同一时间多个请求是否可以同时进入同一资源的关键区。3. 幂等解决同一个业务请求重复执行是否会重复产生业务副作用。支付例子事务订单和支付记录一起更新 锁避免同一订单同一时间多次处理 幂等支付平台重复回调也不会重复发权益 状态机订单只能从待支付变已支付 补偿已支付但订单未归并时后续任务修复十、分布式事务跨服务后本地事务不够了单库里订单表 库存表 支付表可以用一个本地事务。拆成服务后订单服务 - order_db 库存服务 - stock_db 支付服务 - pay_db一个普通Transactional管不了三个数据库也管不了远程服务。这时有几类方案。1. XA / 2PC两阶段提交Prepare所有参与者准备 Commit/Rollback统一提交或回滚优点是一致性强缺点是性能和可用性压力大可能阻塞资源。2. TCCTry预留资源 Confirm确认 Cancel取消余额扣减例子Try冻结 100 Confirm扣除冻结金额 Cancel释放冻结金额优点是业务可控缺点是每个业务都要实现三套动作还要处理幂等、空回滚和悬挂。3. SagaSaga 把一个长事务拆成多个本地事务。每一步成功后进入下一步失败时执行补偿动作。适合允许最终一致的长流程。4. MQ 最终一致性订单和库存例子订单服务本地事务 - 创建订单 - 写出待发送事件 消息投递 - 库存服务消费 - 幂等扣减库存 - 失败重试或补偿这里真正要保证的是消息不能丢。消费端必须幂等。失败要重试。长期失败要告警或人工处理。5. Outbox 本地消息表Outbox 的思路是同一个本地事务里 1. 修改业务表 2. 写入 outbox 消息表 事务提交后 3. 后台任务投递消息 4. 投递成功后标记消息状态这样避免“数据库提交了但消息没发出去”。但投递任务可能重复发送所以消费者仍然要幂等。6. SeataSeata 是常见分布式事务框架提供 AT、TCC、Saga、XA 等模式。面试时可以知道它解决哪类问题但如果项目里没有真实落地不要把它说成生产经验。十一、几个面试综合题1. 下单时如何保证订单和库存一致可以分层回答如果订单和库存在同一个数据库里可以用本地事务包住订单创建和库存扣减。库存扣减不能只先查再改应该使用带库存条件的原子更新例如quantity 1根据影响行数判断是否扣减成功。重复提交要有订单号或请求号幂等。如果订单和库存拆成不同服务普通本地事务就不够了可以使用可靠消息或 Outbox 实现最终一致库存消费端要幂等失败后重试或补偿。2. 支付回调重复通知怎么办推荐回答支付回调要先校验外部流水号、金额和订单状态。状态推进使用条件更新只允许待支付变成已支付。重复回调时影响 0 行不再重复执行业务动作。后续如果还有发权益、订单归并等动作要么在本地事务内完成要么通过消息、Outbox 或定时补偿保证最终一致。3. MQ 消费两次怎么办推荐回答MQ 通常是至少一次投递消费者必须幂等。可以用消息 ID 或业务 ID 建消费记录表先插入消费记录成功才执行业务如果重复消息再次到达发现已处理就直接返回。业务处理和消费状态更新要注意放在同一个本地事务里。4. 为什么用了事务还要锁推荐回答事务解决一次请求内部的提交和回滚锁解决多个请求是否能同时进入同一资源的关键区。比如库存只剩 1 件两个请求同时读到可用事务本身不一定阻止它们都进入扣减逻辑。可以用锁降低并发进入但最终仍要用数据库条件更新和唯一约束兜底。十二、不要这样回答这些说法很容易被面试官追问击穿“加了Transactional就能保证所有一致性。”“Redis 锁就是幂等。”“先查一下有没有有就不插入这样就防重复了。”“REQUIRES_NEW更安全所以所有内部方法都用它。”“MySQL 可重复读就是完全没有幻读。”“用了 MQ 就自然最终一致。”“分布式事务就是上 Seata所有场景都适合。”更好的习惯是先判断问题类型再选择对应方案。十三、结尾事务这块真正要建立的是一张图本地失败 - 事务 并发进入 - 锁 / 原子条件更新 重复执行 - 幂等 状态变化 - 状态机 跨服务 - 分布式事务 / MQ / Outbox 半成功 - 补偿能把这张图讲清楚再结合订单、支付、库存、任务同步这些通用场景事务面试就不再是零散背题而是一个完整的后端一致性模型。