设计稳定的 API 限流与计费系统
设计稳定API限流与计费系统,需统一限流与计费口径,采用分层限流、Redis原子脚本、预扣结算、幂等账本和离线对账,并做好降级恢复与可观测性。
- 限流与计费共用同一份计量结果,避免超卖漏计和重复扣费
- 网关用令牌桶、账户用固定窗口,按接口权重设置cost
- 扣费用条件原子更新,配request_id幂等账本,禁止先查再改
- 中断时三层降级:本地限流、区域Redis、全局配额池
- 降级路径必须无网络调用,指标和压测要覆盖异常路径
从 Q3 2026 的断网说起:限流与计费为什么必须一起设计#
2026 年第三季度,多家网络观测平台给出的结论相当一致:造成大面积连接中断的主因并不是攻击,而是电力故障、自然灾害与区域性网络管制。这类事件有三个共同特征:影响范围跨地域,单机房高可用方案直接失效;发生前没有征兆,恢复后流量会在几十秒内反弹到峰值以上;中断期间写入的计量数据可能丢失、延迟或乱序到达。
对 API 平台而言,这意味着一件事——限流器和计费系统不能各自为政。限流器决定「放不放行」,计费系统决定「扣不扣钱」。如果同一个请求在两者之间得出不一致的结论,就会产生超卖、漏计或重复扣费三种事故。本文以一个多租户 API 网关的视角,给出一套可以直接落地的设计:分层限流 + 预扣结算 + 幂等账本 + 离线对账。
一、把「稳定」拆成四条硬性约束#
“稳定”这个词太抽象,落到工程上应该拆成可验证的约束,否则无法测试、无法验收。
- 依赖失效时不放大故障。限流器依赖的 Redis、配置中心、鉴权服务任意一个挂掉,都不能让请求直接打穿到后端业务集群。默认策略应该是「降级放行一部分」或「按本地桶拒绝」,绝不是「全部放行」。
- 计费不超卖。余额为 0 的用户在任何并发下都不能扣成负数,这要求扣费必须是单条带条件的原子更新,而不是「先查再改」。
- 网络割裂不丢账。计量事件的写入必须可重放,客户端或网关侧保留本地缓冲,恢复后按幂等键补投。
- 限流与计费口径一致。限流用的计量单位(请求数、token 数、返回字节数)必须和计费用的单位来自同一份计量结果,否则对账永远对不平。
这四条里,第 4 条最容易被忽略:很多系统用「请求数」做限流、用「token 数」做计费,结果高并发下限流阈值和成本曲线完全脱钩,热门接口被少量超大请求打爆,而限流器毫无感知。
二、算法选型:令牌桶、滑动窗口与 GCRA#
限流算法的争论常年存在,但结论其实取决于你的业务形态,而不是哪个算法更“高级”。
| 算法 | 突发能力 | 状态开销 | 分布式实现难度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口计数 | 无(边界突刺) | 极低 | 低 | 粗粒度日配额 |
| 滑动窗口日志 | 精确 | 高(存时间戳) | 中 | 风控、审计级限流 |
| 令牌桶 | 允许(桶容量) | 低(两个字段) | 低 | 通用 API 限流 |
| GCRA | 可调(突发容忍度) | 低(一个时间戳) | 低 | 平滑输出、防抖动 |
实践中的推荐组合是:网关层用令牌桶做秒级限流,账户层用固定窗口做日/月配额,结果型接口再叠加 GCRA 做平滑。令牌桶的优势在于同时表达「速率」和「突发容量」两个参数,运营人员容易理解;而固定窗口虽然边界突刺明显,但用作日配额时误差可以忽略,且存储成本最低。
需要注意的是单位问题。对于按权重计费的接口(例如 IP 查询接口 返回结果很小,而图片类接口返回数百 KB),限流的 cost 不应该恒为 1,而应随权重变化:普通查询 cost=1,返回大体积内容的接口 cost=5~20。这一点如果不在设计初期确定,后期改造成本极高。
三、分布式限流的原子实现#
单机限流用内存计数器即可,多实例部署则必须有一个共享的判定点。Redis 是主流选择,关键在于把「读-算-写」封装成一段 Lua,避免并发下的竞态。
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
-- KEYS[1] = 桶的 key,如 rl:uid:1024:api:/doc/get
-- ARGV[1] = capacity ARGV[2] = rate(每秒补充令牌数)
-- ARGV[3] = now_ms ARGV[4] = cost
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = cap
ts = now
end
-- 惰性补充:只在被访问时计算增量,无需定时任务
local delta = math.max(0, now - ts) / 1000.0
local filled = math.min(cap, tokens + delta * rate)
local allowed = 0
if filled >= cost then
filled = filled - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', filled, 'ts', now)
-- 桶空后最多 cap/rate 秒即可恢复,过期时间按此设置,避免 key 无限膨胀
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) + 5000)
return {allowed, math.floor(filled)}
这段脚本有几个必须注意的细节,任何一个处理不好都会在线上出问题:
- 时间源。示例里由调用方传入
now_ms,好处是可以在测试中注入时间,坏处是多实例时钟漂移会导致令牌凭空增加或减少。生产环境建议改用redis.call('TIME')取服务端时间,把漂移收敛到单点。 - 惰性补充。不要写定时任务去补令牌,那会在实例数量上升时变成 O(N) 的写放大。上面脚本只在请求到来时补齐,闲置的桶会自然过期。
- Key 设计。维度顺序应该是「租户 → 接口 → 维度」,例如
rl:{uid}:{api_id},便于用 SCAN 前缀批量清理。Key 里不要放 IP 明文,先做哈希,既能省内存也避免敏感信息落盘。 - 热点问题。同一个大客户的 key 会打到同一个 Redis 分片。对超大客户应做「分片桶 + 汇总校验」,或者直接在网关本地做一级限流。
网关返回时务必带上标准化的限流响应头,让客户端能自行退避,这比任何服务端优化都有效:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1761696000
Retry-After: 3
X-Request-Id: 8f2c1a4e-7d31-4b0a-9c55-2e6f1d0b9a11
四、计费系统:预扣、结算与对账三件套#
限流解决的是「让不让过」,计费解决的是「过了怎么算」。计费系统最常见的错误是把扣费写成两步:先 SELECT 余额判断,再 UPDATE 扣减。在并发下这必然是错的。正确做法是用一条带条件的 UPDATE 完成判定与扣减,配合幂等键去重。
-- 1) 账本表:以 request_id 作为幂等键,唯一索引兜底
CREATE UNIQUE INDEX uniq_ledger_req ON usage_ledger (request_id);
-- 2) 预扣(请求进入时,按估算值冻结)
UPDATE wallet
SET balance = balance - :estimate,
frozen = frozen + :estimate
WHERE user_id = :uid
AND balance >= :estimate; -- 影响行数为 0 即余额不足
-- 3) 结算(请求结束后,按实际用量多退少补)
UPDATE wallet
SET balance = balance - (:actual - :estimate),
frozen = frozen - :estimate
WHERE user_id = :uid;
-- 4) 写入流水,state 从 pending 变为 settled
UPDATE usage_ledger SET amount = :actual, state = 'settled'
WHERE request_id = :req_id AND state = 'pending';
为什么一定要预扣而不是直接后扣?两个原因。其一,后扣无法防止恶意用户在余额耗尽前发起大量并发请求,等你结算时人已经跑了;其二,预扣金额可以按接口的最大成本估算,天然形成一道「配额闸门」。预扣与结算的差额由对账任务每日摊销,只要差额绝对值控制在阈值内,就不需要逐笔人工介入。
幂等键建议直接复用客户端传入的 X-Request-Id,平台侧生成时用 UUIDv7(带时间前缀,便于按时间范围扫描)。如果你们有类似 POST 示例接口 这样的写操作接口,可以要求调用方在创建资源时必传幂等键,服务端返回 409 或首次结果,避免网络重试导致的重复扣费——这也是账本表存在唯一索引的意义。
还有一类容易被忽略的费用:失败请求。4xx 通常不计费,5xx 应当退还预扣。但「退还」同样是账务操作,必须走同一套幂等链路,不能直接改内存计数器。一旦出现「网关已扣费、后端尚未收到请求」的中间态,就需要靠 request_id 在两侧日志里做交叉比对,这也是所有计费系统最终都要建一张对账宽表的原因。
五、中断场景下的降级与恢复#
回到开头的话题:当区域网络割裂或 Redis 集群不可用时,系统怎么表现?推荐三层结构。
- L1 网关本地限流。每个实例内存里跑一个令牌桶,容量按实例数均分,总阈值略高于全局配额(比如 1.2 倍),只用于兜底,防止故障瞬间流量打穿后端。
- L2 区域 Redis 限流。正常情况下的主力,承担精确配额判定。
- L3 全局配额池。按租户批量下发配额(例如每次领 1000 个请求的额度到区域节点),把跨区域往返从「每请求一次」降到「每千次一次」。中断期间各区域继续消耗本地额度,恢复后上报消耗量并重新领用。
批量下发带来一个副作用:某个区域宕机时,已下发但未使用的额度会「蒸发」,导致其他区域短暂无法获取额度。解决办法是给额度设置 TTL,并允许一定比例的欠账额度(debt ceiling),例如允许每个租户透支 5% 的配额,下次同步时归还。用一点点超发换取可用性,在绝大多数业务里是划算的。
计费侧的恢复策略是本地落盘 + 幂等重放。网关把每条计量事件先写入本地环形缓冲(或本地日志文件),异步批量投递到消息队列;队列消费端以 request_id 做幂等。中断期间缓冲会增长,需要设置水位告警和丢弃策略——但请记住,宁可丢计量也不能阻塞请求,丢失的账单可以通过其他信号(如日志聚合、下游统计)估算补齐。
六、可观测性:把假设变成数字#
限流与计费的稳定性无法靠代码审查保证,只能靠指标暴露。建议至少埋以下几组:
- 限流侧:放行数、拒绝数、拒绝率(按接口/租户)、令牌桶水位分布、Lua 脚本 P99(99 分位延迟) 耗时、Redis 超时次数。
- 计费侧:预扣成功率、预扣与结算差额的日累计值、pending 状态超过 5 分钟的记录数、幂等冲突次数。
- 对账侧:账本总额与钱包余额的差值、审计日志与账单条数差异、离线补投的延迟分位数。
压测同样要覆盖异常路径,而不只是正常吞吐。可以拿 随机动漫壁纸接口 这类响应体较大的接口做带宽型压测,观察 cost 权重是否生效;再用 IP 查询接口 配合地域维度做「按地区限流」的验证,确认 Key 的哈希分片不会因某个区域的流量尖峰而倾斜。
一个实用的判断标准:如果你的限流系统在 Redis 完全不可用时,P99(99 分位延迟) 延迟的增幅超过 50ms,说明本地兜底路径设计得不够独立。降级路径必须是无网络调用的。
七、总结:先保证一致性,再谈性能#
设计限流与计费系统时,最容易被性能指标带偏:QPS(Queries Per Second,每秒请求数) 能到多少、Lua 脚本能不能压到 0.2ms。这些当然重要,但真正决定系统能不能长期运行的是一致性——限流判定与计费扣减是否基于同一份计量、扣费是否原子、事件是否幂等可重放。把这四件事做对,性能优化只是参数调整;做错任何一件,流量越大事故越大。
落地顺序建议是:先定义计量单位与权重 → 实现带条件的原子扣减与幂等账本 → 再加分层限流与批量配额 → 最后补对账任务与告警。每一步都可以独立上线验证,不需要一次性重写。
如果你想先用真实接口验证这套设计,可以在 API.PLUS 注册账号获取免费 API Key,用 POST 示例接口 测试幂等键与重试行为,用 IP 查询接口 测试多维度限流。更多接口细节可以在 API 文章中心 查阅,遇到限流参数或计费口径的问题也可以直接联系 技术支持。