用户登录

技术文章

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

API 限流与计费系统设计:令牌桶、配额与对账

一句话回答

用令牌桶加 Redis Lua 实现原子限流来保护上游,用预扣—结算—对账三段式计费产出可核对的账本,两者以 request_id 串联但各自独立演进。

核心要点 TL;DR
  • 限流保可用可降级,计费保一致不可静默丢弃
  • 开放平台首选令牌桶:允许短时突发、长期受平均速率约束
  • Redis Lua 把读-算-写变成一次原子操作,避免并发超发
  • 本地桶阈值设为分布式桶的 1.2~1.5 倍,避免误杀
  • 计费走预扣-结算-回滚,用 request_id 保证幂等

API 开放平台最怕两件事:一是被打爆,二是算错账。前者是可用性问题,后者是收入与信任问题。很多团队把两者塞进同一个中间件,结果限流规则一改,账单跟着变;账单出现差额,又分不清是漏记还是被限流挡掉。下面给出一套可以直接落地的分层设计:限流负责保护上游,计费负责产出可对账的账本,两者共享同一个“请求事实”,但各自独立演进。

一、先分清:限流保可用,计费保一致#

限流的目标是让系统在过载时仍能服务大多数请求,输出是一个布尔值和建议重试时间;计费的目标是让每一次成功消耗都可追溯、可聚合、可对账,输出是不可变的事实记录。前者可以降级(Redis 挂了走本地桶),后者不能随意降级(宁可拒绝服务,也不要产生无法核对的账单)。

把系统切成三个平面会更清晰:

  • 接入平面:认证、限流、配额预扣。要求毫秒级、低状态、无阻塞。
  • 业务平面:真正执行接口逻辑,可能还要调用上游第三方。
  • 账务平面:把用量事件写入流水,异步聚合出账单,负责对账与补差。

接入平面只做“预扣”,账务平面做“确认”,中间用全局唯一的请求 ID 串起来。这样即使进程崩溃,也能靠流水回放定位到具体请求。

二、限流算法选型:令牌桶仍是开放平台的最优解#

先说结论:面向外部开发者的 API,令牌桶(Token Bucket)通常比固定窗口更合适。固定窗口在窗口切换瞬间允许两倍流量通过,监控图上非常难看;滑动窗口日志精确但内存开销大;漏桶强制匀速,会把正常突发打散,体验变差。

算法突发容忍状态与复杂度适用场景
固定窗口计数窗口边界会出现双倍突刺O(1),最简单内部非关键接口
滑动窗口计数近似平滑O(分片数)需要平滑曲线的一般限流
滑动窗口日志精确O(请求数),内存高低频高价值接口
令牌桶允许短时突发,长期受平均速率约束O(1)开放平台首选
漏桶不允许突发O(1)严格匀速的下游保护
GCRA与令牌桶等价,状态更少O(1),单时间戳高并发网关

实际部署建议双层:单机内存桶挡住瞬时打爆(无网络开销、成本最低),Redis 分布式桶做跨节点总量控制(准确但多一次 RTT)。两层阈值不要设成相等,本地桶可以给到分布式桶的 1.2~1.5 倍,避免节点间时间差造成误杀。

像 HackerDome 这类实时 PvP 编程对战、带观战和“捣乱”机制的站点,流量天然是尖峰型的:一局开始瞬间观战者涌入。这种业务如果只做平均速率限制,体验会很糟;正确做法是把桶容量调大、补充速率保持不变,把“允许多大的突发”当成产品参数而不是运维参数来讨论。

三、Redis + Lua:把读-算-写变成一次原子操作#

分布式限流最容易踩的坑是“先 GET 再 SET”:两个并发请求都读到旧值,实际放行量超出预期。用 Lua 在 Redis 内部执行,可以让整个判断过程原子化。

示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。

-- KEYS[1] 限流桶 key;ARGV: capacity, rate(令牌/秒), now_ms, cost
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1])
local ts     = tonumber(b[2])
if tokens == nil then
  tokens = capacity
  ts = now
end

local delta = math.max(0, now - ts) / 1000
tokens = math.min(capacity, tokens + delta * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 60000)
return {allowed, math.floor(tokens)}

几个实现细节值得注意:

  • 时间来源:推荐统一用 Redis 的 TIME 命令取时间,避免多节点时钟漂移导致桶被“回拨”后瞬间补满。
  • 必须设过期:PEXPIRE 不能省,否则冷 Key 会永久驻留,内存会被长尾调用方吃掉。过期时间按“从空桶补满所需时间 + 余量”计算即可。
  • Cluster 兼容:单 Key 操作天然落在同一槽位;如果你把多个维度的计数塞进一个脚本,记得用 hash tag 保证同槽,否则会报 CROSSSLOT。
  • 返回剩余量:把剩余令牌数写进响应头,调用方就能自己做客户端限速,服务端压力会明显下降。

PHP 侧调用示例如下,注意 eval 的第三个参数是 Key 的数量:

$t0  = (int) (microtime(true) * 1000);
$res = $redis->eval($lua, [$bucketKey, 60, 1.0, $t0, 1], 1);
if ((int) $res[0] !== 1) {
    header('Retry-After: 1');
    http_response_code(429);
    exit;
}

四、策略落地:维度、响应头与降级#

限流维度不要只用一个,推荐的组合是:

  1. 按 API Key 限流:保证单个调用方的公平性,这也是计费的主键。
  2. 按 IP 限流:挡住未认证刷量,阈值可以更宽,只做兜底。
  3. 按路由限流:重接口(图片生成、大文件解析)单独设更低的阈值。
  4. 全局兜底:保护上游服务,最后一层保险。

被限流时,响应体要稳定可解析,同时带上标准响应头,让 SDK 能自动退避:

HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793000000

降级策略要提前写死:Redis 超时或不可用时,切换到进程内令牌桶,阈值放大到正常的 1.5 倍,同时打点告警。这里选择 fail-open,是因为限流属于保护性措施,短暂宽松好过全站不可用;但计费侧必须偏 fail-close,或者先把用量事件落到本地磁盘队列,恢复后补记,绝不能静默丢弃。

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

计费模型通常有三种:预付费配额(买包)、后付费账单、订阅套餐。无论哪种,底层都应该是同一张用量流水,关键字段建议如下:

字段说明
request_id全局唯一,作为幂等键,重复写入直接忽略
api_key_id计费主体,不要存明文 Key
route / unit接口标识与计量单位(次、KB、token)
quantity整数,最小计量单位,禁止浮点
statusreserved / committed / refunded
price_version费率版本,调价后历史账单才不会变
created_at服务端时间,用于按小时聚合

金额一律用整数存最小货币单位(分),费率表单独维护。典型流程是预扣 → 执行 → 结算或回滚:

if (!quota_reserve($uid, 1, $requestId)) {   // 原子预扣,带幂等键
    http_response_code(402);                  // 配额不足
    exit;
}
try {
    $data = handle_request();
    quota_commit($requestId);                 // 写流水,幂等提交
} catch (Throwable $e) {
    quota_refund($requestId);                 // 回滚预扣,避免用户为失败的请求付费
    throw $e;
}

三个要点:

  • 预扣必须原子:用 UPDATE ... WHERE balance >= ? 或 Redis DECRBY 加余额校验,不要“读余额—判断—写回”。
  • 结算必须幂等:INSERT ... ON DUPLICATE KEY UPDATE 配合 request_id 去重,网络重试不会重复扣费。
  • 对账必须自动:每小时跑一次,比较流水聚合值与账户余额变动,差额超过阈值就告警。最常见的差额来源是超时未结算的 reserved 记录,需要一个 TTL 清理任务自动回滚,否则余额会越扣越少。

六、可观测性:先能看见,才能稳住#

至少要有四组指标:按维度拆分的放行量与拒绝量、限流规则命中分布、预扣失败率、流水与余额的差额。日志里带 request_id、Key 前缀(不要打全量 Key)、命中的规则 ID。压测时重点看两件事:Redis 单 Key 的 QPS 上限(单个热 Key 很容易成为瓶颈,需要按 Key 分片或本地预分配令牌),以及限流中间件自身引入的 P99(99 分位延迟) 延迟——保护机制本身不能成为新的故障点。

七、在 API.PLUS 上验证你的客户端逻辑#

限流与重试逻辑写完后,建议先用低成本的只读接口验证行为,比如 IP 查询接口,一条命令就能看到真实的响应结构与耗时:

curl "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"

需要验证 JSON 解析与大响应的计量方式,可以用 随机动漫壁纸接口,图片类接口的带宽成本更值得单独核算:

curl -I "https://www.api.plus/api/dongman?type=json"

要验证 POST 场景下的幂等与重试,可参考 POST 示例接口 的字段说明。更多接口设计与工程实践见 API 文章中心,接入过程中遇到问题可以直接走 技术支持。

八、总结#

限流与计费是目标不同的两套机制,却被同一个请求串在一起:用令牌桶加 Redis Lua 做原子限流,用预扣—结算—对账三段式做计费,用 request_id 作为贯穿全链路的幂等键,用分层降级策略区分“可以放宽的限流”和“不能妥协的账务”。把这四件事做扎实,流量高峰和财务对账就都不再是突发事故。

想动手验证本文的方案,可以在 API.PLUS 注册账号即可获取免费 API Key,用免费额度先把限流、退避重试和用量核对跑通,再迁移到生产环境。

常见问题(FAQ)

为什么开放平台限流优先选令牌桶而不是固定窗口?
固定窗口在窗口切换瞬间允许两倍流量通过,监控图上会出现双倍突刺;漏桶强制匀速会把正常突发打散,体验变差。令牌桶状态为 O(1)、实现简单,既允许短时突发又让长期速率受平均速率约束,最适合面向外部开发者的 API。
Redis 分布式限流为什么必须用 Lua 脚本?
先 GET 再 SET 会让两个并发请求都读到旧值,实际放行量超出预期。用 Lua 在 Redis 内部执行可把读-算-写变成一次原子操作。同时建议统一用 Redis TIME 取时间避免多节点时钟漂移,并务必设置 PEXPIRE 防止冷 Key 永久驻留吃掉内存。
本地限流桶和 Redis 分布式桶的阈值该怎么设?
两层阈值不要设成相等,本地内存桶可以给到分布式桶的 1.2~1.5 倍,避免节点间时间差造成误杀。本地桶负责挡住瞬时打爆、无网络开销;Redis 分布式桶负责跨节点总量控制,准确但多一次 RTT。
计费流水最常见的差额来源是什么?
最常见的是超时未结算的 reserved 记录,需要为预扣记录设置 TTL 清理任务自动回滚,否则余额会越扣越少。同时结算必须用 request_id 做幂等去重,并每小时自动对账,比较流水聚合值与账户余额变动,差额超阈值即告警。
Redis 不可用时限流和计费分别该如何降级?
限流侧选 fail-open:切换到进程内令牌桶并把阈值放大到正常的 1.5 倍,同时打点告警,短暂宽松好过全站不可用。计费侧必须偏 fail-close,或先把用量事件落到本地磁盘队列、恢复后补记,绝不能静默丢弃用量。

作者:沐小悠

本文约 12 分钟读完 · 关键词:API 限流、API 计费、令牌桶、Redis 限流、配额对账

更多文章 GET 示例接口

「」标签下的其他文章