五千张券,三秒发出去五千二百张
「刚好」是一个做本地生活团购的产品。运营在后台配了一个新人券活动:满 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,再用减法比较。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。