土法炼钢兴趣小组的算法知识备份

【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放

文章导航

分类入口
architecturefintech
标签入口
#payment-gateway#routing#rate-limit#circuit-breaker#idempotency#hsm#notification#channel

目录

一、为什么需要一个”支付网关”

在早期小规模业务里,支付接入常常是”一路通吃”:业务系统直接调用支付宝 SDK、微信 SDK,把 out_trade_no、金额、回调地址拼进去,发出请求即可。这种做法在通道数量≤2、交易量不高、商户结构简单的时候完全够用。

但只要业务稍微长大一点,问题就开始冒出来:

这些问题的共同特征是:它们都是”通道无关”的横切关注点,堆在业务系统里就会反复污染业务代码。把它们抽出来,就是支付网关(Payment Gateway)

本文的读者画像:已经读过本系列第 06 篇《支付系统全景》与第 08 篇《支付宝、微信支付接入》,对主流通道 API 不陌生;正在或即将承担一个面向 2~10 个通道、日均数十万到数千万笔交易的网关工程。下文不追求穷举所有特性,而是围绕”要上线一个能活过 2~3 年业务增长”的网关,给出骨架、数据模型、状态机与代码片段。

二、网关在支付架构中的定位

2.1 分层视图

一张自上而下的分层图,大部分中等规模公司的支付栈都可以抽象成这样:

flowchart TB
    subgraph Biz[业务域]
        Order[订单/营销/会员]
        Merchant[商户平台]
        Subs[订阅/计费]
    end

    subgraph GW[支付网关]
        API[统一接入 API]
        Route[路由/通道选型]
        Limit[限流/熔断]
        Sign[签名/验签/HSM]
        Notify[异步通知分发]
        Recon[补单/查单 Worker]
    end

    subgraph Ch[通道适配层]
        Ali[支付宝适配器]
        WX[微信适配器]
        UP[银联适配器]
        V[Visa/MC 适配器]
        Str[Stripe/Airwallex]
    end

    subgraph Infra[底层基础设施]
        Ledger[账务/清结算]
        Risk[风控引擎]
        KMS[KMS/HSM]
        Obs[监控/链路追踪]
    end

    Biz --> API
    API --> Route
    Route --> Limit
    Limit --> Sign
    Sign --> Ch
    Ch --> Notify
    Notify --> Biz
    Recon -.-> Ch
    Recon -.-> Ledger

    Route -.-> Risk
    Sign -.-> KMS
    GW -.-> Obs

几个要点:

2.2 边界再明确一点

网关负责

  1. 统一对外接入协议(HTTPS + JSON + 签名)。
  2. 内部业务订单(payments)与通道订单(channel_orders)的建立与状态机推进。
  3. 通道路由与灰度。
  4. 签名/验签、密钥管理、防重放。
  5. 异步通知的接收、验证、分发给业务方。
  6. 超时未决补单(主动查询)、异常重试、对账差错标记。
  7. 可观测性:通道成功率、耗时、单量、错误码分布。

网关不负责

三、核心能力矩阵

下面逐项展开。

3.1 路由与通道选型

一个生产级路由器至少要综合以下维度:

维度 示例 来源
币种 CNY 只能走国内通道,USD/EUR 走国际通道 订单参数
地域 用户 IP / BIN / billing country 订单 + BIN 查询
金额区间 小额走 A,大额走 B(费率阶梯) 通道费率表
产品类型 线上扫码 vs APP 支付 vs 银行卡 订单 trade_type
商户配置 白名单通道、禁用通道、费率包 商户平台
通道健康度 最近 5 分钟成功率、平均耗时 实时监控
成本 通道费率、分账后净成本 费率管理
限额 商户在通道的日/月限额 通道配额

路由器的输出是一个 通道链(Channel Chain):主通道 + 备选通道列表。主通道失败到某一等级(如 5xx、超时、风控拒绝)时,按策略切到下一个。这里”失败”的判定必须非常保守——明确的业务拒绝(余额不足、卡被冻结)不能降级,否则会把同一笔钱重复路由到多个通道,造成重复扣款。

3.2 限流与熔断

限流有三个层级:

  1. 全局限流:保护网关自身(例如 20k QPS)。
  2. 通道级限流:保护下游通道(例如微信支付的商户级 QPS、银联核心系统的通道级限额)。部分通道会在 API 网关层返回 FREQUENCY_LIMITED,不限流则会被通道封禁。
  3. 商户级限流:防止某个商户的刷单流量把整个网关占满。

熔断(Circuit Breaker)则更关注”快速失败”:

3.3 补单与重试

补单(Re-query / Reconcile)的出现场景:

工程上一般有三条补偿路径:

  1. 同步 API 下单时超时:立即查询通道订单(最多 1~2 次短时重试),确认状态;仍未决则标记 UNKNOWN,交给后台 Worker 处理。
  2. 异步通知超时未到:下单后 T 秒、2T 秒、4T 秒…(指数退避)主动 Polling 通道订单接口。
  3. 每日对账兜底:T+1 拉取通道账单,对未落地订单补录或冲正,流程详见第 23 篇《对账系统工程》

