SpringBoot中事务内同步处理 + 事务后异步调用外部系统的通用模式示例

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

三、完整通用示例

场景假设

用户提交一份报告,系统需要:

  1. 本地保存报告数据
  2. 调用外部"审批平台"创建审批流程
  3. 审批平台返回审批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    - 操作时间                   │
└────────────────────────────────────────────┘

作用:

  1. 可追溯 --- 出问题时可以看到发了什么、收到什么
  2. 支持重试 --- 从日志中取出 request_data 重新调用
  3. 问题定位 --- 是参数错了还是外部系统挂了
  4. 数据恢复 --- 即使状态字段被意外修改,日志表能还原真实历史

八、状态流转图

复制代码
         用户操作                    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的消息投递到同一队列分区

十一、总结

这个模式的核心要点:

  1. 事务内只做本地操作,不调用外部系统
  2. 通过 afterCommit 保证本地写成功后才触发下一步
  3. MQ 解耦,异步调用外部系统
  4. Consumer 独立事务(REQUIRES_NEW) 处理外部调用结果
  5. 中间状态 + 操作日志,支持追踪和重试
  6. 定时补偿 兜底 MQ 丢失的情况

本质思想是将一个需要跨系统的复杂操作拆解为多个本地原子操作,通过消息队列串联,通过状态字段和日志表保证可追溯和可恢复

相关推荐
霸道流氓气质1 小时前
Spring 事务传播机制与 REQUIRES_NEW
java·数据库·spring
BerryS3N2 小时前
Java 后端转型大模型:Demo 能跑不等于能上线
java·人工智能·python·java后端·spring ai·langchain4j·大模型转型
心机之蛙qee2 小时前
Docker Compose 多容器编排
java·docker·容器
秋田君2 小时前
QT_QColorDialog颜色对话框
java·数据库·qt
AskHarries2 小时前
用户埋点怎么设计
后端
Arya_aa2 小时前
jdk动态代理和AOP面向切面编程
spring boot
极客悟道3 小时前
VS Code + JetTUI 开发 Spring Boot项目,教程来了
后端
Yeauty3 小时前
自建 HLS 第一问:fMP4 还是 TS?用 Rust 在进程内把两种都跑出来
开发语言·后端·rust
用户91366296220123 小时前
ViewModelScope 简介与使用
java