《互联网产品的服务端架构·交易、增长与平台化》6.2 秒杀链路与限流

网关每秒放五千个请求进来,服务内部一次失败重试三次,库存就在重试里被打穿。文章区分令牌桶、滑动窗口、漏桶和并发信号量,说明秒杀链路为什么要短、限流为什么要分层、排队与快速失败如何选择,并给出令牌桶 Allow 的 Go 实现、边界测试与真实运行输出。

网关放进来五千,库存只够两千

「刚好」的第二场秒杀吸取了上次的教训,库存提前预热好了,缓存也能扛住读。运营把活动入口挂在首页,预计峰值每秒三千个请求。技术方案在网关加了一条限流:每个活动每秒最多放行五千个请求,超过的直接返回“当前人数过多”。

活动开始后,网关确实把入口流量压到了五千。但活动接口的平均耗时从八十毫秒涨到四百毫秒,库存服务的超时率升到 12%,最终有两千三百件商品成交,库存却显示负数。运营很困惑:网关明明限住了,为什么库存还是被打穿?

排查发现,活动服务在库存扣减超时后会重试三次,客户端在接口超时后也会重试。网关限制的是“客户端请求”,服务内部的重试不经过网关,五千个请求最多放大成一万五千次库存操作。库存扣减又不是幂等的,超时的那次其实已经成功,重试又扣了一次。这些数字是案例假设值,用来说明放大关系,不是线上实测。

限流不是一种算法,而是四类问题

很多团队一说限流就想到令牌桶,但“限流”至少包含四类不同的问题:限制速率、限制窗口内次数、限制并发、限制队列长度。它们的输入、状态和适用位置都不一样。

机制控制对象状态优点盲区
令牌桶平均速率加突发令牌数、速率、容量、上次时间O(1)、允许有限突发不保证窗口内绝对次数
滑动窗口窗口内累计次数时间分片或事件列表口径直观、适合业务规则状态更大、边界实现复杂
漏桶恒定流出速率队列或水位输出平滑突发请求排队,延迟不可控
并发信号量同时在处理的请求数已占用名额直接保护下游连接和线程不限制单位时间总量

秒杀入口更适合令牌桶:它关心的是“平均每秒放多少”,同时允许少量突发,状态只有四个数,判断是常数时间。防刷规则更适合滑动窗口:运营说的是“每个用户十分钟内最多三次”,这是窗口内计数,不是速率。下游保护更适合并发信号量:数据库连接池只有两百个连接,超过就不该再放请求进去。

选择哪种机制,取决于你要回答的问题。如果问题是“系统每秒能处理多少”,用令牌桶;如果问题是“这个用户是不是太频繁”,用滑动窗口;如果问题是“下游还有没有能力”,用并发信号量。把三者混成一个计数器,通常会在某个维度上失效。

令牌桶的容量决定了能容忍多大的瞬时突发,速率决定了长期平均放行量。容量设得太大,突发会直接压垮下游;设得太小,正常的页面并发和重试就会被误伤。容量通常按“下游能承受的瞬时并发”来定,而不是按“用户可能点多快”来定。

阈值也不应该拍脑袋。一个可用的方法是反推:先测出库存层每秒能安全处理多少次扣减,再减去峰值时的其他写入,得到库存层的预算;服务层的并发上限由连接池和下游超时反推;网关层的阈值再根据服务层预算乘一个安全系数。每一层的阈值都要有来源,不能三家各填一个数。

阈值还要随容量变化。扩容后实例数变了,单机桶的阈值不变,总放行量会线性增加;缩容或下游降级时,阈值不调整就会把压力重新压到已经变弱的依赖上。阈值应该和实例数、下游健康度一起配置,而不是写死在代码里。

秒杀链路要短,限流要分层

秒杀请求的路径越长,可被放大的点越多。一次请求如果经过网关、活动服务、商品服务、库存服务、订单服务、支付服务,每一层都可能超时、重试、排队,最终到达库存层的请求数可能是入口的几倍。