3.4 签名、验签与防重放

每个通道的签名算法各异:支付宝用 RSA2(SHA256withRSA)、微信支付 V3 用 RSA-SHA256 + 证书链、银联用 RSA + MD5、Visa/MC 在收单链路上用 MAC + PIN Block(见第 07 篇)。网关内部必须:

3.5 异步通知分发

网关收到通道异步通知后:

  1. 验签、校验金额与订单号。
  2. 写入 notify_events 表(幂等)。
  3. 查询内部订单状态机,决定是否推进。
  4. 把通知转发给业务方(下游订单系统、账务、风控),按业务方订阅关系发多路。

转发的重试策略需要独立于通道的重试(通道自己会重发若干次,但不能依赖它):

3.6 统一业务单号与幂等

Idempotency-Key 是网关的基石:

四、数据模型

4.1 四张核心表

-- 主订单(幂等主键:merchant_id + out_trade_no)
CREATE TABLE payments (
  id             BIGINT PRIMARY KEY,
  merchant_id    BIGINT NOT NULL,
  out_trade_no   VARCHAR(64) NOT NULL,
  biz_line       VARCHAR(32) NOT NULL,
  amount         BIGINT NOT NULL,            -- 最小货币单位(分/cent)
  currency       CHAR(3) NOT NULL,           -- ISO 4217
  subject        VARCHAR(128),
  payer_country  CHAR(2),
  state          VARCHAR(24) NOT NULL,       -- 见 5.1 状态机
  risk_score     SMALLINT,
  created_at     DATETIME(3) NOT NULL,
  updated_at     DATETIME(3) NOT NULL,
  finished_at    DATETIME(3),
  UNIQUE KEY uk_merchant_trade (merchant_id, out_trade_no),
  KEY idx_state_time (state, created_at)
);

-- 通道订单(一条主订单可能有多条通道尝试)
CREATE TABLE channel_orders (
  id              BIGINT PRIMARY KEY,
  payment_id      BIGINT NOT NULL,
  channel_code    VARCHAR(32) NOT NULL,      -- alipay / wechatpay / unionpay / stripe ...
  channel_trade_no VARCHAR(64),              -- 通道返回订单号
  attempt_seq     INT NOT NULL,              -- 第几次尝试
  state           VARCHAR(24) NOT NULL,      -- 见 5.2
  request_body    MEDIUMTEXT,                -- 脱敏后
  response_body   MEDIUMTEXT,
  error_code      VARCHAR(64),
  error_msg       VARCHAR(512),
  created_at      DATETIME(3) NOT NULL,
  updated_at      DATETIME(3) NOT NULL,
  UNIQUE KEY uk_payment_attempt (payment_id, attempt_seq),
  KEY idx_channel_tradeno (channel_code, channel_trade_no)
);

-- 异步通知事件
CREATE TABLE notify_events (
  id              BIGINT PRIMARY KEY,
  channel_code    VARCHAR(32) NOT NULL,
  channel_event_id VARCHAR(64) NOT NULL,     -- 通道侧事件 ID,用于幂等
  payment_id      BIGINT,
  raw_body        MEDIUMTEXT,
  received_at     DATETIME(3) NOT NULL,
  verified        TINYINT NOT NULL,
  dispatch_state  VARCHAR(24) NOT NULL,      -- PENDING / DISPATCHED / DLQ
  retry_count     INT NOT NULL DEFAULT 0,
  next_retry_at   DATETIME(3),
  UNIQUE KEY uk_channel_event (channel_code, channel_event_id),
  KEY idx_dispatch (dispatch_state, next_retry_at)
);

-- 退款单
CREATE TABLE refund_orders (
  id              BIGINT PRIMARY KEY,
  payment_id      BIGINT NOT NULL,
  out_refund_no   VARCHAR(64) NOT NULL,
  channel_refund_no VARCHAR(64),
  amount          BIGINT NOT NULL,
  reason          VARCHAR(256),
  state           VARCHAR(24) NOT NULL,
  created_at      DATETIME(3) NOT NULL,
  updated_at      DATETIME(3) NOT NULL,
  UNIQUE KEY uk_payment_refund (payment_id, out_refund_no)
);

几点值得强调:

4.2 请求幂等键

网关对外 HTTP API 建议:

POST /v1/payments
Idempotency-Key: 3f9a7b2e-...
Content-Type: application/json

{
  "merchant_id": 1001,
  "out_trade_no": "ORD20260422001",
  "amount": 9900,
  "currency": "CNY",
  "subject": "会员季度",
  "return_url": "https://merchant.example.com/return",
  "notify_url": "https://merchant.example.com/notify",
  "channel_hint": "alipay"      // 可选,否则由网关路由决策
}

