《互联网产品的服务端架构·交易、增长与平台化》13.3 进入卷四

从一次只看 GMV、退款率悄悄翻倍的活动复盘切入,说明交易链路为什么需要支付成功率、履约时长、对账差异率三条指标,以及它们各自的分母。给出计算支付率与退款率、用严格阈值判定达标的 Go 实现与边界测试,并说明单一 GMV 如何掩盖真实亏损。

只看 GMV,退款率上涨没人发现

「拾集」在双十一做了一轮补贴活动。活动结束后,数据看板上的 GMV 同比上涨 32%,运营在复盘会上把这一页投到屏幕上,结论是“活动很成功”。两周后财务给出结算数据:扣除退款和补贴后的净收入比去年同期还低 6%。

再往下拆,问题并不隐蔽。活动期间的支付成功率从 4.6% 掉到 3.8%,退款率从 4.5% 升到 11%,履约 P95 从 6 小时拉长到 31 小时,对账差异金额占到 GMV 的 0.8%。这些指标每天都在产生,只是没有一个被放在活动复盘的首页。

GMV 是一个好讲的故事,但不是一条好的防线。它把“用户付了多少钱”当成“平台赚了多少钱”,把退款、补贴、履约成本和坏账都留在后面。更危险的是,GMV 可以被短期手段推高:大额补贴、放宽退款、延迟发货,都会让当天的数字变好看,同时把成本推到下一周。

交易链路需要三条质量指标,分别回答三个不同的问题:钱有没有顺利收到,货有没有按时发出,账有没有对上。它们不是 GMV 的补充说明,而是判断 GMV 是否真实的三个前提。

三条指标分别回答什么问题

支付成功率回答“来的人里有多少付了钱”。它的分母是访问量,不是下单量。用下单量做分母会把“下单后放弃支付”的问题藏起来,而那恰恰是支付链路最常见的流失点。这个指标下降,通常意味着渠道故障、支付方式变少、风控误杀或页面加载变慢。

履约时长回答“付了钱的人多久能拿到货”。它应该用分位数而不是平均数,因为少数超长订单会被平均数稀释。履约时长恶化往往不会立刻影响收入,但会推高客诉、退款和客服成本,最终反映在退款率上。

对账差异率回答“系统记的账和渠道记的账差多少”。它的分子是差异金额或差异订单数,分母可以是 GMV 或总订单数,但必须在团队内固定口径。差异率上升可能是重复入账、漏单、退款未回冲或手续费计算错误,每一种都对应不同的修复动作。

指标分子分母主要信号恶化时先查什么
支付成功率支付成功订单数访问量渠道可用性、支付方式覆盖渠道错误码、风控拦截率、页面耗时
履约时长支付成功到出库的时间已支付订单仓库作业、库存准确性仓库接口延迟、缺货率、波次排程
对账差异率差异金额或差异订单数GMV 或总订单数账实一致性重复入账、漏单、退款回冲、手续费
退款率(辅助)退款订单数支付订单数商品质量、履约体验、补贴滥用退款原因分布、履约时长、活动规则

退款率放在辅助位置,不是因为它不重要,而是因为它通常是结果指标。履约时长恶化、商品描述不符、活动规则有漏洞,最后都会推高退款率。把退款率和三条过程指标放在一起看,才能从“结果变差”追到“哪一步变差”。

为什么不能只看 GMV?第一,GMV 是毛额,不扣退款、补贴和渠道手续费,和净收入之间隔着好几层。第二,GMV 有统计窗口,活动期间的退款可能发生在活动结束后,当期看板看不到。第三,GMV 可以被运营手段短期推高,指标本身没有约束力。三条质量指标的作用,是给 GMV 加一个“是否可持续”的校验。

这三条指标也不需要做成一个复杂的大屏。最小可用版本是每天一张表:支付成功率、履约 P95、对账差异金额和差异率,按渠道、品类、活动维度各拆一行。只要口径固定、数据每天到,异常就能被看见。

失败场景:GMV 达标掩盖了退款与履约恶化

