MQ 异步 + 回调:外部系统集成模式详解

MQ 异步 + 回调:外部系统集成模式详解

一、要解决的核心问题

当你的系统需要和外部系统(第三方服务、下游平台)协作完成一件事时,天然面临三个难题:

  1. 外部系统慢或不稳定:如果同步等待它返回,你的主流程会被拖慢甚至拖垮
  2. 处理是"长流程":对方可能需要人工介入或长时间处理,不可能在一次 HTTP 请求内返回结果
  3. 耦合风险:外部系统宕机不应该导致你的核心业务不可用

"MQ 异步 + 回调"就是解决这类问题的经典组合拳:

  • 用 MQ 异步解决"发起请求"这一侧的解耦与削峰
  • 用回调解决"获取结果"这一侧的长流程异步通知

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、两个独立的概念先讲清

2.1 消息队列(MQ / Message Queue)

生产者把消息投递到队列就立即返回,消费者独立地从队列取消息处理。核心价值:

价值 说明
解耦 生产者不直接依赖消费者,双方只认识队列
异步 生产者不等消费者处理完,投递即返回
削峰 突发流量堆在队列里,消费者按自己节奏消费
容错 消费失败可重试,消费者宕机消息不丢

2.2 回调(Callback / Webhook)

A 系统请求 B 系统做一件耗时的事,B 不在当次请求里返回结果,而是事后主动调用 A 预留的接口把结果推回来。

  • 同步调用:A 问 B「做完了吗」→ B 当场答(请求-响应,一来一回)
  • 回调:A 告诉 B「做完了打我这个电话」→ B 稍后主动打回来(事件通知,两次独立调用)

回调也叫 Webhook(当通知方式是 HTTP 回调时)。

三、组合起来:一次完整的集成链路

以"向外部系统提交一个待处理任务,并接收处理结果"为例,整个链路分两个方向:

复制代码
【发起方向 ------ MQ 异步】
业务系统  --(1)写业务数据--> DB
业务系统  --(2)发MQ-------> 队列
                            |
消费者    <--(3)消费--------+
消费者    --(4)调用外部系统提交任务--> 外部系统
消费者    <--(5)返回任务ID(ack)------ 外部系统   // 只确认"收到了",不是最终结果
消费者    --(6)保存任务ID与业务的映射--> DB

   ...... 外部系统内部处理 / 人工操作,耗时不定 ......

【结果方向 ------ 回调】
外部系统  --(7)处理完,回调--> 业务系统的回调接口
业务系统  --(8)用任务ID反查业务--> DB
业务系统  --(9)处理结果,更新业务状态
业务系统  --(10)返回ack-----> 外部系统

关键点:

  • 第(2)~(5)步用 MQ 异步:业务主流程只管写数据+发消息,不等外部系统,响应飞快
  • 第(6)步保存 任务ID ↔ 业务ID 的映射关系,这是连接"发起"和"回调"两个独立调用的桥梁
  • 第(7)步的 回调 解决"外部处理是长流程、结果不确定何时产生"的问题

四、通用示例代码

4.1 发起侧:业务提交 + 发 MQ

java 复制代码
@Service
public class TaskSubmitService {

    @Resource private TaskRepository taskRepository;
    @Resource private SubmitTaskMqSender mqSender;

    /**
     * 提交任务:只写本地数据 + 发MQ,不同步等外部系统
     */
    @Transactional(rollbackFor = Exception.class)
    public Long submit(TaskRequest request) {
        // 1. 写本地业务数据,状态=待提交
        TaskEntity task = new TaskEntity();
        task.setBizData(request.getData());
        task.setStatus(TaskStatus.PENDING.getCode());
        taskRepository.save(task);

        // 2. 事务提交后再发MQ(关键:避免消息发出但本地事务回滚)
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    mqSender.send(task.getId());
                }
            });

        return task.getId();
    }
}

为什么"事务提交后再发 MQ"? 如果在事务里直接发 MQ,可能出现"消息已发出去、本地事务却回滚了",导致消费者处理一个数据库里不存在的任务。注册 afterCommit 回调能保证消息只在本地数据真正落库后才发出。

4.2 消费侧:调用外部系统 + 保存映射