Idempotency-Keyout_trade_no 双重保护:前者用于防止客户端重试产生多条请求(例如 HTTP 超时重试),后者用于业务级订单唯一。两者一般相同,但 Stripe 的 Idempotency-Key 是独立于业务单号的请求级唯一键,也有采用该模式的网关实现。

五、双状态机设计

5.1 主订单状态机(内部)

stateDiagram-v2
    [*] --> INIT
    INIT --> ROUTING: 风控通过
    INIT --> REJECTED: 风控拒绝
    ROUTING --> SUBMITTED: 选中通道、提交通道
    ROUTING --> FAILED: 所有通道不可用
    SUBMITTED --> PAID: 通道返回成功通知
    SUBMITTED --> UNKNOWN: 超时/异常
    UNKNOWN --> PAID: 补单确认成功
    UNKNOWN --> FAILED: 补单确认失败
    SUBMITTED --> FAILED: 明确失败
    PAID --> REFUNDING: 发起退款
    REFUNDING --> REFUNDED: 退款成功
    REFUNDING --> PAID: 退款失败回滚
    FAILED --> [*]
    REFUNDED --> [*]
    REJECTED --> [*]

关键点:

5.2 通道订单状态机

stateDiagram-v2
    [*] --> PREPARED
    PREPARED --> SENT: HTTP 已发出
    SENT --> ACK: 通道返回 2xx
    SENT --> TIMEOUT: 网络超时
    ACK --> CH_SUCCESS: 异步通知 SUCCESS
    ACK --> CH_FAILED: 异步通知 FAIL
    ACK --> CH_CLOSED: 主动关单
    TIMEOUT --> CH_SUCCESS: 补单确认
    TIMEOUT --> CH_FAILED: 补单确认
    CH_SUCCESS --> [*]
    CH_FAILED --> [*]
    CH_CLOSED --> [*]

主订单 state 与通道订单 state 的映射关系建议做成一张独立的跃迁表,而不是散落在代码里的 switch:

CREATE TABLE state_transition_rules (
  trigger         VARCHAR(64),       -- CH_SUCCESS / CH_FAILED / TIMEOUT / ...
  from_state      VARCHAR(24),
  to_state        VARCHAR(24),
  action          VARCHAR(64),       -- NOTIFY_BIZ / LEDGER_POST / ...
  PRIMARY KEY (trigger, from_state)
);

这样一条日志 + 一张规则表 + 一个状态机执行器,故障回放和对账差异的排查效率会大幅提升。

六、路由策略

6.1 常见策略

  1. 加权轮询(Weighted Round Robin):按通道费率/成功率做静态权重。
  2. 成功率动态路由:实时计算近 N 分钟各通道成功率,按 EWMA(指数加权移动平均)调整权重,成功率低于阈值的通道降权或下线。
  3. 主备通道:对大额订单或关键商户配置主备,主通道在 3~5 秒内无响应即切备。
  4. 金额/BIN 分段路由:按 BIN(卡号前 6~8 位)识别卡组织与发卡行,定向到费率更优或成功率更高的通道。
  5. 灰度路由:新通道按比例(如 1%→5%→25%→50%→100%)逐步放量,详见 9.1。

6.2 一个最小可用的 Go 路由器

package router

import (
    "context"
    "errors"
    "sort"
    "sync"
    "time"
)

type Channel struct {
    Code    string
    Weight  int
    Enabled bool
    Healthy func() bool
    Fee     func(amount int64, currency string) int64
    Support func(req *Request) bool
}

type Request struct {
    MerchantID int64
    Amount     int64
    Currency   string
    BIN        string
    Country    string
    Hint       string
}

type Router struct {
    mu       sync.RWMutex
    channels map[string]*Channel
    metrics  MetricsProvider
}

func (r *Router) Pick(ctx context.Context, req *Request) ([]*Channel, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    var candidates []*Channel
    for _, ch := range r.channels {
        if !ch.Enabled || !ch.Healthy() {
            continue
        }
        if !ch.Support(req) {
            continue
        }
        candidates = append(candidates, ch)
    }
    if len(candidates) == 0 {
        return nil, errors.New("no available channel")
    }

    // 综合成功率、费率、权重
    type scored struct {
        ch    *Channel
        score float64
    }
    scoredList := make([]scored, 0, len(candidates))
    for _, ch := range candidates {
        sr := r.metrics.SuccessRate(ch.Code, 5*time.Minute)
        fee := float64(ch.Fee(req.Amount, req.Currency))
        score := sr*0.7 + (1.0/(1.0+fee/100.0))*0.2 + float64(ch.Weight)*0.1
        if ch.Code == req.Hint {
            score += 0.5
        }
        scoredList = append(scoredList, scored{ch, score})
    }
    sort.Slice(scoredList, func(i, j int) bool {
        return scoredList[i].score > scoredList[j].score
    })

    chain := make([]*Channel, 0, len(scoredList))
    for _, s := range scoredList {
        chain = append(chain, s.ch)
    }
    return chain, nil
}

