代码 支付回调用 payment_id 去重,退款事件就永远进不来:event_id 与两把锁

2026-10-24 21:32:34

支付 webhook 去重:event_id 和 payment_id 是两把锁,混用会吞掉退款

浏览器跳回 /success?status=ok 不能当成支付成功。URL 里的参数由前端控制,用户改成 ?status=failed 也一样能打开。跳回页只是“去查一下”的信号,真正的状态来自服务端主动查单,或验签后的 webhook。

下面这些点来自对接 eSewa、IMEPay、Dodo Payments、Paddle 时踩过的坑,顺序按一次请求从进来到落库。

验签要基于原始字节

不验签的 webhook 等于公开一个 POST /webhook {"type":"payment_succeeded"} 接口。

签名算的是原始 body 的字节,不是反序列化之后的 JSON 对象。很多框架先把 body 解析成 map,再序列化回去,键顺序、空白、数字格式全变了,签名自然对不上。于是有人干脆把验签关掉。

NestJS 要开 rawBody: true,并且确认该路由拿到的是未被改动的 buffer。Paddle 的文档明确要求不能改动 raw body,连空白都不行。SDK 的验签 helper 还会拒绝时间戳超出容忍窗口的请求(文档默认 5 秒),不少厂商走的是 Standard Webhooks 规范,这部分逻辑可以直接复用。

Go 里大概是这个形状:

