Java 后端事务:从 Spring 代理到幂等、状态机和补偿

Java 后端事务:从 Spring 代理到幂等、状态机和补偿

开头

很多人复习事务时,会先背几个词:

ACID
传播级别
隔离级别
脏读
幻读
分布式事务

这些当然要会,但如果只停在概念,面试里很容易被追问打散。

更接近真实业务的理解应该是:

一次业务操作
├── 中间失败怎么办?       -> 事务
├── 同时有多个请求怎么办? -> 锁 / 条件更新
├── 重复请求怎么办?       -> 幂等
├── 状态能不能这样变?     -> 状态机
├── 跨服务怎么办?         -> 分布式事务 / MQ / Outbox
└── 半成功后怎么办?       -> 补偿

也就是说,事务不是孤立知识点。它经常和锁、幂等、状态机、消息和补偿一起出现。

一、事务到底解决什么问题

事务最核心的作用是:

保证一组数据库操作要么全部成功,要么全部失败。

比如一个下单动作:

创建订单
扣减库存
生成支付记录

如果订单创建成功,库存扣减失败,系统状态就不一致了。所以这些数据库操作通常要放在一个本地事务里。

数据库本身提供事务能力:

begin;

insert into orders(...);
update stock set quantity = quantity - 1 where sku_id = ?;
insert into payment_record(...);

commit;

Spring 的 @Transactional 不是替代数据库事务,而是帮我们管理事务边界,避免每个方法都手写 begincommitrollback

二、ACID 不要只背定义

事务有四个经典特性:ACID。

特性含义例子
Atomicity 原子性一个事务里的操作不可分割转账时 A 扣钱和 B 加钱必须一起成功
Consistency 一致性事务前后满足业务规则转账前后总金额不变
Isolation 隔离性并发事务之间不能产生不可接受的互相干扰两人同时买最后一件商品,不能都扣成功
Durability 持久性提交后数据持久保存支付提交后服务重启,订单仍是已支付

面试里比较好的回答方式是:

事务是一组数据库操作的执行单元,保证这些操作满足 ACID。比如下单时订单、库存、支付记录必须保持一致;如果中间失败,需要整体回滚。

三、Spring 事务是怎么实现的

Spring 声明式事务的关键是:

AOP 代理 + 事务拦截器 + 数据库事务管理器

一次正常调用大概是:

Controller
  -> Spring 代理对象
      -> 开启或加入事务
      -> 调用真实 Service 方法
      -> 正常返回:提交
      -> 抛出异常:按规则回滚

可以理解成下面这个伪代码:

public Object invoke() {
    beginOrJoinTransaction();
    try {
        Object result = target.method();
        commit();
        return result;
    } catch (Throwable ex) {
        if (shouldRollback(ex)) {
            rollback();
        } else {
            commit();
        }
        throw ex;
    }
}

所以 @Transactional 的关键不是“标了就一定生效”,而是调用必须经过 Spring 代理。

四、@Transactional 常见失效场景

1. 同类内部调用

@Service
public class OrderService {
    public void createOrder() {
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // insert order
    }
}

createOrder() 里调用 saveOrder(),本质是 this.saveOrder(),没有经过 Spring 代理,所以事务逻辑可能不会生效。

常见解决方式是拆成两个 Service,让调用经过 Spring Bean。

2. 异常被吃掉

@Transactional
public void createOrder() {
    saveOrder();
    try {
        deductStock();
    } catch (Exception e) {
        log.error("deduct stock failed", e);
    }
}

异常被捕获后方法正常返回,Spring 会认为可以提交事务。关键失败不能只打印日志,要继续抛出异常,或者显式标记回滚。

3. 异常类型不匹配

Spring 默认对 RuntimeExceptionError 回滚。对于 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. 常见幂等方案

唯一业务号 + 唯一索引

比如支付流水号:

create unique index uk_payment_no on payment_record(payment_no);

第一次插入成功,重复插入违反唯一约束。重要业务要尽量把幂等兜底放在数据库层。

状态机 + 条件更新

支付回调不要无条件更新:

update orders
set status = 'PAID'
where id = ?
  and status = 'WAIT_PAY';

第一次回调影响 1 行,重复回调影响 0 行。

幂等表

MQ 消费常用:

message_consume_record
├── message_id
├── business_id
├── consume_status
└── consume_time

先记录消息是否处理过,再执行业务。业务执行和消费记录最好放在一个本地事务里。

Token 令牌

适合短周期防重复提交:

获取 token
-> 提交时携带 token
-> 服务端校验并删除 token
乐观锁
update approval
set status = 'PASSED',
    version = version + 1
where id = ?
  and version = ?
  and status = 'WAIT_AUDIT';

更新失败说明数据已经被别人改过。

分布式锁

分布式锁解决的是“同一时间只允许一个请求进入”。它不是完整幂等。

锁过期后,重复请求仍然可能再次进入。所以重要业务还要有唯一键、状态条件更新或幂等表兜底。

八、状态机:控制业务能不能这样变化

很多业务系统本质上都是状态流转。

订单:

WAIT_PAY -> PAID -> COMPLETED

审批:

WAIT_AUDIT -> PASSED / REJECTED

任务:

WAITING -> RUNNING -> SUCCESS / FAILED

状态机解决的是:

当前状态是否允许迁移到目标状态。

它和幂等经常结合在一起:

update orders
set status = 'PAID'
where id = ?
  and status = '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

半成功
  -> 补偿

能把这张图讲清楚,再结合订单、支付、库存、任务同步这些通用场景,事务面试就不再是零散背题,而是一个完整的后端一致性模型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值