短链路的原则是:能在前面拒绝的,不要带到后面;能用一次原子操作解决的,不要拆成多次远程调用。活动页只做资格校验和库存预扣,订单落库可以异步;库存判断和扣减最好在一个原子操作里完成,而不是先查再扣。

一条尽量短的秒杀链路可以是这样:

  1. 网关做来源和频率的粗筛,命中直接返回。
  2. 活动服务校验活动状态、用户资格和限购,不查商品详情。
  3. 库存服务用一次原子操作完成“判断余量、扣减、写流水”,并带幂等键。
  4. 订单服务异步落库,失败时通过流水补偿,不阻塞用户拿到结果。
  5. 支付和履约后置,活动链路只保证名额占用。

链路上的每一步都要能独立限流和降级。比如商品详情不是下单必需的,就不要放在主链路里;优惠计算可以异步预估,不要在主链路里同步调用多个服务。

限流也要分层,因为每一层知道的信息不同:

  • 网关层知道来源 IP、用户标识、接口路径和总流量,适合做粗粒度的入口保护和恶意流量清洗。
  • 服务层知道自己的并发度、队列长度和下游健康度,适合做实例级的自我保护,防止单机被压垮。
  • 库存层知道活动还剩多少、每个桶的余量,适合做最后的原子扣减和超卖保护。
  • 下游层知道数据库连接、缓存命中率和第三方接口配额,适合用并发信号量或熔断器兜底。

分层之后还要回答:被限住的请求怎么处理。秒杀入口通常不应该排队:队列越长,用户等待越久,最后大概率还是失败,同时占着连接和内存。快速失败更诚实:返回一个明确的“已抢完”或“人数过多”,让客户端停止重试。

如果业务确实需要排队,就要把队列长度、等待时间和超时策略一起设计。队列满时仍然要拒绝,不能无限接收;排队中的请求要有超时,超时后主动出队;排队结果要可查询,不能只给一个转圈页面。最怕的是“表面排队、实际丢弃”:用户等了三十秒,最后收到一个笼统的错误。

限流返回什么状态码也要统一。系统限流用 429 并带 Retry-After,库存售罄用业务错误码,参数错误用 400。三者混用会让客户端无法判断该不该重试,也会让监控无法区分“系统扛不住”和“商品卖完了”。

只在网关限流,等于把“库存是否够”交给一个不知道库存的组件判断。网关放行的请求数可以远大于库存数,服务内部再重试几次,库存层承受的写入就会被放大。只在库存层限流,又会让大量无效请求走完整条链路,浪费连接和线程。

失败场景:网关限住了,服务重试打穿了库存

回到开头。网关按客户端请求限流,每秒五千;活动服务对库存扣减设置了三次重试,间隔五十毫秒;客户端 SDK 在接口超时后自动重试两次。三层叠加,最坏情况下一个入口请求会变成九次库存操作。

库存扣减的实现是先 GET 余量、判断大于零、再 DECR。第一次请求超时,实际上 DECR 已经成功;服务重试时读到新的余量,又扣了一次。客户端重试再扣一次。一个用户的一次点击,扣掉了三件库存。

第二个变种是重试风暴。库存服务因为网络抖动超时,所有实例同时重试,原本五千 QPS 在两百毫秒内变成一万五千 QPS,把刚刚恢复的库存服务再次打挂。第三个变种是限流键选错:按 IP 限流,攻击者用代理池绕过;按用户限流,未登录用户全部落在同一个空用户键上,正常用户被一起限掉。

这类事故的根因不是“限流阈值设小了”,而是限流只覆盖了入口,没有覆盖重试和内部调用;库存扣减也没有幂等键。正确的顺序是:入口限流挡住大部分流量,服务层限制并发和重试预算,库存层用原子脚本和幂等键保证一次请求只扣一次。

限流键的选择也决定了公平性。按用户限流保护的是单个用户体验,按活动限流保护的是库存,按 IP 限流保护的是入口,按实例限流保护的是自己。一个请求可以同时命中多个桶,但拒绝原因要能区分:是用户太频繁、活动太热,还是本机过载。否则线上只能看到一个笼统的拒绝率,没法定位是哪一层在起作用。

