货发了三次,钱只收了一次
「摊位」上线自提订单后,门店反馈同一个订单出了三张取货码。查日志发现,渠道的回调其实成功到达了三次:第一次服务端处理完、写库成功,但返回响应时网关超时;渠道按规则重试,第二次、第三次都拿到了同样的通知。业务代码每次都执行了「把订单改成已支付并发货」,于是库存扣了三次,取货码生成了三张。
这不是渠道的错。异步通知的语义就是至少一次,渠道不保证只发一次,只保证在收到成功应答前会重试。服务端必须把「收到通知」和「处理通知」分开:收到可以很多次,业务副作用只能发生一次。
回调处理的四道关
一条回调从进入服务到产生副作用,至少要过四道关,每一道都有对应的失败模式。
| 关卡 | 检查内容 | 典型失败 | 兜底手段 |
|---|---|---|---|
| 验签 | 渠道签名、证书、时间戳 | 伪造通知、重放旧通知 | 验签失败直接拒绝,记录原始报文 |
| 业务校验 | 订单存在、金额一致、状态合法 | 金额被篡改、订单已关闭 | 以本地订单金额为准,不一致告警 |
| 幂等 | 渠道流水号是否已处理 | 重复发货、重复加积分 | 幂等表唯一约束,按 TradeNo 去重 |
| 状态推进 | 当前状态能否迁移到目标状态 | 已退款订单又被改成已支付 | 状态机校验,非法迁移拒绝 |
验签通常由渠道 SDK 完成,业务代码容易忽略的是后三道。尤其是幂等:很多实现只在内存里放一个 map,进程重启或多实例部署后立刻失效。幂等键必须持久化,并且要有唯一约束兜底。
幂等键为什么是渠道流水号
订单号不能做幂等键,因为一笔订单可能多次发起支付,失败后换渠道重试会产生多个渠道流水号。客户端生成的请求 ID 也不稳,重试时可能重新生成。渠道流水号是渠道对一笔实际资金的唯一标识,同一笔资金的通知重试一定带同一个 TradeNo,这是最合适的幂等键。
如果渠道允许不同渠道复用同一个 TradeNo,幂等表的主键应该加上渠道维度,变成 (channel, trade_no)。本案例假设 TradeNo 全局唯一,简化成单键,但注释里写明了这个前提。
掉单不能只等回调
回调可能永远不来:渠道侧网络故障、商户回调地址配置错误、服务端在回调到达时正好发布重启、防火墙拦截了渠道 IP。这些情况下,用户已经付钱,本地订单却停在「待支付」,就是掉单。
掉单只能靠主动查单补偿。定时任务扫描超过一定时间仍为「处理中」的支付单,调用渠道的查询接口,拿到成功结果后走和回调相同的幂等处理路径。查单和回调必须共用同一个 Apply,否则两条路径会各自发货。
失败场景:三次重试,三次发货
回到开头的事故。第一版代码在回调入口只做了验签和金额校验,然后直接执行:
UPDATE orders SET status = 'paid' WHERE id = ?;
INSERT INTO shipments ...;
没有幂等表,也没有状态机。第一次回调把订单改成已支付并发货;第二次回调看到订单已经是已支付,但代码没有判断,仍然插入发货记录;第三次同理。库存服务收到三次扣减,其中两次扣成了负库存,触发超卖告警。
更隐蔽的变种是「先发货、后写幂等表」。如果写幂等表失败,发货已经发生,重试时会再发一次。正确顺序是先在同一个事务里写幂等记录并推进订单状态,再把发货消息投递到可靠队列;消费侧再按发货单号做一次幂等。跨服务场景没有单一事务,必须靠「本地事务加可靠消息」或「幂等消费」补齐。
还有一个变种是只按订单号幂等。用户第一次用支付宝失败,第二次用微信成功,两条回调的订单号相同但 TradeNo 不同。如果幂等键是订单号,微信的成功通知会被当成重复丢掉,订单永远停在待支付。
用 Verify 和 Apply 分离校验与副作用
callback.go 把「这条通知可不可信」和「这条通知处理过没有」拆成两个函数。Verify 只做业务字段校验,Apply 只负责幂等登记。
// file: internal/payment/callback.go
// tier: L1
// verify: go test ./internal/payment/
package payment
import (
"errors"
"fmt"
)
var (
// ErrEmptyTradeNo 表示渠道流水号为空。没有渠道流水号就无法做幂等,
// 也无法和渠道账单对上,必须在入口拒绝。
ErrEmptyTradeNo = errors.New("payment: empty trade no")
// ErrAmountMismatch 表示回调金额和订单金额不一致。
ErrAmountMismatch = errors.New("payment: callback amount mismatch")
// ErrInvalidStatus 表示回调状态不是 success 或 failed。
ErrInvalidStatus = errors.New("payment: callback status must be success or failed")
// ErrNilApplied 表示幂等表没有初始化,无法安全写入。
ErrNilApplied = errors.New("payment: nil applied map")
)
// Callback 是渠道异步通知里与本业务相关的最小字段集合。生产环境还要
// 保存签名、通知时间、渠道原始报文,这里为了聚焦幂等只保留关键字段。
type Callback struct {
Channel Channel
OrderID string
TradeNo string
AmountCents int64
Status string
}
// Verify 校验回调是否可信且与本地订单一致。它假定调用方已经完成验签,
// 只检查业务字段:渠道流水号、金额和状态。
func Verify(cb Callback, amountCents int64) error {
if cb.TradeNo == "" {
return ErrEmptyTradeNo
}
if cb.AmountCents != amountCents {
return fmt.Errorf("%w: callback %d, order %d", ErrAmountMismatch, cb.AmountCents, amountCents)
}
if cb.Status != "success" && cb.Status != "failed" {
return fmt.Errorf("%w: %q", ErrInvalidStatus, cb.Status)
}
return nil
}
// Apply 把回调登记到幂等表,返回本次是否第一次处理。同一个 TradeNo
// 第二次调用返回 (false, nil),调用方据此跳过发货、加积分等副作用。
//
// applied 是调用方持有的幂等表,键是渠道流水号。函数只写这一个 map,
// 不修改 cb 的任何字段;cb 按值传入,也保证了回调内容不会被就地改写。
// 空 TradeNo 或 nil map 返回错误,避免把不同订单混成一条。
func Apply(applied map[string]bool, cb Callback) (bool, error) {
if cb.TradeNo == "" {
return false, ErrEmptyTradeNo
}
if applied == nil {
return false, ErrNilApplied
}
if applied[cb.TradeNo] {
return false, nil
}
applied[cb.TradeNo] = true
return true, nil
}
几个设计点:
Verify假定调用方已经完成验签。验签依赖渠道证书和原始报文,不适合塞进这个纯业务函数;但验签失败必须在进入Verify之前拒绝。- 金额比较用本地订单金额,不用回调里的金额做任何业务计算。回调金额只用来比对,不一致就告警并拒绝。
- 状态只接受
success和failed。pending、refund、大小写变体都不应该推进订单,渠道新增状态时要显式适配。 Apply按TradeNo登记,第一次返回 true,第二次返回 false 且不报错。调用方只有在 true 时才执行发货、加积分等副作用。Apply不改动Callback的字段。Callback按值传入,天然不会被就地修改;幂等表由调用方持有,函数只写这一个 map。- 空
TradeNo和 nil map 都返回错误。空键会把所有异常通知混成一条,nil map 写入会 panic。
生产实现里,applied 应该替换成数据库幂等表,TradeNo 上有唯一索引。先 INSERT,捕获唯一键冲突就说明重复;这样多实例、重启和并发都挡得住。内存 map 只适合单进程演示。
测试:重复、并发与不改入参
回调测试的重点不是「正常路径能通过」,而是「重复通知不会产生第二次副作用」。
// file: internal/payment/callback_test.go
// tier: L1
// verify: go test ./internal/payment/
package payment
import (
"errors"
"strings"
"testing"
)
func baseCallback() Callback {
return Callback{
Channel: Alipay,
OrderID: "o1",
TradeNo: "t1",
AmountCents: 9900,
Status: "success",
}
}
func TestVerifyAcceptsSuccessAndFailed(t *testing.T) {
for _, status := range []string{"success", "failed"} {
cb := baseCallback()
cb.Status = status
if err := Verify(cb, 9900); err != nil {
t.Fatalf("status %q err = %v; want nil", status, err)
}
}
}
func TestVerifyRejectsEmptyTradeNo(t *testing.T) {
cb := baseCallback()
cb.TradeNo = ""
if err := Verify(cb, 9900); !errors.Is(err, ErrEmptyTradeNo) {
t.Fatalf("err = %v; want ErrEmptyTradeNo", err)
}
}
func TestVerifyRejectsAmountMismatchAndReportsBoth(t *testing.T) {
cb := baseCallback()
err := Verify(cb, 9800)
if !errors.Is(err, ErrAmountMismatch) {
t.Fatalf("err = %v; want ErrAmountMismatch", err)
}
msg := err.Error()
if !strings.Contains(msg, "9900") || !strings.Contains(msg, "9800") {
t.Fatalf("message %q should contain both amounts", msg)
}
}
func TestVerifyRejectsInvalidStatus(t *testing.T) {
for _, status := range []string{"", "pending", "SUCCESS", "refund"} {
cb := baseCallback()
cb.Status = status
if err := Verify(cb, 9900); !errors.Is(err, ErrInvalidStatus) {
t.Fatalf("status %q err = %v; want ErrInvalidStatus", status, err)
}
}
}
func TestApplyIsIdempotentPerTradeNo(t *testing.T) {
applied := map[string]bool{}
cb := baseCallback()
first, err := Apply(applied, cb)
if err != nil || !first {
t.Fatalf("first Apply = %v, %v; want true, nil", first, err)
}
second, err := Apply(applied, cb)
if err != nil {
t.Fatalf("second Apply err = %v; want nil", err)
}
if second {
t.Fatal("second Apply = true; want false")
}
if len(applied) != 1 {
t.Fatalf("applied size = %d; want 1", len(applied))
}
}
func TestApplyDoesNotDoubleFireSideEffect(t *testing.T) {
applied := map[string]bool{}
shipped := 0
for i := 0; i < 3; i++ {
fresh, err := Apply(applied, baseCallback())
if err != nil {
t.Fatalf("Apply #%d err = %v", i+1, err)
}
if fresh {
shipped++
}
}
if shipped != 1 {
t.Fatalf("side effect fired %d times; want 1", shipped)
}
}
func TestApplyDifferentTradeNosBothApply(t *testing.T) {
applied := map[string]bool{}
a := baseCallback()
b := baseCallback()
b.TradeNo = "t2"
if fresh, err := Apply(applied, a); err != nil || !fresh {
t.Fatalf("Apply(t1) = %v, %v; want true, nil", fresh, err)
}
if fresh, err := Apply(applied, b); err != nil || !fresh {
t.Fatalf("Apply(t2) = %v, %v; want true, nil", fresh, err)
}
if len(applied) != 2 {
t.Fatalf("applied size = %d; want 2", len(applied))
}
}
func TestApplyRejectsEmptyTradeNoAndNilMap(t *testing.T) {
cb := baseCallback()
cb.TradeNo = ""
if _, err := Apply(map[string]bool{}, cb); !errors.Is(err, ErrEmptyTradeNo) {
t.Fatalf("empty trade no err = %v; want ErrEmptyTradeNo", err)
}
if _, err := Apply(nil, baseCallback()); !errors.Is(err, ErrNilApplied) {
t.Fatalf("nil map err = %v; want ErrNilApplied", err)
}
}
func TestApplyDoesNotMutateCallback(t *testing.T) {
applied := map[string]bool{}
cb := baseCallback()
before := cb
if _, err := Apply(applied, cb); err != nil {
t.Fatalf("Apply err = %v", err)
}
if cb != before {
t.Fatalf("callback mutated: before %+v, after %+v", before, cb)
}
}
TestVerifyAcceptsSuccessAndFailed 锁定两个合法状态。TestVerifyRejectsEmptyTradeNo 和 TestVerifyRejectsAmountMismatchAndReportsBoth 验证入口校验,后者还要求错误信息同时带上回调和订单金额,方便排查。
TestVerifyRejectsInvalidStatus 覆盖空串、pending、大写 SUCCESS 和 refund,防止渠道新增状态时被静默接受。TestApplyIsIdempotentPerTradeNo 验证同一 TradeNo 第二次返回 false 且不报错,幂等表只留一条。
TestApplyDoesNotDoubleFireSideEffect 用计数器模拟发货,连续三次回调只允许副作用发生一次。TestApplyDifferentTradeNosBothApply 保证不同流水号各自生效,避免把幂等做成「整张表只处理一次」。TestApplyRejectsEmptyTradeNoAndNilMap 覆盖两个入口错误,TestApplyDoesNotMutateCallback 断言回调内容没有被改写。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/payment/ -run 'TestVerify|TestApply' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestVerifyAcceptsSuccessAndFailed
--- PASS: TestVerifyAcceptsSuccessAndFailed (0.00s)
=== RUN TestVerifyRejectsEmptyTradeNo
--- PASS: TestVerifyRejectsEmptyTradeNo (0.00s)
=== RUN TestVerifyRejectsAmountMismatchAndReportsBoth
--- PASS: TestVerifyRejectsAmountMismatchAndReportsBoth (0.00s)
=== RUN TestVerifyRejectsInvalidStatus
--- PASS: TestVerifyRejectsInvalidStatus (0.00s)
=== RUN TestApplyIsIdempotentPerTradeNo
--- PASS: TestApplyIsIdempotentPerTradeNo (0.00s)
=== RUN TestApplyDoesNotDoubleFireSideEffect
--- PASS: TestApplyDoesNotDoubleFireSideEffect (0.00s)
=== RUN TestApplyDifferentTradeNosBothApply
--- PASS: TestApplyDifferentTradeNosBothApply (0.00s)
=== RUN TestApplyRejectsEmptyTradeNoAndNilMap
--- PASS: TestApplyRejectsEmptyTradeNoAndNilMap (0.00s)
=== RUN TestApplyDoesNotMutateCallback
--- PASS: TestApplyDoesNotMutateCallback (0.00s)
PASS
ok webapp/internal/payment 0.234s
ok webapp/internal/payment 表示 3.2 相关的九个用例全部通过。这里的幂等表是内存 map,只用于演示语义;线上必须换成带唯一约束的持久化表。
小结
回调的语义是至少一次,重复是常态,不是异常。处理顺序应该是:先验签,再校验金额和状态,再用渠道流水号写幂等表,最后才执行副作用。幂等键用 TradeNo 而不是订单号,因为一笔订单可能对应多条渠道单。
回调之外还要有主动查单。掉单时用户已经付钱,等渠道再通知可能永远等不到;查单和回调必须共用同一个幂等处理路径。下一节把视角拉到日终:渠道账单和本地记录对不上时,如何找出缺失、如何退款、如何区分退款与冲正。
阅读导航:上一节:3.1 支付渠道接入 · 下一节:3.3 对账、退款与冲正 。
常见错误与边界
- 用订单号做幂等键。一笔订单多次支付会产生多个渠道流水号,按订单号去重会把后续成功通知丢掉。
- 幂等表只放内存。进程重启、多实例部署或扩容后,重复通知会重新执行副作用。
- 先执行业务副作用再写幂等记录。写幂等失败时副作用已经发生,重试会再来一次。
- 把回调金额当成实收金额入账。回调金额只用于和本地订单比对,入账金额应以本地订单和渠道账单为准。
- 接受任意状态字符串。
pending、refund或大小写变体可能被误判为成功,状态白名单要显式维护。 - 只做回调、不做查单。回调丢失时订单永远停在处理中,必须有定时查单补偿。
- 查单和回调走两套处理逻辑。两套逻辑各自发货,幂等形同虚设,应该共用同一个 Apply。
- 忽略验签或只校验部分字段。没有验签的回调入口可以被任意伪造,金额和状态校验都建立在不可信输入上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。