如何设计一个稳定的 API 限流与计费系统:把计量、限额与结算拆开
限流的稳定性和计费的准确性是两件事,应使用不同的数据结构与失败策略。文章建议把计量、限额、计费单位三个概念分开,计数放到共享存储并接受近似误差,计费走预扣—结算—对账三段,同时用幂等键、悬挂预扣清理和显式的降级策略处理失败模式。
先说结论:一个限流与计费系统稳定,多半不是因为选对了某个中间件,而是因为做对了三个决定,即计量与限额分开、计数放在共享存储并允许近似、计费走“预扣—结算—对账”三段而不是实时扣款。下面按这三个决定展开;涉及具体厂商行为的部分本次没有可核验材料,明确标注待核实。
先把三个概念拆开#
- 限额(rate limit):单位时间允许多少次请求,保护后端容量。
- 配额(quota):一个周期内的总量上限,多为商业约束,如每月调用次数。
- 计费单位(billing unit):按什么计量——请求数、输入输出 token、字节数或计算时长。
三者常被塞进同一个计数器,后果是限流触发的拒绝和账单上的扣费无法互相解释。计费单位一旦对外公布,改动成本很高,因此要在设计初期固定下来。如果计费单位是 token 类用量,需要先确认上游返回的用量字段是否权威、是否会因重试而变化;这一点取决于具体服务,本次材料未提供,待核实。
计数放在共享存储,并接受近似#
单机内存计数在水平扩容后立刻失效:同一调用方落到不同实例,各自从零开始计数,实际放行量等于实例数的倍数。常见做法是把计数放到共享存储(如 Redis),代价是多一次网络往返,还要面对存储不可用时的降级决策。
| 算法 | 内存开销 | 突发流量 | 边界误差 | 适合 |
|---|---|---|---|---|
| 固定窗口计数 | 低 | 允许,可能双倍 | 窗口交界处偏宽 | 粗略保护 |
| 滑动窗口日志 | 高(存每次请求时间) | 精确 | 无 | 低 QPS、要求精确 |
| 滑动窗口计数(加权近似) | 低 | 较平滑 | 有,可接受 | 通用场景 |
| 令牌桶 | 低 | 可配置突发上限 | 无(对桶而言) | 对外 API 限额 |
关键判断是:限流允许误差。它的目标是保护后端,窗口交界处多放行几个请求通常可以接受;计费不允许误差,因为那是钱。所以两者不应该共用同一个计数器——限流可以近似,账必须精确。
令牌桶的最小形态(示例未执行)#
下面的代码是设计示意,未实际运行;假设环境为 Python 3、使用支持原子脚本的共享存储。真实实现需要按所选存储的原子操作改写,并补齐并发与失败处理。
# 未执行的示例:仅示意字段与判断顺序
def try_acquire(key, rate, burst, now):
# 1. 读取 tokens 与 last_refill(须为原子读改写)
tokens, last = store.get(key)
# 2. 按时间差补充令牌,上限为 burst
tokens = min(burst, tokens + (now - last) * rate)
# 3. 判断后写回,读改写必须是一个原子操作
if tokens >= 1:
store.set(key, (tokens - 1, now))
return True
store.set(key, (tokens, now))
return False
注意两个容易写错的点:补充与扣减必须是同一个原子操作,否则并发下会超发;now 应由服务端统一时钟产生,不要信任客户端时间。
计费走三段:预扣、结算、对账#
- 预扣:请求进入时按上限预留额度,防止并发请求同时通过额度检查后一起超额。
- 结算:请求成功返回后,按实际用量提交,多退少补。
- 对账:定时任务扫描超时未结算的预扣记录,释放或转为异常单,避免额度被永久占住。
“悬挂预扣”是这类系统最常见的账目偏差来源:进程在预扣之后、结算之前崩溃,额度就被锁死了。对账任务不是可选项,而是主流程的一部分。
幂等同样重要。客户端重试、网关重放都会让同一个请求被计两次费。解决办法是要求请求携带幂等键(idempotency key),或由服务端从鉴权主体加请求特征派生;没有幂等键的重试,在计费语义上无法区分“新请求”和“重发”。
需要提前写清的失败模式#
- 共享存储不可用:放行(fail-open)还是拒绝(fail-close)取决于接口性质。读接口放行的风险是短时过载,写或计费接口拒绝的风险是可用性下降。这个选择要写进文档,而不是留给值班时临场决定。
- 时钟漂移:多实例时间不一致会让窗口计算偏移,应统一时间源并留出容差。
- 并发竞态:任何“先读后写”的计数在没有原子性保证时都会超发。
- 限额头部缺失:不返回剩余额度与重置时间,调用方只能靠试错撞限,重试会更密集。
对外契约上至少暴露什么#
返回剩余额度、重置时间,被拒绝时用 429 并给出 Retry-After;限额维度(按密钥、按账号还是按 IP)要在文档里明确。至于具体的头部名称与字段格式,属于接口设计选择,本文不假定某个平台已采用哪种。
最后一点判断标准:如果一个系统能回答“这个请求为什么被拒绝”和“这笔费用由哪些请求构成”,它就算稳定;回答不了,说明计量与限额还混在一起。