《互联网产品的服务端架构·交易、增长与平台化》4.1 券模板、发放与核销

从一次「限量五千张、三秒发出五千二百张」的领券事故写起,说明券模板与券实例为什么必须分开、发放链路要在哪一层挡住超发、核销为什么要单独做幂等,并给出 Validate 与 Issue 的 Go 实现、边界测试和真实运行输出。所有金额统一用 int64 分表示。

五千张券,三秒发出去五千二百张

「刚好」是一个做本地生活团购的产品。运营在后台配了一个新人券活动:满 100 元减 20 元,限量 5000 张,上午十点开抢。活动页用了倒计时,十点整的瞬时请求大约是平时峰值的六倍。这个数字是案例假设值,不是线上实测。

十点零三秒,客服群里开始有人截图:领券按钮显示“已抢光”,但自己的券包里躺着一张券。运营后台的已发数量停在 5231。更麻烦的是,有 17 个用户各领到了两张,而规则写的是每人限领一张。

排查后发现三件事叠在一起。第一,活动服务有四个实例,每个实例先在本地缓存里判断“还有余量”,再异步写数据库,四个实例各自认为余量充足。第二,数据库更新用的是“先查当前已发数量,再写回已发加一”,两个请求同时读到 4999,都写回 5000。第三,领券接口没有幂等键,用户连点两次就落了两条领取记录。

这不是“加个锁”就能讲完的问题。它暴露的是对象边界没划清:运营改的是券模板,用户领的是券实例,而代码把两者混成了一条记录。

券模板与券实例是两种对象

券模板描述“这是一张什么券、总共能发多少”,券实例描述“这张券属于谁、什么时候领的、用没用”。两者的生命周期、读写频率和一致性要求完全不同。

维度券模板券实例混在一起会怎样
谁创建运营后台发放服务运营改规则时误改已发券
数量一条配置最多 Total 条模板被复制成几千行
读频率每次领券都读每次下单都读热点集中,缓存失效
关键约束总量、面值、门槛归属人、状态、有效期超发和重复领取都难查
变更方式可下架、可复制新版本状态机流转改模板会把历史券一起改掉

模板是“规则”,实例是“事实”。规则可以改,事实不能篡改。运营把满 100 减 20 改成满 120 减 20,已经发出去的券仍应按旧规则核销;如果只有一张表,这次修改会把历史券的门槛也改掉,客诉会直接指向“你们偷偷改了规则”。

因此模板表至少要存:模板 ID、类型、面值、使用门槛、发行总量、已发数量、有效期规则。实例表至少存:实例 ID、模板 ID、归属用户、领取时间、状态、核销订单号。实例的面值和门槛建议在领取时快照一份,避免模板后续变更影响已发券。

发放链路的三道门

一次领券请求要过三道门,任何一道缺失都会留下事故。

第一道是规则门:模板是否存在、是否在有效期内、用户是否满足领取资格、券类型和面值是否合法。这一层是纯判断,不写状态,失败可以直接返回。

第二道是库存门:模板的已发数量加上本次数量不能超过总量。这一层必须原子。单机可以用互斥锁,多实例必须用数据库条件更新、Redis 原子脚本或独立的库存服务。先查后写在没有事务保护时必然超发。

第三道是幂等门:同一个用户对同一个活动重复提交,只能成功一次。幂等键通常由 (活动 ID, 用户 ID, 请求 ID) 或 (模板 ID, 用户 ID) 组成,落库时加唯一约束。只靠前端禁用按钮挡不住重试和并发。

核销是另一条链路。领券解决“给谁”,核销解决“这张券用在哪一单”。核销接口必须接收订单号,并以 (券实例 ID, 订单号) 或订单号本身做幂等:同一张券对同一订单重复核销,应该返回第一次的结果;同一张券对第二个订单核销,必须失败。核销失败后的回滚不能简单把状态改回“未使用”,否则用户可以在支付失败和重试之间反复占用库存,正确做法是状态机加超时释放。

