找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

6027

积分

0

好友

771

主题
发表于 前天 20:34 | 查看: 0| 回复: 0

云栈社区 关于支付容灾的讨论里,一笔支付最危险的时刻,不是 PSP 明确返回失败,而是系统收到了超时:请求可能根本没有发出,也可能已被 PSP 接收、授权甚至扣款,只是响应在路上丢了。

《从零搭建企业级支付渠道中台》07
从结果确定性出发,设计不会重复扣款的重试、切换、恢复与补偿流程。

Payment Core  -- Create -->  PSP
                         PSP 已扣款
Payment Core  <-- timeout --  响应丢失

此时若把超时当失败,立即换 PSP 再扣一次,最终可能得到两笔成功交易。本文只解决一个核心问题:在不确定外部资金结果时,怎样恢复可用性,同时不把不确定性变成重复扣款。

读完后,你应该能完成四件事:区分重放、业务重试和跨渠道切换;为 UNKNOWN 建立可恢复的状态与任务;让 Webhook、查询和人工介入安全并发;把恢复策略发布、观察和演练起来。


一、超时不是失败:先判断资金结果是否确定

普通 RPC 可以按 TimeoutException、HTTP 500、参数错误分类。支付首先要回答的却是:这次调用是否可能已经在 PSP 产生资金副作用?

结果确定性 典型证据 Attempt 状态 下一步
NO_SIDE_EFFECT 本地校验失败;能够证明请求未发出 FAILED 或未创建 可重新路由
DEFINITE_FAILURE PSP 明确拒绝且协议证明未受理 FAILED 按错误策略决定是否新建 Attempt
PENDING PSP 明确处理中或等待用户动作 PROCESSING 等回调或查询原渠道
CONFIRMED_SUCCESS PSP 成功、可信回调或权威查询 SUCCEEDED 收敛并通知业务
UNKNOWN 写后断连、响应丢失、不可解释的网关错误 UNKNOWN 只恢复原 Attempt,禁止盲目切换

DNS 失败、连接失败或 HTTP 5xx 并不天然属于 NO_SIDE_EFFECT。代理、负载均衡器和客户端缓冲区都会让“请求到底有没有送达”变得不可证明。只要证明不了 PSP 一定没执行,就按 UNKNOWN 处理。

这带来全篇最重要的门槛:

只有原 Attempt 已被证明不会再成功,才允许创建跨 PSP 的新 Attempt。

它牺牲的是少量即时成功率,保护的是用户不会因一次网络故障被扣两次钱。


二、三种“再试”不是一回事

很多事故来自把它们全部写成 retry()

1. Transport Replay
   同一 Attempt + 同一 PSP + 同一参数 + 同一 Idempotency Key
   目标:取回原操作的结果,不创造新支付。

2. Business Retry
   用户换卡、修改金额、重新认证后再次发起。
   目标:新的业务支付;通常创建新的 Attempt。

3. Failover
   旧 Attempt 已明确失败,路由到另一个 PSP。
   目标:新的渠道支付;必须创建新的 Attempt 和新的幂等键。

因此数据关系应是:

PaymentOrder
  ├─ Attempt-01 / PSP-A
  │   ├─ Request-01 → timeout
  │   └─ Request-02 → 同 key 重放 → success
  └─ Attempt-02 / PSP-B       # 只有 Attempt-01 已明确失败后才能出现

HTTP 重发不是新的 PaymentAttempt。而 PSP-A 到 PSP-B 的切换绝不能复用旧 Attempt:渠道账户、provider reference、token 归属和 PSP 幂等域都变了。更不能因为订单号相同就假设跨 PSP 幂等;这两个 PSP 根本不共享幂等事实。

对于同 PSP 的 Create,平台生成的 provider_idempotency_key 必须在 Attempt 创建时固化。重放时参数摘要也必须相同;换了金额、币种或支付方式却继续使用旧 key,应被拒绝而不是悄悄发出请求。


三、UNKNOWN 的恢复闭环:拿证据,不重扣款

UNKNOWN 的含义是“本地没有可信终局事实”,不是“失败后待重试”。恢复的顺序由渠道能力决定:

UNKNOWN
  ├─ 支持幂等重放:同 key、同参数重放,尝试取回 provider reference
  ├─ 支持 provider id 查询:查询原 PSP 的原交易
  ├─ 支持 merchant reference 搜索:定位交易后再查询
  ├─ Webhook 权威:等待并校验回调
  └─ 无实时证据:按渠道 SLA 进入对账或人工核查

不能假设所有渠道都有 query(paymentId)。有的只支持 Retrieve Resource,有的依赖 Webhook 或结算文件;有的异步支付需要数小时甚至数天才会有最终结果。适配器应显式暴露能力,而不是让核心层猜测:

