Java 后端事务:从 Spring 代理到幂等、状态机和补偿
开头
很多人复习事务时,会先背几个词:
ACID 传播级别 隔离级别 脏读 幻读 分布式事务这些当然要会,但如果只停在概念,面试里很容易被追问打散。
更接近真实业务的理解应该是:
一次业务操作 ├── 中间失败怎么办? -> 事务 ├── 同时有多个请求怎么办? -> 锁 / 条件更新 ├── 重复请求怎么办? -> 幂等 ├── 状态能不能这样变? -> 状态机 ├── 跨服务怎么办? -> 分布式事务 / MQ / Outbox └── 半成功后怎么办? -> 补偿也就是说,事务不是孤立知识点。它经常和锁、幂等、状态机、消息和补偿一起出现。
一、事务到底解决什么问题
事务最核心的作用是:
保证一组数据库操作要么全部成功,要么全部失败。
比如一个下单动作:
创建订单 扣减库存 生成支付记录如果订单创建成功,库存扣减失败,系统状态就不一致了。所以这些数据库操作通常要放在一个本地事务里。
数据库本身提供事务能力:
begin;insertintoorders(...);updatestocksetquantity=quantity-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{Objectresult=target.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(rollbackFor=Exception.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.NESTED
NESTED是保存点,不是完全独立事务。
外层事务 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);第一次插入成功,重复插入违反唯一约束。重要业务要尽量把幂等兜底放在数据库层。
状态机 + 条件更新
支付回调不要无条件更新:
updateorderssetstatus='PAID'whereid=?andstatus='WAIT_PAY';第一次回调影响 1 行,重复回调影响 0 行。
幂等表
MQ 消费常用:
message_consume_record ├── message_id ├── business_id ├── consume_status └── consume_time先记录消息是否处理过,再执行业务。业务执行和消费记录最好放在一个本地事务里。
Token 令牌
适合短周期防重复提交:
获取 token -> 提交时携带 token -> 服务端校验并删除 token乐观锁
updateapprovalsetstatus='PASSED',version=version+1whereid=?andversion=?andstatus='WAIT_AUDIT';更新失败说明数据已经被别人改过。
分布式锁
分布式锁解决的是“同一时间只允许一个请求进入”。它不是完整幂等。
锁过期后,重复请求仍然可能再次进入。所以重要业务还要有唯一键、状态条件更新或幂等表兜底。
八、状态机:控制业务能不能这样变化
很多业务系统本质上都是状态流转。
订单:
WAIT_PAY -> PAID -> COMPLETED审批:
WAIT_AUDIT -> PASSED / REJECTED任务:
WAITING -> RUNNING -> SUCCESS / FAILED状态机解决的是:
当前状态是否允许迁移到目标状态。
它和幂等经常结合在一起:
updateorderssetstatus='PAID'whereid=?andstatus='WAIT_PAY';这条 SQL 同时表达:
只有 WAIT_PAY 才能变成 PAID 重复 PAID 不再执行 并发更新只能有一个成功九、事务、锁、幂等的关系
这三个东西经常被混在一起。
1. 事务
解决:
一次请求内部,多张表操作是否一起提交或回滚。2. 锁
解决:
同一时间,多个请求是否可以同时进入同一资源的关键区。3. 幂等
解决:
同一个业务请求重复执行,是否会重复产生业务副作用。支付例子:
事务:订单和支付记录一起更新 锁:避免同一订单同一时间多次处理 幂等:支付平台重复回调也不会重复发权益 状态机:订单只能从待支付变已支付 补偿:已支付但订单未归并时,后续任务修复十、分布式事务:跨服务后本地事务不够了
单库里:
订单表 库存表 支付表可以用一个本地事务。
拆成服务后:
订单服务 -> order_db 库存服务 -> stock_db 支付服务 -> pay_db一个普通@Transactional管不了三个数据库,也管不了远程服务。
这时有几类方案。
1. XA / 2PC
两阶段提交:
Prepare:所有参与者准备 Commit/Rollback:统一提交或回滚优点是一致性强,缺点是性能和可用性压力大,可能阻塞资源。
2. TCC
Try:预留资源 Confirm:确认 Cancel:取消余额扣减例子:
Try:冻结 100 Confirm:扣除冻结金额 Cancel:释放冻结金额优点是业务可控,缺点是每个业务都要实现三套动作,还要处理幂等、空回滚和悬挂。
3. Saga
Saga 把一个长事务拆成多个本地事务。每一步成功后进入下一步,失败时执行补偿动作。
适合允许最终一致的长流程。
4. MQ 最终一致性
订单和库存例子:
订单服务本地事务 -> 创建订单 -> 写出待发送事件 消息投递 -> 库存服务消费 -> 幂等扣减库存 -> 失败重试或补偿这里真正要保证的是:
- 消息不能丢。
- 消费端必须幂等。
- 失败要重试。
- 长期失败要告警或人工处理。
5. Outbox 本地消息表
Outbox 的思路是:
同一个本地事务里: 1. 修改业务表 2. 写入 outbox 消息表 事务提交后: 3. 后台任务投递消息 4. 投递成功后标记消息状态这样避免“数据库提交了但消息没发出去”。
但投递任务可能重复发送,所以消费者仍然要幂等。
6. Seata
Seata 是常见分布式事务框架,提供 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 半成功 -> 补偿能把这张图讲清楚,再结合订单、支付、库存、任务同步这些通用场景,事务面试就不再是零散背题,而是一个完整的后端一致性模型。