这段代码有意写得朴素:打分函数在生产中会上升到一个独立的策略服务,支持热加载权重配置。但”候选集过滤 + 打分排序 + 返回链表”这个骨架不变。

6.3 决策流

flowchart LR
    A[下单请求] --> B{参数校验}
    B -->|失败| Z1[400]
    B -->|通过| C{风控前置}
    C -->|拒绝| Z2[REJECTED]
    C -->|通过| D[候选通道过滤]
    D --> E[打分排序]
    E --> F[选主通道]
    F --> G{限流/熔断}
    G -->|触发| H[切备通道]
    H --> F
    G -->|通过| I[签名调用]
    I --> J{通道响应}
    J -->|成功| K[SUBMITTED]
    J -->|业务失败| L[FAILED]
    J -->|系统错误/超时| H
    K --> M[等异步通知]

要点:“业务失败”不能降级。比如 Visa 返回 05 Do not honor14 Invalid card number51 Insufficient funds,这些都是发卡行明确拒绝,换通道重试要么无效要么涉嫌规避风控(尤其是拒付类)。只有系统错误(网络超时、5xx、通道自身限流)才能切备。

6.4 路由策略的常见反模式

6.5 路由配置热加载

路由策略不可能一次写死,权重、阈值、灰度比例都会频繁调整。一个生产级路由器必须支持:

七、熔断器实现示例

7.0 为什么熔断对支付网关尤其重要

支付通道是典型的”外部依赖 + 慢故障”:它不会突然 100% 宕机,而是先进入”大量请求 hang 30s 再 502”的状态。如果不熔断,几秒内网关的所有 worker 都会被这种慢请求占满,导致本来健康的通道也无法服务新的请求。这种级联故障在 2021~2023 年的多次公开支付事故复盘里都出现过。

熔断的目的不是”替通道失败”,而是”用 5% 的主动放弃换 95% 的整体可用”。下面给一个可直接使用的实现骨架。

7.1 一个可落地的实现

一个常用形态是”滑动窗口 + 半开态探测”:

package breaker

import (
    "errors"
    "sync"
    "sync/atomic"
    "time"
)

type State int32

const (
    Closed State = iota
    Open
    HalfOpen
)

type Breaker struct {
    name         string
    failureRate  float64       // 触发熔断的失败率阈值
    minSamples   int64         // 统计最少样本数
    window       time.Duration
    sleepWindow  time.Duration // Open -> HalfOpen 的冷却时间
    halfOpenMax  int64         // HalfOpen 态允许的探测并发数

    state        atomic.Int32
    openedAt     atomic.Int64
    halfOpenInflight atomic.Int64

    mu           sync.Mutex
    bucketStart  time.Time
    totalCount   int64
    failureCount int64
}

var ErrOpen = errors.New("circuit breaker open")

func (b *Breaker) Allow() error {
    switch State(b.state.Load()) {
    case Closed:
        return nil
    case Open:
        if time.Now().UnixNano()-b.openedAt.Load() > int64(b.sleepWindow) {
            if b.state.CompareAndSwap(int32(Open), int32(HalfOpen)) {
                return nil
            }
        }
        return ErrOpen
    case HalfOpen:
        if b.halfOpenInflight.Add(1) > b.halfOpenMax {
            b.halfOpenInflight.Add(-1)
            return ErrOpen
        }
        return nil
    }
    return nil
}

func (b *Breaker) Report(success bool) {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    if now.Sub(b.bucketStart) > b.window {
        b.bucketStart = now
        b.totalCount = 0
        b.failureCount = 0
    }
    b.totalCount++
    if !success {
        b.failureCount++
    }

    switch State(b.state.Load()) {
    case HalfOpen:
        b.halfOpenInflight.Add(-1)
        if success {
            b.state.Store(int32(Closed))
            b.failureCount = 0
            b.totalCount = 0
        } else {
            b.state.Store(int32(Open))
            b.openedAt.Store(now.UnixNano())
        }
    case Closed:
        if b.totalCount >= b.minSamples &&
            float64(b.failureCount)/float64(b.totalCount) >= b.failureRate {
            b.state.Store(int32(Open))
            b.openedAt.Store(now.UnixNano())
        }
    }
}

要配合每个”通道 × 商户”单独一套实例,不要全局一把锁。Report 里只对”通道故障”错误计数——业务拒绝(如余额不足)应该 Report(true),否则高拒付率商户会反复触发熔断。

八、异步通知与补单 Worker

8.1 异步通知的坑

真实世界里,异步通知几乎无法假设”必达 + 有序”:

8.2 通道通知接收