public interface PaymentRecoveryCapability {
    boolean supportsIdempotentReplay();
    boolean supportsRetrieveByProviderId();
    boolean supportsSearchByMerchantReference();
    boolean webhookAuthoritative();
    boolean settlementReportAvailable();
}

恢复流程的五个问题必须闭环:谁在何时失败;本地和 PSP 各自知道什么;哪条约束阻止二次扣款;谁读取什么证据来恢复;怎样证明恢复没有重复执行。缺任何一个,UNKNOWN → FAILED → failover 都只是把风险藏起来。

一个典型场景是:Stripe Create 在写出请求后 Read Timeout。本地将 Attempt-01 标为 UNKNOWN,订单保持 PROCESSING;若该操作支持强幂等,则以原 key、原参数重放。拿到成功后收敛 Attempt-01,整个过程不会创建 Attempt-02。重放仍失败时,继续等待回调、查询或对账,不能直接跳到第二个 PSP。


四、把恢复任务做成一等对象,而不是定时脚本

不要在支付请求线程里 sleep(5min),也不要每五分钟扫描全表。每个待恢复 Attempt 应有一条可领取、可退避、可审计的任务。

create table payment_attempt (
  attempt_id bigint primary key,
  payment_id bigint not null,
  channel_account_id bigint not null,
  provider_payment_id varchar(128),
  provider_idempotency_key varchar(128) not null,
  create_request_hash char(64) not null,
  status varchar(24) not null,
  result_certainty varchar(32) not null,
  version bigint not null default 0,
  created_at timestamp not null,
  updated_at timestamp not null,
  unique (channel_account_id, provider_idempotency_key)
);

create table payment_recovery_task (
  task_id bigint primary key,
  attempt_id bigint not null unique,
  status varchar(24) not null,
  strategy varchar(32) not null,
  due_at timestamp not null,
  attempt_count integer not null default 0,
  lease_owner varchar(96),
  lease_until timestamp,
  evidence_id bigint,
  version bigint not null default 0,
  updated_at timestamp not null
);

第一个唯一约束限定同一渠道账户中的 PSP 幂等域;create_request_hash 保护“重放的是同一操作”。第二个唯一约束保证同一 Attempt 不会被多个入口创建出并发恢复任务。若要保存任务历史,可将活跃任务与历史记录分表,或用数据库支持的部分唯一索引;不要只在应用层“先查再插”。

多个 Worker 领取任务时,租约只用于减少重复工作,正确性仍由状态机和 CAS 保证。以 PostgreSQL 为例:

begin;
with due as (
  select task_id from payment_recovery_task
  where (status = 'READY' and due_at <= now())
  or (status = 'LEASED' and lease_until < now())
  order by due_at, task_id
  limit :batch_size for update skip locked
)
update payment_recovery_task t
set status = 'LEASED', lease_owner = :worker_id,
    lease_until = now() + interval '45 seconds',
    attempt_count = attempt_count + 1, version = version + 1,
    updated_at = now()
from due where t.task_id = due.task_id
returning t.*;
commit;

领取事务结束后才可以调用 PSP。把网络调用放在事务内,会把数据库锁暴露给 PSP 延迟、网络超时和 Worker 崩溃。租约到期后可接管;旧 Worker 即使晚到,也必须经过 CAS,不能覆盖已到达的 Webhook 结果。


五、Webhook、查询和恢复并发时,状态机 是最终裁判

Webhook、查询 Worker、恢复 Worker 可能同时得出成功结论。它们不需要强行串行化,但必须共享同一套合法迁移和持久化边界:

ProviderEvidence
  → StateMachine 决定迁移
  → CAS 更新 Attempt
  → 写 StateHistory / Evidence / Outbox
  → 结束或重排 RecoveryTask

以下代码展示了关键分支。它不是完整项目,但没有隐藏资金正确性所依赖的状态、事务和竞争处理:

public RecoveryOutcome resolve(ClaimedTask task, ProviderEvidence evidence, Instant now) {
    PaymentAttempt attempt = attempts.findForRecovery(task.attemptId());
    if (attempt.isFinal()) {
        return tasks.resolveAlreadyFinal(task.taskId(), task.version(), now);
    }

    Transition transition = stateMachine.decide(attempt, evidence);
    if (transition == Transition.KEEP_UNKNOWN) {
        return tasks.reschedule(task.taskId(), task.version(),
                schedule.nextRun(attempt, task.attemptCount(), now), evidence.errorCode());
    }

    return transaction.execute(status -> {
        if (!attempts.compareAndSet(attempt.id(), attempt.version(),
                    transition.targetStatus(), evidence.certainty(), now)) {
            return RecoveryOutcome.CONCURRENTLY_RESOLVED;
        }
        evidenceStore.append(attempt.id(), task.taskId(), evidence, now);
        history.append(attempt.id(), transition, "RECOVERY", now);
        outbox.append(transition.domainEvent(attempt.paymentId(), attempt.id()), now);
        tasks.resolve(task.taskId(), task.version(), now);
        return RecoveryOutcome.RESOLVED;
    });
}

