设计稳定的 API 限流与计费系统:先共用一份用量事实
限流与计费常被分别实现,导致对账不一致。稳定的做法是让两者共用请求标识、计量维度与时间基准:限流做事前决策,计费做事后结算。限流建议本地挡洪峰、中心管配额,并预先写死中心存储故障时的失败开放或关闭策略;计费采用预扣、提交、释放三段式,账本只追加,靠请求 ID 去重。具体阈值与容量须按实际流量确定。
- 限流与计费应共用请求 ID、计量维度与时间基准,二者是同一条数据链的两端。
- 计费采用预扣—提交—释放,账本只追加,纠错用冲正记录。
- 中心存储故障时的失败开放或失败关闭策略,须按用途区分并提前写死。
限流和计费常被当成两个模块分别实现,结果是对不上账:限流放过的请求,计费侧因为重试记了两次;或者计费已经扣了额度,限流侧还按旧配额放行。稳定的关键不是算法多精巧,而是两者共用同一份“用量事实”——同一个请求标识、同一套计量维度、同一个时间基准。限流是事前决策,计费是事后结算,二者是同一条数据链的两端。
先定义计量事实,再谈算法#
动手写限流器之前,需要先确定以下内容,否则后续改动会同时影响两个系统:
- 计量单位:按请求数、token 数、字节数还是时长?单位不同,限流算法要重选。
- 维度:账号、密钥、端点、模型通常都要,但计费归属只能有一个主维度(一般是账号),其余维度用于细分统计。
- 时间窗:窗口长度既决定突发容忍度,也决定账单的可解释性。
- 幂等标识:由入口网关生成请求 ID,向限流、记账、对账三处透传。没有它,超时重发与重试就无法去重。
限流:本地挡洪峰,中心管配额#
常见算法的取舍:
| 算法 | 特点 | 代价 |
|---|---|---|
| 令牌桶 | 允许一定突发,速率上限清晰 | 桶容量需按业务调 |
| 漏桶 | 输出平滑,适合下游脆弱时整形 | 突发请求会被排队或丢弃 |
| 固定窗口 | 实现简单 | 窗口边界可能放过双倍流量 |
| 滑动窗口 | 统计更准 | 存储与计算成本更高 |
工程上更可行的组合是:本地令牌桶按实例数分摊额度,挡住瞬时洪峰,避免每个请求都访问中心存储;中心存储只承担跨实例的全局配额。两层阈值不同源,需要显式配置对应关系,否则扩容时全局配额会被悄悄放大。
中心存储不可用时的行为必须提前写死:防滥用类限流可以失败开放并触发告警;付费配额类应失败关闭,或降级到本地保守值。返回 429 时一并给出 Retry-After 与剩余配额,客户端才好做退避。
计费:预扣、提交、释放#
请求进入时按最坏用量预扣,处理完成后按实际用量提交差额,失败则释放。这样上限可控,不会因为事后才发现超额而透支。用量事件写入追加型账本,聚合异步进行:账本只追加不修改,纠错用冲正记录,重复投递靠请求 ID 去重。
# 示意,未执行,仅表达调用顺序
hold = quota.reserve(account_id, request_id, max_cost)
if not hold.ok:
return 429
try:
cost = handle(request)
quota.commit(request_id, cost) # 多退少补
except Exception:
quota.release(request_id) # 释放预扣
共用的时间与一致性边界#
速率计算用单调时钟,窗口边界按 UTC 对齐,避免实例间时钟漂移放大误差。跨实例计数器通常是最终一致,会带来短时超发,需要评估可接受量。账单与限流报表应能追溯到同一条原始事件,否则争议发生时无从复核。
本文的边界#
这里不给具体 QPS、超时、存储容量或成本数字,它们取决于真实流量形态,须压测后确定。上面的代码是示意片段,未在真实环境执行。具体中间件选型与最新版本信息,本文未核实。