API 限流与计费系统怎么设计:先把配额判断和账单记录分开
稳定的限流与计费系统应把限流判断和账单记录拆成两套职责:限流可以近似、可以丢弃,账单必须持久化、可重放、可追溯。计量单位、取整方式、窗口时钟和重试是否计费应写入契约并带版本号;聚合失败时延迟出账而不是估算;用原始事件与聚合结果对账。
- 限流计数器不能直接当账单使用:两者的精度要求和失败处理不同。
- 计量口径要版本化,用量由服务端计算,重试靠幂等键去重。
- 聚合失败时延迟出账,并用原始事件对账,而不是按估算补账。
设计稳定的 API 限流与计费系统,关键不在挑一个更精细的限流算法,而在先把两件事分开:限流负责保护系统,计费负责解释账单。前者可以近似、可以拒绝、可以丢弃中间状态;后者必须持久、可重放、可追溯。本文给出一套不绑定具体厂商的分层做法。具体平台的配额语义、计费单位和价格,需要按其文档逐项核对,本文不作判断。
为什么不能共用一份计数#
拿限流计数器直接出账,常见后果是:窗口边界的误差被写进账单;被限流拒绝的请求到底计不计费说不清;聚合服务重启后数字对不上。反过来,把账单逻辑塞进限流中间件,会让每个请求都同步写账,故障面被放大。
| 维度 | 限流 | 计费 |
|---|---|---|
| 主要目标 | 保护上游与租户配额 | 解释用量与应收 |
| 精度要求 | 允许近似与短时抖动 | 要求可重放、可追溯 |
| 失败处理 | 可快速失败或降级放行 | 不能丢事件,只能延迟处理 |
| 数据形态 | 滚动计数器、令牌桶状态 | 不可变的用量事件 |
四层职责怎么划#
- 接入层:鉴权、路由、基础拒绝。只做与身份相关的最小判断。
- 配额层:按配额键做令牌桶或滑动窗口,输出放行或拒绝以及剩余额度,允许近似。
- 计量层:把一次请求转换成用量事件并持久化,不参与放行决策。
- 结算层:按口径版本聚合、出账、对账。它读事件,不回写配额。
这样划分后,任何一层故障的影响范围都可描述:配额层不可用时可以选择失败关闭或临时放行,但计量层不能因此少记事件。
先把计量口径写进契约#
计量单位是“一次请求”“一个 token”“一字节”还是“一秒”;小数如何取整;最小计费粒度是多少;失败请求和重试请求是否计量;窗口边界用哪台机器的时钟——这些都属于契约,不是实现细节。口径需要版本号,历史事件按当时的版本解释,不能事后用新口径重算旧账。
用量事件的最小字段
下面的 JSON 仅示意字段结构,未执行、未接入任何存储。字段名可按团队习惯调整。
{
"event_id": "evt_demo_0001",
"request_id": "req_demo_0001",
"tenant_id": "tenant_demo",
"quota_key": "sk_live_xxx_hashed",
"meter": "requests",
"quantity": 1,
"unit": "request",
"meter_version": "2026-11-01",
"occurred_at": "2026-11-01T00:00:00Z",
"idempotency_key": "client-supplied-or-server-derived"
}
三个约束值得写死:用量由服务端计算,不接受客户端上报;occurred_at 用服务端时间,客户端时间只作参考;idempotency_key 用于重试去重,同一键重复到达只记一次。配额键应存散列值,避免把原始密钥写进日志。
限流与账单之间的三条边界#
- 限流结果不写入账单。“被拒绝”只影响可用性指标,不改变已发生的用量。
- 事件先落持久存储,再异步聚合。聚合是派生数据,可以重跑。
- 聚合失败时延迟出账,不按估算补账。估算值一旦进入账单,就很难与原始事件对齐。
错误码也应分开:配额拒绝与余额不足使用不同状态码或错误类型,客户端才能决定是退避重试还是提示充值。
对账与验证#
对账任务应比较“原始用量事件”和“聚合结果”,差异按租户、按口径版本列出,而不是只比总额。口径变更前,可用历史事件重放验证新旧版本差异;新链路可用影子流量并行记录,但不影响真实放行。这里没有提供任何测试数据或吞吐指标,具体容量需在目标环境中自行测量。
安全与运维边界#
密钥只在服务端使用,不下发到客户端;日志中的密钥、请求体和用户标识需脱敏;口径切换保留双写过渡期,确认聚合结果一致后再停旧链路。稳定性最终来自可解释与可重放,而不是更复杂的限流算法。