失败场景:四个实例同时看到“还有余量”

回到开头的事故。模板总量 5000,已发 4999,四个实例的本地缓存都还没刷新,都判断“还剩至少一张”。请求 A 和请求 B 同时进入,各自执行“查询已发 4999、写回 5000”。数据库没有行锁,也没有版本号,两条更新都成功,已发变成 5000,但实际创建了两条实例。第三个、第四个请求读到的是旧快照,继续通过。

如果只是这样,最多超发几张。真正把数字推到 5231 的是重试:领券接口超时后客户端自动重试,而接口没有幂等键,每次重试都创建新实例。四个实例的本地缓存又放大了窗口,最终超发 231 张,17 个用户重复领取。

后果不只是多送几千块钱。券包出现重复券,用户下单时叠加规则会算出异常优惠;客服需要逐条核对哪些是重复发放;财务对账时活动成本比预算多了 4.6%。如果是案例中的 20 元券,231 张就是 4620 元,金额不大,但同一套代码放到大促就是几十万。

更隐蔽的变种是“回滚把库存还多了”。发放失败时,代码把已发数量减一,但失败发生在实例写入之后、响应返回之前,调用方重试又发了一张。库存看起来正常,实例却多了一张。这类问题只能靠“实例表唯一约束 + 库存条件更新”双重兜底,不能只信任计数。

用 Validate 与 Issue 收口模板规则

模板层的代码应该尽量小:只做字段校验和数量推演,不碰数据库、不加锁、不发消息。这样它可以在创建模板、发放前校验、后台导入等多个场景复用,也容易测试。

// file: internal/coupon/template.go
// tier: L1
// verify: go test ./internal/coupon/

package coupon

import (
	"errors"
	"fmt"
)

// Kind 是券的优惠类型。
type Kind string

const (
	// KindAmount 是立减券,Value 表示减免的金额,单位分。
	KindAmount Kind = "amount"
	// KindPercent 是折扣券,Value 表示折扣百分比,例如 80 表示八折。
	KindPercent Kind = "percent"
)

var (
	// ErrEmptyTemplateID 表示券模板缺少 ID。
	ErrEmptyTemplateID = errors.New("coupon: empty template id")
	// ErrInvalidKind 表示券类型不是 amount 或 percent。
	ErrInvalidKind = errors.New("coupon: invalid kind")
	// ErrInvalidValue 表示券面值不合法。
	ErrInvalidValue = errors.New("coupon: invalid value")
	// ErrInvalidTemplate 表示总量、已发或门槛为负数,或已发超过总量。
	ErrInvalidTemplate = errors.New("coupon: invalid template")
	// ErrNegativeIssue 表示本次发放数量为负数。
	ErrNegativeIssue = errors.New("coupon: negative issue")
	// ErrOverIssue 表示本次发放会超过模板总量。
	ErrOverIssue = errors.New("coupon: issue exceeds total")
)

// Template 是券模板,描述“能发多少、发什么券”;某一张具体券是它的实例。
// 所有金额单位都是分,用 int64 保存,避免浮点误差。
type Template struct {
	ID             string
	Kind           Kind
	Value          int64
	MinAmountCents int64
	Total          int64
	Issued         int64
}

// Validate 检查模板字段是否自洽。它不检查库存是否用完,只检查定义是否合法,
// 因此可以用于创建模板和发放前的重复校验。
func Validate(t Template) error {
	if t.ID == "" {
		return ErrEmptyTemplateID
	}
	if t.Total < 0 || t.Issued < 0 || t.MinAmountCents < 0 {
		return fmt.Errorf("%w: total=%d issued=%d min=%d", ErrInvalidTemplate, t.Total, t.Issued, t.MinAmountCents)
	}
	if t.Issued > t.Total {
		return fmt.Errorf("%w: issued %d > total %d", ErrInvalidTemplate, t.Issued, t.Total)
	}
	switch t.Kind {
	case KindAmount:
		if t.Value <= 0 {
			return fmt.Errorf("%w: amount value %d", ErrInvalidValue, t.Value)
		}
	case KindPercent:
		if t.Value < 1 || t.Value > 100 {
			return fmt.Errorf("%w: percent value %d", ErrInvalidValue, t.Value)
		}
	default:
		return fmt.Errorf("%w: %q", ErrInvalidKind, t.Kind)
	}
	return nil
}