「拾集」双十一活动的案例假设值如下:

  • 活动期间 GMV 125 万元,同比上涨 32%,看板只展示这一条。
  • 访问量 10,000,支付订单 420 笔,支付成功率 4.2%。
  • 退款订单 46 笔,退款率约 11%,上个月是 4.5%。
  • 履约 P95 从 6 小时升到 31 小时,客诉量涨了 2.7 倍。
  • 对账差异金额 1 万元,占 GMV 的 0.8%,上个月是 0.1%。

活动复盘只看了 GMV,所以以上四条都没有进入结论。等到财务结算,净收入同比下降 6%,团队才开始排查。此时退款已经发生,补贴已经发完,履约拥堵也已经变成客诉,能做的只有事后补救。

如果当时有三条指标,预警会这样发生:

  1. 支付成功率从 4.6% 掉到 4.2%,先触发渠道和风控排查,确认不是支付故障。
  2. 履约 P95 超过 24 小时阈值,触发仓库和库存排查,发现爆款缺货导致大量订单卡在拣货。
  3. 退款率超过 8% 阈值,触发退款原因分析,发现缺货和延迟发货占了多数。
  4. 对账差异率超过 0.5% 阈值,触发账务排查,发现部分退款没有回冲。

这四步不需要预测未来,只需要在活动期间每天看一眼。指标的价值不在于精确解释原因,而在于把“哪里不对”缩小到一个可以排查的范围。

Go 实现:Aggregate 与 Meets

下面的代码来自本书配套的 webapp-lab。Aggregate 从一个统计窗口的原始样本里计算支付率和退款率,Meets 用严格不等式判断指标是否越过阈值。它不采集数据,也不做告警,只负责把口径固定下来,避免每个看板各算一套。

支付率的分母是访问量,退款率的分母是支付订单数。两个分母不同,不能互相替代。访问量为 0 时支付率返回 0,支付订单为 0 时退款率返回 0,而不是返回 NaN 或 Inf;否则下游的阈值判断和序列化都会出问题。

所有输入字段都拒绝负数。负数通常意味着上游统计口径错了,比如把退款记成了负的支付订单。让 Aggregate 在这里失败,比让一个负的 GMV 流入看板更容易定位。

// file: internal/growthbase/metrics.go
// tier: L1
// verify: go test ./internal/growthbase/

package growthbase

import "fmt"

// Sample 是一个统计窗口内的交易原始数据。
type Sample struct {
	GMV            int64
	PaidOrders     int64
	RefundedOrders int64
	Visits         int64
}

// Summary 是派生后的质量指标。
type Summary struct {
	GMV        int64
	PaidRate   float64
	RefundRate float64
}

// Aggregate 计算支付成功率与退款率。
//
// 支付率的分母是访问量,用来看“来了多少人、多少人付了钱”;
// 退款率的分母是支付订单数,用来看“付了钱的人里有多少退掉”。
// 两个比率的分母不同,不能混用,也不能用一个 GMV 代替。
func Aggregate(s Sample) (Summary, error) {
	if s.GMV < 0 {
		return Summary{}, fmt.Errorf("growthbase: GMV %d must not be negative", s.GMV)
	}
	if s.PaidOrders < 0 {
		return Summary{}, fmt.Errorf("growthbase: paid orders %d must not be negative", s.PaidOrders)
	}
	if s.RefundedOrders < 0 {
		return Summary{}, fmt.Errorf("growthbase: refunded orders %d must not be negative", s.RefundedOrders)
	}
	if s.Visits < 0 {
		return Summary{}, fmt.Errorf("growthbase: visits %d must not be negative", s.Visits)
	}

	out := Summary{GMV: s.GMV}
	if s.Visits > 0 {
		out.PaidRate = float64(s.PaidOrders) / float64(s.Visits)
	}
	if s.PaidOrders > 0 {
		out.RefundRate = float64(s.RefundedOrders) / float64(s.PaidOrders)
	}
	return out, nil
}

// Meets 用严格不等式判断指标是否越过阈值。
//
// 等于阈值不算达标:阈值是底线而不是目标,刚好踩线通常意味着
// 下一分钟就会跌破。调用方应把阈值设成业务可接受的下限。
func Meets(s Summary, minPaidRate, maxRefundRate float64) bool {
	return s.PaidRate > minPaidRate && s.RefundRate < maxRefundRate
}

