预算五十万,计划跑七天,三十小时就没了
「刚好」做了一次拉新补贴:新用户领 20 元无门槛券,活动总预算 50 万元,计划跑七天。运营的预期是每天消耗七万左右,留出周末高峰的余量。这些数字是案例假设值,用于说明结构,不是线上实测。
活动上线三十小时后,预算池见底。后台看板显示领取人数一万两千多,客单价和核销率都正常,不像是被大规模刷单。风控把设备指纹拉出来才发现,其中约三千个账号集中在八百多台设备上,每台设备平均关联三到四个账号,支付工具也有明显复用。
原来的限领规则只有一条:每个账号每天限领一张。刷子注册一批账号,在同一台设备上轮流登录,每个账号都合法地领一张。账号维度看起来完全合规,预算却在快速流失。
事后复盘,团队补了设备维度,又补了支付工具维度,但新的问题马上出现:正常用户的家庭共享设备被误伤,夫妻俩各有账号却只能领一张。防刷不是“维度越多越好”,而是在预算、体验和攻击成本之间找平衡。
预算不是一个数字,而是三层
很多团队把预算做成一个总数,消耗到零就停。这个模型只能防“超支”,防不了“提前烧完”和“被少数人吃掉”。
| 层级 | 约束对象 | 典型粒度 | 作用 | 缺失后的后果 |
|---|---|---|---|---|
| 总预算 | 整个活动 | 活动周期内累计 | 控制财务上限 | 超支或提前耗尽 |
| 日预算 | 活动每天 | 自然日或滚动 24 小时 | 平滑消耗、留出高峰余量 | 第一天被领空,后续无券可发 |
| 用户预算 | 单个用户 | 活动周期内累计 | 防止单人占用过多 | 少数账号吃掉大部分预算 |
| 渠道预算 | 投放渠道或来源 | 渠道维度 | 控制单个渠道的 ROI | 低质渠道烧钱,无法归因 |
| 品类预算 | 品类或商家 | 品类维度 | 控制补贴结构 | 高毛利品类被过度补贴 |
总预算、日预算、用户预算是三个独立的约束,任何一层都不能替代另一层。总预算防超支,日预算防节奏失控,用户预算防集中度。渠道预算和品类预算属于经营分析层的细分,是否强约束取决于业务,但至少应该可观测。
预算是“占用”还是“核销”也要先定义。领券时占用预算、核销时确认、过期时释放,这是最常见的做法;也有团队按核销才扣预算,领券只做预估。两种口径的财务结果不同,必须和财务对齐,不能代码里一套、报表里另一套。
防刷要覆盖账号、设备、支付工具
只按账号限领,攻击者只需要多注册账号。只按设备限领,正常用户的家庭共享设备会被误伤。只按支付工具限领,攻击者可以换卡。三个维度各自有盲区,组合起来才能提高攻击成本。
账号维度最容易做,也最容易被绕过。注册成本低、接码平台成熟,单靠身份证或手机号并不能证明是真人。账号维度的价值在于沉淀历史行为:注册时长、历史订单、退款率、收货地址稳定性,这些信号比“是否新注册”更有区分度。
设备维度比账号难伪造,但会误伤。设备指纹可以检测改机、模拟器和多开,但同一台手机上的家人账号、同一台电脑上的多个浏览器,都是正常场景。设备维度更适合做“软限制”:同一设备超过 N 个账号领券时进入人工审核或降低优惠,而不是直接拒绝。
支付工具维度最接近资金侧。同一张银行卡、同一个支付账户、同一组收货地址,往往能把换账号、换设备的攻击串起来。但支付工具涉及隐私和合规,不能无限收集,也不能把“共用一张卡”直接判定为作弊。
除了这三个维度,还应该有行为维度:领取时间是否集中在脚本可预测的整点、请求间隔是否过于均匀、设备指纹是否在短时间内频繁变化、收货地址是否集中在少数仓库。行为信号不一定要实时拦截,可以先打标、限流、进人工审核。
失败场景:账号合规,设备复用
回到开头。活动规则是每个账号每天限领一张,服务端在领取前查询“该用户今天已领数量”,小于 1 就放行。这个查询本身没有错,错在它是唯一的约束。
刷子的操作路径很朴素:准备一批账号,在同一台设备上依次登录,每个账号领一张 20 元券,然后换下一台设备。对服务端来说,每个请求都来自一个“今天还没领过”的账号,设备信息没有被记录,支付工具也没有被关联。三千个账号在三十小时里领走了六万元,占总预算的 12%。
如果只补设备维度,攻击者可以换设备;只补支付工具,攻击者可以换卡;三个维度都补上,正常用户的家庭共享设备又会被误伤。事故的真正教训不是“少了一个维度”,而是“预算消耗速度没有被单独监控”:日预算没有设置,总预算见底才发现,中间没有任何预警。
第二个变种是预算并发。两个请求同时读到“已用 499980 元、总预算 500000 元”,都判断还剩 20 元,都放行,最终超支。第三个变种是回滚把预算还多:发放失败后释放预算,但券实际已经发出,预算被重复使用。第四个变种是日预算按自然日切分,却没有考虑时区,跨时区用户在一天内经历两次重置。
用 Consume 与 Allow 收口预算和频控
预算和频控都应该做成纯函数:输入当前状态和本次请求,输出是否放行以及新状态。存储层的原子性由数据库或 Redis 保证,纯函数只负责规则。
// file: internal/coupon/budget.go
// tier: L1
// verify: go test ./internal/coupon/
package coupon
import (
"errors"
"fmt"
)
var (
// ErrNegativeAmount 表示优惠金额、用量或预算为负数。
ErrNegativeAmount = errors.New("coupon: negative amount")
// ErrInvalidBudget 表示预算总额或已用金额不自洽。
ErrInvalidBudget = errors.New("coupon: invalid budget")
// ErrBudgetExhausted 表示本次占用会超过预算。
ErrBudgetExhausted = errors.New("coupon: budget exhausted")
// ErrInvalidRule 表示频控规则里出现了负数。
ErrInvalidRule = errors.New("coupon: invalid rule")
)
// Budget 是一个营销预算池。金额单位是分;TotalCents 是上限,UsedCents 是
// 已经占用或核销的金额。它可以是总预算,也可以是某个活动、某个渠道的预算。
type Budget struct {
TotalCents int64
UsedCents int64
}
// Consume 尝试从预算中占用 n 分。成功返回更新后的 Budget;失败返回原
// Budget 和错误,调用方看到的已用金额不会变化。n 为 0 是合法的空操作。
//
// 与 Template.Issue 一样,Consume 是纯函数。线上必须用条件更新或事务保证
// “读余量、判断、写回”原子,否则并发请求会一起通过检查。
func Consume(b Budget, n int64) (Budget, error) {
if n < 0 {
return b, fmt.Errorf("%w: consume %d", ErrNegativeAmount, n)
}
if b.TotalCents < 0 || b.UsedCents < 0 || b.UsedCents > b.TotalCents {
return b, fmt.Errorf("%w: total=%d used=%d", ErrInvalidBudget, b.TotalCents, b.UsedCents)
}
if n > b.TotalCents-b.UsedCents {
return b, fmt.Errorf("%w: used %d + %d > total %d", ErrBudgetExhausted, b.UsedCents, n, b.TotalCents)
}
b.UsedCents += n
return b, nil
}
// Rule 是防刷的频控规则。三个字段里任何一个为 0 都表示该维度不限制;
// 正数表示上限。UserDaily 和 DeviceDaily 按领取次数计,UserTotal 按分计。
type Rule struct {
UserDaily int
DeviceDaily int
UserTotal int64
}
// Allow 判断这次领取是否还在规则内。任一维度达到上限就返回 false。
// 负数的规则或用量都视为调用错误,返回 false 和错误,而不是静默放行。
func Allow(r Rule, usedUserDay, usedDeviceDay int, usedUserTotal int64) (bool, error) {
if r.UserDaily < 0 || r.DeviceDaily < 0 || r.UserTotal < 0 {
return false, fmt.Errorf("%w: %+v", ErrInvalidRule, r)
}
if usedUserDay < 0 || usedDeviceDay < 0 || usedUserTotal < 0 {
return false, fmt.Errorf("%w: user_day=%d device_day=%d user_total=%d", ErrNegativeAmount, usedUserDay, usedDeviceDay, usedUserTotal)
}
if r.UserDaily > 0 && usedUserDay >= r.UserDaily {
return false, nil
}
if r.DeviceDaily > 0 && usedDeviceDay >= r.DeviceDaily {
return false, nil
}
if r.UserTotal > 0 && usedUserTotal >= r.UserTotal {
return false, nil
}
return true, nil
}
实现里有几个边界:
Consume先校验本次金额非负,再校验预算本身自洽:总额、已用不能为负,已用不能超过总额。最后用n > Total-Used判断是否超支,避免Used+n溢出。- 失败时返回原
Budget和错误,调用方看到的已用金额不变。成功时返回新值,不修改入参。 n为 0 是合法空操作,方便调用方在“本次没有补贴”时走同一条路径。Rule的三个字段任何一个为 0 都表示该维度不限制。这样运营可以只配设备维度,也可以只配用户总额,不需要额外的开关字段。Allow对负数规则和负数用量都返回错误,而不是静默放行。脏数据必须显式失败,否则一次错误的负数会永久绕过限制。- 判断用
>=:已用次数达到上限就拒绝。边界上“第 N 次”是否允许,要和产品文案一致。
Consume 和 Allow 都是纯函数,线上必须把“读取、判断、写回”放进同一个原子操作。预算可以用条件更新 UPDATE budget SET used = used + ? WHERE used + ? <= total,频控可以用 Redis 的原子计数加过期时间;不能先 GET 再 SET。
测试:预算边界、零值不限制与负数
预算和频控的错误大多在边界:刚好用完、超一分、上限为零、负数用量。测试要把这些行为固定下来。
// file: internal/coupon/budget_test.go
// tier: L1
// verify: go test ./internal/coupon/
package coupon
import (
"errors"
"testing"
)
func TestConsumeWithinBudget(t *testing.T) {
b := Budget{TotalCents: 100000, UsedCents: 40000}
got, err := Consume(b, 60000)
if err != nil {
t.Fatalf("Consume = %v, want nil", err)
}
if got.UsedCents != 100000 {
t.Fatalf("UsedCents = %d, want 100000", got.UsedCents)
}
if b.UsedCents != 40000 {
t.Fatalf("input mutated: UsedCents = %d, want 40000", b.UsedCents)
}
}
func TestConsumeRejectsOverBudgetWithoutMutation(t *testing.T) {
b := Budget{TotalCents: 100000, UsedCents: 40000}
got, err := Consume(b, 60001)
if !errors.Is(err, ErrBudgetExhausted) {
t.Fatalf("err = %v, want ErrBudgetExhausted", err)
}
if got != b {
t.Fatalf("got = %+v, want original %+v", got, b)
}
}
func TestConsumeRejectsNegativeAndInvalid(t *testing.T) {
b := Budget{TotalCents: 100}
if _, err := Consume(b, -1); !errors.Is(err, ErrNegativeAmount) {
t.Fatalf("negative consume = %v, want ErrNegativeAmount", err)
}
if _, err := Consume(Budget{TotalCents: -1}, 1); !errors.Is(err, ErrInvalidBudget) {
t.Fatalf("negative total = %v, want ErrInvalidBudget", err)
}
if _, err := Consume(Budget{TotalCents: 10, UsedCents: 11}, 0); !errors.Is(err, ErrInvalidBudget) {
t.Fatalf("used over total = %v, want ErrInvalidBudget", err)
}
}
func TestConsumeZeroAndExactTotal(t *testing.T) {
if got, err := Consume(Budget{TotalCents: 100}, 0); err != nil || got.UsedCents != 0 {
t.Fatalf("zero consume = %+v, %v; want unchanged", got, err)
}
if got, err := Consume(Budget{TotalCents: 100}, 100); err != nil || got.UsedCents != 100 {
t.Fatalf("exact consume = %+v, %v; want 100 used", got, err)
}
}
func TestAllowUnlimitedWhenZero(t *testing.T) {
ok, err := Allow(Rule{}, 1000, 1000, 1<<40)
if err != nil || !ok {
t.Fatalf("Allow unlimited = %v, %v; want true, nil", ok, err)
}
}
func TestAllowStopsAtEachLimit(t *testing.T) {
cases := []struct {
name string
rule Rule
userDay, deviceDay int
userTotal int64
want bool
}{
{"user-day-ok", Rule{UserDaily: 2}, 1, 0, 0, true},
{"user-day-hit", Rule{UserDaily: 2}, 2, 0, 0, false},
{"device-day-hit", Rule{DeviceDaily: 3}, 0, 3, 0, false},
{"user-total-hit", Rule{UserTotal: 5000}, 0, 0, 5000, false},
{"all-ok", Rule{UserDaily: 2, DeviceDaily: 2, UserTotal: 5000}, 1, 1, 4999, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Allow(c.rule, c.userDay, c.deviceDay, c.userTotal)
if err != nil {
t.Fatalf("Allow = %v, want nil", err)
}
if got != c.want {
t.Fatalf("Allow = %v, want %v", got, c.want)
}
})
}
}
func TestAllowRejectsNegativeUsageAndRule(t *testing.T) {
if _, err := Allow(Rule{}, -1, 0, 0); !errors.Is(err, ErrNegativeAmount) {
t.Fatalf("negative usage = %v, want ErrNegativeAmount", err)
}
if _, err := Allow(Rule{DeviceDaily: -1}, 0, 0, 0); !errors.Is(err, ErrInvalidRule) {
t.Fatalf("negative rule = %v, want ErrInvalidRule", err)
}
if _, err := Allow(Rule{UserTotal: -1}, 0, 0, 0); !errors.Is(err, ErrInvalidRule) {
t.Fatalf("negative total rule = %v, want ErrInvalidRule", err)
}
}
TestConsumeWithinBudget 从已用四万消耗六万,断言刚好到达十万且入参不变。TestConsumeRejectsOverBudgetWithoutMutation 多消耗一分,断言返回 ErrBudgetExhausted 且预算保持原值。TestConsumeRejectsNegativeAndInvalid 覆盖负数消耗、负总额和已用超过总额。TestConsumeZeroAndExactTotal 验证零消耗和刚好用满。
TestAllowUnlimitedWhenZero 用全零规则和很大的历史用量,断言仍然放行。TestAllowStopsAtEachLimit 分别命中用户日限、设备日限和用户总额,再验证三个维度都满足时放行。TestAllowRejectsNegativeUsageAndRule 保证负数和负规则不会被当成“不限制”。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/coupon/ -run 'TestConsume|TestAllow' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestConsumeWithinBudget
--- PASS: TestConsumeWithinBudget (0.00s)
=== RUN TestConsumeRejectsOverBudgetWithoutMutation
--- PASS: TestConsumeRejectsOverBudgetWithoutMutation (0.00s)
=== RUN TestConsumeRejectsNegativeAndInvalid
--- PASS: TestConsumeRejectsNegativeAndInvalid (0.00s)
=== RUN TestConsumeZeroAndExactTotal
--- PASS: TestConsumeZeroAndExactTotal (0.00s)
=== RUN TestAllowUnlimitedWhenZero
--- PASS: TestAllowUnlimitedWhenZero (0.00s)
=== RUN TestAllowStopsAtEachLimit
=== RUN TestAllowStopsAtEachLimit/user-day-ok
=== RUN TestAllowStopsAtEachLimit/user-day-hit
=== RUN TestAllowStopsAtEachLimit/device-day-hit
=== RUN TestAllowStopsAtEachLimit/user-total-hit
=== RUN TestAllowStopsAtEachLimit/all-ok
--- PASS: TestAllowStopsAtEachLimit (0.00s)
--- PASS: TestAllowStopsAtEachLimit/user-day-ok (0.00s)
--- PASS: TestAllowStopsAtEachLimit/user-day-hit (0.00s)
--- PASS: TestAllowStopsAtEachLimit/device-day-hit (0.00s)
--- PASS: TestAllowStopsAtEachLimit/user-total-hit (0.00s)
--- PASS: TestAllowStopsAtEachLimit/all-ok (0.00s)
=== RUN TestAllowRejectsNegativeUsageAndRule
--- PASS: TestAllowRejectsNegativeUsageAndRule (0.00s)
PASS
ok webapp/internal/coupon 0.238s
PASS 表示预算与频控的 7 个用例全部通过。它们验证的是单次判断的正确性;并发下的原子性、预算回滚和跨维度风控仍然需要存储层和风控系统配合。
小结
预算要拆成总预算、日预算和用户预算三层,分别防超支、防节奏失控、防集中度。防刷要同时看账号、设备和支付工具,再加行为信号;任何一个维度单独使用都有明显盲区。
Consume 把预算判断收成一个纯函数,金额非负、超支报错、失败不改入参;Allow 把频控规则收成三个可独立关闭的维度,零表示不限制、负数表示调用错误。到这里,优惠券的模板、叠加和预算三条线已经闭环;下一章转到另一类增长机制:会员等级与成长值。
阅读导航:上一节:4.2 叠加规则与最优解 · 下一节:5.1 会员等级与成长值 。
常见错误与边界
- 只设总预算不设日预算。预算会在第一天被领空,后续投放和自然流量都无券可发。
- 只按账号限领。注册成本低,换账号就能继续领,必须叠加设备和支付工具维度。
- 设备和支付工具一刀切。家庭共享设备、共用银行卡是正常场景,硬拒绝会误伤真实用户。
- 预算先查后写,没有原子更新。并发请求会同时通过检查,超支是必然结果。
- 用
Used+n判断超支。两个大数相加可能溢出,应该先确认Used <= Total,再用减法比较。 - 把频控规则的 0 当成“禁止”。0 应该表示不限制,禁止应该用正数上限或独立的开关表达。
- 负数用量被当成合法值。负一次领取会抵消历史计数,必须显式报错。
- 预算回滚不区分“券是否已发出”。发放失败但券已落库时释放预算,会让同一笔钱被用两次。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。