API 限流与计费系统设计:令牌桶、配额与对账
用令牌桶加 Redis Lua 实现原子限流来保护上游,用预扣—结算—对账三段式计费产出可核对的账本,两者以 request_id 串联但各自独立演进。
- 限流保可用可降级,计费保一致不可静默丢弃
- 开放平台首选令牌桶:允许短时突发、长期受平均速率约束
- 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;
}四、策略落地:维度、响应头与降级#
限流维度不要只用一个,推荐的组合是:
- 按 API Key 限流:保证单个调用方的公平性,这也是计费的主键。
- 按 IP 限流:挡住未认证刷量,阈值可以更宽,只做兜底。
- 按路由限流:重接口(图片生成、大文件解析)单独设更低的阈值。
- 全局兜底:保护上游服务,最后一层保险。
被限流时,响应体要稳定可解析,同时带上标准响应头,让 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 | 整数,最小计量单位,禁止浮点 |
| status | reserved / 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 >= ?或 RedisDECRBY加余额校验,不要“读余额—判断—写回”。 - 结算必须幂等:
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,用免费额度先把限流、退避重试和用量核对跑通,再迁移到生产环境。