func (h *NotifyHandler) Handle(w http.ResponseWriter, r *http.Request) {
    ch := mux.Vars(r)["channel"]
    body, _ := io.ReadAll(r.Body)
    adapter := h.registry.Get(ch)
    if adapter == nil {
        http.Error(w, "unknown channel", 404)
        return
    }

    ev, err := adapter.ParseNotify(body, r.Header)
    if err != nil {
        h.metrics.IncVerifyFail(ch)
        http.Error(w, "verify failed", 400)
        return
    }

    // 幂等写入
    if err := h.repo.SaveEvent(r.Context(), ev); err != nil {
        if errors.Is(err, store.ErrDuplicate) {
            adapter.WriteAck(w)
            return
        }
        http.Error(w, "db error", 500)
        return
    }

    // 异步推进状态机与下游分发
    h.queue.Enqueue(ev.ID)

    adapter.WriteAck(w) // 每个通道的确认体格式不同
}

这里一个关键习惯:先写入 notify_events 再返回 ACK。如果先推进状态机再写事件表,进程挂掉后事件丢失,通道不会再重发。

8.3 补单(Polling)Worker

func (w *ReconcileWorker) Run(ctx context.Context) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            orders := w.repo.FindPendingOlderThan(ctx, []string{"SUBMITTED", "UNKNOWN"}, 30*time.Second, 500)
            for _, o := range orders {
                w.pool.Submit(func() { w.query(ctx, o) })
            }
        }
    }
}

func (w *ReconcileWorker) query(ctx context.Context, o *ChannelOrder) {
    adapter := w.registry.Get(o.ChannelCode)
    resp, err := adapter.Query(ctx, o)
    switch {
    case err != nil && isTransient(err):
        w.repo.BumpRetry(ctx, o, backoff(o.RetryCount))
    case err != nil:
        w.stateMachine.Apply(ctx, o, "CH_FAILED", err.Error())
    case resp.State == "SUCCESS":
        w.stateMachine.Apply(ctx, o, "CH_SUCCESS", "")
    case resp.State == "FAILED", resp.State == "CLOSED":
        w.stateMachine.Apply(ctx, o, "CH_FAILED", resp.ErrorCode)
    default:
        w.repo.BumpRetry(ctx, o, 30*time.Second)
    }
}

工程约定:

8.4 通知分发重试

func (d *Dispatcher) dispatch(ev *NotifyEvent) {
    subscribers := d.subs.For(ev.BizLine, ev.PaymentID)
    for _, s := range subscribers {
        err := d.postJSON(s.URL, ev, s.Secret)
        if err == nil {
            d.repo.MarkDispatched(ev.ID, s.ID)
            continue
        }
        if ev.RetryCount >= 10 {
            d.repo.MoveToDLQ(ev.ID, s.ID)
            d.alert.Fire("notify_dlq", ev.ID)
            continue
        }
        d.repo.ScheduleNext(ev.ID, s.ID, backoffSeq[ev.RetryCount])
    }
}

backoffSeq 一般配 [30s, 1m, 5m, 15m, 1h, 6h, 24h, 24h, 24h, 24h]。死信队列不是”扔掉”,而是”不再自动重试、进入人工处理通道”,通常对接工单系统。

8.5 通知有序性与幂等落地

业务方在接收网关分发的通知时,同样要做两件事:

  1. 幂等:以 event_id(payment_id, event_type) 作为唯一键,重复到达直接返回成功。
  2. 状态保序:业务方自己维护订单状态机,只接受”前向推进”。PAID 收到后如果再收到 SUBMITTED,忽略即可。

这两点如果业务方不做,网关再努力也无法保证最终一致。因此网关的 SDK / 接入文档必须把”业务方必须做”的清单写清楚,最好附上样例代码与一个可下载的接入自测工具包。

九、风控点接入

网关与风控的交互有两个点:

  1. 下单前(Pre-risk):收到业务请求后,在路由前调用风控,拿到 allow/review/rejectrisk_score。这个调用必须是低延迟、必达(超时直接 fail-close 或降级到本地规则),否则会被黑产打爆。
  2. 通道返回后(Post-risk):通道 SUCCESS 后,在账务落账前再评估一次。对高风险订单可放入”待放款”队列,人工复核或延迟出款。

风控接口建议放入 risk_scorepayments 表,便于后续采样分析和模型回归。详细的规则引擎、特征工程、Flink 实时流留给第 19 篇。

9.1 Pre-risk 的降级与兜底

Pre-risk 是”拦截在路由之前”的关键一步,但它同时引入了一个新的单点:风控本身的可用性。工程上常见的降级策略有三种:

  1. Fail-open(通过):风控超时/不可用时直接放行。优点是用户体验无感,缺点是遇到黑产集中攻击时会造成批量资损。
  2. Fail-close(拒绝):风控超时即拒绝交易。优点是安全,缺点是风控故障瞬间业务成功率会暴跌。
  3. Fail-local(本地降级规则):在网关侧内置一套保守的本地规则(黑名单 IP、异常金额、异常频次),风控不可用时走本地规则。这是大多数生产系统的选择。

在订单入账侧的 Post-risk 则通常 fail-close——反正钱已经收了,宁可让客服人工复核,也不要把高风险订单直接放款给下游。