// Issue 返回发放 n 张后的新模板。成功时 Issued 增加 n;失败时返回零值模板
// 和错误,调用方的入参不会被修改。n 为 0 是合法的空操作。
//
// Issue 是纯函数,不做加锁,也不负责持久化。线上并发领券必须把“读取模板、
// 判断余量、写回已发数量”放进同一个事务或原子操作,否则两个请求会同时
// 看到同一个余量。
func Issue(t Template, n int64) (Template, error) {
	if n < 0 {
		return Template{}, fmt.Errorf("%w: %d", ErrNegativeIssue, n)
	}
	if err := Validate(t); err != nil {
		return Template{}, err
	}
	if n > t.Total-t.Issued {
		return Template{}, fmt.Errorf("%w: issued %d + %d > total %d", ErrOverIssue, t.Issued, n, t.Total)
	}
	t.Issued += n
	return t, nil
}

实现里有几个刻意选择:

  • Kind 只有 amount 和 percent 两种。立减券的 Value 是减免金额,单位分;折扣券的 Value 是折扣百分比,80 表示八折。类型和面值的含义绑在一起,避免同一个字段在不同类型下有两种解释。
  • 金额一律用 int64 分。用 float64 存 19.99 元会在累加和折扣计算里出现 1998.9999999999998 这类值,最终对账差一分。
  • Validate 检查模板是否自洽:总量、已发、门槛不能为负,已发不能超过总量,面值必须在类型允许的范围内。它不检查“是否已经发完”,因为发完是发放时的状态,不是模板定义错误。
  • Issue 先校验 n 是否为负,再校验模板,最后判断 n > Total-Issued。这个减法在两边都非负且 Issued <= Total 时不会溢出,比 Issued+n > Total 更安全。
  • 失败时返回零值模板和错误,不修改入参。Go 的按值传递让“入参不变”天然成立,但返回零值可以防止调用方误用半成品。
  • Issue 是纯函数,不负责并发。线上必须把“读模板、算余量、写回”放进同一个事务,或者用 UPDATE ... SET issued = issued + ? WHERE issued + ? <= total 这样的条件更新,并检查影响行数。

这段代码只解决模板层的“账算得对不对”。真正防止超发的是存储层的原子性和实例表的唯一约束,两者不能互相替代。

测试:超发、空操作与入参不变

模板层的错误大多集中在边界:刚好发完、多发一张、零张、已发已经等于总量。测试要把这些边界固定下来。

// file: internal/coupon/template_test.go
// tier: L1
// verify: go test ./internal/coupon/

package coupon

import (
	"errors"
	"testing"
)

func TestValidateAcceptsValidTemplates(t *testing.T) {
	cases := []Template{
		{ID: "t-amount", Kind: KindAmount, Value: 500, MinAmountCents: 3000, Total: 100, Issued: 0},
		{ID: "t-percent", Kind: KindPercent, Value: 80, MinAmountCents: 0, Total: 0, Issued: 0},
		{ID: "t-full", Kind: KindAmount, Value: 1, Total: 10, Issued: 10},
	}
	for _, c := range cases {
		if err := Validate(c); err != nil {
			t.Fatalf("Validate(%s) = %v, want nil", c.ID, err)
		}
	}
}

