用户登录

技术文章

外部链接将在新标签页打开。

设计稳定的 API 限流与计费系统:先拆两条链路,再做预扣与对账

一句话回答

限流与计费应作为两条独立链路:入口用令牌桶或滑动窗口做快速放行判定,计费走预扣、结算、对账三步,用请求 ID 做幂等键。金额用整数最小单位,账本追加写;限流状态集中存储并用原子脚本读改写。故障时的放行或拒绝策略、API.PLUS 的具体配额与结算参数需以实际文档为准。

核心要点 TL;DR
  • 限流解决“这次请求放不放行”,计费解决“这次调用算多少钱”,两者用请求 ID 关联,不要混在一条事务里。
  • 额度扣减推荐预扣—结算—对账三段式:预扣按上限冻结,结算按实际用量多退少补,日终对账找差额归属。
  • 共享存储上的限流状态必须原子读改写;本地缓存只适合做粗筛,不能作为最终判定。

一个稳定的 API 限流与计费系统,关键不在挑哪个算法,而在先把两件事拆开:限流回答“这次请求放不放行”,计费回答“这次调用算多少钱”。前者要在入口用常数级开销快速判定,后者要在事后做可对账的记账,两者通过请求 ID 关联,而不塞进同一个事务。API.PLUS 的具体配额粒度、计费单位与结算周期,本次材料未提供,需以其实际文档为准(待核实);本文只讨论设计层面的取舍。

一、先把计量语义写进契约#

实现之前先定三件事,否则后面所有数字都对不上:

  • 限流键:按账号、按密钥、按 IP 还是按路由。粒度越细越公平,但存储与统计成本越高;多键并存时通常取“任一超限即拒绝”。
  • 窗口与单位:每分钟请求数、每月 token 数、还是按字节计。契约里要明确“一次调用算几次”,尤其是批量接口和流式响应。
  • 失败请求算不算:参数错误、鉴权失败、上游超时是否计量。这一条最容易在账单争议里出现。

二、限流:令牌桶管突发,滑动窗口管平滑#

令牌桶(token bucket)用“容量 burst + 补充速率 r”描述配额,允许短时突发,状态只需 tokens 与上次补充时间戳。固定窗口计数实现最简单,但窗口边界会出现双倍突刺;滑动窗口计数是它的近似改良,内存开销小。选择依据是业务能否接受突发,而不是哪个更“先进”。

多实例部署时,判定状态必须放在共享存储里,并用原子脚本完成读—改—写,否则并发下会超发。下面的 Lua 片段仅为结构示意,未执行,也未针对任何具体 Redis 版本验证:

-- 未执行示例:令牌桶伪代码,示意原子读改写
-- KEYS[1]=桶键  ARGV={now_ms, rate_per_sec, burst, want}
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or tonumber(ARGV[3])
local ts = tonumber(b[2]) or tonumber(ARGV[1])
local delta = math.max(0, tonumber(ARGV[1]) - ts) / 1000
tokens = math.min(tonumber(ARGV[3]), tokens + delta * tonumber(ARGV[2]))
local ok = tokens >= tonumber(ARGV[4])
if ok then tokens = tokens - tonumber(ARGV[4]) end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[1])
return ok and 1 or 0

时间基准要用存储端时间或单调时钟,避免多机时钟漂移把补充量算错。单机本地缓存只适合做粗筛,不能作为最终判定,否则各实例的放行总量会超出配额。

三、计费:预扣、结算、对账#

  1. 预扣:按本次调用的最大可能消耗冻结额度,冻结失败直接拒绝,避免透支。
  2. 结算:按实际用量多退少补,以请求 ID 作为幂等键;重复回调、客户端重试、上游重放都靠它去重。
  3. 对账:按固定周期比对“计量流水总和”与“余额变动总和”,任何差额都要能归到具体请求,不能只报一个总数。

两个实现细节值得提前定:金额用整数最小单位(分或更小)存储,不用浮点;账本采用追加写,不做原地更新,修正通过冲正记录表达,这样任何时点的余额都能由流水重算。

四、故障时怎么退化#

限流存储不可用时,放行(fail-open)保住可用性但可能超配额,拒绝(fail-closed)保住配额但把存储故障放大成业务故障。计费链路不可用时,应先把流水落到本地队列或日志再异步补记,宁可延迟结算,不可丢流水。这些选择需要写进对外条款和告警规则,而不是留在代码的默认分支里。

五、验收看什么#

  • 放行率、429 占比、预扣失败率、结算延迟、对账差额,五项指标要能按限流键下钻。
  • 客户端侧:收到 429 时读取 Retry-After,采用指数退避加抖动,不要在拒绝后立即重试。
  • 没有可核验的压测记录前,只验证契约与边界条件,不对外宣称吞吐或延迟数字。

限流与计费的共同前提是同一套请求标识和同一份计量口径。先把这两样固定下来,算法与存储的替换都不会牵动账单。

常见问题(FAQ)

为什么不用余额直接扣减,而要预扣加结算?
一次调用在开始前往往不知道最终消耗(例如按 token 或按耗时计费)。直接扣减要么高估、要么低估:高估需要频繁退款,低估会透支额度。预扣把上限先占住,结算再按实际用量调整,差额有据可查。代价是需要一条可靠的结算补偿链路。
限流存储挂掉时应该放行还是拒绝?
没有通用答案,取决于业务对超额的容忍度。放行(fail-open)保住可用性但可能超出配额,需要在恢复后补记;拒绝(fail-closed)保住配额但把存储故障直接放大成业务故障。这个选择要写进对外条款和内部告警,而不是留给代码里的默认分支。

作者:沐小悠

本文约 5 分钟读完 · 关键词:API 限流、计费系统、令牌桶、滑动窗口、幂等

更多文章 GET 示例接口

「」标签下的其他文章