重试预算也要显式配置。一个请求在整条链路上最多重试几次、每次间隔多少、哪些错误可以重试,应该在客户端和服务端各有一份预算,并且总量有上限。库存扣减这类非幂等操作,重试必须带幂等键;没有幂等键时,宁可不重试,让用户重新发起。

用令牌桶 Allow 收口单机限流

令牌桶的状态可以收成四个字段:当前令牌、补充速率、容量、上次结算时间。每次判断先按流逝时间补令牌,补到容量为止,再尝试扣一个。状态更新放在指针上,调用方不需要自己回写。

// file: internal/activity/seckill.go
// tier: L1
// verify: go test ./internal/activity/

package activity

import (
	"errors"
	"fmt"
)

var (
	// ErrInvalidLimiter 表示令牌桶参数不合法。
	ErrInvalidLimiter = errors.New("activity: invalid limiter")
	// ErrLimiterClockBackwards 表示传入的 now 早于桶的上次结算时间。
	ErrLimiterClockBackwards = errors.New("activity: limiter clock backwards")
)

// Bucket 是令牌桶的当前状态。Tokens 是可用令牌数,允许小数;
// Rate 是每秒补充的令牌数;Burst 是容量上限;LastAt 是上次结算时间,
// 用 Unix 秒表示,允许小数。
type Bucket struct {
	Tokens float64
	Rate   float64
	Burst  int
	LastAt float64
}

// Allow 判断在 now 时刻是否放行一次请求。它会先按流逝时间补充令牌,
// 补充后不超过 Burst,然后尝试扣掉一个令牌。
//
// 无论放行还是拒绝,LastAt 都会推进到 now,剩余令牌也会写回,
// 这样被拒绝的请求不会“攒”出额外的令牌。now 早于 LastAt 说明时钟
// 或调用顺序异常,直接报错且不修改桶状态。
func Allow(b *Bucket, now float64) (bool, error) {
	if b == nil {
		return false, fmt.Errorf("%w: nil bucket", ErrInvalidLimiter)
	}
	if b.Rate <= 0 || b.Burst <= 0 {
		return false, fmt.Errorf("%w: rate=%v burst=%d", ErrInvalidLimiter, b.Rate, b.Burst)
	}
	if b.Tokens < 0 {
		return false, fmt.Errorf("%w: negative tokens %v", ErrInvalidLimiter, b.Tokens)
	}
	if now < b.LastAt {
		return false, fmt.Errorf("%w: now=%v last=%v", ErrLimiterClockBackwards, now, b.LastAt)
	}
	tokens := b.Tokens + (now-b.LastAt)*b.Rate
	if limit := float64(b.Burst); tokens > limit {
		tokens = limit
	}
	b.LastAt = now
	if tokens >= 1 {
		b.Tokens = tokens - 1
		return true, nil
	}
	b.Tokens = tokens
	return false, nil
}

实现里有几个关键点:

  • 补充是惰性的:不启动后台定时器,只在请求到来时按 now-LastAt 计算。空闲很久的桶会在下一次请求时一次补满,代价是一次乘加。
  • 补充后立即用 Burst 截断。没有这一步,空闲十分钟的桶会攒出十分钟的令牌,等于把容量放大。
  • now 小于 LastAt 直接报错,并且不修改桶状态。时钟回拨、调用方传错时间、并发下先拿到的请求后执行,都会触发这个错误;静默接受会让桶状态永久错乱。
  • 拒绝时也要更新 LastAt 和剩余令牌。否则被拒绝的请求不会推进结算时间,下一次请求会把同一段时间重复补一遍,限流形同虚设。
  • 允许小数时间。秒杀场景常用毫秒或微秒作为时间单位,整数秒的粒度太粗,容易在边界上放过一整批请求。
  • 令牌数允许小数,但只有凑够一个才放行。这样低速率场景不会因为取整而永久拒绝。

这个实现是单进程的,每个实例各有一份桶。它适合做服务层的自我保护:即使网关被绕过或限流配置失效,单机也不会被瞬间打满。要做全局限流,需要把桶状态放进 Redis,用 Lua 脚本保证“读、补、扣、写”原子;本地桶仍然可以保留,作为第二道闸。

