SpringBoot中事务内同步处理 + 事务后异步调用外部系统的通用模式示例
一、解决的核心问题
在业务系统中经常遇到这样的需求:一个用户操作既要修改本地数据库,又要调用外部系统(HTTP接口、第三方平台等)。
直接在一个事务里做这两件事会产生严重问题:
❌ 错误做法:
开启事务 → 写本地数据库 → 调用外部HTTP接口(耗时2-5秒) → 提交事务
问题1:长事务 --- HTTP超时期间数据库连接和行锁被占用,阻塞其他请求
问题2:数据不一致 --- 外部接口成功了但本地事务提交失败,外部状态无法回退
问题3:数据丢失 --- 外部接口超时/异常导致本地事务回滚,之前的写入全部丢失
二、解决方案:事务提交后异步调用
将流程拆分为两阶段:
阶段1(同步,事务内):校验 + 写库 + 注册事务后回调
阶段2(异步,事务外):MQ消费 → 调用外部系统 → 根据结果更新状态
┌────────────── 阶段1:用户请求处理 ──────────────┐
│ │
│ 开始事务 │
│ → 前置校验(权限、数据完整性等) │
│ → 准备数据(生成编号、组装参数等) │
│ → 写入数据库(状态设为"处理中") │
│ → 注册 afterCommit 回调(发送MQ消息) │
│ 提交事务 │
│ → 触发回调 → MQ消息发出 │
│ │
└────────────────────────────────────────────────────┘
│
(消息队列)
│
▼
┌────────────── 阶段2:异步消费处理 ──────────────┐
│ │
│ MQ Consumer 收到消息 │
│ → 调用外部系统(HTTP/RPC) │
│ → 成功:更新状态为"完成" │
│ → 失败:更新状态为"失败"(可重试) │
│ → 记录操作日志 │
│ │
└────────────────────────────────────────────────────┘
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
三、完整通用示例
场景假设
用户提交一份报告,系统需要:
- 本地保存报告数据
- 调用外部"审批平台"创建审批流程
- 审批平台返回审批ID后,本地关联保存
3.1 实体和枚举
java
// 报告状态枚举
public enum ReportStatus {
DRAFT(0, "草稿"),
SUBMITTING(1, "提交中"), // 已提交但审批平台还没响应
SUBMITTED(2, "已提交"), // 审批平台已确认
SUBMIT_FAILED(3, "提交失败"); // 审批平台调用失败
private Integer code;
private String desc;
}
// 报告实体
@Entity
@Table(name = "report")
public class Report {
@Id
private Integer id;
private String title;
private String content;
private Integer status; // 报告状态
private String approvalId; // 外部审批平台返回的ID
private LocalDateTime submitTime;
}
// 操作日志实体(记录与外部系统的每次交互)
@Entity
@Table(name = "report_submit_log")
public class ReportSubmitLog {
@Id
private Integer id;
private Integer reportId;
private String requestData; // 发送给外部的数据(JSON)
private String responseData; // 外部返回的数据(JSON)
private String transmitFlag; // T=成功, F=失败
private String errorMsg; // 失败原因
private LocalDateTime createTime;
}
3.2 阶段1:Service 层处理用户请求
java
@Service
public class ReportServiceImpl implements ReportService {
@Autowired private ReportRepository reportRepository;
@Autowired private ReportSubmitMqSender reportSubmitMqSender;
/**
* 提交报告(用户直接调用的方法).
* 这个方法在事务内执行,只做本地数据操作。
*/
@Transactional(rollbackFor = Exception.class)
@Override
public Result<Void> submitReport(Integer reportId, Integer userId) {
// ======== 第一步:前置校验 ========
Report report = reportRepository.findById(reportId)
.orElseThrow(() -> new BusinessException("报告不存在"));
if (!ReportStatus.DRAFT.getCode().equals(report.getStatus())) {
throw new BusinessException("只有草稿状态的报告才能提交");
}
// 校验用户权限
if (!userId.equals(report.getCreatorId())) {
throw new BusinessException("只有创建者才能提交");
}
// ======== 第二步:准备数据、修改本地状态 ========
report.setStatus(ReportStatus.SUBMITTING.getCode()); // 设为"提交中"
report.setSubmitTime(LocalDateTime.now());
reportRepository.save(report);
// ======== 第三步:组装外部调用参数 ========
ApprovalCreateParam approvalParam = new ApprovalCreateParam();
approvalParam.setTitle(report.getTitle());
approvalParam.setContent(report.getContent());
approvalParam.setCallbackUrl("http://my-service/api/approval-callback");
// ======== 第四步:注册事务提交后的回调 ========
// 关键:不在事务内发MQ,而是注册"事务提交成功后"的回调
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 这里在事务成功提交后才执行
reportSubmitMqSender.send(approvalParam, reportId);
}
// 如果事务回滚,afterCommit 不会被调用,MQ消息不会发出
}
);
return Result.success();
}
}
3.3 事务后回调的封装工具类
上面直接写匿名类比较繁琐,实际项目中通常封装一个收集器:
java
/**
* 事务后置动作收集器.
* 用于收集需要在事务提交后执行的动作(通常是发送MQ消息).
*/
public class AfterCommitActionCollector implements TransactionSynchronization {
private final List<Runnable> actions = new ArrayList<>();
/**
* 添加一个事务提交后要执行的动作.
*/
public void addAction(Runnable action) {
actions.add(action);
}
@Override
public void afterCommit() {
// 事务提交成功后,依次执行所有注册的动作
for (Runnable action : actions) {
try {
action.run();
} catch (Exception e) {
// 记录日志但不抛异常,因为本地事务已经提交了
log.warn("事务后置动作执行失败", e);
}
}
}
}
使用方式更简洁:
java
// 在事务方法中
AfterCommitActionCollector collector = new AfterCommitActionCollector();
TransactionSynchronizationManager.registerSynchronization(collector);
// 可以注册多个动作
collector.addAction(() -> reportSubmitMqSender.send(approvalParam, reportId));
collector.addAction(() -> notificationMqSender.send(userId, "报告已提交"));
3.4 MQ 生产者
java
@Component
public class ReportSubmitMqSender {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 发送MQ消息.
* 消息体包含调用外部系统所需的参数和业务ID.
*/
public void send(ApprovalCreateParam param, Integer reportId) {
Map<String, Object> message = new HashMap<>();
message.put("param", param);
message.put("reportId", reportId);
log.info("发送报告提交MQ: reportId={}", reportId);
try {
rabbitTemplate.convertAndSend("report.exchange", "report.submit", message);
} catch (Exception e) {
// MQ发送失败的兜底:记录日志,后续由定时任务扫描"提交中"超时的记录来补偿
log.error("报告提交MQ发送失败: reportId={}", reportId, e);
}
}
}
3.5 阶段2:MQ 消费者
java
@Component
public class ReportSubmitMqConsumer {
@Autowired private ReportService reportService;
/**
* 消费MQ消息,调用外部审批平台.
*/
@RabbitListener(queues = "report.submit.queue")
public void consume(Map<String, Object> message) {
ApprovalCreateParam param = (ApprovalCreateParam) message.get("param");
Integer reportId = (Integer) message.get("reportId");
log.info("消费报告提交MQ: reportId={}", reportId);
// 委托给Service层处理(Service中有独立事务)
reportService.callExternalApproval(param, reportId);
}
}
3.6 阶段2:调用外部系统的业务逻辑
java
@Service
public class ReportServiceImpl implements ReportService {
@Autowired private ReportRepository reportRepository;
@Autowired private ReportSubmitLogRepository submitLogRepository;
@Autowired private ApprovalPlatformClient approvalClient; // HTTP客户端
/**
* 调用外部审批平台(MQ消费后执行).
* 使用 REQUIRES_NEW 确保独立事务,不受Consumer框架事务影响.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
@Override
public void callExternalApproval(ApprovalCreateParam param, Integer reportId) {
Report report = reportRepository.findById(reportId).orElse(null);
if (report == null) {
return; // 数据被删除了,直接结束
}
// 创建操作日志(无论成败都要记录)
ReportSubmitLog submitLog = new ReportSubmitLog();
submitLog.setReportId(reportId);
submitLog.setRequestData(JsonUtil.toJson(param));
submitLog.setCreateTime(LocalDateTime.now());
try {
// ======== 调用外部系统 ========
ApprovalCreateResult result = approvalClient.createApproval(param);
if (result.isSuccess()) {
// 成功:更新本地状态
report.setStatus(ReportStatus.SUBMITTED.getCode());
report.setApprovalId(result.getApprovalId());
submitLog.setTransmitFlag("T");
submitLog.setResponseData(JsonUtil.toJson(result));
} else {
// 业务失败:状态回退
report.setStatus(ReportStatus.SUBMIT_FAILED.getCode());
submitLog.setTransmitFlag("F");
submitLog.setErrorMsg(result.getErrorMsg());
}
reportRepository.save(report);
} catch (Exception e) {
// 异常:状态回退,记录错误
report.setStatus(ReportStatus.SUBMIT_FAILED.getCode());
reportRepository.save(report);
submitLog.setTransmitFlag("F");
submitLog.setErrorMsg(e.getMessage());
log.warn("调用审批平台失败: reportId={}", reportId, e);
} finally {
// 无论如何都保存操作日志
submitLogRepository.save(submitLog);
}
}
}
四、为什么用 REQUIRES_NEW
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
MQ Consumer 本身可能有事务上下文(框架自动管理ACK),用 REQUIRES_NEW 开启独立事务的好处:
Consumer 框架事务
│
├── callExternalApproval() 的独立事务
│ → 成功:独立提交
│ → 失败:独立回滚(不影响Consumer的ACK)
│
└── Consumer 正常结束,ACK消息
如果不用 REQUIRES_NEW,业务异常会导致 Consumer 事务回滚 → MQ消息重新入队 → 无限重试。
五、失败重试机制
定时扫描补偿
java
@Scheduled(fixedRate = 300000) // 每5分钟
public void retryFailedSubmissions() {
// 查找"提交中"超过10分钟的记录(可能MQ丢失了)
List<Report> stuckReports = reportRepository
.findByStatusAndSubmitTimeBefore(
ReportStatus.SUBMITTING.getCode(),
LocalDateTime.now().minusMinutes(10));
for (Report report : stuckReports) {
// 重新发送MQ
reportSubmitMqSender.send(buildParam(report), report.getId());
}
}
手动重试接口
java
@GetMapping("/retry-submit")
public Result<Void> retrySubmit(@RequestParam Integer reportId) {
ReportSubmitLog lastLog = submitLogRepository
.findTopByReportIdOrderByCreateTimeDesc(reportId);
if (lastLog != null && "F".equals(lastLog.getTransmitFlag())) {
ApprovalCreateParam param = JsonUtil.fromJson(lastLog.getRequestData(), ...);
callExternalApproval(param, reportId);
}
return Result.success();
}
六、TransactionSynchronizationManager 原理
这是 Spring 提供的事务同步管理器,核心机制:
java
// Spring 事务提交流程(简化)
public void commit() {
// 1. 执行业务SQL
doCommit();
// 2. 提交成功后,调用所有注册的 synchronization.afterCommit()
for (TransactionSynchronization sync : synchronizations) {
sync.afterCommit();
}
// 3. 最终清理
for (TransactionSynchronization sync : synchronizations) {
sync.afterCompletion(STATUS_COMMITTED);
}
}
public void rollback() {
// 回滚时不会调用 afterCommit()
doRollback();
// 只调用 afterCompletion
for (TransactionSynchronization sync : synchronizations) {
sync.afterCompletion(STATUS_ROLLED_BACK);
}
}
关键保证 :afterCommit() 只在事务成功提交后才执行。如果事务回滚,这个方法不会被调用。
可用的生命周期钩子:
| 方法 | 调用时机 |
|---|---|
beforeCommit(boolean readOnly) |
事务提交前(可以抛异常阻止提交) |
afterCommit() |
事务提交成功后 |
beforeCompletion() |
事务完成前(提交或回滚都会调) |
afterCompletion(int status) |
事务完成后,status 区分提交/回滚 |
七、操作日志表的设计意义
每次与外部系统的交互都记录在日志表中:
┌────────────────────────────────────────────┐
│ report_submit_log │
├────────────────────────────────────────────┤
│ id - 主键 │
│ report_id - 关联业务ID │
│ request_data - 发送数据(JSON) │
│ response_data - 返回数据(JSON) │
│ transmit_flag - T成功 / F失败 │
│ error_msg - 失败原因 │
│ create_time - 操作时间 │
└────────────────────────────────────────────┘
作用:
- 可追溯 --- 出问题时可以看到发了什么、收到什么
- 支持重试 --- 从日志中取出 request_data 重新调用
- 问题定位 --- 是参数错了还是外部系统挂了
- 数据恢复 --- 即使状态字段被意外修改,日志表能还原真实历史
八、状态流转图
用户操作 MQ消费后
┌───────────────────┐ ┌──────────────────────────────┐
│ │ │ │
│ DRAFT(草稿) │ │ 调用外部接口成功 │
│ │ │ │ → SUBMITTED(已提交) │
│ │ [提交] │ │ │
│ ▼ │ │ 调用外部接口失败 │
│ SUBMITTING │─MQ─→│ → SUBMIT_FAILED(提交失败) │
│ (提交中) │ │ │
│ │ │ 可重试 → 重新进入消费流程 │
└───────────────────┘ └──────────────────────────────┘
"提交中"是一个过渡状态,表示本地已经处理完毕但外部系统还没响应。这个中间状态的存在让系统能够:
- 阻止用户重复提交(状态不是DRAFT了)
- 识别卡住的记录(定时扫描超时的SUBMITTING)
- 正确显示进度(前端可以展示"处理中")
九、整体时序图
用户 Controller Service(事务) DB MQ Consumer 外部系统
│ │ │ │ │ │ │
│──提交请求──→│ │ │ │ │ │
│ │──调用──→ │ │ │ │ │
│ │ │──校验查询──→│ │ │ │
│ │ │←─返回数据──│ │ │ │
│ │ │──更新状态──→│ │ │ │
│ │ │ (SUBMITTING)│ │ │ │
│ │ │──注册回调──→ (暂存) │ │ │
│ │ │──提交事务──→│ │ │ │
│ │ │ │ │ │ │
│ │ │──afterCommit触发──→ │ │ │
│ │ │ │ 发消息│ │ │
│←─返回成功──│←────────────│ │ │ │ │
│ │ │ │ │──消费──→ │ │
│ │ │ │ │ │──HTTP调用──→│
│ │ │ │ │ │←─返回结果──│
│ │ │ │←─更新状态(SUBMITTED)│ │
│ │ │ │←─保存日志──────────│ │
十、这个模式的适用边界
适用场景
- 本地操作和外部调用需要"最终一致性"(不需要强一致)
- 外部调用耗时较长(>500ms)
- 外部系统可能不稳定,需要重试
- 需要记录操作日志用于审计和排查
不适用场景
- 需要强一致性(本地和外部必须同时成功或同时失败)→ 考虑分布式事务(Saga/TCC)
- 外部调用极快且稳定(<100ms)→ 可以直接在事务内调用
- 不需要异步,用户必须等待外部结果 → 同步调用 + 超时处理
潜在风险及应对
| 风险 | 应对方式 |
|---|---|
| 事务提交成功但MQ发送失败 | 定时任务扫描"卡住"的中间状态记录 |
| MQ消息重复消费 | Consumer 做幂等处理(检查状态是否已变更) |
| 外部系统长时间不可用 | 重试次数限制 + 人工介入接口 |
| 消息顺序问题 | 同一业务ID的消息投递到同一队列分区 |
十一、总结
这个模式的核心要点:
- 事务内只做本地操作,不调用外部系统
- 通过
afterCommit保证本地写成功后才触发下一步 - MQ 解耦,异步调用外部系统
- Consumer 独立事务(REQUIRES_NEW) 处理外部调用结果
- 中间状态 + 操作日志,支持追踪和重试
- 定时补偿 兜底 MQ 丢失的情况
本质思想是将一个需要跨系统的复杂操作拆解为多个本地原子操作,通过消息队列串联,通过状态字段和日志表保证可追溯和可恢复。