一笔“已发货”却查不到物流的订单
「拾集」是一个为本书构造的假想社区电商产品。用户在小程序里下单,支付成功后商家在后台拣货、发货,物流单号回填到订单。后文出现的订单量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。
一位用户买了两箱牛奶,支付成功后的第二天打开订单页,状态显示“已发货”,但点进物流详情只有一行“暂无轨迹”。他等了三天,仍然没有揽收记录,于是发起客诉。客服查后台,订单确实被标记为已发货,可仓库系统里这批货还躺在待出库区,物流单号是空的。
复盘时发现,履约服务在调用仓库接口时设置了 3 秒超时。那天仓库系统因为批量盘点变慢,响应到了 4 秒才返回。履约服务判定超时,把任务标记为失败;一个重试任务看到“失败”,又把订单状态推进到了“已发货”,并给用户发了通知。仓库那边其实成功了,但履约服务没有拿到回执,也没有把仓库单号写回订单。
这个故障暴露的不是某个接口慢,而是四个阶段被当成了一个整体:下单、支付、履约、对账。每个阶段的输入、输出、失败语义都不同,却共用一套“成功/失败”的判断。把它们拆开,分别定义同步边界和补偿方式,才能回答“这一步到底完成没有”。
四个阶段各自的同步与异步边界
交易链路的阶段不是按团队划分的,而是按“谁对外部世界做出承诺”划分的。下单承诺金额与库存,支付承诺钱已到账,履约承诺货已出库,对账承诺账实一致。承诺不同,等待方式也不同。
| 阶段 | 输入 | 输出 | 同步还是异步 | 失败语义 |
|---|---|---|---|---|
| 下单 order | 用户、商品、库存、价格快照、风控结果 | 金额确定的待支付订单 | 必须同步 | 任一校验失败就不创建订单,不占用后续资源 |
| 支付 pay | 订单号、渠道回调、实付金额 | 已支付状态与支付流水 | 必须同步确认结果,回调可异步到达 | 金额不符拒绝入账;超时只能查单,不能猜成功 |
| 履约 fulfill | 已支付订单、仓库作业、物流单 | 出库记录与物流单号 | 允许异步 | 超时是未知,不是成功;必须凭仓库回执推进状态 |
| 对账 reconcile | 渠道账单、支付流水、退款流水 | 差异清单与调账结果 | 允许异步批处理 | 差异必须落表并有人处理,不能静默抹平 |
前两个阶段必须同步,是因为调用方要立刻拿到确定结果才能继续。用户点支付,页面必须知道这笔钱到底扣没扣;如果系统回答“可能扣了”,用户只能重复支付或放弃。后两个阶段允许异步,是因为仓库、物流和渠道账单的响应时间不受自己的服务控制,强行同步只会把外部抖动放大成自己的超时。
同步不等于一直阻塞。支付阶段同步的是“结果确认”,不是“渠道处理”。常见做法是:用户发起支付后拿到一个支付单号,前端轮询或等待回调;服务端在回调到达时用支付单号做幂等,再去查一次渠道状态。真正不能异步的是“把未确认当成已确认”这个动作。
异步也不等于可以随意重试。履约的每一次重试都必须带同一个幂等键,并且以仓库回执为准。超时只能说明“不知道”,不能说明“失败”,更不能说明“成功”。这三种状态必须有不同的后续动作:失败可以重试,成功可以推进,未知只能查询或等待。
对账阶段最容易被忽略。很多团队把支付成功当成交易结束,直到财务发现渠道账单和内部流水对不上。对账的输入不是“订单表”,而是渠道账单、支付流水、退款流水三份独立数据;它的输出也不是一个布尔值,而是一张带差异原因和处理状态的清单。
失败场景:履约超时被当成成功
「拾集」在大促期间的一组案例假设值如下:
- 日均订单 12,000 笔,履约服务调用仓库接口的超时设为 3 秒。
- 大促当天仓库接口 P99 从 800 毫秒升到 4.2 秒,约 6% 的调用触发超时。
- 超时订单中 137 笔被重试任务错误推进为“已发货”,实际没有物流单号。
- 41 位用户发起客诉,客服平均处理时长 26 分钟。
- 三天后对账发现 137 笔订单已收款但无出库记录,涉及金额 8.6 万元。
修复不是把超时从 3 秒调到 10 秒。调大超时只能降低触发概率,不能改变“超时是未知”的语义。正确的顺序是:
- 履约状态机只允许凭仓库回执进入“已发货”,超时进入“待确认”,不允许重试任务直接推进。
- 每次调用仓库使用订单号加履约任务号作为幂等键,重试不会生成第二张出库单。
- 增加一个查询任务,对“待确认”状态主动向仓库查单,查到回执再推进,查不到则转人工。
- 对账任务把“已收款未出库”作为独立差异类型,按天出清单,而不是只在月底汇总。
这套改动的代价是履约链路多了一个中间状态,前端也要能展示“发货确认中”。但它把“不知道”从二值判断里救了出来。交易系统里,承认未知比假装确定安全得多。
Go 实现:Validate 校验链路形状
下面的代码来自本书配套的 webapp-lab。Validate 不执行任何一步,只检查链路形状:四个阶段是否齐全、顺序是否单调、下单和支付是否同步、步骤名是否为空或重复。它适合在配置加载或发布前运行,把“链路配错了”挡在流量之前。
阶段用字符串常量表示,排序用一张显式的秩表。这样新增阶段时必须同时改常量和秩表,编译器不会帮忙,但代码评审能看到。步骤名只去掉首尾空白,内部空格保留,因为“create order”和“create-order”是两个不同的名字,强行归一化反而会掩盖配置错误。
// file: internal/growthbase/flow.go
// tier: L1
// verify: go test ./internal/growthbase/
package growthbase
import (
"fmt"
"strings"
)
// Stage 是交易链路中的一个阶段。
type Stage string
const (
StageOrder Stage = "order"
StagePay Stage = "pay"
StageFulfill Stage = "fulfill"
StageReconcile Stage = "reconcile"
)
// Step 是链路中的一个步骤。Async 只对履约与对账阶段有意义。
type Step struct {
Name string
Stage Stage
Async bool
}
// Flow 是一条按顺序排列的交易链路。
type Flow struct {
Steps []Step
}
var stageRank = map[Stage]int{
StageOrder: 0,
StagePay: 1,
StageFulfill: 2,
StageReconcile: 3,
}
// Validate 检查链路是否覆盖四个阶段且顺序正确。
//
// 下单与支付必须是同步步骤:调用方要在这两步拿到确定结果,才能决定
// 是否继续。履约与对账允许异步,因为它们依赖仓库、物流和渠道文件,
// 这些外部系统的响应时间不可控。
func Validate(f Flow) error {
if len(f.Steps) == 0 {
return fmt.Errorf("growthbase: flow has no steps")
}
seen := make(map[string]struct{}, len(f.Steps))
present := make(map[Stage]struct{}, len(stageRank))
last := -1
for i, s := range f.Steps {
name := strings.TrimSpace(s.Name)
if name == "" {
return fmt.Errorf("growthbase: step %d has empty name", i)
}
if _, ok := seen[name]; ok {
return fmt.Errorf("growthbase: duplicate step name %q", name)
}
seen[name] = struct{}{}
rank, ok := stageRank[s.Stage]
if !ok {
return fmt.Errorf("growthbase: step %q has unknown stage %q", name, s.Stage)
}
if rank < last {
return fmt.Errorf("growthbase: step %q stage %q is out of order", name, s.Stage)
}
last = rank
present[s.Stage] = struct{}{}
if s.Async && (s.Stage == StageOrder || s.Stage == StagePay) {
return fmt.Errorf("growthbase: step %q stage %q must be synchronous", name, s.Stage)
}
}
for _, st := range []Stage{StageOrder, StagePay, StageFulfill, StageReconcile} {
if _, ok := present[st]; !ok {
return fmt.Errorf("growthbase: flow is missing stage %q", st)
}
}
return nil
}
代码里有三个刻意选择。第一,空链路直接报错,而不是当成“没有步骤所以通过”,否则一个配置失误会让整条链路静默消失。第二,阶段顺序只允许单调不减,同一阶段可以有多个步骤,但后一阶段不能出现在前一阶段之前。第三,异步限制只作用在下单和支付上,履约与对账既可以同步也可以异步,由具体实现决定。
Validate 不检查“每个阶段至少一个同步步骤”之外的东西。比如它不知道支付步骤有没有调渠道,也不知道履约步骤有没有幂等键。这些属于执行器和服务实现的职责,放进形状校验会让函数越来越难测试,错误信息也会从“链路配错了”变成“某个外部系统可能有问题”。
测试:四阶段、乱序与异步限制
测试用一个完整的合法链路做基线,再用表驱动覆盖八类错误。每个用例只匹配错误信息的关键片段,不绑定完整句子,这样调整文案不会误伤,但删掉检查一定会失败。
// file: internal/growthbase/flow_test.go
// tier: L1
// verify: go test ./internal/growthbase/
package growthbase
import (
"strings"
"testing"
)
func fullFlow() Flow {
return Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill, Async: true},
{Name: "reconcile", Stage: StageReconcile, Async: true},
}}
}
func TestValidateAcceptsFullFlow(t *testing.T) {
if err := Validate(fullFlow()); err != nil {
t.Fatalf("valid flow rejected: %v", err)
}
}
func TestValidateRejects(t *testing.T) {
cases := []struct {
name string
flow Flow
want string
}{
{"empty", Flow{}, "no steps"},
{"missing-stage", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill},
}}, `missing stage "reconcile"`},
{"out-of-order", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "ship", Stage: StageFulfill},
{Name: "pay-order", Stage: StagePay},
{Name: "reconcile", Stage: StageReconcile},
}}, "out of order"},
{"async-order", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder, Async: true},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill},
{Name: "reconcile", Stage: StageReconcile},
}}, "must be synchronous"},
{"async-pay", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay, Async: true},
{Name: "ship", Stage: StageFulfill},
{Name: "reconcile", Stage: StageReconcile},
}}, "must be synchronous"},
{"empty-name", Flow{Steps: []Step{
{Name: " ", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill},
{Name: "reconcile", Stage: StageReconcile},
}}, "empty name"},
{"duplicate-name", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill},
{Name: "ship", Stage: StageReconcile},
}}, "duplicate step name"},
{"unknown-stage", Flow{Steps: []Step{
{Name: "create-order", Stage: StageOrder},
{Name: "pay-order", Stage: StagePay},
{Name: "ship", Stage: StageFulfill},
{Name: "settle", Stage: Stage("settle")},
}}, "unknown stage"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := Validate(c.flow)
if err == nil {
t.Fatal("want error, got nil")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("error %q does not contain %q", err, c.want)
}
})
}
}
fullFlow 里履约和对账都设为异步,是为了证明“允许异步”不是“必须异步”。如果把这两个阶段改成同步,Validate 同样应该通过,因为限制只写在 order 和 pay 上。反过来,async-order 和 async-pay 两个用例证明这条限制确实生效。
duplicate-name 用例把两个不同阶段的步骤取成同一个名字。名字重复会让日志、指标和重试任务无法区分步骤,所以即使阶段不同也必须拒绝。unknown-stage 用例则防止配置里出现拼写错误,比如把 reconcile 写成 settle 后被当成合法阶段。
测试没有覆盖“阶段齐全但顺序正确”的所有排列。四个阶段的全排列有 24 种,逐个枚举价值不大;单调秩检查是一条独立分支,用乱序用例证明它能拦住逆序就够了。真正需要枚举的是业务步骤组合,那属于集成测试的范围。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/growthbase/ -run 'TestValidate' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsFullFlow
--- PASS: TestValidateAcceptsFullFlow (0.00s)
=== RUN TestValidateRejects
=== RUN TestValidateRejects/empty
=== RUN TestValidateRejects/missing-stage
=== RUN TestValidateRejects/out-of-order
=== RUN TestValidateRejects/async-order
=== RUN TestValidateRejects/async-pay
=== RUN TestValidateRejects/empty-name
=== RUN TestValidateRejects/duplicate-name
=== RUN TestValidateRejects/unknown-stage
--- PASS: TestValidateRejects (0.00s)
--- PASS: TestValidateRejects/empty (0.00s)
--- PASS: TestValidateRejects/missing-stage (0.00s)
--- PASS: TestValidateRejects/out-of-order (0.00s)
--- PASS: TestValidateRejects/async-order (0.00s)
--- PASS: TestValidateRejects/async-pay (0.00s)
--- PASS: TestValidateRejects/empty-name (0.00s)
--- PASS: TestValidateRejects/duplicate-name (0.00s)
--- PASS: TestValidateRejects/unknown-stage (0.00s)
PASS
ok webapp/internal/growthbase 0.277s
输出全绿只说明 Validate 与测试一致。链路形状对了,不代表执行器会在超时时查单,也不代表对账差异会有人处理。形状校验是第一道门,不是最后一道。
小结
下单、支付、履约、对账是四个承诺,不是四个函数。下单同步锁定金额与库存,支付同步确认钱是否到账,履约异步等待仓库回执,对账异步核对三份独立流水。每一步都要区分成功、失败和未知,尤其不能把超时当成成功。
Validate 做的是很小的一件事:在流量到来之前,确认链路覆盖四个阶段、顺序正确、关键阶段同步、步骤名唯一。它拦不住业务错误,但能拦住“配置少了一个阶段”“支付被误配成异步”这类会让整条链路失真的问题。
阅读导航:上一节:目录与学习路径 · 下一节:13.2 券与活动的端到端验证 。
常见错误与边界
- 把履约超时当成失败并自动重试。超时是未知,重试前必须先查单,否则会重复出库。
- 支付回调没到就置为已支付。回调丢失是常态,正确做法是主动查单,不是猜成功。
- 用订单状态同时表达支付状态和履约状态。两个状态机耦合后,退款、取消和发货会互相覆盖。
- 对账只对总额。总额相等可能是个别订单多收和少收互相抵消,必须对到订单和流水号。
- 链路校验只检查阶段数量,不检查顺序。四个阶段都在,但 pay 排在 order 前面,同样是坏配置。
- 步骤名允许重复。日志里两个 ship 无法区分,重试任务也可能把结果写到错误的步骤上。
- 把异步阶段的重试当成无限重试。重试要有上限、退避和人工兜底,否则故障会持续放大。
- 认为调大超时就能解决超时问题。超时阈值可以调,但“超时是未知”的语义不能改。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。