测试:补充、截断、拒绝与时钟回拨

令牌桶的测试要覆盖四类行为:按时间补充、补充不超过容量、拒绝后状态仍然推进、异常输入不被静默接受。

// file: internal/activity/seckill_test.go
// tier: L1
// verify: go test ./internal/activity/

package activity

import (
	"errors"
	"testing"
)

func TestAllowRefillsOverFractionalTime(t *testing.T) {
	b := &Bucket{Tokens: 0, Rate: 4, Burst: 2, LastAt: 0}
	if ok, err := Allow(b, 0.25); err != nil || !ok {
		t.Fatalf("Allow(0.25) = %v, %v; want true, nil", ok, err)
	}
	if b.Tokens != 0 {
		t.Fatalf("tokens = %v, want 0", b.Tokens)
	}
	if ok, err := Allow(b, 0.5); err != nil || !ok {
		t.Fatalf("Allow(0.5) = %v, %v; want true, nil", ok, err)
	}
	if b.LastAt != 0.5 {
		t.Fatalf("LastAt = %v, want 0.5", b.LastAt)
	}
}

func TestAllowRejectKeepsRemainderAndAdvancesClock(t *testing.T) {
	b := &Bucket{Tokens: 0, Rate: 0.5, Burst: 2, LastAt: 10}
	ok, err := Allow(b, 11)
	if err != nil || ok {
		t.Fatalf("Allow(11) = %v, %v; want false, nil", ok, err)
	}
	if b.LastAt != 11 || b.Tokens != 0.5 {
		t.Fatalf("after reject: LastAt=%v Tokens=%v, want 11 and 0.5", b.LastAt, b.Tokens)
	}
	ok, err = Allow(b, 11.5)
	if err != nil || ok {
		t.Fatalf("Allow(11.5) = %v, %v; want false, nil", ok, err)
	}
	if b.LastAt != 11.5 || b.Tokens != 0.75 {
		t.Fatalf("after second reject: LastAt=%v Tokens=%v, want 11.5 and 0.75", b.LastAt, b.Tokens)
	}
	ok, err = Allow(b, 12)
	if err != nil || !ok {
		t.Fatalf("Allow(12) = %v, %v; want true, nil", ok, err)
	}
	if b.Tokens != 0 {
		t.Fatalf("tokens = %v, want 0", b.Tokens)
	}
}

func TestAllowCapsAtBurst(t *testing.T) {
	b := &Bucket{Tokens: 0, Rate: 10, Burst: 3, LastAt: 0}
	ok, err := Allow(b, 100)
	if err != nil || !ok {
		t.Fatalf("Allow(100) = %v, %v; want true, nil", ok, err)
	}
	if b.Tokens != 2 {
		t.Fatalf("tokens = %v, want 2 after cap at burst 3", b.Tokens)
	}
}

func TestAllowRejectsInvalidState(t *testing.T) {
	if _, err := Allow(nil, 0); !errors.Is(err, ErrInvalidLimiter) {
		t.Fatalf("nil bucket = %v, want ErrInvalidLimiter", err)
	}
	if _, err := Allow(&Bucket{Rate: 0, Burst: 1}, 0); !errors.Is(err, ErrInvalidLimiter) {
		t.Fatalf("zero rate = %v, want ErrInvalidLimiter", err)
	}
	if _, err := Allow(&Bucket{Rate: 1, Burst: 0}, 0); !errors.Is(err, ErrInvalidLimiter) {
		t.Fatalf("zero burst = %v, want ErrInvalidLimiter", err)
	}
	if _, err := Allow(&Bucket{Tokens: -1, Rate: 1, Burst: 1}, 0); !errors.Is(err, ErrInvalidLimiter) {
		t.Fatalf("negative tokens = %v, want ErrInvalidLimiter", err)
	}
	b := &Bucket{Tokens: 0.5, Rate: 1, Burst: 1, LastAt: 10}
	if _, err := Allow(b, 9.5); !errors.Is(err, ErrLimiterClockBackwards) {
		t.Fatalf("backwards clock = %v, want ErrLimiterClockBackwards", err)
	}
	if b.LastAt != 10 || b.Tokens != 0.5 {
		t.Fatalf("error mutated bucket: LastAt=%v Tokens=%v", b.LastAt, b.Tokens)
	}
}