func TestValidateRejectsBadTemplates(t *testing.T) {
	cases := []struct {
		name string
		in   Template
		want error
	}{
		{"empty-id", Template{Kind: KindAmount, Value: 1}, ErrEmptyTemplateID},
		{"unknown-kind", Template{ID: "x", Kind: Kind("gift"), Value: 1}, ErrInvalidKind},
		{"amount-zero", Template{ID: "x", Kind: KindAmount, Value: 0}, ErrInvalidValue},
		{"amount-negative", Template{ID: "x", Kind: KindAmount, Value: -1}, ErrInvalidValue},
		{"percent-zero", Template{ID: "x", Kind: KindPercent, Value: 0}, ErrInvalidValue},
		{"percent-over", Template{ID: "x", Kind: KindPercent, Value: 101}, ErrInvalidValue},
		{"negative-total", Template{ID: "x", Kind: KindAmount, Value: 1, Total: -1}, ErrInvalidTemplate},
		{"negative-min", Template{ID: "x", Kind: KindAmount, Value: 1, MinAmountCents: -1}, ErrInvalidTemplate},
		{"issued-over-total", Template{ID: "x", Kind: KindAmount, Value: 1, Total: 2, Issued: 3}, ErrInvalidTemplate},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if err := Validate(c.in); !errors.Is(err, c.want) {
				t.Fatalf("Validate = %v, want %v", err, c.want)
			}
		})
	}
}

func TestIssueWithinTotal(t *testing.T) {
	tmpl := Template{ID: "t1", Kind: KindAmount, Value: 500, Total: 10, Issued: 3}
	got, err := Issue(tmpl, 7)
	if err != nil {
		t.Fatalf("Issue = %v, want nil", err)
	}
	if got.Issued != 10 {
		t.Fatalf("Issued = %d, want 10", got.Issued)
	}
	if tmpl.Issued != 3 {
		t.Fatalf("input mutated: Issued = %d, want 3", tmpl.Issued)
	}
}

func TestIssueRejectsOverIssueWithoutMutation(t *testing.T) {
	tmpl := Template{ID: "t1", Kind: KindAmount, Value: 500, Total: 10, Issued: 3}
	got, err := Issue(tmpl, 8)
	if !errors.Is(err, ErrOverIssue) {
		t.Fatalf("err = %v, want ErrOverIssue", err)
	}
	if got != (Template{}) {
		t.Fatalf("got = %+v, want zero template on error", got)
	}
	if tmpl.Issued != 3 {
		t.Fatalf("input mutated: Issued = %d, want 3", tmpl.Issued)
	}
}

func TestIssueZeroAndZeroTotal(t *testing.T) {
	tmpl := Template{ID: "t0", Kind: KindAmount, Value: 1, Total: 0, Issued: 0}
	got, err := Issue(tmpl, 0)
	if err != nil {
		t.Fatalf("Issue zero = %v, want nil", err)
	}
	if got.Issued != 0 {
		t.Fatalf("Issued = %d, want 0", got.Issued)
	}
	if _, err := Issue(tmpl, 1); !errors.Is(err, ErrOverIssue) {
		t.Fatalf("Issue one into empty total = %v, want ErrOverIssue", err)
	}
}

func TestIssueRejectsNegativeAndInvalidTemplate(t *testing.T) {
	valid := Template{ID: "t1", Kind: KindAmount, Value: 500, Total: 10}
	if _, err := Issue(valid, -1); !errors.Is(err, ErrNegativeIssue) {
		t.Fatalf("negative issue = %v, want ErrNegativeIssue", err)
	}
	invalid := Template{ID: "t1", Kind: KindPercent, Value: 0, Total: 10}
	if _, err := Issue(invalid, 1); !errors.Is(err, ErrInvalidValue) {
		t.Fatalf("invalid template = %v, want ErrInvalidValue", err)
	}
}

TestValidateAcceptsValidTemplates 覆盖三种合法形态:普通立减券、没有发行上限的折扣券、已经发满的模板。TestValidateRejectsBadTemplates 用表驱动列出九种非法输入,包括空 ID、未知类型、零面值、负总量和已发超过总量。