func webhook(w http.ResponseWriter, r *http.Request) {
    raw, err := io.ReadAll(r.Body) // 只读一次,后面都用这份
    if err != nil {
        http.Error(w, "bad body", http.StatusBadRequest)
        return
    }
    if err := verify(r.Header, raw); err != nil {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    var ev Event
    if err := json.Unmarshal(raw, &ev); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    handle(r.Context(), ev, raw)
}

验签只回答“这是谁发的”。它不阻止重复处理。

厂商至少投递一次

慢、超时、返回 500,都会触发重发。就算你每次都 200,也不保证只来一次。所以真正要解决的是重复,不是签名。

去重是两把锁,不是一个键

两个不同的问题:

  • 同一条 webhook 投递两次 → 用 event id 建立唯一约束,见过就跳过;
  • 同一笔支付被记录两次 → 用 payment id 建立唯一约束。

不能共用同一个键。一笔支付会随时间产生多条事件:paid、refunded、disputed。如果用 payment_id 当唯一键去重,退款事件到达时,你在库里查到“这笔支付已经处理过了”,于是跳过,退款永远不发生,权益继续发放。

create table webhook_events (
  event_id    text primary key,
  type        text not null,
  occurred_at timestamptz not null,
  payload     jsonb not null
);

create table payments (
  provider_payment_id text primary key,
  order_id            text not null,
  amount_minor        bigint not null,
  currency            char(3) not null,
  status              text not null,
  occurred_at         timestamptz not null
);

event_id 管“这条消息见过没”,provider_payment_id 管“这笔钱记过没”。两条约束各管各的。

用唯一约束,不要 select-then-insert

代码里写“先查一下存在吗,不存在就插入”在并发下必然出问题:两份相同的 webhook 同时到达,两个事务都查到“没有”,然后都插入。唯一约束保证只有一个能提交,另一个报冲突(用 PostgreSQL 的 INSERT ... ON CONFLICT DO NOTHING 直接变成静默跳过)。

别把去重放在应用层的 map 或 Redis 里做唯一判断,那是缓存,会过期,也不是权威。

记录支付和发放权益放同一个事务,不要提前 return

分两步写的经典 bug:

  1. 插入 payments 成功;
  2. 调发权益的接口前进程崩了。

重试时看到 payment 已存在,代码 return,用户付了钱,永远拿不到权益。

修复是把两条语句放进同一个事务,两条都写成幂等的,每次投递都跑一遍:

func handle(ctx context.Context, ev Event) error {
    return db.WithTx(ctx, func(tx pgx.Tx) error {
        // 事件去重:和业务写入在同一事务里,命中说明上次已完整提交
        tag, err := tx.Exec(ctx, `
            insert into webhook_events (event_id, type, occurred_at, payload)
            values ($1,$2,$3,$4) on conflict do nothing`,
            ev.ID, ev.Type, ev.OccurredAt, ev.Raw)
        if err != nil {
            return err
        }
        if tag.RowsAffected() == 0 {
            return nil
        }

        if _, err := tx.Exec(ctx, `
            insert into payments (provider_payment_id, order_id, amount_minor, currency, status, occurred_at)
            values ($1,$2,$3,$4,$5,$6)
            on conflict (provider_payment_id) do nothing`,
            ev.PaymentID, ev.OrderID, ev.AmountMinor, ev.Currency, ev.Status, ev.OccurredAt); err != nil {
            return err
        }

        if _, err := tx.Exec(ctx, `
            insert into entitlements (order_id) values ($1)
            on conflict (order_id) do nothing`, ev.OrderID); err != nil {
            return err
        }
        return nil
    })
}

注意这里没有“payment 已存在就 return”。重复投递可能是补完上次半成品的机会,让它跑完那两条 on conflict do nothing 的语句,代价几乎为零。

另外,事件记录之所以能“见过就跳过”,前提是它和业务写入在同一个事务里。如果你先单独记一条“收到了”再去处理业务,中间失败,重投时那条记录会让整条事件被跳过,直接丢单。要记“收到”,就得和业务写入一起提交。

外部副作用交给 outbox

事务只覆盖自己的数据库。发邮件、调外部 API 都不在事务里,回滚不掉。用 transactional outbox:事务内往待办表写一行,后台 worker 取走并重试。

create table outbox (
  id       bigserial primary key,
  topic    text not null,
  payload  jsonb not null,
  done_at  timestamptz
);
_, err = tx.Exec(ctx,
    `insert into outbox (topic, payload) values ('payment.receipt', $1)`, ev.OrderID)

worker 用 select ... for update skip locked 取未完成的,做完置 done_at,失败就下次再来。这样“记支付并发放权益”与“发收据”之间不会互相拖累。

webhook 不保证顺序

典型场景:subscription.canceled(10:00:05)先到,subscription.updated(10:00:01)后到,后者把状态改回 active,库里显示用户还在订阅。

两个处理方式:

存事件时间 occurred_at,比库里旧就忽略。 更新语句自带条件:

update payments
set status = $2, occurred_at = $3
where provider_payment_id = $1
  and occurred_at < $3;

写“状态改成 canceled”这种 set 操作是幂等的;写“加 10 积分”这种 increment 不是。 乱序到达时两次 increment 会变成 20 分。凡是增量语义,要么按事件 id 记录后再重算,要么干脆改成“根据事件全集推导当前值”。

事件不完整、看着过期、或者引用了本地还不存在的对象,就回查厂商 API,不要信 payload 里那份快照。

也不要用“故意返回错误让厂商重试”来修顺序问题。重试依然可能乱序,你只是把问题推迟了,还额外制造了重复。

金额用最小单位整数,并且和本地订单对一遍

签名有效、状态是 paid,只证明这条消息来自厂商。它不证明金额和你本地订单一致。

必须拿本地订单比对金额、币种、订单号、状态。绝不要用浏览器传来的金额。对不上就标记待审,不开通权益。

存储用最小货币单位的整数加币种:NPR 10.50 存 1050 paisa。别用浮点,0.1 + 0.2 = 0.30000000000000004 这种误差在金额上不可接受。

各厂商的格式还不一样,有的给 "10.50" 字符串,有的给 1050 整数。只在金额进入系统时转换一次,展示的时候再格式化,中间全程用整数:

func toMinor(s, currency string) (int64, error) {
    d, err := decimal.NewFromString(s)
    if err != nil {
        return 0, err
    }
    return d.Mul(decimal.NewFromInt(100)).IntPart(), nil
}

5 秒内回 200,慢活推后台

Paddle 要求 5 秒内返回 200,否则重试。生产账号最多重试 60 次,跨 3 天。把慢活塞在 webhook 处理链路里,等于超时、重发、慢活做两遍。

顺序是:验签 → 落库(事件、支付、权益、outbox)→ 返回 200,其余交给 worker。

但有个前提:在事件和待办安全落库之前,不能先回 200。否则你回完就崩,厂商认为已投递、永不重发,任务就此消失。宁可这里慢一点、失败让厂商重试。

“不知道”是一种状态,不是失败

请求超时之后,你不知道那笔支付到底创建成功没有。直接重试可能付第二笔。

有幂等键就用幂等键(不是所有厂商都支持)。没有的话,先按订单号查状态,只在确认没创建时才重试。

在本地也一样,给结果未知的支付留一个单独状态(unknown / pending_confirm),由对账任务去回查。把它当失败处理,就变成重复扣款。

剩下的边界

  • 客户付完直接关标签页,跳回压根不会发生,只能靠 webhook 或主动查单发现。
  • 订单长期 pending 是正常的,有些渠道几分钟甚至几小时后才落账。对账任务应该去回查状态,而不是到点直接关单。
  • 同一订单被真付两次:双击、开两个标签页。两个 webhook 都去重了,但两笔钱真的都扣了。做法是只履约一次、两笔都记录、退掉多出来的那笔。
  • 退款和拒付不显式处理,就会继续发权益。部分退款、全额退款、chargeback 三者的语义要分开定义,别用一个 refunded 状态糊过去。
  • 测试密钥和生产密钥、测试端点和生产端点混用,是最容易被忽略又最疼的一类事故。
  • 收到不认识的商品 ID 或用户 ID 的 webhook,意味着有一笔钱你没记下来。这是告警条件,不是日志条件。原始 payload 要留着,映射修好之后能重放。

可以在工程上固定下来的几件事

每个厂商包一层适配器,把事件归一成自己控制的 payment_succeeded / refunded / subscription_cancelled,业务代码不碰厂商 SDK。

单独维护一张只追加的支付事件日志表(厂商 id、事件类型、金额、币种、原始 payload)。当前状态用于做决策,事件历史用于对账和排障。

每天跑一次与厂商的对账。

异常(不认识商品、金额不符、验签失败)要变成消息发出来,不能躺在日志里等出事再翻。

测试用真实 webhook 样例回放,至少覆盖两次投递和并发投递这两种情况。这两条过了,大部分重复问题就挡住了。

推荐文章

程序员茄子在线接单