TestAllowRefillsOverFractionalTime 用速率 4、容量 2 的桶,在 0.25 秒和 0.5 秒各放行一次,验证小数时间能正确补出一个令牌。TestAllowRejectKeepsRemainderAndAdvancesClock 用速率 0.5 的桶连续拒绝两次,断言 LastAt 每次都推进、剩余令牌从 0.5 累积到 0.75,第三次刚好凑够一个令牌才放行。

TestAllowCapsAtBurst 让桶空闲一百秒,验证补充被截断在容量 3,放行一次后只剩 2 个令牌,而不是九十九个。TestAllowRejectsInvalidState 覆盖空指针、零速率、零容量、负令牌和时钟回拨,并确认报错路径不会修改桶状态。

还有一类边界值得补测:now 恰好等于 LastAt,此时没有时间流逝,只能消耗存量令牌。这个用例能发现“用整数时间做差再补令牌”的实现错误,也能固定住“同一时刻连续请求”的行为。测试数据尽量用手写的固定时间,不要依赖 time.Now(),否则用例会在不同机器和不同时刻出现偶发失败。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/activity/ -run 'TestAllow' -count=1 -v

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

=== RUN   TestAllowRefillsOverFractionalTime
--- PASS: TestAllowRefillsOverFractionalTime (0.00s)
=== RUN   TestAllowRejectKeepsRemainderAndAdvancesClock
--- PASS: TestAllowRejectKeepsRemainderAndAdvancesClock (0.00s)
=== RUN   TestAllowCapsAtBurst
--- PASS: TestAllowCapsAtBurst (0.00s)
=== RUN   TestAllowRejectsInvalidState
--- PASS: TestAllowRejectsInvalidState (0.00s)
PASS
ok  	webapp/internal/activity	0.264s

四个用例全部通过。它们验证的是单进程桶的状态机;分布式限流的原子性、Redis 脚本的一致性和多实例之间的阈值分摊,仍然需要存储层和压测来验证。

小结

限流要先分清问题:限速率用令牌桶,限窗口次数用滑动窗口,限并发用信号量。秒杀链路要尽量短,限流要分层,入口、服务、库存和下游各管一段,任何一层都不能替代另一层。

Allow 把令牌桶收成一个纯状态机:惰性补充、容量截断、拒绝也推进时钟、异常时钟显式报错。令牌桶解决的是速率,滑动窗口解决的是次数,两者经常同时出现在一条链路上:入口用令牌桶削峰,用户维度用滑动窗口限频。把它们放在不同层,才能各自回答自己擅长的问题。

下一节处理链路里另一类请求:它们不一定量大,但会换账号、换设备、换 IP,试图把活动库存当成免费资源。

阅读导航:上一节:6.1 活动模型与库存预热 · 下一节:6.3 防刷、风控与兜底 。

常见错误与边界

  • 只在网关限流。网关不知道库存和下游容量,放行的请求仍然会把服务层打满。
  • 限流不覆盖重试。客户端和服务内部的重试不经过入口,实际放大倍数远高于网关看到的 QPS。
  • 库存扣减没有幂等键。超时重试会重复扣减,库存变负,用户订单却只有一笔。
  • 令牌桶只在请求时补充,却忘记截断容量。空闲越久,下一次能放行的突发越大。
  • 拒绝请求时不更新状态。被拒绝的请求会反复补同一段时间,限流阈值被悄悄放大。
  • 用整数秒做时间单位。毫秒级突发会被整秒边界合并,限流曲线和真实流量对不上。
  • 用 IP 或空用户做唯一限流键。代理池可以换 IP,未登录用户会共享同一个空键。
  • 把单机令牌桶当成全局限流。多实例各自放行,总放行量是阈值的实例数倍。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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