Meets 使用严格大于和严格小于:支付率必须高于下限,退款率必须低于上限。等于阈值不算达标,因为阈值是底线而不是目标,刚好踩线通常意味着下一分钟就会跌破。如果业务希望“大于等于”才算达标,应该在调用方把阈值下调一个精度单位,而不是在 Meets 里偷偷放宽。

Aggregate 不检查退款订单数是否大于支付订单数。退款可能来自上一个统计窗口,也可能同一订单多次部分退款,简单的“退款数不能超过支付数”会误伤合法数据。真正需要约束的是退款金额和订单状态,那属于账务系统的职责。

测试:分母为零与阈值边界

测试覆盖三类情况:正常比率、分母为零、负数输入。正常比率用 420 笔支付、10,000 次访问、21 笔退款,得到 4.2% 的支付率和 5% 的退款率;浮点比较用一个小容差,避免二进制表示误差导致测试不稳定。

// file: internal/growthbase/metrics_test.go
// tier: L1
// verify: go test ./internal/growthbase/

package growthbase

import (
	"math"
	"strings"
	"testing"
)

func almostEqual(a, b float64) bool {
	return math.Abs(a-b) < 1e-9
}

func TestAggregateRates(t *testing.T) {
	s := Sample{GMV: 1250000, PaidOrders: 420, RefundedOrders: 21, Visits: 10000}
	got, err := Aggregate(s)
	if err != nil {
		t.Fatal(err)
	}
	if got.GMV != 1250000 {
		t.Fatalf("gmv = %d, want 1250000", got.GMV)
	}
	if !almostEqual(got.PaidRate, 0.042) {
		t.Fatalf("paid rate = %v, want 0.042", got.PaidRate)
	}
	if !almostEqual(got.RefundRate, 0.05) {
		t.Fatalf("refund rate = %v, want 0.05", got.RefundRate)
	}
}

func TestAggregateZeroDenominators(t *testing.T) {
	got, err := Aggregate(Sample{GMV: 0, PaidOrders: 0, RefundedOrders: 0, Visits: 0})
	if err != nil {
		t.Fatal(err)
	}
	if got.PaidRate != 0 || got.RefundRate != 0 {
		t.Fatalf("rates = %v/%v, want 0/0", got.PaidRate, got.RefundRate)
	}
}

func TestAggregatePaidWithoutVisits(t *testing.T) {
	got, err := Aggregate(Sample{GMV: 100, PaidOrders: 4, RefundedOrders: 2, Visits: 0})
	if err != nil {
		t.Fatal(err)
	}
	if got.PaidRate != 0 {
		t.Fatalf("paid rate = %v, want 0", got.PaidRate)
	}
	if !almostEqual(got.RefundRate, 0.5) {
		t.Fatalf("refund rate = %v, want 0.5", got.RefundRate)
	}
}

func TestAggregateRejectsNegative(t *testing.T) {
	cases := []struct {
		name string
		s    Sample
		want string
	}{
		{"gmv", Sample{GMV: -1}, "GMV -1 must not be negative"},
		{"paid", Sample{PaidOrders: -1}, "paid orders -1 must not be negative"},
		{"refunded", Sample{RefundedOrders: -1}, "refunded orders -1 must not be negative"},
		{"visits", Sample{Visits: -1}, "visits -1 must not be negative"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := Aggregate(c.s)
			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)
			}
		})
	}
}

func TestMeetsStrictThresholds(t *testing.T) {
	s := Summary{GMV: 1250000, PaidRate: 0.042, RefundRate: 0.05}
	if !Meets(s, 0.04, 0.06) {
		t.Fatal("expected metrics above/below thresholds to pass")
	}
	if Meets(s, 0.042, 0.06) {
		t.Fatal("paid rate equal to threshold must not pass")
	}
	if Meets(s, 0.04, 0.05) {
		t.Fatal("refund rate equal to threshold must not pass")
	}
	if Meets(s, 0.05, 0.06) {
		t.Fatal("paid rate below threshold must not pass")
	}
}

