实战干货:从零设计 RocketMQ 支付中台——架构思路与核心封装

实战干货:从零设计 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 封装,核心围绕三点:

  1. 事务消息保一致:下单链路中,本地事务与 MQ 发送原子完成,任何一步失败都有反查兜底
  2. 渠道适配器解耦合:策略模式统一封装各渠道差异,新增渠道只需实现接口而不破坏主链路
  3. 回调 + 主动查询双保险:不依赖任何单一通道,延迟消息补足回调丢失的场景

完整代码量较大,本文侧重思路讲解。如需某一部分的完整源码(如 ChannelAdapter 实现类、对账文件解析器),欢迎评论区留言,会单独成篇展开。