【如何解决“支付成功,但订单失败”的分布式系统难题?】
一、总体架构蓝图:可靠消息驱动的最终一致性
面对不可靠的外部回调,我们的核心设计思想是:不信任外部通知,以我方持久化的数据为准,主动求证。
为此,我们设计的总体方案是:通过“本地消息表”持久化支付状态,采用“事件驱动+定时兜底”的混合模式触发状态核对,通过“幂等回查与指数退避”策略与第三方交互,并借助“配置中心”实现动态调控,最后通过“监控告警”确保系统健康。
端到端数据流如下:
事务内写入: 用户支付时,在同一个本地事务内完成业务订单和支付消息(状态为PENDING)的写入。这是保证原子性的第一步。
调用第三方: 发起对第三方支付网关的调用。
处理回调(理想情况): 收到第三方回调,将消息表状态更新为 SUCCESS,并发布一个可靠消息到 RocketMQ,通知下游服务(如订单、库存、积分)进行异步更新。
补偿机制(异常情况): 若回调丢失或延迟,后台的补偿服务会扫描到PENDING状态的消息,主动调用第三方查询接口,根据查询结果完成状态补偿。
数据同步: 我们还使用 Canal 订阅数据库的 binlog。任何订单或支付状态的变更都会被捕获并推送到 RocketMQ,用于缓存更新、数据聚合等场景,确保数据在整个系统中的一致性。
二、方案基石:作为“唯一可信源”的本地消息表
本地消息表是整个方案的核心。它将转瞬即逝的支付状态“物化”为一条持久化的数据库记录,成为后续所有操作的“唯一真相来源”(Single Source of Truth)。
精细化表结构设计:
CREATE TABLE payment_check_msg (id BIGINT PRIMARY KEY AUTO_INCREMENT,order_sn VARCHAR(64) NOT NULL COMMENT '业务订单号,用于反查业务',trade_no VARCHAR(128) NULL COMMENT '第三方支付流水号',channel VARCHAR(20) NOT NULL COMMENT '支付渠道(WECHAT, ALIPAY)',status TINYINT NOT NULL DEFAULT 0 COMMENT '0=PENDING, 1=IN_PROGRESS, 2=SUCCESS, 3=FAILED, 4=DEAD',try_count INT NOT NULL DEFAULT 0 COMMENT '已尝试次数',next_retry_at DATETIME NOT NULL COMMENT '下一次重试时间点',result_text VARCHAR(512) NULL COMMENT '最后一次查询结果描述',created_at DATETIME DEFAULT CURRENT_TIMESTAMP,updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,UNIQUE KEY uk_order_sn (order_sn) COMMENT '防止重复插入',INDEX idx_status_nextretry (status, next_retry_at) COMMENT '补偿任务高效查询的核心索引'
) ENGINE=InnoDB;
设计要点解读:
核心索引 (status, next_retry_at): 这是补偿任务查询性能的生命线,确保每次只捞取少量“到期”且“待处理”的消息,避免数据库慢查询。
唯一约束 (uk_order_sn): 从数据库层面保证了同一笔订单不会产生多条待处理消息。
状态机 (status): IN_PROGRESS 状态是并发控制的关键,用于锁定正在被处理的消息。DEAD 状态用于标记超过重试上限、需要人工介入的消息。
三、驱动引擎:兼顾实时与高效的混合驱动策略
如何触发补偿?传统的纯定时轮询要么延迟高,要么空耗资源。我们采用“实时+兜底”的混合策略。
事件驱动 (为主): 在支付消息写入数据库后,应用内立即发布一个事件,或直接通过 wait-notify 机制唤醒一个后台的补偿线程。该线程被唤醒后,会立即处理这条新消息,实现近实时的状态核对,极大提升了用户体验。
定时兜底 (为辅): 同时,一个低频的定时任务(如每60秒)会作为安全网运行。它负责处理因应用重启、事件丢失等极端情况而遗漏的消息,确保100%的可靠性。
多实例并发控制:
在分布式环境下,必须防止多个服务实例处理同一条消息。我们采用乐观锁思想的“抢占式更新”,而非长时间持有数据库行锁的 SELECT ... FOR UPDATE。
-- 步骤1: 抢占N条到期的任务,并将其状态置为IN_PROGRESS
UPDATE payment_check_msg
SET status = 1, -- 1=IN_PROGRESStry_count = try_count + 1,updated_at = NOW()
WHERE status = 0 -- 0=PENDINGAND next_retry_at <= NOW()
ORDER BY next_retry_at
LIMIT 200; -- 每次处理的批次大小-- 步骤2: 查询刚刚被自己抢占到的任务进行处理
-- (需要一种方式识别是哪个实例抢占的,比如在UPDATE中加入实例ID,或在后续查询时使用时间戳窗口)
这种方式将锁竞争降到最低,大大提升了系统的吞吐能力。
四、核心逻辑:幂等的回查与自适应的重试策略
抢占到任务后,补偿线程会调用第三方支付的查询接口。
幂等处理:
业务层: 任何状态更新操作都必须是幂等的。例如:UPDATE orders SET status='PAID' WHERE order_sn=? AND status!='PAID',确保订单状态不会被重复修改。
消息层: 往下游MQ推送消息时,应使用幂等生产者或确保下游消费者具备幂等处理能力。
指数退避重试 (Exponential Backoff): 对于查询结果仍为“待支付”或查询失败的情况,我们采用一种更智能的重试策略,避免在第三方服务故障时发动“攻击风暴”。
重试次数 | 延迟间隔 | 策略说明 |
第1次 | 60秒 | 快速响应,应对网络瞬时抖动 |
第2次 | 5分钟 | 给予第三方系统更多恢复时间 |
第3次 | 15分钟 | 进一步拉长间隔,降低无效调用 |
3次以上 | 标记为DEAD | 停止自动重试,触发告警,转入人工处理流程 |
五、运维与治理:可观测、可调节的自愈系统
一个无法被有效监控和管理的系统是脆弱的。我们通过以下手段赋予系统“可治理”的能力。
动态配置 (Nacos): 所有的核心参数,如重试策略、批处理大小、线程池并发数、兜底任务频率等,全部托管在Nacos配置中心。当遇到流量高峰时,运维人员无需发布代码,即可在线动态调整参数,例如临时降低扫描频率以减轻数据库压力,或增加并发数以加速补偿。
立体化监控与告警 (Prometheus & Grafana):
关键指标: PENDING消息积压数、DEAD消息新增数、补偿任务处理速率、平均重试次数、第三方接口调用成功率/延迟。
告警规则:
PENDING消息数持续超过阈值(如1000条),告警。
DEAD消息出现,立即告警。
第三方接口失败率突增,告警。
运维平台: 提供一个后台界面,允许运维人员查看、手动重试或标记处理DEAD状态的消息,并记录所有人工操作,以备审计。
六、总结
我们通过**“本地消息表”为不确定性建立了可靠的锚点,以“事件驱动+定时兜底”实现了高效与稳健的平衡,用“抢占式更新与指数退避”确保了并发安全与系统礼貌,最后通过“配置中心与立体化监控”**赋予了系统强大的运维治理能力。
这套体系化方案,本质上是在不可控的外部依赖面前,将系统的主动权牢牢掌握在自己手中。它将一个被动的、可能出错的回调流程,改造成了一个主动的、可控的、最终必然一致的健壮系统,为核心业务的稳定运行提供了坚实的保障。