java 复制代码
@Component
@Slf4j
public class SubmitTaskMqConsumer {

    @Resource private TaskRepository taskRepository;
    @Resource private ExternalSystemClient externalClient;

    @RabbitListener(queues = "task.submit.queue")
    public void consume(Long taskId) {
        log.info("消费提交任务MQ,taskId={}", taskId);
        try {
            TaskEntity task = taskRepository.findById(taskId).orElse(null);
            if (task == null) {
                log.warn("任务不存在,taskId={}", taskId);
                return; // 幂等:消息对应数据不存在,直接丢弃
            }

            // 调用外部系统提交,外部只返回"受理ID",不是最终结果
            ExternalResult result = externalClient.submitTask(task.getBizData());

            // 关键:保存 外部任务ID <-> 本地业务ID 的映射,供回调时反查
            task.setExternalTaskId(result.getExternalTaskId());
            task.setStatus(TaskStatus.SUBMITTED.getCode());
            taskRepository.save(task);

            log.info("提交外部系统成功,taskId={},externalTaskId={}",
                taskId, result.getExternalTaskId());
        } catch (Exception e) {
            // 消费异常:记录日志,交由MQ重试机制(或进死信队列)
            log.warn("提交任务消费异常,taskId={}", taskId, e);
            throw e; // 抛出以触发重试
        }
    }
}

4.3 回调侧:接收外部结果 + 更新业务

java 复制代码
@RestController
@RequestMapping("/api/callback")
@Slf4j
public class TaskCallbackController {

    @Resource private TaskRepository taskRepository;

    /**
     * 外部系统处理完后主动回调此接口
     * 入参格式由外部系统约定(常见form或json)
     */
    @PostMapping("/task-result")
    public ResponseEntity<Boolean> onTaskResult(CallbackParam param) {
        log.info("收到外部回调,externalTaskId={},result={}",
            param.getExternalTaskId(), param.getResult());

        // 1. 校验:关键字段为空直接返回成功(幂等容错,避免对方重试风暴)
        if (param.getExternalTaskId() == null) {
            log.warn("回调externalTaskId为空");
            return ResponseEntity.ok(Boolean.TRUE);
        }

        // 2. 用外部任务ID反查本地业务(发起侧保存的映射在此发挥作用)
        TaskEntity task = taskRepository.findByExternalTaskId(param.getExternalTaskId());
        if (task == null) {
            log.warn("回调未查到本地任务,externalTaskId={}", param.getExternalTaskId());
            return ResponseEntity.ok(Boolean.TRUE); // 幂等:查不到也返回成功
        }

        // 3. 幂等:已处理过的回调不重复处理
        if (TaskStatus.isFinished(task.getStatus())) {
            log.info("任务已是终态,忽略重复回调,taskId={}", task.getId());
            return ResponseEntity.ok(Boolean.TRUE);
        }

        // 4. 根据回调结果更新业务状态
        try {
            task.setStatus(param.isSuccess()
                ? TaskStatus.SUCCESS.getCode()
                : TaskStatus.FAILED.getCode());
            task.setResultMsg(param.getMessage());
            taskRepository.save(task);
            log.info("回调处理完成,taskId={},status={}", task.getId(), task.getStatus());
        } catch (Exception e) {
            log.warn("回调处理异常,externalTaskId={}", param.getExternalTaskId(), e);
            // 返回失败,让外部系统按其重试策略重试
            return ResponseEntity.ok(Boolean.FALSE);
        }

        return ResponseEntity.ok(Boolean.TRUE);
    }
}

五、这个模式里的关键设计点

5.1 任务ID映射(串联两次独立调用的桥梁)

发起时拿到外部系统的 externalTaskId,必须和本地 bizId 建立持久化映射。回调时外部系统只会带 externalTaskId,靠它反查本地业务。没有这个映射,回调回来你不知道它对应哪笔业务。

5.2 幂等性(最重要)

回调可能被重复调用(网络重试、对方故障恢复后补发),所以回调接口必须幂等:

  • 查不到记录 → 返回成功(不报错)
  • 已是终态 → 直接返回成功,不重复处理
  • 用唯一键/状态机防止重复更新

MQ 消费也可能重复,消费侧同样要幂等。