9.2 三道口子的执行顺序

一笔订单在网关内一般会经过”三道口子”:

  1. 参数校验与签名验签(微秒~毫秒级,必过)。
  2. Pre-risk 风控(十毫秒级,允许降级)。
  3. 路由决策 + 限流熔断(毫秒级,本地逻辑)。

三道口子全部通过后才会真正调用通道。这个顺序不能随意打乱,尤其不能把”调用通道”放在”风控”之前,否则攻击者可以通过大量伪造请求让网关持续调通道、产生费用并污染统计数据。

十、密钥与证书管理

10.1 层次

10.2 管理方式

按安全等级由低到高:

  1. 配置中心 + 加密列:仅适合 dev/staging。
  2. KMS 包裹(Envelope Encryption):主密钥在云 KMS,数据密钥用主密钥加密后存库。启动时用主密钥解开。
  3. HSM(Hardware Security Module):密钥永不落盘,签名操作在 HSM 内完成。PCI DSS 3.2 对卡数据密钥管理强制要求 FIPS 140-2 Level 3 以上的 HSM;中国人行在《银行卡收单业务管理办法》与 JR/T 0025 系列中对加密机也有强约束。
  4. TEE/云 HSM 托管:AWS CloudHSM、阿里云加密服务、华为云 DEW,成本与运维更友好。

10.3 证书轮换与泄露应急

10.4 接口抽象

type KeyProvider interface {
    Sign(ctx context.Context, keyID string, payload []byte) ([]byte, error)
    Verify(ctx context.Context, keyID string, payload, sig []byte) error
    Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error)
    Decrypt(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error)
}

keyID 是一个稳定的逻辑 ID(如 alipay/merchant/1001/sign),Provider 负责解析到具体的 KMS/HSM 资源。这样当从 KMS 迁移到 HSM 时,业务代码无需改动。

10.5 防重放的细节

签名正确不代表请求合法。一个常见攻击是重放攻击(Replay Attack):攻击者截获一笔合法请求后,在短时间内反复重放,如果网关没有防重放机制,同一笔款会被反复扣多次。标准做法:

10.6 一个值得抄的规范:PCI DSS 的密钥管理要求

PCI DSS v4.0 的第 3 章与第 8 章对密钥管理有非常细的硬性要求,值得任何涉及卡数据的团队直接参考:

不做卡数据的国内业务虽然不强制 PCI,但其方法论同样可用于支付宝/微信的商户私钥保护。

十一、可观测性

11.0 为什么支付网关的观测要”双轨”

一般后端服务的监控聚焦于”系统是否健康”:QPS、延迟、错误率、资源使用。支付网关除了这些 SRE 指标之外,还要承担资金安全的监控责任:

两条轨道的受众也不一样——SRE 指标给值班工程师看,资金指标给财务、风控、客服看。看板要分开做,告警通道也分开(例如系统告警进 PagerDuty,资金告警进财务值班群 + 电话)。

11.1 链路追踪

每个入网请求生成一个 traceId,透传到:

链路贯通后,“一笔订单从下单到入账”的完整时序图可以从 Jaeger/Tempo 直接拉出来,大幅降低故障排查成本。

11.2 核心指标

指标 维度 告警示例
下单成功率 通道、商户、产品 5 分钟内 < 95%
通道平均耗时(P95/P99) 通道、产品 P99 > 3s
异步通知延迟(到达 - 成功时间) 通道 P99 > 60s
通知分发失败率 业务方 > 1%
补单积压 状态 UNKNOWN > 100 持续 10 分钟
熔断触发次数 通道 × 商户 任一触发即告警
验签失败数 通道 突增(同比 > 3σ)

11.3 业务看板

除了 SRE 指标,给业务和财务团队看的日看板至少包含:

十二、灰度与发布

12.1 通道灰度

新接入通道/新签名算法/新路由策略的发布策略:

  1. 影子流量(Shadow):真实流量 100% 走旧通道,同时复制到新通道,只比对响应不影响真实交易。适合验证签名、响应解析的正确性。
  2. 按商户白名单:指定若干低风险商户先切 1%→5%→25%。
  3. 按金额档:新通道先跑小额(≤100 元),观察 1~2 周后放开。
  4. 按时段:先在交易低峰(凌晨)开启,避免问题在高峰放大。

12.2 功能开关(Feature Flag)

网关的每个策略点都应该有开关:路由策略、熔断阈值、通知重试参数、补单频率。开关中心(如 Apollo、Nacos、自研)必须支持秒级生效与审计日志。金融场景下的一个铁律是:配置改动也要有变更单,因为一个错误的阈值比一行错误代码造成的资损可能更大。

12.3 回滚

12.4 发布窗口

金融场景的发布窗口与普通互联网公司差异明显:

把这些规范写进 CI/CD 流水线(禁止时段自动阻断、灰度卡点自动等待),比写进文档有效十倍。

