MQ 异步 + 回调:外部系统集成模式详解
一、要解决的核心问题
当你的系统需要和外部系统(第三方服务、下游平台)协作完成一件事时,天然面临三个难题:
- 外部系统慢或不稳定:如果同步等待它返回,你的主流程会被拖慢甚至拖垮
- 处理是"长流程":对方可能需要人工介入或长时间处理,不可能在一次 HTTP 请求内返回结果
- 耦合风险:外部系统宕机不应该导致你的核心业务不可用
"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) | 需要实时推送 | 实时性强 | 连接管理复杂,不适合跨系统 |
七、常见误区
- 在事务内直接发 MQ:可能消息发出但事务回滚 → 数据不一致。应事务后发或用事务消息。
- 回调接口不做幂等:对方重试导致重复处理(如重复扣款、重复更新)。
- 忘记保存任务ID映射:回调回来无法关联本地业务。
- 回调完全信任、不鉴权:回调接口被伪造请求攻击。
- 只依赖回调、无兜底对账:回调一旦丢失,业务永远停在中间态。
- MQ 消费吞异常不重试 :
catch后不抛出也不记录,消息"假装"消费成功,数据丢失。
八、一句话总结
MQ 异步 让你"发起请求"时不被外部系统拖累(解耦、削峰、不阻塞);回调 让你"获取结果"时不必苦等长流程(事件驱动、异步通知)。两者之间靠任务ID映射 串联,靠幂等 + 事务一致性 + 对账兜底保证可靠。它是对接"慢、长、不稳定"外部系统的标准范式。