TestAggregateZeroDenominators 同时把访问量和支付订单设为 0,验证两个比率都返回 0。TestAggregatePaidWithoutVisits 则覆盖“有支付但没有访问量”的异常组合:支付率为 0,退款率仍然按支付订单计算。这个用例是为了锁住“两个分母互不影响”的行为。

TestAggregateRejectsNegative 对四个字段逐一注入负数,确认错误信息包含字段名。字段名很重要,上游看到“must not be negative”却不知道是哪个字段,排查会多花一轮。

TestMeetsStrictThresholds 把边界固定下来:高于下限且低于上限通过,等于任一阈值都不通过。这三个断言看起来吹毛求疵,但阈值判断最容易被“大于等于还是大于”这种细节改变行为,测试必须把这个约定写死。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/growthbase/ -run 'TestAggregate|TestMeets' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAggregateRates
--- PASS: TestAggregateRates (0.00s)
=== RUN   TestAggregateZeroDenominators
--- PASS: TestAggregateZeroDenominators (0.00s)
=== RUN   TestAggregatePaidWithoutVisits
--- PASS: TestAggregatePaidWithoutVisits (0.00s)
=== RUN   TestAggregateRejectsNegative
=== RUN   TestAggregateRejectsNegative/gmv
=== RUN   TestAggregateRejectsNegative/paid
=== RUN   TestAggregateRejectsNegative/refunded
=== RUN   TestAggregateRejectsNegative/visits
--- PASS: TestAggregateRejectsNegative (0.00s)
    --- PASS: TestAggregateRejectsNegative/gmv (0.00s)
    --- PASS: TestAggregateRejectsNegative/paid (0.00s)
    --- PASS: TestAggregateRejectsNegative/refunded (0.00s)
    --- PASS: TestAggregateRejectsNegative/visits (0.00s)
=== RUN   TestMeetsStrictThresholds
--- PASS: TestMeetsStrictThresholds (0.00s)
PASS
ok  	webapp/internal/growthbase	0.262s

输出通过说明计算口径与测试一致,不说明阈值选得对。支付成功率低于多少要告警、履约 P95 超过几小时算异常、对账差异率容忍到多少,这些数字必须由业务、财务和运维一起定,不能由代码默认值决定。

小结

GMV 回答“卖了多少”,三条质量指标回答“卖得是否可持续”。支付成功率看钱有没有顺利收到,履约时长看货有没有按时发出,对账差异率看账有没有对上。退款率是结果指标,应该和三条过程指标一起看,而不是单独用来解释一切。

Aggregate 和 Meets 做的是口径固定和阈值判断:两个比率各自有明确分母,零分母返回 0,负数输入直接报错,阈值比较严格。它们很小,但能防止每个看板各算一套,也能防止“刚好踩线”被当成达标。

到这里,卷三关于交易、增长与平台化的讨论告一段落。卷四《互联网产品的服务端架构·规模化与架构演进》会把视角从单条交易链路抬到整个平台:当产品从一条链路长成多业务、多地域、多团队共用的系统时,服务拆分、容量治理、发布与演进会变成新的主要矛盾。本卷留下的订单、支付、券、活动和对账,会成为卷四讨论规模化时反复引用的具体例子。

阅读导航:上一节:13.2 券与活动的端到端验证 · 下一节:目录与学习路径 。

常见错误与边界

  • 只看 GMV 做活动复盘。GMV 是毛额,退款、补贴、手续费和履约成本都在后面。
  • 支付成功率用下单量做分母。这样会把“下单后放弃支付”的流失藏起来,看到的只是支付环节内部的成功率。
  • 履约时长只看平均数。少数超长订单会被大量正常订单稀释,必须看 P95 或 P99。
  • 对账差异率口径不固定。有人按金额、有人按订单数,两个团队看同一份数据会得出不同结论。
  • 零分母返回 NaN 或 Inf。下游阈值判断会失效,序列化也可能直接报错。
  • 阈值比较用大于等于。刚好踩线被当成达标,指标轻微恶化时不会触发任何告警。
  • 退款率单独看。退款率上升可能是履约、商品或活动规则的问题,必须和过程指标一起拆。
  • 指标只做看板不做告警。每天有人看当然好,但活动期间没人盯屏,阈值告警才是兜底。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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