十三、真实案例

下面几个案例的事实以公开资料为准,具体数字仅用作工程参考。

13.1 拉卡拉(Lakala)

拉卡拉作为 A 股上市的第三方支付机构(股票代码 300773),在其招股说明书与年报中披露了其”全渠道支付”的技术架构:统一接入层对接银联、网联、卡组织国际通道(Visa/Mastercard/JCB 等),商户通过 API/POS/扫码多入口下单,网关内部做路由与合规校验。对于 A 股公众公司而言,其年报中披露的”交易处理能力”与”通道冗余”信息,是研究国内大型收单机构网关设计的公开可验证资料。

工程启示:通道冗余不是靠多写几套适配器,而是把所有通道抽象到同一套状态机下,否则运维成本会随通道数指数增长。

13.2 收钱吧

收钱吧作为聚合支付的代表玩家,对外是”一个二维码收全渠道”,对内实际上是一个多通道网关 + 商户服务体系。其官网与备案信息显示其同时持有支付业务许可证(由 2018 年收购上海钱方获得相关牌照体系)。聚合支付网关的一个典型工程挑战是:同一张收款码背后会在不同场景下落到不同通道(支付宝扫->支付宝、微信扫->微信、银联云闪付扫->银联),网关要做 User-Agent 与 scheme 识别后再路由。

13.3 Airwallex 多通道路由

Airwallex(空中云汇)是澳洲起家的跨境支付科技公司,主营多币种收单/付款/换汇。其官方工程博客中公开提到”smart routing”:根据发卡国、卡组织、历史成功率动态选择收单机构(acquirer),在欧洲区域甚至能够一笔交易在多家 acquirer 之间 fall-back。其路由器是上文 6.1 第 2 种”成功率动态路由”的生产实现。

工程启示:跨境场景下,同一张卡在不同 acquirer 上的批准率差异可达 10~20 个百分点。一个聪明的路由器直接等于利润。

13.4 Stripe Radar + Connect

Stripe 是国际支付 SaaS 的标杆:

13.5 历史事故借鉴

十三补、常见通道差异速查

下表是一张经常用到的通道差异速查表。它不追求穷举,而是把”写适配器时最容易踩坑”的点列出来,供团队新人快速对齐。

维度 支付宝 微信支付 V3 银联 Visa / Mastercard Stripe
签名算法 RSA2(SHA256withRSA) RSA-SHA256 + 证书链 RSA + SHA1/SHA256 MAC + PIN Block(ISO 8583) HMAC-SHA256(Webhook)
密钥形式 应用私钥 + 支付宝公钥 商户私钥 + 平台证书(可轮换) 双向 TLS 证书 + 报文签名 HSM 内 MK/ZMK 体系 API Key + Webhook Secret
金额单位 元(字符串,两位小数) 分(整数) 分(整数) 最小货币单位(整数) 最小货币单位(整数)
订单号 out_trade_no ≤ 64 位 out_trade_no 6-32 位 orderId 商户自定义 无统一字段,走 STAN client_reference_id
异步通知 POST form,重试 8 次 POST JSON,重试 15 次 POST form,重试 5 次 不直接回调,走批处理 POST JSON,最长 3 天
通知确认 返回文本 success 返回 JSON {code:"SUCCESS"} 返回 00 成功码 N/A 200 状态码即可
查单接口 alipay.trade.query /v3/pay/transactions/out-trade-no/{x} 5.1.2 交易查询 走清算文件(Clearing) /v1/payment_intents/{id}
退款粒度 支持部分退款,多次 支持部分退款,多次 支持部分退款 支持,但要看 MCC 与发卡行 支持部分退款、多次
幂等 out_trade_no 幂等 out_trade_no 幂等 orderId 幂等 STAN + Date 幂等 Idempotency-Key 幂等
对账文件 次日 T+1 对账单 API 次日 T+1 对账单 API T+1 清算文件 T+1 / T+2 清算文件(Base II / IPM) API 拉取 balance transactions

把上述差异沉淀成每个 Adapter 实现的 golden-test,能显著减少”改 A 通道不小心把 B 通道弄坏”的回归。

