系统设计 22:限流器的原子计数与全局配额
“每个用户每秒允许十次”还不足以实现限流。十次是自然秒内计数、任意一秒窗口,还是长期每秒十次并允许短时突发?配额由一个实例独占,还是十个网关各发十个令牌?请求到达时间、计数位置和原子边界不同,允许数量就不同。
本篇把 API 限流设计成明确的资源分配协议:令牌桶容量为 B、补充速率为 r,请求花费一个令牌;多个入口共享同一逻辑配额。目标是限制成本较高的查询,不承担账户余额、座位库存等不可超发资源的最终正确性。所有容量数字为教学假设,实验在真实 SQLite 上验证共享状态事务,不声称验证了 Redis Lua。
限流键也是授权边界
网关先鉴权,再用稳定的租户 ID 和 API 类别组成配额键。直接信任客户端提交的 user_id 会允许换键绕过限流;只按 IP 则可能惩罚共享出口的公司或学校。未认证请求可以用 IP 做粗粒度保护,但不能由此推导认证用户公平性。
限流调用可表述为 acquire(tenant, route_class, cost, request_id),返回 allowed, retry_after, remaining。业务 API 被拒绝时返回 429;RFC 6585定义了这个状态码,并允许通过 Retry-After 说明重试等待。剩余令牌是某一时刻的观察值,并发请求返回前它可能已经改变,客户端不能把这个数字当预留承诺。
一个教学 SLO 是在网关完成鉴权之后,限流决策 p99 小于 5 ms;共享配额存储不可用时昂贵查询拒绝执行,普通健康检查走独立的有界本地预算。这里没有负载测试证明 5 ms;目标只是把额外延迟约束暴露出来。拒绝请求也消耗连接和鉴权资源,因此限流需要分层,但层数越多越不能让所有层都无限重试。
时间模型决定边界突发
固定窗口按 floor(t / W) 分桶,实现简单。若上一个窗口末尾允许 N 次、下一个窗口开头再允许 N 次,极短时间内就可能有 2N 次请求。滑动日志保存每次时间戳,语义直接但每次请求都增加记录;近似滑动计数节省空间,必须给出误差。令牌桶用容量和补充速率描述突发,允许从满桶瞬间取 B 个,不承诺任意一秒都不超过 r。
令牌更新公式是 tokens = min(B, old_tokens + max(0, now - last) × r),足够支付 cost 才扣除。读取、补充、判断和扣减必须在同一原子操作中。若二十个线程先各读到五个令牌,再各写四个,二十个请求全部成功,最后记录只少一个。给更新加数据库事务但把读取留在事务外,仍然没有解决这个竞态。
时间由配额服务提供,不能采用请求方的时间戳。单进程计时可用单调时钟;重启或跨节点迁移时单调时钟的原点并不共享,需要定义持久化时间和回拨策略。实验使用确定性逻辑秒,遇到回拨将时间截断到上次更新时间,这只验证“不能因为回拨重复补充”,不是完整的跨机时钟协议。
一个共享桶的成本
假设每天 1 亿次需计数的请求,平均 100000000 / 86400 = 1157.41 request/s,峰值系数 10 后为 11574.1 request/s。每次配额往返按请求加响应 160 B,峰值有效载荷约 1.85 MB/s。若往返平均 2 ms,稳态平均在途约 11574.1 × 0.002 = 23.15;这不代表 p99 延迟,也不适用于排队持续增长的过载状态。
活跃桶 100 万,每桶状态和键按 80 B 估计,逻辑数据 80 MB;三份副本为 240 MB,索引、对象与日志开销另计。若同一大租户占全部峰值的 30%,单键约 3472 次/秒,比平均分片 QPS 更值得关注。减少一次网络往返可以改善延迟,却不会自动消除单键串行更新的吞吐上限。
当服务扩成十个实例时,各配一个容量 B=100 的本地桶,会把初始总突发从 100 放大到 1000;总补充速率也从 r 变成 10r。如果允许近似全局限额,可把令牌按小批量租给实例,批次大小 b、实例数 m 时要把尚未消费的在途配额纳入误差分析。租约失效后不能直接重新发同一批令牌,却允许旧实例继续用,否则故障恰好造成超发。
flowchart LR
C[调用者] -->|身份凭据| G[网关鉴权]
G -->|租户与API类别| L[配额服务]
L -->|原子补充与扣减| D[(共享桶状态)]
L -->|允许| G
G -->|已获配额请求| A[昂贵查询]
L -->|拒绝与等待时间| C
最小服务优先共享权威桶。瓶颈出现后可分片不同租户,保持同一租户有唯一写入归属;这无法拆分单个极热租户。若业务允许每分钟少量超额,才讨论本地批量分配。相反,如果限流控制的是按次付费账务,预算冻结和结算还需要独立账本,不能只靠一个会丢失的内存计数器。
失败策略不能只有 fail-open
存储超时存在三种状态:操作没执行、已经扣令牌但回复丢失、明确拒绝。盲目再扣一次会让合法请求损失两次预算;直接放行则可能突破保护。对成本较高的读取,宁可拒绝并允许稍后重新申请;如果需要相同请求重试不重复计费,可以持久化短期申请 ID,但去重记录本身会增加存储与清理成本。
sequenceDiagram
participant G as 网关
participant L as 配额服务
participant D as 桶数据库
G->>L: acquire 请求q7
L->>D: 事务补充并扣减
D-->>L: 提交
Note over L,G: 回复丢失,是否扣减未知
G-->>G: 拒绝昂贵调用并记录不确定决策
G->>L: 稍后按契约重新申请
Note over G,D: 若要求申请幂等,必须存储q7的结果
公平性和上限正确性也不同。一个桶能保证总量不超,却可能让抢到连接的线程占完配额。需要租户最小保障时,可以为租户分桶再叠加服务总桶;分配权重、队列等待上限和取消释放成为新的设计内容。只统计拒绝率无法判断公平,应同时看各租户允许量、连续等待时间和大租户占比。
真实事务验证边界与并发
运行 python3 examples/system-design/labs/22/limiter.py。二十个线程各自建立 SQLite 连接,通过屏障同时申请容量为五的同一个桶。BEGIN IMMEDIATE 把读取和扣减放在写事务内,允许数量必须正好五;其他十五个正常被拒绝,不算测试失败。SQLite 事务文档说明该模式的写事务行为,本文不把它外推成 PostgreSQL 行锁或 Redis 的吞吐结果。
桶以每秒两个令牌补充,0.499 秒必须拒绝,0.5 秒必须允许一个,紧接的请求再次拒绝;负时间不能凭空补充。实验还算出三个独立本地桶会允许十五个,比共享目标多十个,并通过关闭的数据库连接触发明确的拒绝策略。原始版本、允许数量和负向结果保存在 examples/system-design/evidence/22/run.json。
这些证据证明有限并发下的数据库原子性和逻辑时间边界,没有证明跨机故障切换后不丢配额,也没有模拟网络中断后的申请去重。面试追加“节点宕机也必须严格全局限额”时,需要追问持久化确认、副本切换和剩余租约;追加“只要保护后端”时,有界本地预算可能已经足够。
| 约束 | 选择 | 代价与改变条件 |
|---|---|---|
| 严格共享预算 | 原子权威桶 | 每次往返;可接受误差后才分批 |
| 可容忍窗口边界突发 | 固定窗口 | 实现简单;任意窗口限制要求变化时改算法 |
| 下游成本高且存储故障 | 拒绝或小额独立应急预算 | 可用性下降;不能默认无限放行 |
参考资料
- RFC 6585:HTTP 429 与 Retry-After。
- SQLite Transaction:实验使用的真实事务边界。





