网关放进来五千,库存只够两千
「刚好」的第二场秒杀吸取了上次的教训,库存提前预热好了,缓存也能扛住读。运营把活动入口挂在首页,预计峰值每秒三千个请求。技术方案在网关加了一条限流:每个活动每秒最多放行五千个请求,超过的直接返回“当前人数过多”。
活动开始后,网关确实把入口流量压到了五千。但活动接口的平均耗时从八十毫秒涨到四百毫秒,库存服务的超时率升到 12%,最终有两千三百件商品成交,库存却显示负数。运营很困惑:网关明明限住了,为什么库存还是被打穿?
排查发现,活动服务在库存扣减超时后会重试三次,客户端在接口超时后也会重试。网关限制的是“客户端请求”,服务内部的重试不经过网关,五千个请求最多放大成一万五千次库存操作。库存扣减又不是幂等的,超时的那次其实已经成功,重试又扣了一次。这些数字是案例假设值,用来说明放大关系,不是线上实测。
限流不是一种算法,而是四类问题
很多团队一说限流就想到令牌桶,但“限流”至少包含四类不同的问题:限制速率、限制窗口内次数、限制并发、限制队列长度。它们的输入、状态和适用位置都不一样。
| 机制 | 控制对象 | 状态 | 优点 | 盲区 |
|---|---|---|---|---|
| 令牌桶 | 平均速率加突发 | 令牌数、速率、容量、上次时间 | O(1)、允许有限突发 | 不保证窗口内绝对次数 |
| 滑动窗口 | 窗口内累计次数 | 时间分片或事件列表 | 口径直观、适合业务规则 | 状态更大、边界实现复杂 |
| 漏桶 | 恒定流出速率 | 队列或水位 | 输出平滑 | 突发请求排队,延迟不可控 |
| 并发信号量 | 同时在处理的请求数 | 已占用名额 | 直接保护下游连接和线程 | 不限制单位时间总量 |
秒杀入口更适合令牌桶:它关心的是“平均每秒放多少”,同时允许少量突发,状态只有四个数,判断是常数时间。防刷规则更适合滑动窗口:运营说的是“每个用户十分钟内最多三次”,这是窗口内计数,不是速率。下游保护更适合并发信号量:数据库连接池只有两百个连接,超过就不该再放请求进去。
选择哪种机制,取决于你要回答的问题。如果问题是“系统每秒能处理多少”,用令牌桶;如果问题是“这个用户是不是太频繁”,用滑动窗口;如果问题是“下游还有没有能力”,用并发信号量。把三者混成一个计数器,通常会在某个维度上失效。
令牌桶的容量决定了能容忍多大的瞬时突发,速率决定了长期平均放行量。容量设得太大,突发会直接压垮下游;设得太小,正常的页面并发和重试就会被误伤。容量通常按“下游能承受的瞬时并发”来定,而不是按“用户可能点多快”来定。
阈值也不应该拍脑袋。一个可用的方法是反推:先测出库存层每秒能安全处理多少次扣减,再减去峰值时的其他写入,得到库存层的预算;服务层的并发上限由连接池和下游超时反推;网关层的阈值再根据服务层预算乘一个安全系数。每一层的阈值都要有来源,不能三家各填一个数。
阈值还要随容量变化。扩容后实例数变了,单机桶的阈值不变,总放行量会线性增加;缩容或下游降级时,阈值不调整就会把压力重新压到已经变弱的依赖上。阈值应该和实例数、下游健康度一起配置,而不是写死在代码里。
秒杀链路要短,限流要分层
秒杀请求的路径越长,可被放大的点越多。一次请求如果经过网关、活动服务、商品服务、库存服务、订单服务、支付服务,每一层都可能超时、重试、排队,最终到达库存层的请求数可能是入口的几倍。
短链路的原则是:能在前面拒绝的,不要带到后面;能用一次原子操作解决的,不要拆成多次远程调用。活动页只做资格校验和库存预扣,订单落库可以异步;库存判断和扣减最好在一个原子操作里完成,而不是先查再扣。
一条尽量短的秒杀链路可以是这样:
- 网关做来源和频率的粗筛,命中直接返回。
- 活动服务校验活动状态、用户资格和限购,不查商品详情。
- 库存服务用一次原子操作完成“判断余量、扣减、写流水”,并带幂等键。
- 订单服务异步落库,失败时通过流水补偿,不阻塞用户拿到结果。
- 支付和履约后置,活动链路只保证名额占用。
链路上的每一步都要能独立限流和降级。比如商品详情不是下单必需的,就不要放在主链路里;优惠计算可以异步预估,不要在主链路里同步调用多个服务。
限流也要分层,因为每一层知道的信息不同:
- 网关层知道来源 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,未登录用户会共享同一个空键。
- 把单机令牌桶当成全局限流。多实例各自放行,总放行量是阈值的实例数倍。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。