十四、工程坑点

  1. 双扣款:同一笔订单切换通道后,旧通道延迟成功,导致两边都扣款。解法:切换前必须先拿到旧通道”明确失败 + 已关单”确认,或做 T+0 自动冲正。
  2. 通知乱序:先到 PAID 后到 CLOSED。解法:以状态机的前向推进为准,不接受倒退;对冲突通知记录审计日志人工复核。
  3. 金额不匹配:通道通知回来的金额与下单金额不一致(罕见但出现过)。解法:验签通过后再比对金额,任何差异直接 reject 通知并告警。
  4. 时钟漂移:签名用 timestamp,容器时钟偏差导致验签失败。必须配 NTP,并允许 ±300s 容差。
  5. 通道文档与实际不一致:支付宝某些错误码在不同版本 SDK 上含义不同;微信 V2/V3 签名算法差异巨大。解法:每个通道适配器要维护自己的”错误码映射 + 版本矩阵”测试集。
  6. 回调 URL 被爬虫命中:网关 notify_url 一旦暴露在 DNS,会被各种扫描器探测。解法:对未带签名的请求快速返回 400,记录来源 IP,必要时 WAF 拉黑。
  7. 幂等键被复用:商户 SDK bug 把同一 out_trade_no 用在不同金额订单。解法:幂等键命中后严格比对”关键字段(金额、币种、subject)“是否一致,否则返回 409。
  8. 数据库热点payments 表按时间写,主键自增导致尾页热点。解法:主键用雪花 ID 或 TiDB 的 AUTO_RANDOM(见第 04 篇《账务数据库设计》)。
  9. 日志脱敏失败:调试期不小心把卡号、CVV 写进日志,过了 PCI 审计就是灾难。解法:日志框架内置敏感字段过滤器 + 单元测试兜底。
  10. 补单无限循环:通道接口对不存在的订单返回 UNKNOWN,Worker 反复查。解法:给补单加最大次数 + 最大时长双重上限,过期关单。
  11. 退款超过原单金额:由于并发或人工错误,退款总额累加后超过支付金额。解法:refund_orders 插入前在事务中锁 payments 行并做 SUM(已退金额) + 本次 <= 支付金额 校验。
  12. 商户 notify_url 被替换:运营后台被越权操作导致 notify_url 指向他人服务器,交易通知被劫持。解法:notify_url 改动需二次确认,并对回调 URL 做域名白名单与 HTTPS 强制。
  13. 退款先于支付成功通知到达:极端情况下商户发起退款,异步流水导致退款通知早于原支付通知到达。解法:状态机里严格要求 PAID → REFUNDING,否则写入待处理队列延迟处理。
  14. 金额单位误用:支付宝 API 金额是”元.分分”字符串,微信是”分”整数,一线工程师切换时把 100(分)误当成 1.00 元传出去,差 100 倍。解法:Adapter 层统一入参是内部 Money{amount, currency},出参转换集中一个 helper。
  15. 节假日 / 凌晨对账切换:通道对账文件在某些节假日延迟 1~2 天,或跨年时日期格式变化。解法:对账任务的时间窗口配置化,异常不要直接告警爆炸,而是带”文件未到”的独立告警等级。

十五、选型与落地清单

面对一个新项目,推荐按下列顺序推进:

  1. 明确通道清单与商户模型:第一版 1~2 个通道、单一商户结构就够了,别上来就通用化。
  2. 定义统一下单 API:字段设计参考 4.2,先让上游业务接进来。
  3. 落地数据模型:四张核心表先上,状态机用枚举 + 规则表表达。
  4. 写第一个通道适配器:用真实通道跑通”下单 + 异步通知 + 查单 + 退款”四个动作,作为”参考实现”。
  5. 抽离路由器与熔断器:当第二个通道出现时立刻抽离,不要等第三个。
  6. 补单 Worker:补单比路由重要,先上线。
  7. 通知分发服务:通知可靠性 > 通知性能。
  8. 密钥托管:至少做到 KMS 封装,PCI 相关业务再上 HSM。
  9. 可观测性:上线前必须有核心指标、链路追踪、对账差异看板。
  10. 演练:密钥泄露、通道宕机、DB 主从切换三个场景每半年演练一次。

十六、小结

支付网关在整个金融科技栈里,是”离钱最近、坑最多、但最容易被低估”的一层。它看起来只是协议转换和转发,实际是一个有状态、强幂等、高可用、强合规的分布式系统。做好它的关键不在某个花哨算法,而在:

从团队形态上讲,一个成熟支付网关背后通常是”三角结构”:

三方的边界不清,网关就会被塞进各种”临时补丁”——一个特殊商户的绕风控开关、一个通道的特殊退款流程、一段写死的费率。这些”临时”最终都会变成事故的放大器。清晰的分工、契约化的接口、完善的审计日志,是网关工程能活得久的根本。

回顾全文,我们从”为什么需要网关”讲到”分层结构”,从”能力矩阵”讲到”状态机与数据模型”,再落到”路由器 / 熔断器 / 补单 Worker”的代码骨架,最后以”密钥 / 灰度 / 观测 / 案例”收尾。对于一个刚起步的团队,建议不要一次把全部能力做完,按第十五节的清单分阶段落地;对于已经有规模的团队,建议用本文的章节作为 review checklist,逐项对照自家系统是否有显式的、可观测的、可回滚的实现。

下一篇我们从”把钱收进来”走到”持续向用户收钱”:订阅与计费系统的用量计量、账单、出海税务。

参考资料


上一篇《支付宝、微信支付接入:服务商模式、预授权、分账、退款、对账》

下一篇《订阅与计费系统:用量计量、账单、发票、出海 VAT/GST》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿

支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。


By .