TestIssueWithinTotal 从已发 3 张发到 10 张,并断言原变量的 Issued 仍是 3。TestIssueRejectsOverIssueWithoutMutation 多发一张,断言返回 ErrOverIssue、返回零值模板、入参不变。TestIssueZeroAndZeroTotal 验证零张是合法空操作,而总量为零时发一张必须失败。TestIssueRejectsNegativeAndInvalidTemplate 保证负数和非法模板不会绕过校验。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/coupon/ -run 'TestValidate|TestIssue' -count=1 -v

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

=== RUN   TestValidateAcceptsValidTemplates
--- PASS: TestValidateAcceptsValidTemplates (0.00s)
=== RUN   TestValidateRejectsBadTemplates
=== RUN   TestValidateRejectsBadTemplates/empty-id
=== RUN   TestValidateRejectsBadTemplates/unknown-kind
=== RUN   TestValidateRejectsBadTemplates/amount-zero
=== RUN   TestValidateRejectsBadTemplates/amount-negative
=== RUN   TestValidateRejectsBadTemplates/percent-zero
=== RUN   TestValidateRejectsBadTemplates/percent-over
=== RUN   TestValidateRejectsBadTemplates/negative-total
=== RUN   TestValidateRejectsBadTemplates/negative-min
=== RUN   TestValidateRejectsBadTemplates/issued-over-total
--- PASS: TestValidateRejectsBadTemplates (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/empty-id (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/unknown-kind (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/amount-zero (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/amount-negative (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/percent-zero (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/percent-over (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/negative-total (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/negative-min (0.00s)
    --- PASS: TestValidateRejectsBadTemplates/issued-over-total (0.00s)
=== RUN   TestIssueWithinTotal
--- PASS: TestIssueWithinTotal (0.00s)
=== RUN   TestIssueRejectsOverIssueWithoutMutation
--- PASS: TestIssueRejectsOverIssueWithoutMutation (0.00s)
=== RUN   TestIssueZeroAndZeroTotal
--- PASS: TestIssueZeroAndZeroTotal (0.00s)
=== RUN   TestIssueRejectsNegativeAndInvalidTemplate
--- PASS: TestIssueRejectsNegativeAndInvalidTemplate (0.00s)
PASS
ok  	webapp/internal/coupon	0.229s

PASS 表示模板层的 15 个用例全部通过。它证明的是规则校验和数量推演正确,不证明线上不会超发;并发安全仍然由数据库事务、条件更新和唯一约束负责。

小结

券系统第一刀要切在模板和实例之间。模板是可变更的规则,实例是不可篡改的事实;已发券的面值和门槛应该在领取时快照。发放链路要依次经过规则校验、原子库存和幂等落库,核销则要按订单做幂等和状态机。

Validate 和 Issue 把模板层的边界收在一个纯函数里:金额用分、类型决定面值含义、超发返回错误且不碰入参。下一节讨论用户手里有多张券时,结算页应该怎么选出优惠最大的组合。

阅读导航:上一节:目录与学习路径 · 下一节:4.2 叠加规则与最优解 。

常见错误与边界

  • 把模板和实例放在一张表。运营改一次规则,历史券的面值和门槛跟着变,客诉和审计都说不清。
  • 用浮点数存金额。0.1 加 0.2 不等于 0.3,折扣再一乘,对账必然差分。
  • 先查余量再写回,没有事务或条件更新。多实例下两个请求会同时通过,超发是必然而不是偶然。
  • 只在前端限制领取次数。重试、脚本和多端登录都能绕开,幂等必须落在服务端唯一约束上。
  • 把“已发完”当成模板非法。发完是运行时状态,Validate 不应该因为它返回模板定义错误。
  • 核销只改状态、不记录订单号。同一张券被两个订单先后核销时,无法判断哪一次是重复。
  • 核销失败直接把状态改回未使用。支付重试期间券会被反复占用,应该用状态机和超时释放。
  • 用 Issued + n > Total 判断超发。两个值都很大时可能溢出;先确认 Issued <= Total,再用减法比较。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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