没有终局证据时,状态保持 UNKNOWN 并退避;CAS 失败意味着别的执行者已改变事实,应重新读取而不是重写。Attempt 更新、状态历史、外部证据引用、Outbox 和任务结论在同一事务里提交,避免“状态已成功但业务事件丢失”。

ProviderEvidence 至少记录证据来源(回调、查询、对账或人工)、provider status、provider object ID、观察时间、关联请求 ID 和原始响应的脱敏摘要/哈希。卡号、token 和个人数据不能原样写日志;排障日志只关联内部支付 ID、Attempt ID、策略版本和错误码。


六、切换、熔断与降级:控制新流量,不篡改在途交易

跨 PSP Failover 的条件应该被做成保守的 Guard:

public FailoverDecision evaluate(PaymentAttempt previous, PaymentMethodPolicy policy) {
    if (!previous.isFinalFailure()
            || previous.resultCertainty() != ResultCertainty.DEFINITE_FAILURE) {
        return FailoverDecision.deny("PREVIOUS_ATTEMPT_NOT_PROVEN_FAILED");
    }
    if (!previous.failure().failoverEligible()) {
        return FailoverDecision.deny("ERROR_POLICY_DENIES_FAILOVER");
    }
    if (policy.requiresCustomerReauthentication() || previous.hasPendingCustomerAction()) {
        return FailoverDecision.deny("CUSTOMER_ACTION_REQUIRED");
    }
    return FailoverDecision.allow(exclude(previous.failureDomain()));
}

Guard 允许后,才新建 Attempt、新幂等键和新的路由决策记录。若旧渠道属于整个 PSP 或地区的故障域,路由还应排除相关账户,而不是只排除一个账号。

Circuit Breaker 的职责是停止把支付送往故障渠道,不是把已有 PROCESSINGUNKNOWN 改为失败。历史 Attempt 的 Query、Webhook、退款、撤销和对账仍需保留受控预算,因为这些资金动作天然绑定原渠道账户:

New Create       → circuit open → 从路由候选中排除
Existing Attempt → 原 channel_account_id 上查询 / 回调 / 退款
UNKNOWN          → 不切换,直到获得明确失败证据

降级也不能把 3DS/SCA、签名校验或风控当开关。可降的是新流量的渠道选择、非关键体验和排队策略;合规与资金确认边界不应为表面成功率让路。


七、补单、对账与补偿分别修复什么

“补单”通常不是再发一次扣款,而是用外部事实修复本地未收敛的状态:

PSP SUCCESS + 本地 PROCESSING
  → Query / Webhook / 对账证据
  → 状态机收敛 SUCCEEDED
  → Outbox 通知订单、账务等下游

因此禁止:

update payment_attempt set status = 'SUCCEEDED' where attempt_id = :id;

人工处理也必须创建带操作者、工单、原因、证据和审批记录的 ManualRecoveryCommand,再走同一状态机。高金额或敏感调整应采用 maker/checker 双人审批。

对账是最后防线,不是实时恢复的替代品。API 响应、Webhook 和恢复查询解决绝大多数交易;结算文件和周期性对账发现“外部成功但本地没成功”或“本地成功却没有对应资金记录”的漏网情况。证据可信度由支付方式和 PSP 契约决定,不能机械地说“Query 永远覆盖一切”。

补偿则是另一类新事实。若确认同一订单有两笔成功 Attempt,不能把其中一笔改为 FAILED:钱已经发生。系统应冻结重复履约,选择 canonical Attempt,创建退款、撤销或人工 Case。自动退款前要确认 Capture/Authorization 状态、金额关联、时间窗、手续费/汇率影响和业务规则;证据不足时宁可人工审核。


八、恢复策略必须可发布、可回滚、可观察

银行卡、钱包、转账和异步本地方式的最终确认周期差异极大,不应全局固定“30 秒查三次”。策略按渠道、支付方式和操作版本化;以下参数只是结构示意:

version: recovery-policy-2026-09-01
rules:
- match: {channel: stripe, payment_method: card, operation: create}
  strategies: [idempotent_replay, retrieve, wait_webhook]
  delays: [10s, 30s, 2m, 10m, 30m]
  max_unknown_age: 2h
  query_qps: 20
  query_concurrency: 8
