十点开场,九点五十九分还在写库存
「刚好」做了一次整点秒杀:十月二十日上午十点,五千件限量商品,活动价是日常价的六折。运营在前一天配置好活动,技术方案是活动开始时把商品库存从数据库读到缓存,用户下单时只读缓存。
十点整,接口 QPS 在两秒内从两百涨到一万二。缓存里还没有库存键,第一批请求全部穿透到数据库,数据库连接池在七秒内被打满,活动接口超时。运营看到的是“活动还没开始就结束了”,用户在群里问是不是系统崩了。这些数字是案例假设值,用来说明链路,不是线上实测。
事后看,问题不在缓存,也不在数据库,而在“什么时候把库存放进缓存”。活动库存的准备动作被放在了活动开始之后,和用户请求抢同一份资源。预热要解决的,就是把这一步提前,并且保证提前做的动作不会碰坏商品库存。
活动库存和商品库存必须分开
很多团队图省事,活动开始时直接把商品表的可售库存改成活动库存,活动结束后再改回来。短期看少了一张表,长期看会同时失去追溯能力和回滚能力。
| 维度 | 商品库存 | 活动库存 | 分开后的好处 |
|---|---|---|---|
| 归属 | 商品/SKU 的长期可售量 | 某个活动周期内的配额 | 活动结束不影响日常售卖 |
| 扣减时机 | 下单占用、支付确认、超时释放 | 活动下单时扣活动池 | 两套状态机互不阻塞 |
| 回滚对象 | 退订单、退商品库存 | 退活动配额或标记失效 | 退款不会把活动库存放大 |
| 读压力 | 全站商品页和下单页 | 活动页和活动下单链路 | 热点只集中在活动侧 |
| 生命周期 | 跟商品一致 | 跟活动开始/结束时间一致 | 过期即可清理,不污染主数据 |
| 对账口径 | 可售、占用、已售 | 发放、消耗、剩余 | 财务和运营各看各的口径 |
商品库存回答的是“这个商品现在还能卖多少”,活动库存回答的是“这个活动还剩多少名额”。前者要跨活动长期一致,后者只需要在一个时间窗内自洽。把两者塞进同一个字段,活动一结束就要做一次大规模的数据修复,任何一步失败都会让商品页显示错误的可售量。
分开之后,活动库存可以放在独立的存储里:一张活动库存表、一组 Redis 键,或者一个按活动分片的计数服务。它可以接受更高的写入并发,也可以用更粗的持久化策略,因为活动库存通常允许在活动结束后按订单明细重建。
预热不是抄数字,是把读压力搬出峰值
预热的目标不是让缓存里“有”这个键,而是让峰值期间不再产生回源。判断预热是否成功,至少看三个指标:预热完成时间是否早于活动开始、预热后的键是否都能读到、活动峰值期间的回源比例是否接近零。
把总库存均分到多个桶,是为了避免单键热点,也让失败重试有更小的粒度。十个桶意味着同一个活动的库存写入可以分散到十个键,单个键的读压力降到十分之一;如果某个桶写入失败,只需要重试那一个桶,而不是整个活动。
分桶方案要同时定义两件事:怎么分,以及每个桶最多能装多少。前者决定读写分布,后者是安全阈值。调用方传入单桶容量上限,预热函数先算出最大的桶,确认它没有超过上限,再返回分桶结果。超过上限说明分桶方案承载不了这次库存,应该拒绝预热并告警,而不是先把数据写进去再说。
预热还要能回退。常见做法是先写临时键,全部桶写完后做一次校验,再把活动指针切到临时键;任何一步失败就删除临时键,正式键保持不变。这样活动要么完整可用,要么完全没开始,不会出现“七个桶有货、三个桶是空键”的半成品状态。
失败场景:预热时扣了商品库存
回到开头那场活动。第一版预热脚本做了一件看起来合理的事:调用商品服务的“冻结库存”接口,把五千件从可售库存扣到活动池,然后把活动库存写进缓存。脚本在活动前半小时执行,执行成功后打印“预热完成”。
运营在九点四十分改了活动开始时间,从十点改到十点十分。发布系统监听到配置变更,自动重跑了一次预热任务。脚本没有做幂等判断,又调用了一次冻结接口,商品可售库存从五千变成零,活动池变成一万。活动还没开始,商品详情页已经显示“无货”,日常用户下单失败。
第二个变种是活动取消。预热已经把库存冻结,运营临时下线活动,但没有调用释放接口,五千件库存一直挂在活动池里,直到第二天对账才发现。
第三个变种是部分成功。十个桶写了七个,脚本在第八个桶超时退出,没有回滚。活动开始后,前七个桶很快卖完,后三个桶是空键,用户看到“已售罄”,实际还有三千件库存没有暴露。运营以为被刷了,技术排查了半天才发现是预热只做了一半。
这三个变种的共同点是:预热动作有副作用,却没有和活动状态绑定。预热应该只写活动侧的数据,不直接扣商品库存;如果业务必须冻结,也要用活动 ID 做幂等键,并且把“冻结成功”记录成可查询、可补偿的状态。
用 Validate、State 与 Preheat 收口活动模型
活动模型先收成三个纯函数:校验、状态判断、分桶计算。它们不访问数据库,也不写缓存,存储层的原子性和幂等由调用方保证。这样规则可以单独测试,预热脚本和在线接口也能复用同一套判断。
// file: internal/activity/activity.go
// tier: L1
// verify: go test ./internal/activity/
package activity
import (
"errors"
"fmt"
)
// 活动状态。pending 表示未开始,running 表示进行中,ended 表示已结束。
const (
StatePending = "pending"
StateRunning = "running"
StateEnded = "ended"
)
// BucketCount 是库存预热的固定桶数。活动库存会被均分到这些桶里,
// 余数统一放进最后一桶。
const BucketCount = 10
var (
// ErrInvalidActivity 表示活动定义不完整或字段不自洽。
ErrInvalidActivity = errors.New("activity: invalid activity")
// ErrInvalidPreheat 表示单桶容量配置不合法,无法安全预热。
ErrInvalidPreheat = errors.New("activity: invalid preheat")
)
// Activity 是一个营销活动的定义。StartAt 与 EndAt 是 Unix 秒,
// 区间为左闭右开:[StartAt, EndAt)。
type Activity struct {
ID string
StartAt int64
EndAt int64
Stock int64
}
// Validate 检查活动定义是否可用。ID 不能为空,EndAt 必须大于 StartAt,
// Stock 不能为负。它不检查活动是否已经开始,那由 State 负责。
func Validate(a Activity) error {
if a.ID == "" {
return fmt.Errorf("%w: empty id", ErrInvalidActivity)
}
if a.EndAt <= a.StartAt {
return fmt.Errorf("%w: end %d <= start %d", ErrInvalidActivity, a.EndAt, a.StartAt)
}
if a.Stock < 0 {
return fmt.Errorf("%w: negative stock %d", ErrInvalidActivity, a.Stock)
}
return nil
}
// State 根据当前时间返回活动状态。now 早于 StartAt 为 pending,
// 到达 EndAt 为 ended(左闭右开),中间为 running。
func State(a Activity, now int64) (string, error) {
if err := Validate(a); err != nil {
return "", err
}
if now < a.StartAt {
return StatePending, nil
}
if now >= a.EndAt {
return StateEnded, nil
}
return StateRunning, nil
}
// Preheat 把活动库存均分到 BucketCount 个桶,余数进最后一桶。
// perBucket 是单个桶允许承载的库存上限;如果最大的桶会超过它,
// 说明分桶方案不安全,直接返回错误,调用方不应写入任何桶。
//
// Preheat 只计算分桶结果,不修改商品库存,也不写 Redis 或数据库。
func Preheat(a Activity, perBucket int64) ([]int64, error) {
if err := Validate(a); err != nil {
return nil, err
}
if perBucket <= 0 {
return nil, fmt.Errorf("%w: per-bucket %d", ErrInvalidPreheat, perBucket)
}
base := a.Stock / BucketCount
remainder := a.Stock % BucketCount
buckets := make([]int64, BucketCount)
for i := range buckets {
buckets[i] = base
}
buckets[BucketCount-1] += remainder
if buckets[BucketCount-1] > perBucket {
return nil, fmt.Errorf("%w: largest bucket %d > per-bucket %d",
ErrInvalidPreheat, buckets[BucketCount-1], perBucket)
}
return buckets, nil
}
几个边界值得单独说明:
Validate只做静态检查:ID 非空、结束时间晚于开始时间、库存非负。它不判断活动是否已经开始,避免把“配置错误”和“时间未到”混成一个错误。State用左闭右开区间:now == StartAt是 running,now == EndAt是 ended。边界必须和产品文案、库存释放任务对齐,否则会出现“活动已结束但还能下单”或“活动刚开始就提示结束”。Preheat先校验活动,再校验perBucket为正。容量检查放在分桶之后,用最大的桶和上限比较,而不是只看平均值。- 余数统一进最后一桶。对五千件库存,每桶五百;对五千零五件,前九桶五百,最后一桶五百零五。最后一桶天然最大,所以只需要检查它。
Preheat不修改商品库存,也不写任何存储。它返回的是“应该写什么”,不是“已经写完”。真正的写入、校验和切换由预热任务负责。perBucket是安全阈值,不是目标值。调用方可以传得比实际每桶大,但不能小到装不下最大的桶。
测试:边界、余数与容量
活动模型的错误大多在边界:开始时间前一秒、结束时间当秒、库存刚好整除、余数大于零、单桶上限刚好卡住。测试要把这些行为固定下来。
// file: internal/activity/activity_test.go
// tier: L1
// verify: go test ./internal/activity/
package activity
import (
"errors"
"testing"
)
func validActivity() Activity {
return Activity{ID: "act-618", StartAt: 1000, EndAt: 2000, Stock: 1005}
}
func TestValidateRejectsIncompleteActivity(t *testing.T) {
cases := []struct {
name string
a Activity
}{
{"empty-id", Activity{StartAt: 1, EndAt: 2}},
{"end-equals-start", Activity{ID: "a", StartAt: 10, EndAt: 10, Stock: 1}},
{"end-before-start", Activity{ID: "a", StartAt: 10, EndAt: 9, Stock: 1}},
{"negative-stock", Activity{ID: "a", StartAt: 1, EndAt: 2, Stock: -1}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := Validate(c.a); !errors.Is(err, ErrInvalidActivity) {
t.Fatalf("Validate(%+v) = %v, want ErrInvalidActivity", c.a, err)
}
})
}
if err := Validate(validActivity()); err != nil {
t.Fatalf("valid activity = %v, want nil", err)
}
}
func TestStateBoundaries(t *testing.T) {
a := validActivity()
cases := []struct {
now int64
want string
}{
{999, StatePending},
{1000, StateRunning},
{1999, StateRunning},
{2000, StateEnded},
{3000, StateEnded},
}
for _, c := range cases {
got, err := State(a, c.now)
if err != nil {
t.Fatalf("State(now=%d) = %v, want nil", c.now, err)
}
if got != c.want {
t.Fatalf("State(now=%d) = %q, want %q", c.now, got, c.want)
}
}
if _, err := State(Activity{ID: ""}, 1); !errors.Is(err, ErrInvalidActivity) {
t.Fatalf("invalid activity = %v, want ErrInvalidActivity", err)
}
}
func TestPreheatEvenSplit(t *testing.T) {
buckets, err := Preheat(Activity{ID: "a", StartAt: 1, EndAt: 2, Stock: 1000}, 100)
if err != nil {
t.Fatalf("Preheat = %v, want nil", err)
}
if len(buckets) != BucketCount {
t.Fatalf("len = %d, want %d", len(buckets), BucketCount)
}
var sum int64
for i, n := range buckets {
if n != 100 {
t.Fatalf("buckets[%d] = %d, want 100", i, n)
}
sum += n
}
if sum != 1000 {
t.Fatalf("sum = %d, want 1000", sum)
}
}
func TestPreheatRemainderGoesToLastBucket(t *testing.T) {
buckets, err := Preheat(Activity{ID: "a", StartAt: 1, EndAt: 2, Stock: 1005}, 105)
if err != nil {
t.Fatalf("Preheat = %v, want nil", err)
}
for i := 0; i < BucketCount-1; i++ {
if buckets[i] != 100 {
t.Fatalf("buckets[%d] = %d, want 100", i, buckets[i])
}
}
if buckets[BucketCount-1] != 105 {
t.Fatalf("last bucket = %d, want 105", buckets[BucketCount-1])
}
}
func TestPreheatZeroStock(t *testing.T) {
buckets, err := Preheat(Activity{ID: "a", StartAt: 1, EndAt: 2, Stock: 0}, 1)
if err != nil {
t.Fatalf("Preheat = %v, want nil", err)
}
for i, n := range buckets {
if n != 0 {
t.Fatalf("buckets[%d] = %d, want 0", i, n)
}
}
}
func TestPreheatRejectsUnsafeConfig(t *testing.T) {
a := Activity{ID: "a", StartAt: 1, EndAt: 2, Stock: 1005}
if _, err := Preheat(a, 0); !errors.Is(err, ErrInvalidPreheat) {
t.Fatalf("per-bucket 0 = %v, want ErrInvalidPreheat", err)
}
if _, err := Preheat(a, -1); !errors.Is(err, ErrInvalidPreheat) {
t.Fatalf("per-bucket -1 = %v, want ErrInvalidPreheat", err)
}
if _, err := Preheat(a, 100); !errors.Is(err, ErrInvalidPreheat) {
t.Fatalf("per-bucket 100 = %v, want ErrInvalidPreheat", err)
}
if _, err := Preheat(Activity{ID: ""}, 100); !errors.Is(err, ErrInvalidActivity) {
t.Fatalf("invalid activity = %v, want ErrInvalidActivity", err)
}
}
TestValidateRejectsIncompleteActivity 覆盖空 ID、结束等于开始、结束早于开始和负库存,并确认合法活动能通过。TestStateBoundaries 把 StartAt-1、StartAt、EndAt-1、EndAt 四个点都钉住,避免边界被后续重构改掉。
TestPreheatEvenSplit 用一千件库存验证十个桶各一百,并检查总和没有丢。TestPreheatRemainderGoesToLastBucket 用一千零五件验证前九桶各一百、最后一桶一百零五。TestPreheatZeroStock 确认零库存也能返回十个零,而不是空切片。TestPreheatRejectsUnsafeConfig 覆盖零上限、负上限、容量不足和非法活动四种拒绝路径。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/activity/ -run 'TestValidate|TestState|TestPreheat' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateRejectsIncompleteActivity
=== RUN TestValidateRejectsIncompleteActivity/empty-id
=== RUN TestValidateRejectsIncompleteActivity/end-equals-start
=== RUN TestValidateRejectsIncompleteActivity/end-before-start
=== RUN TestValidateRejectsIncompleteActivity/negative-stock
--- PASS: TestValidateRejectsIncompleteActivity (0.00s)
--- PASS: TestValidateRejectsIncompleteActivity/empty-id (0.00s)
--- PASS: TestValidateRejectsIncompleteActivity/end-equals-start (0.00s)
--- PASS: TestValidateRejectsIncompleteActivity/end-before-start (0.00s)
--- PASS: TestValidateRejectsIncompleteActivity/negative-stock (0.00s)
=== RUN TestStateBoundaries
--- PASS: TestStateBoundaries (0.00s)
=== RUN TestPreheatEvenSplit
--- PASS: TestPreheatEvenSplit (0.00s)
=== RUN TestPreheatRemainderGoesToLastBucket
--- PASS: TestPreheatRemainderGoesToLastBucket (0.00s)
=== RUN TestPreheatZeroStock
--- PASS: TestPreheatZeroStock (0.00s)
=== RUN TestPreheatRejectsUnsafeConfig
--- PASS: TestPreheatRejectsUnsafeConfig (0.00s)
PASS
ok webapp/internal/activity 0.289s
PASS 表示活动模型的六个用例全部通过,其中校验用例还包含四个子用例。它们验证的是规则本身;预热任务的幂等、存储层的批量写入和活动结束后的库存释放,仍然需要任务编排与存储层配合。
小结
活动库存和商品库存必须分开:一个跟着活动周期走,一个跟着商品生命周期走。预热要把读压力搬出峰值,分桶要同时定义分配方式和单桶容量上限,并且失败时能整体回退。
Validate 守住静态字段,State 守住时间边界,Preheat 守住分桶结果。三个函数都不产生副作用,预热任务只负责把结果安全地写进去。下一节沿着这条链路往下走:库存准备好之后,秒杀请求会怎样把服务打穿,限流应该放在哪几层。
阅读导航:上一节:目录与学习路径 · 下一节:6.2 秒杀链路与限流 。
常见错误与边界
- 把活动库存直接覆盖到商品库存。活动结束或取消时无法区分回滚对象,容易造成商品页长期显示错误可售量。
- 活动开始后才读库存进缓存。第一批请求会同时穿透到数据库,把连接池打满。
- 预热只写缓存不校验。部分桶写入失败时,活动会出现“显示售罄但库存还在”的半成品状态。
- 预热脚本不幂等。配置变更或重试会重复冻结商品库存,活动没开始商品先无货。
- 分桶只做除法不看上限。单桶超过存储承载能力时仍然写入,热点只是从一个键转移到另一个键。
- 状态边界用闭区间或混用开闭区间。
EndAt当秒到底算不算结束,必须在函数、任务和文案里保持一致。 - 把预热当成扣库存。预热只准备活动侧的读数据,真正的扣减应该发生在下单链路里。
- 失败回退只删缓存不释放冻结。活动取消后库存长期挂在活动池,只能靠对账发现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。