支付渠道说成功,用户却看见“待支付”。这时最容易做错的一件事,就是让用户再付一次。
问题可能出在回调丢失,也可能是数据库已经更新、后续消息却没有发出去。接入 MQ 只是把工作移到了另一个进程,消息的可靠性仍然需要从业务事务开始设计。
这篇文章沿着“支付结果进入系统 → 订单更新 → 权益发放”的路径,拆开每个可能留下半成品的地方。
先区分三种成功
支付成功、消息发布成功、权益发放成功,是三个独立的事实。前端收到支付组件的成功提示,不能直接成为后台记账依据;RabbitMQ 的 publisher confirm,也不能证明消费端已经执行完业务。
发布确认描述生产者与 Broker 之间的交互,消费确认描述消费者与 Broker 之间的交互,两者互不替代。RabbitMQ 确认机制
回调入口先按渠道协议校验签名、商户、订单、金额、币种及目标支付状态。某些渠道要求对原始请求字节验签,不能在解析、重排 JSON 后再验证。验证通过后,还需要检查渠道流水和内部支付单的对应关系,避免一笔渠道交易被绑定到多个订单。
数据库与 MQ 之间存在一个空隙
先写库再发消息:数据库提交后进程退出,消息就没有发出去。先发消息再写库:消费者已经收到事件,数据库事务却可能回滚。
把 send() 放进 @Transactional 方法,并不会自动让 MySQL 和 RabbitMQ 共享一次原子提交。一个更容易理解的方案,是把“以后要发的消息”也写进同一个数据库事务,这就是 Outbox。
1 | 渠道回调 |
这个方案选择接受重复投递,用数据库约束避免重复生效。
同一个状态迁移只生成一个事件
下面是支付单首次确认成功时的核心 SQL。前提是回调已经验证,渠道交易号的唯一约束也已落实;event_id 为这次业务事件生成后保持不变的 ID。
1 | START TRANSACTION; |
应用必须检查行数并控制分支。已支付且字段一致的重复回调,可以返回既有结果;金额不一致、渠道流水冲突、订单已关闭后才到账,都应进入明确的核对流程,不能简单把状态覆盖为 PAID。
Outbox 至少需要 event_id 主键、事件类型、业务标识、载荷版本、重试次数、下次重试时间,以及任务领取的租约信息。支付事件的业务唯一键,例如 (payment_id, event_type),也应根据支付单是否允许多次尝试来定义;订单号不能直接代替每一次支付尝试的身份。
发布器最重要的动作,是承认确认也会丢
发布器可能收到 Broker 确认,却在把 Outbox 标成 SENT 前退出。重启后它会再发一次同样的事件。这是正常恢复路径,所以重试时必须复用 event_id。
多实例领取任务,可以通过短事务加锁和租约实现;适用版本的 MySQL 可以用 FOR UPDATE SKIP LOCKED 跳过已被其他发布器领取的行。不要一直持有数据库事务等待网络确认。领取事务提交后再发送,状态回写需匹配本次租约令牌,超时任务才允许重新领取。
同时需要配置匹配可靠性要求的队列与消息持久化、publisher confirm,以及不可路由消息的返回处理。收到 confirm 并不单独证明消息成功路由到了目标队列;交换机、绑定或 routing key 错误必须被检测并进入重试或告警。
消费成功以后再 ACK,仍然需要幂等
消费端提交数据库成功,ACK 在网络上丢了,Broker 仍可能重新投递。处理方式是把消费记录和业务动作放进同一个事务。
消费表对 (consumer_name, event_id) 建唯一约束。消费者先尝试插入,再修改订单或权益,任何一步失败都一起回滚。若唯一键冲突,退出当前事务后确认已有记录对应同一事件内容;只有已提交的处理结果才可以当作重复成功。
为什么还要业务唯一键?因为上游可能把同一业务动作包装成两个不同的事件 ID。比如课程解锁,对 (user_id, course_id, purchase_id) 的业务约束,需要独立于消息去重存在。
ACK 应发生在业务事务提交之后。使用 Spring 时,可以让监听器调用另一个 Bean 的事务服务,服务成功返回后再执行确认;遇到异常进入有界重试。消费记录的保留周期要覆盖可重放的消息范围,不能为了节省空间随意清空。
重试会放大故障,必须有停止位置
数据库已经很慢时,把失败消息立即 requeue,可能形成一个几乎不做有效工作的高速循环。CPU 忙、日志暴涨、数据库更慢,队列却没有减少。
可恢复错误采用退避与抖动,设置最大重试次数或时间窗口;格式错误、签名失败、非法状态等永久错误进入隔离队列。重放需要保留原事件 ID,记录操作者、原因和处理结果,而不是重新包装成“新事件”。
消费并发和 prefetch 应匹配数据库连接池及下游容量。增加消费者前,先看最老消息的等待时间、处理中消息数、连接池等待和单条处理耗时。队列长度只能说明积压多少,不能说明积压是否正在失控。
对账是业务闭环的一部分
即使 Outbox 和消费幂等都正确,渠道回调仍可能一次也没有进入系统。需要用支付单的业务标识主动查询,并把渠道账单与内部支付单、订单权益、退款记录进行核对。
不要让“重新调用接口”成为通用补偿。支付状态不明时先查询;权益未发放时补发权益;退款结果不明时查询原退款单。不同的缺口,对应不同的修复动作。
最有价值的验证,是在这些位置主动终止进程:数据库提交前、提交后发送前、发布确认后回写前、消费提交后 ACK 前。每个位置都恢复一遍,才能看清系统究竟依赖了哪些未写下来的假设。
延伸阅读:交易余额的并发与幂等、订单慢查询与分页优化。