用户登录

技术文章

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

稳定的 API 限流与计费系统:先定计量口径,再选算法

一句话回答

限流与计费应共用同一份请求事件:先在文档和代码中固定计量单位、计量主体与失败请求的处理方式,再选令牌桶或滑动窗口并用原子计数落地;计费侧用幂等键防重、用流水与账单分离、用周期性对账兜底。超时、重试、时钟漂移和双层判定是主要的不一致来源。

核心要点 TL;DR
  • 限流与计费分开实现两套统计,最终必然对不上账;应共用一份请求事件。
  • 窗口边界造成的突发是常见问题,令牌桶加原子扣减通常比固定窗口更好解释。
  • 计费写入以 request_id 作幂等键,流水只追加,账单由汇总生成,二者可回溯。
  • 计数器不可用时,要按接口预先决定放行还是拒绝,并记录降级日志。

稳定性的关键不在选哪个限流算法,而在于限流与计费共用同一份请求事件:同一处判定是否放行,同一处产生用量记录。分开实现两套统计,短期内看不出问题,长期必然对不上账。下面是一条可落地的设计顺序,不依赖具体厂商实现。涉及 API.PLUS 自身的配额字段、鉴权方式与错误体格式,以官方文档为准(本次写作未联网核验,待核实)。

一、先把计量口径固定下来#

口径不清,后面的算法和账单都没有意义。至少先回答四个问题:

  • 计量单位:一次 HTTP 请求、一次模型调用,还是按 token、按字符数?直接对齐上游计费单位最省事,但必须能向用户解释。
  • 计量主体:API key、账号、项目还是 IP。多级配额要明确优先级,以及一次请求扣减哪些层级的额度。
  • 失败请求:4xx 是否计入配额、5xx 是否退还用量,要写进文档,并且文档与代码保持一致。常见做法是鉴权失败不计费但计入防爆破限流,服务端 5xx 不计费。
  • 预扣与结算:长耗时或按量计费的请求先预扣上限,结束后按实际用量补退;预扣未结算的记录必须有 TTL 回收。

二、限流:窗口边界比算法名字更重要#

方案状态量已知问题
固定窗口一个计数器窗口交界处可能出现接近两倍额度的突发
滑动窗口日志每次请求一条记录内存随请求量增长,需要裁剪
滑动窗口计数按小格分桶精度取决于格子大小
令牌桶令牌数 + 时间戳允许突发,需要原子扣减

如果对外承诺的是“平均速率 + 允许一定突发”,令牌桶更容易解释;如果只承诺“每小时 N 次”,固定窗口够用,但要在文档里承认交界处的突发。

三、原子性与判定位置#

分布式计数要保证“读—判断—写”是一个原子操作,常见做法是用 Redis 加 Lua 脚本。下面的脚本未执行,仅示意扣减逻辑;生产环境应把时间取自 Redis 的 TIME,避免各节点时钟漂移,并为 key 设置过期时间。

-- 未执行示例:令牌桶原子扣减
-- KEYS[1]=bucket  ARGV: rate(个/秒) burst now_ms cost
local rate, burst, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[4])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local v = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(v[1]) or burst
local ts = tonumber(v[2]) or now
tokens = math.min(burst, tokens + (now - ts) / 1000 * rate)
if tokens < cost then
  redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
  return 0
end
redis.call('HMSET', KEYS[1], 'tokens', tokens - cost, 'ts', now)
return 1

判定位置同样重要:只在网关或只在应用层判一次。如果两层都判且不共享计数,用户实际拿到的额度是两者之和,与文档不符。计数器不可用时要有明确策略——读接口倾向放行并记录降级,写接口或高价接口倾向拒绝,并返回可解析的错误体。

四、计费:幂等、流水与对账#

  • 幂等键:服务端为每次请求生成 request_id,扣费写入以它为唯一键,重试落在同一条记录上。
  • 流水与账单分离:用量流水只追加、不修改,是事实来源;账单是按周期对流水聚合得到的视图,可以重算。
  • 周期性对账:网关访问日志、用量流水、账单三者按日聚合比对,差异超过阈值告警。上游成本与下游售价分开记录,便于定位是计量错还是定价错。

五、边界条件清单#

  • 重试与超时:预扣成功但结算丢失的“悬空单”需要回收任务。
  • 批量与流式响应:计量点放在请求结束还是首字节,要与文档一致。
  • 多区域写入:同一主体跨区计数要么单点写入,要么接受短暂超额并记录。
  • 时钟:所有窗口计算使用服务端统一时间源。

落地顺序建议反过来:先跑通“一条请求 → 一条用量流水 → 一张可解释的账单”的最小闭环,再叠加多级配额、实时看板与告警。闭环没通之前,限流算法调得再精细也证明不了系统稳定。

常见问题(FAQ)

固定窗口和令牌桶该怎么选?
如果只需要“每小时 N 次”这种容易解释的额度,固定窗口实现最省事,但要接受窗口交界处短时双倍突发;如果既要限制平均速率,又允许小幅突发,令牌桶更合适,代价是需要保存令牌数与时间戳两个状态,并保证扣减的原子性。
为什么计费一定要幂等键?
客户端重试、网关超时重发、上游回调重复投递都很常见。如果扣费只依赖“收到一次请求”这个动作,重复投递就会重复扣费。用服务端生成的 request_id 作幂等键,可以让重试落在同一条记录上,而不是新增一条记录。
限流计数器不可用时该放行还是拒绝?
没有通用答案:读接口通常倾向于放行并记录降级,写接口或高价接口倾向于拒绝并返回明确错误体。关键是这个选择要写进文档、按接口分别配置,并且降级期间产生的用量仍要能补记,否则限流和计费会一起漂移。

作者:沐小悠

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

更多文章 GET 示例接口

「」标签下的其他文章