《互联网产品的服务端架构·交易、增长与平台化》6.1 活动模型与库存预热

十点开场,九点五十九分五十秒才把库存写进缓存,用户已经点进来:库存预热不是把数字抄一遍,而是把读压力提前搬走。文章拆开活动库存与商品库存、分桶预热和单桶容量校验,给出 Validate、State、Preheat 的 Go 实现、边界测试与真实运行输出。

十点开场,九点五十九分还在写库存

「刚好」做了一次整点秒杀:十月二十日上午十点,五千件限量商品,活动价是日常价的六折。运营在前一天配置好活动,技术方案是活动开始时把商品库存从数据库读到缓存,用户下单时只读缓存。

十点整,接口 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 当秒到底算不算结束,必须在函数、任务和文案里保持一致。
  • 把预热当成扣库存。预热只准备活动侧的读数据,真正的扣减应该发生在下单链路里。
  • 失败回退只删缓存不释放冻结。活动取消后库存长期挂在活动池,只能靠对账发现。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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