5.3 事务与消息的一致性

  • 事务提交后再发 MQ(如 4.1):避免"消息发了、数据回滚了"
  • 进阶方案:本地消息表 / 事务消息(如 RocketMQ 事务消息)/ Outbox 模式,保证"写业务"和"发消息"的最终一致

5.4 回调接口的安全与返回约定

  • 鉴权:回调接口要校验来源(签名、token、IP 白名单),防止被伪造调用
  • 返回值约定:明确告诉对方"成功/失败",对方据此决定是否重试(如返回 200+true 表示已接收)
  • Content-Type :按外部系统约定(常见 application/x-www-form-urlencoded 或 application/json)

5.5 可观测性

全链路用关键 ID(bizId、externalTaskId)贯穿日志,这样线上能把"发起 → 提交 → 回调"三段独立调用串成一条链路排查。

5.6 兜底与补偿

回调不是 100% 可靠(可能丢失)。生产级方案通常补充:

  • 定时对账任务:扫描长时间停在"已提交未回调"的记录,主动查询外部系统状态
  • 超时处理:超过约定时长未回调的任务标记异常/告警

六、与其他方案的对比

方案 适用场景 优点 缺点
同步调用 外部处理快、结果立即可得 简单直观 外部慢会拖垮主流程,强耦合
轮询(Polling) 外部无回调能力 实现简单 实时性差、空轮询浪费资源
MQ 异步 + 回调 外部处理慢/长流程、要求解耦 解耦、削峰、不阻塞主流程 复杂度高、需处理幂等/对账
长连接推送(SSE/WebSocket) 需要实时推送 实时性强 连接管理复杂,不适合跨系统

七、常见误区

  1. 在事务内直接发 MQ:可能消息发出但事务回滚 → 数据不一致。应事务后发或用事务消息。
  2. 回调接口不做幂等:对方重试导致重复处理(如重复扣款、重复更新)。
  3. 忘记保存任务ID映射:回调回来无法关联本地业务。
  4. 回调完全信任、不鉴权:回调接口被伪造请求攻击。
  5. 只依赖回调、无兜底对账:回调一旦丢失,业务永远停在中间态。
  6. MQ 消费吞异常不重试 :catch 后不抛出也不记录,消息"假装"消费成功,数据丢失。

八、一句话总结

MQ 异步 让你"发起请求"时不被外部系统拖累(解耦、削峰、不阻塞);回调 让你"获取结果"时不必苦等长流程(事件驱动、异步通知)。两者之间靠任务ID映射 串联,靠幂等 + 事务一致性 + 对账兜底保证可靠。它是对接"慢、长、不稳定"外部系统的标准范式。

相关推荐
滕州市燕猫虎计算机科技工作室个体工商户1 个月前
RabbitMQ和RocketMQ
消息队列·mq
阿昌喜欢吃黄桃2 个月前
RocketMQ 同一 JVM 启动多个 Producer 导致 NameServer 只连一个的问题
jvm·rocketmq·mq
阿昌喜欢吃黄桃4 个月前
RocketMq事务消息原理
java·中间件·消息队列·rocketmq·mq
better_liang5 个月前
每日Java面试场景题知识点之-消息队列MQ核心场景与实战
java·面试·kafka·消息队列·rabbitmq·rocketmq·mq
rising start5 个月前
从客户端通信到分布式消息中间件
redis·分布式·kafka·rabbitmq·mq
无籽西瓜a5 个月前
【西瓜带你学Kafka | 第八期】 Kafka的主从同步、消息可靠性、流处理与顺序消费(文含图解)
java·分布式·后端·kafka·消息队列·mq
轻刀快马5 个月前
穿透 MQ 专栏 (一):【架构觉醒】服务器又被打挂了?用 MQ 筑起拦下十万并发的“三峡大坝”
消息队列·mq
无籽西瓜a5 个月前
【西瓜带你学Kafka | 第六期】Kafka 生产确认、消费 API 与分区分配策略(文含图解)
java·分布式·后端·kafka·消息队列·mq
无籽西瓜a5 个月前
【西瓜带你学Kafka | 第七期】Kafka 日志存储体系:保留清理、消息格式与分段刷新策略(文含图解)
java·分布式·后端·kafka·消息队列·mq