- match: {channel: local_bank, payment_method: bank_transfer, operation: create}
  strategies: [wait_webhook, merchant_reference_search, reconciliation]
  delays: [5m, 30m, 2h, 24h]
  max_unknown_age: 72h
  query_qps: 2
  query_concurrency: 1

发布路径是:DRAFT → Schema 和渠道能力校验 → 脱敏历史请求模拟 → 稳定 hash 分桶灰度 → 观察 → 全量,或回滚 Last Known Good。模拟需要发现无策略、空候选、受影响范围和查询预算突增;影子计算只记录决策,不得执行第二个 Create、查询或退款。

在途恢复任务应固化 policy_version,避免同一笔未知交易在规则变更后悄悄改变行为。紧急停用时也要产生显式“暂停/改期”记录,而不是改一条全局常量。

值班人员需要能从信号直接行动:

信号 先查什么 动作
unknown_rate 集中在一个渠道账户 请求阶段、Adapter 错误码、渠道健康度 排除新建流量;保留 Query/Webhook 小预算
recovery_oldest_age 超过方式 SLA 队列积压、租约、PSP 限流、回调延迟 调整优先级或升级人工;不要无限加 QPS
failover_denied 增长 certainty、查询结果、幂等重放结果 修复证据链或等待原渠道,不能绕过 Guard
multiple_success_attempts 非零 履约、Capture、退款状态 冻结重复履约,创建补偿 Case 并告警

指标和日志应关联 payment_idattempt_idrecovery_task_id、渠道账户、证据来源、错误码、策略版本与路由版本,以便回答“谁在何时、依据什么把状态改成成功”。


九、用两个故障案例校验设计

案例一:响应丢失。 PAY_10001 路由到 PSP-A,Create 的写操作完成后客户端超时。系统创建/唤醒 PSP-A 的 Recovery Task,Attempt 为 UNKNOWN。同 key 重放得到成功,或后续 Webhook/Query 给出成功证据;CAS 让一个执行者转为 SUCCEEDED,Outbox 发出一次 PaymentSucceeded。全程没有 PSP-B。

案例二:明确技术失败。 PSP-A 返回协议定义的 CHANNEL_UNAVAILABLE,Adapter 能证明未受理请求,并标记 DEFINITE_FAILURE + failoverEligible。Failover Guard 通过,系统保留 Attempt-01 的失败历史,排除其故障域,使用 PSP-B 创建 Attempt-02 和新 key。PSP-B 成功后,订单收敛成功。

反例是最危险的一行:

catch (TimeoutException e) {
    failover(); // 错误:把“不知道”当成“已失败”
}

它会让 PSP-A 的隐藏成功和 PSP-B 的新成功共同发生。无论监控上的成功率多漂亮,这都不是高可用。


十、上线前的最小验收与边界

至少验证以下场景,并用自动测试、演练记录或可检索指标留下证据:

注入场景 期望结果
PSP 已创建交易、响应丢失 UNKNOWN;同 key 重放;不产生第二个 Attempt
Webhook 与 Recovery 同时成功 仅一次 CAS 迁移、一条状态历史和一条领域事件
Worker 领取后崩溃 租约到期可接管;有效租约内不并发执行
查询持续 5xx 保持 UNKNOWN,按退避和预算执行,超 SLA 后人工升级
策略缺少渠道能力或造成空候选 静态校验或模拟阻止发布;可回滚 Last Known Good
对账发现外部成功、本地未知 证据化事件经状态机和 Outbox 收敛,非直接改表

容量要基于工作负载验证:以峰值 UNKNOWN 数乘每笔计划查询次数估算恢复请求上界,再分别验证渠道账户 QPS、Worker 并发、数据库领取批次与 Outbox 积压。本文的配置时间和阈值均为示意,不代表任何 PSP 的线上推荐值或性能承诺。

这套设计也刻意没有默认引入 Redis、MQ 或分布式锁。单库能够承受恢复任务时,唯一约束、租约领取、CAS 和 Outbox 足以定义正确性边界;只有任务规模、跨区域调度或查询隔离确实超过这一边界时,才将额外组件作为带新故障面的演进方案。

真正成熟的支付容灾不是“请求永远成功”,而是在失败不可避免时,系统始终知道:什么可以重试,什么必须等待,什么证据足以收敛,以及什么情况下只能交给人工和对账。


下一篇

故障处理建立以后,系统还要判断:到底是 PSP 变差,还是用户、发卡行、风控或平台本身出了问题。

08|如何把支付成功率从 95% 做到 98%?支付渠道监控体系设计




上一篇:AI 时代做产品,为什么克制反而更难了
下一篇:Android 17收紧USB调试权限 开发者选项状态始终返回0
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-13 18:22 , Processed in 1.410771 second(s), 45 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表