实战干货:从零设计 RocketMQ 支付中台------架构思路与核心封装
支付中台是连接业务系统与第三方支付通道(如微信、支付宝、银联)的桥梁。它的核心职责不是「做支付」,而是「管支付」------统一接入协议、统一异常处理、统一对账流程、统一监控告警。
本文围绕 RocketMQ 在支付中台中的角色,系统阐述如何从业务分层、消息模型、异常处理三个维度完成支付中台的封装设计。适合有一定 MQ 基础、正在构建支付平台的后端工程师参考。
一、支付中台的核心挑战
在单体支付场景中,支付逻辑可能就是一个 service 方法:
java
// 单体时代:同步直连
public void pay(Order order) {
ThirdPayResult result = thirdPayClient.pay(order);
if ("SUCCESS".equals(result.getCode())) {
orderService.updatePaid(order);
}
}
但当业务扩展到多渠道、多租户、异步对账、失败重试、幂等保障时,这种同步直连的写法会迅速腐化:
makefile
问题1: 第三方接口超时 → 业务侧不知道该重试还是放弃
问题2: 多个渠道接入逻辑散落在各处 → 改一个接口要改 N 处
问题3: 支付回调与业务处理耦合 → 回调崩则业务崩
问题4: 账务对账依赖定时轮询 → 实时性差、数据库压力大
支付中台的目标,就是把这些分散的、不确定的逻辑,收敛到一个可观测、可控、可扩展的架构里。
二、整体架构设计
2.1 分层模型
java
┌─────────────────────────────────────────────────────────┐
│ 接入层(Open API) │
│ 统一签约 · 统一下单 · 统一回调 · 统一查询 │
└──────────────────────────┬──────────────────────────────┘
│ 同步/异步分发
┌──────────────────────────▼──────────────────────────────┐
│ 交易编排层(Orchestration) │
│ 支付路由 · 流量控制 · 幂等分发 · 事务协调 │
└──────────────────────────┬──────────────────────────────┘
│ 消息驱动(RocketMQ)
┌──────────────────────────▼──────────────────────────────┐
│ 支付执行层(Execution) │
│ 渠道适配器 · 签名加密 · 响应归一化 │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ 账务处理层(Accounting) │
│ 账户记账 · 冻结解冻 · 差错处理 · 对账 │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ 渠道网关层(Channel Gateway) │
│ 微信 native/JSAPI · 支付宝当面付 · 银联云闪付 │
└─────────────────────────────────────────────────────────┘
2.2 RocketMQ 在架构中的定位
RocketMQ 在支付中台中承担异步解耦、事务协调、最终一致三大职责,是连接各层的「神经中枢」:
| 消息 Topic | 职责 | 消费者 |
|---|---|---|
TOPIC_PAY_ORDER |
支付下单主链路 | 支付执行、账务记账 |
TOPIC_PAY_CALLBACK |
第三方回调通知 | 状态同步、对账触发 |
TOPIC_PAY_RECONCILE |
对账延迟消息 | 差错处理 |
TOPIC_PAY_NOTIFY |
业务回调通知 | 订单系统、会员系统 |
TOPIC_PAY_RETRY |
重试队列 | 重试处理器 |
TOPIC_PAY_DLQ |
死信队列 | 人工介入 |
三、核心封装设计
3.1 支付下单:事务消息封装
下单是支付中台的入口,核心诉求是:本地事务(创建订单、冻结资金)与 MQ 发送必须原子完成。
PayTransactionService(事务发送器)
java
@Service
public class PayTransactionService {
@Autowired
private TransactionMQProducer transactionProducer;
/**
* 发送支付下单事务消息
* @param request 支付下单请求
* @return 支付流水号
*/
public String sendPayOrderTransaction(PayOrderRequest request) {
// 1. 生成支付流水号
String payNo = SnowflakeIdGenerator.nextId();
// 2. 构造消息(包含完整支付信息)
PayOrderMessage message = PayOrderMessage.builder()
.payNo(payNo)
.orderId(request.getOrderId())
.amount(request.getAmount())
.currency(request.getCurrency())
.channel(request.getChannel())
.notifyUrl(request.getNotifyUrl())
.expireTime(LocalDateTime.now().plusMinutes(30))
.build();
Message msg = new Message(
"TOPIC_PAY_ORDER",
request.getChannel(), // tag = channel code
payNo,
JSON.toJSONBytes(message)
);
msg.putUserProperty("payNo", payNo);
msg.putUserProperty("version", "1.0");
// 3. 发送事务消息(本地事务在 listener 中执行)
SendResult result = transactionProducer.sendMessageInTransaction(msg, request);
log.info("[支付中台] 事务消息已发送, payNo={}, msgId={}", payNo, result.getMsgId());
return payNo;
}
}
PayOrderTransactionListener(事务监听器)
java
@Component
public class PayOrderTransactionListener implements TransactionListener {
@Autowired
private OrderRepository orderRepository;
@Autowired
private AccountFreezeService accountFreezeService;
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
PayOrderRequest request = (PayOrderRequest) arg;
PayOrderMessage message = parseMessage(msg);
try {
// 开启本地事务:创建支付流水 + 冻结账户余额
// 这一步必须与 MQ 发送原子完成
orderRepository.createPayOrder(message);
accountFreezeService.freeze(message.getOrderId(), message.getAmount());
log.info("[支付中台] 本地事务执行成功, payNo={}", message.getPayNo());
return LocalTransactionState.COMMIT_MESSAGE;
} catch (InsufficientBalanceException e) {
// 余额不足,无需重试,直接回滚
log.warn("[支付中台] 余额不足, payNo={}", message.getPayNo());
return LocalTransactionState.ROLLBACK_MESSAGE;
} catch (DuplicatePayException e) {
// 重复下单,幂等处理
log.warn("[支付中台] 重复下单已幂等处理, payNo={}", message.getPayNo());
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
// 其他异常:未知状态,让 Broker 反查
log.error("[支付中台] 本地事务执行异常, payNo={}", message.getPayNo(), e);
return LocalTransactionState.UNKNOW;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String payNo = msg.getUserProperty("payNo");
OrderStatus status = orderRepository.getPayOrderStatus(payNo);
return switch (status) {
case CREATED -> LocalTransactionState.COMMIT_MESSAGE;
case ROLLBACK -> LocalTransactionState.ROLLBACK_MESSAGE;
default -> LocalTransactionState.UNKNOW; // 继续等待
};
}
}
3.2 渠道适配器:策略模式封装
支付中台需要对接多个第三方(微信、支付宝、银联等),每个渠道的接口协议、签名方式、回调格式各不相同。适配器模式可以将差异收敛到各自实现类中。
ChannelAdapter 接口
java
/**
* 支付渠道适配器接口
* 统一封装各渠道的差异,对外暴露一致的接口
*/
public interface ChannelAdapter {
/**
* 获取渠道编码
*/
ChannelType getChannel();
/**
* 构建支付请求(跳转第三方前的参数准备)
*/
ChannelPayRequest buildPayRequest(PayOrderMessage message);
/**
* 解析回调通知
*/
ChannelCallbackResult parseCallback(HttpServletRequest request);
/**
* 验证回调签名
*/
boolean verifyCallback(ChannelCallbackResult callback);
/**
* 申请退款
*/
ChannelRefundResult refund(RefundOrderMessage message);
/**
* 查询支付状态
*/
ChannelPayStatus queryPayStatus(String channelTradeNo);
}
微信支付适配器实现
java
@Component
public class WechatPayAdapter implements ChannelAdapter {
@Autowired
private WechatPayConfig wechatConfig;
@Override
public ChannelType getChannel() {
return ChannelType.WECHAT_PAY;
}
@Override
public ChannelPayRequest buildPayRequest(PayOrderMessage message) {
// 微信 JSAPI 统一下单
String prePayId = wechatUnifiedOrder(buildUnifiedOrderParams(message));
// 构建 JSAPI 调起参数
Map<String, String> jsapiParams = new HashMap<>();
jsapiParams.put("appId", wechatConfig.getAppId());
jsapiParams.put("timeStamp", String.valueOf(System.currentTimeMillis() / 1000));
jsapiParams.put("nonceStr", UUID.randomUUID().toString());
jsapiParams.put("package", "prepay_id=" + prePayId);
jsapiParams.put("signType", "RSA");
jsapiParams.put("paySign", wechatSign(jsapiParams));
return ChannelPayRequest.builder()
.payNo(message.getPayNo())
.channel(ChannelType.WECHAT_PAY)
.payUrl("weixin://wxpay/bizpayurl") // APP 调起
.qrCodeUrl(null) // JSAPI 无二维码
.jsapiParams(jsapiParams) // H5/小程序调起参数
.expireTime(message.getExpireTime())
.build();
}
@Override
public ChannelCallbackResult parseCallback(HttpServletRequest request) {
// 微信回调为 XML,需单独解析
String xmlBody = readRequestBody(request);
Map<String, String> data = XmlUtils.parse(xmlBody);
return ChannelCallbackResult.builder()
.channelTradeNo(data.get("transaction_id"))
.outTradeNo(data.get("out_trade_no"))
.amount(new BigDecimal(data.get("total_fee")).divide(new BigDecimal(100)))
.status("SUCCESS".equals(data.get("result_code")) ? PayStatus.PAID : PayStatus.FAILED)
.rawData(xmlBody)
.callbackTime(LocalDateTime.now())
.build();
}
@Override
public boolean verifyCallback(ChannelCallbackResult callback) {
// 微信使用 RSA 验签
String sign = callback.getRawDataMap().get("sign");
return wechatConfig.getPlatformPublicKey()
.equals(RsaUtils.verify(callback.getSignedData(), sign));
}
}
渠道路由工厂
java
@Component
public class ChannelRouter {
private final Map<ChannelType, ChannelAdapter> adapters;
@Autowired
public ChannelRouter(List<ChannelAdapter> adapterList) {
this.adapters = adapterList.stream()
.collect(Collectors.toMap(ChannelAdapter::getChannel, identity()));
}
public ChannelAdapter getAdapter(ChannelType channel) {
ChannelAdapter adapter = adapters.get(channel);
if (adapter == null) {
throw new UnsupportedChannelException("不支持的支付渠道: " + channel);
}
return adapter;
}
public ChannelPayRequest routePay(PayOrderMessage message) {
ChannelAdapter adapter = getAdapter(message.getChannel());
return adapter.buildPayRequest(message);
}
}
3.3 回调处理:幂等 + 状态机
支付回调是第三方通知支付结果的唯一入口,必须做到:收到即处理,处理即幂等,结果即通知。
java
@Service
@Slf4j
public class PayCallbackService {
@Autowired
private ChannelRouter channelRouter;
@Autowired
private PayOrderRepository payOrderRepository;
@Autowired
private AccountService accountService;
@Autowired
private ConsumeRecordService consumeRecordService;
@Autowired
private MQProducerWrapper mqProducer;
/**
* 处理第三方支付回调
* 核心原则:先处理,再应答,最后异步通知
*/
public void handleCallback(ChannelType channel, HttpServletRequest request) {
// 1. 解析回调数据
ChannelAdapter adapter = channelRouter.getAdapter(channel);
ChannelCallbackResult callback = adapter.parseCallback(request);
// 2. 幂等检查:同一个 outTradeNo 只处理一次
ConsumeRecord record = consumeRecordService.getByOutTradeNo(callback.getOutTradeNo());
if (record != null && record.getStatus() == ConsumeStatus.SUCCESS) {
log.info("[支付中台] 回调已处理,跳过, outTradeNo={}", callback.getOutTradeNo());
return;
}
// 3. 记录处理中(防并发重复处理)
try {
consumeRecordService.insertProcessing(callback);
} catch (DuplicateKeyException e) {
log.warn("[支付中台] 回调正在被处理, outTradeNo={}", callback.getOutTradeNo());
return;
}
// 4. 执行业务:更新支付状态 + 账户解冻 + 入账
try {
processCallback(callback);
consumeRecordService.updateSuccess(callback.getOutTradeNo());
// 5. 发送业务通知消息(与回调处理解耦)
sendBizNotifyMessage(callback);
} catch (Exception e) {
consumeRecordService.updateFailed(callback.getOutTradeNo(), e.getMessage());
throw e; // 抛出让第三方继续重试回调
}
}
private void processCallback(ChannelCallbackResult callback) {
String outTradeNo = callback.getOutTradeNo();
// 状态机:只处理从未处理过的
PayOrder order = payOrderRepository.findByPayNo(outTradeNo);
if (order.getStatus() == PayStatus.PAID) {
log.info("[支付中台] 订单已支付,跳过, payNo={}", outTradeNo);
return;
}
if (order.getStatus() != PayStatus.PENDING) {
throw new IllegalStateException("非待支付状态无法处理回调: " + order.getStatus());
}
// 支付成功:更新状态 + 解冻 + 入账(原子操作)
if (callback.getStatus() == PayStatus.PAID) {
payOrderRepository.updatePaid(outTradeNo, callback.getChannelTradeNo());
accountService.unfreezeAndCredit(order, callback.getAmount());
log.info("[支付中台] 支付成功, payNo={}, channel={}", outTradeNo, callback.getChannel());
} else {
payOrderRepository.updateFailed(outTradeNo, callback.getErrorMsg());
accountService.unfreeze(order); // 失败解冻
log.warn("[支付中台] 支付失败, payNo={}, reason={}", outTradeNo, callback.getErrorMsg());
}
}
}
3.4 对账体系:延迟消息 + 主动查询
支付中台不能完全依赖回调,还需要主动对账兜底。
T+1 日终对账
每天凌晨,支付中台拉取第三方渠道的对账文件,与本地流水逐条核对:
java
@Service
public class ReconcileService {
@Autowired
private ChannelConfigRepository channelConfigRepository;
@Autowired
private FileTransferService fileTransferService; // SFTP 上传/下载
/**
* 日终对账入口
*/
@Scheduled(cron = "0 30 4 * * ?") // 每天凌晨 4:30
public void dailyReconcile() {
String date = LocalDate.now().minusDays(1).format(DateTimeFormatter.ISO_DATE);
List<ChannelConfig> channels = channelConfigRepository.findActiveChannels();
for (ChannelConfig channel : channels) {
try {
reconcile(channel, date);
} catch (Exception e) {
alertService.sendAlert("日终对账失败: " + channel.getChannelName(), e);
}
}
}
private void reconcile(ChannelConfig channel, String date) {
// 1. 下载渠道对账文件
String remotePath = channel.getReconcileFilePath(date);
File localFile = fileTransferService.download(remotePath);
// 2. 解析文件(各渠道格式不同,适配器模式处理)
ChannelAdapter adapter = channelRouter.getAdapter(channel.getChannelType());
List<ReconcileItem> channelItems = adapter.parseReconcileFile(localFile);
// 3. 查本地流水
List<PayOrder> localOrders = payOrderRepository.findByDateAndChannel(date, channel.getChannelType());
// 4. 核对
List<ReconcileDiff> diffs =核对(channelItems, localOrders);
// 5. 记录差错
for (ReconcileDiff diff : diffs) {
reconcileRecordService.save(diff);
}
log.info("[对账] 渠道={}, 日期={}, 成功={}, 差错={}",
channel.getChannelName(), date, channelItems.size() - diffs.size(), diffs.size());
}
}
支付结果延迟查询(回调丢失兜底)
回调可能因网络问题丢失,需要延迟消息触发主动查询:
java
@Service
public class PayQueryService {
@Autowired
private DefaultMQProducer producer;
/**
* 下单时同时发送延迟查询消息(15分钟后触发)
*/
public void schedulePayQuery(PayOrderMessage message) {
Message msg = new Message("TOPIC_PAY_QUERY", "QUERY", message.getPayNo(), null);
msg.setDelayTimeLevel(14); // Level 14 = 10分钟
producer.send(msg);
}
}
@Component
@RocketMQMessageListener(topic = "TOPIC_PAY_QUERY", consumerGroup = "pay-query-consumer-group")
public class PayQueryConsumer implements RocketMQListener<MessageExt> {
@Autowired
private ChannelRouter channelRouter;
@Autowired
private PayOrderRepository orderRepository;
@Override
public void onMessage(MessageExt msg) {
String payNo = msg.getKeys();
PayOrder order = orderRepository.findByPayNo(payNo);
// 订单已支付,无需查询
if (order.getStatus() == PayStatus.PAID) return;
// 主动向第三方查询
ChannelAdapter adapter = channelRouter.getAdapter(order.getChannel());
ChannelPayStatus status = adapter.queryPayStatus(order.getChannelTradeNo());
if (status == ChannelPayStatus.PAID) {
// 查询到已支付:触发补账
accountService.credit(order);
orderRepository.updatePaid(payNo, status.getChannelTradeNo());
log.info("[延迟查询] 补账成功, payNo={}", payNo);
} else if (status == ChannelPayStatus.CLOSED) {
orderRepository.updateClosed(payNo);
log.info("[延迟查询] 订单已关闭, payNo={}", payNo);
}
}
}
四、生产级配置清单
| 配置项 | 推荐值 | 说明 |
|---|---|---|
transactionCheckThreadPoolSize |
8 | 事务反查线程池,太小会导致反查堆积 |
checkRequestHoldInterval |
2000ms | 反查间隔 |
maxReconsumeTimes |
16 | 消费重试上限,约 4 小时后进 DLQ |
consumeMessageBatchMaxSize |
1 | 支付回调单条消费,避免批量失败 |
sendMessageTimeout |
5000ms | 发送超时,第三方可能较慢 |
retryTimesWhenSendFailed |
3 | 发送失败重试 |
五、总结
支付中台的 RocketMQ 封装,核心围绕三点:
- 事务消息保一致:下单链路中,本地事务与 MQ 发送原子完成,任何一步失败都有反查兜底
- 渠道适配器解耦合:策略模式统一封装各渠道差异,新增渠道只需实现接口而不破坏主链路
- 回调 + 主动查询双保险:不依赖任何单一通道,延迟消息补足回调丢失的场景
完整代码量较大,本文侧重思路讲解。如需某一部分的完整源码(如 ChannelAdapter 实现类、对账文件解析器),欢迎评论区留言,会单独成篇展开。