《互联网产品的服务端架构·交易、增长与平台化》3.2 回调、幂等与掉单

回调重试三次导致重复发货,是支付接入最常见的生产事故。文章把验签之后的业务校验拆成渠道流水号、金额和状态三项,用持久化幂等表保证同一个 TradeNo 只产生一次副作用,并说明掉单为什么要靠主动查单补偿而不是被动等待渠道再次通知,最后给出重复回调的测试。

货发了三次,钱只收了一次

「摊位」上线自提订单后,门店反馈同一个订单出了三张取货码。查日志发现,渠道的回调其实成功到达了三次:第一次服务端处理完、写库成功,但返回响应时网关超时;渠道按规则重试,第二次、第三次都拿到了同样的通知。业务代码每次都执行了「把订单改成已支付并发货」,于是库存扣了三次,取货码生成了三张。

这不是渠道的错。异步通知的语义就是至少一次,渠道不保证只发一次,只保证在收到成功应答前会重试。服务端必须把「收到通知」和「处理通知」分开:收到可以很多次,业务副作用只能发生一次。

回调处理的四道关

一条回调从进入服务到产生副作用,至少要过四道关,每一道都有对应的失败模式。

关卡检查内容典型失败兜底手段
验签渠道签名、证书、时间戳伪造通知、重放旧通知验签失败直接拒绝,记录原始报文
业务校验订单存在、金额一致、状态合法金额被篡改、订单已关闭以本地订单金额为准,不一致告警
幂等渠道流水号是否已处理重复发货、重复加积分幂等表唯一约束,按 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。
  • 忽略验签或只校验部分字段。没有验签的回调入口可以被任意伪造,金额和状态校验都建立在不可信输入上。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查