只看 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%,团队才开始排查。此时退款已经发生,补贴已经发完,履约拥堵也已经变成客诉,能做的只有事后补救。
如果当时有三条指标,预警会这样发生:
- 支付成功率从 4.6% 掉到 4.2%,先触发渠道和风控排查,确认不是支付故障。
- 履约 P95 超过 24 小时阈值,触发仓库和库存排查,发现爆款缺货导致大量订单卡在拣货。
- 退款率超过 8% 阈值,触发退款原因分析,发现缺货和延迟发货占了多数。
- 对账差异率超过 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。下游阈值判断会失效,序列化也可能直接报错。
- 阈值比较用大于等于。刚好踩线被当成达标,指标轻微恶化时不会触发任何告警。
- 退款率单独看。退款率上升可能是履约、商品或活动规则的问题,必须和过程指标一起拆。
- 指标只做看板不做告警。每天有人看当然好,但活动期间没人盯屏,阈值告警才是兜底。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。