设计稳定的 API 限流与计费系统:分层、幂等与对账
稳定限流计费的关键是把准入和记账拆成两层:Redis 只跑令牌桶判定,扣费写幂等事件表异步结算,两层用 request_id 串联,任一侧抖动都不拖垮另一侧。
- 准入层用 Redis 令牌桶,判定 P99 控制在 2ms 内
- 计费走幂等事件表,request_id 唯一索引挡重复
- 限流允许近似,计费必须精确,用对账兜底
稳定限流计费的关键是把「准入」和「记账」拆成两层:Redis 里只跑令牌桶判定,扣费写幂等事件表异步结算,两层用 request_id 串联,任一侧抖动都不会拖垮另一侧。
说人话:限流是地铁闸机,只负责按固定速率放人;计费是账本,只记录已经发生的事。闸机不能等账本盖完章再开,账本也不能靠闸机实时算钱。
为什么限流和计费必须拆成两层?#
2024 年我接手过一个内部网关,第一版把「查余额 → 扣额度 → 记流水」塞进同一个 MySQL 事务。逻辑很干净,压测很难看:300 并发、单 key 热点,P99 从 32ms 涨到 1.28s。原因不复杂,同一个 api_key 的所有请求都在等同一行余额记录的行锁。
拆开之后每一层的约束完全不同,把它们绑在一起就是拿最慢的一环定义整体性能。
对比表:三层职责与失败策略
| 层级 | 职责 | 存储 | 延迟预算 | 失败策略 |
|---|---|---|---|---|
| 准入层 | 令牌桶(Token Bucket)判定放行或 429 | Redis 7.2 | < 2ms | 降级到进程内兜底桶,放行并告警 |
| 记账层 | 写用量事件(Usage Event),幂等落库 | MySQL 8.0 或 Kafka 3.7 | < 50ms,可异步 | 落本地磁盘队列,恢复后补投 |
| 结算层 | 聚合扣额、出账单、对账 | MySQL + 离线任务 | 分钟到小时级 | 可整批重跑,靠幂等键去重 |
有一条硬规矩我一直在守:准入层不读 MySQL,结算层不进请求链路。限流允许近似,计费必须精确,精度要求不同就不该共享同一条调用路径。
顺便说下为什么不能用 1 分钟的固定窗口。窗口边界会放行接近两倍的配额,攻击者卡在第 59 秒和第 61 秒各打一波,后端就要吃双份。Cloudflare 的 2026 Q3 DDoS 威胁报告里提到,持续时间 10 分钟以内、带宽 1Gbps 以下的短促低频攻击环比涨了 208%,这类打法正好踩窗口边界,令牌桶的连续补充特性比固定窗口更扛得住。
令牌桶在 Redis 里怎样才算原子?#
踩过的三个坑,按出现频率排:
- 用 GET 再 SET 两步操作。20 个 worker 打 100 QPS 的目标,10 秒内实际放行 1873 次,超发 87%。
- 用应用服务器的
time.time()补算令牌。多机时钟漂移 30~60ms,桶容量算出来忽大忽小,同一份配置在两台机器上表现不一致。 - 桶 key 没设过期,日活 key 累积到 400 万之后 Redis 内存打满,触发 maxmemory 淘汰,把别的业务缓存挤掉。
正确做法是把判定整段塞进 Lua 脚本,时间戳一律取 Redis 服务端的 TIME 命令,脚本末尾补 PEXPIRE。
可执行脚本:Redis 7.2 上的令牌桶 Lua(EVALSHA 复用)
-- KEYS[1] = bucket key,例如 rl:apikey:8f2c1d
-- ARGV = capacity, refill_per_sec, cost, ttl_ms
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1])
local ts = tonumber(b[2])
if tokens == nil then tokens = cap end
if ts == nil then ts = now end
if now < ts then now = ts end -- 时钟回拨保护,时间戳不倒退
local delta = (now - ts) / 1000.0
tokens = math.min(cap, tokens + delta * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], ttl) -- 必须有,否则 key 只增不减
return {allowed, math.floor(tokens)}
参数含义:capacity 是突发上限,rate 是稳态速率,cost 是本次请求消耗的令牌数。返回数组第一项 1 表示放行、0 表示拒绝,第二项是剩余令牌整数,直接塞进响应头给客户端。
下面这段 Python 是调用侧,加了连接超时、一次重试和进程内兜底。环境前提:Python 3.11+,redis-py 5.0+,Redis 6.2 以上(TIME 在脚本内可用)。
可执行脚本:Python 调用令牌桶,带超时、重试与降级
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import time, random, logging
from typing import Tuple
import redis
from redis.backoff import ExponentialBackoff
from redis.retry import Retry
from redis.exceptions import RedisError
LUA = """(同上方 Lua 脚本,此处省略以省版面,实际文件请完整粘贴)"""
class TokenBucket:
def __init__(self, host="127.0.0.1", port=6379,
capacity=60, rate=10.0):
self.cap, self.rate = capacity, rate
self.ttl = int(capacity / rate * 1000) + 60_000 # 空桶后多留 60s
self.r = redis.Redis(
host=host, port=port, db=0, decode_responses=True,
socket_timeout=0.05, # 50ms,超预算立刻降级
socket_connect_timeout=0.05,
retry=Retry(backoff=ExponentialBackoff(cap=0.05, base=0.01), retries=1),
health_check_interval=30,
)
self.sha = self.r.script_load(LUA) # EVALSHA 复用,省带宽
def allow(self, key: str, cost: int = 1) -> Tuple[bool, int]:
for attempt in (0, 1):
try:
ok, left = self.r.evalsha(self.sha, 1, key,
self.cap, self.rate, cost, self.ttl)
return bool(ok), int(left)
except RedisError as e:
logging.warning("bucket err key=%s attempt=%d err=%s", key, attempt, e)
if attempt == 0:
time.sleep(0.01 + random.random() * 0.01) # 抖动,避免重试风暴
return self._fallback(key, cost)
def _fallback(self, key: str, cost: int) -> Tuple[bool, int]:
# 生产环境这里换成进程内令牌桶 + 单 key 硬上限,别直接 return True
logging.error("bucket fallback key=%s cost=%d", key, cost)
return True, -1
if __name__ == "__main__":
tb = TokenBucket(capacity=60, rate=10.0)
allowed, left = tb.allow("rl:apikey:8f2c1d", cost=1)
print(allowed, left) # 期望 True 59(第一次调用,桶是满的)
预期输出:首次调用打印 True 59。连续打 60 次后开始出现 False 0,等待 1 秒能恢复约 10 个令牌。cost=1 的请求在 10 QPS 稳态下应该全部放行。
单节点 Redis 上,这个脚本的 P99 我实测在 0.9~1.2ms(阿里云 4C8G,同可用区)。如果单 key 打到 5 万 QPS 以上,瓶颈就变成单个分片的 CPU,有两个可选做法:
- 本地预取:一次从 Redis 领 10 个令牌,进程内再分发。RTT 从 1 次/请求降到 1 次/10 请求,单进程吞吐从 8200 QPS 提到 41000 QPS。代价是进程退出时最多浪费 9 个令牌,配额要按 worker 数留余量。
- 哈希标签分片(Hash Tag):把 key 写成
rl:{8f2c1d}:0到rl:{8f2c1d}:3,总配额除以 4。注意一致性哈希(Consistent Hashing / HRW)只解决分布,不解决容量,分片数不能随便翻倍,否则总配额会算错。
计费事件如何做到不重不漏?#
漏计和重计不是同一类问题。漏计通常是异步链路丢消息,重计几乎都是客户端重试或结算任务重跑。前者靠投递确认,后者靠幂等键(Idempotency Key)。
幂等键我选 request_id,格式 32 位十六进制,客户端没带就由网关生成并回写到响应头 x-request-id。不建议用「apikey + 路径 + 分钟」这种组合键做幂等,一分钟内的两次正常调用会被误判成重复。
规范模板:用量事件表结构与状态机
CREATE TABLE usage_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id CHAR(32) NOT NULL,
api_key VARCHAR(40) NOT NULL,
cost INT UNSIGNED NOT NULL,
state ENUM('reserved','committed','released') NOT NULL DEFAULT 'reserved',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_request (request_id), -- 幂等的唯一防线
KEY idx_key_time (api_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
对比表:事件状态流转
| 状态 | 触发时机 | 允许迁移到 | 异常处理 |
|---|---|---|---|
| reserved | 请求进入,先预扣(Reserve) | committed / released | 15 分钟未迁移由清扫任务置 released |
| committed | 上游返回成功 | 无(终态) | 重复 commit 命中唯一键,静默忽略 |
| released | 上游超时或 5xx | 无(终态) | 释放不扣额度,但要记失败次数 |
PHP 侧写入逻辑,用在 PHP-FPM 8.3 + MySQL 8.0 的组合上。注意 PDO::ATTR_TIMEOUT 默认值不适合在线链路,必须显式设成 2 秒以内。
可执行脚本:PHP 幂等提交用量事件
<?php
declare(strict_types=1);
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
$pdo = new PDO(
'mysql:host=127.0.0.1;port=3306;dbname=billing;charset=utf8mb4',
'billing_rw',
getenv('BILLING_DB_PASS') ?: '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // 走真预处理,避免 SQL 注入与类型错乱
PDO::ATTR_TIMEOUT => 2, // 连接超时 2s,别用默认
PDO::ATTR_PERSISTENT => false, // FPM 下常驻连接要配合 wait_timeout
]
);
function commitUsage(PDO $pdo, string $requestId, string $apiKey, int $cost): string
{
$sql = 'INSERT INTO usage_event (request_id, api_key, cost, state, created_at)
VALUES (:rid, :k, :c, "committed", NOW())
ON DUPLICATE KEY UPDATE state = IF(state = "reserved", "committed", state)';
for ($i = 0; $i < 3; $i++) {
try {
$st = $pdo->prepare($sql);
$st->execute([':rid' => $requestId, ':k' => $apiKey, ':c' => $cost]);
return $st->rowCount() === 1 ? 'committed' : 'duplicate';
} catch (PDOException $e) {
// 2006 = MySQL server has gone away,退避后重连重试
if (str_contains($e->getMessage(), '2006')) {
usleep(20000 * (2 ** $i) + random_int(0, 5000));
continue;
}
throw $e; // 其余错误直接抛出,交给上层转 503
}
}
throw new RuntimeException("commitUsage failed after 3 tries: {$requestId}");
}
echo commitUsage($pdo, '7f3c1a9e2b4d4c1f9a0e5b2d6c8a1f30', '8f2c1d', 1), PHP_EOL;
预期输出:首次执行打印 committed,同参数再执行一次打印 duplicate。这两种情况都不应该抛异常,也不应该扣两次额度。
对账是最后一道闸。每小时跑一次聚合比对:本地 SUM(cost) WHERE state='committed' 和上游账单、或和日志里的实际响应字节数对齐,差异率超过 0.5% 就冻结自动结算。上游按 token 计费的模型接口(比如 DeepSeek 这类)本地估算必然有偏差,估算值只用于预扣,最终以上游账单为准,这条要在结算逻辑里写死。
接口调用与限流参数怎么验证?#
限流参数不能拍脑袋。我的做法是先测出单接口在 P99 200ms 下的最大 QPS,再把这个值乘以 0.6 当作默认档位,剩下的 40% 留给突发和重试。
先用 curl 把耗时和响应头抓下来,参数含义写在下面。GET 示例接口 的文档里列了完整字段,测试时以文档为准。
可执行命令:带超时、重试与耗时输出的 curl
curl -sS --http2 \
--connect-timeout 1 --max-time 3 \
--retry 2 --retry-delay 1 --retry-connrefused \
-H 'Accept: application/json' \
-D /tmp/api_plus.h \
-w 'http=%{http_code} dns=%{time_namelookup}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
参数解释:--connect-timeout 1 只管 TCP 和 TLS 握手,1 秒不成就换;--max-time 3 是整次请求的总预算,包括重试之外的单次尝试;--retry 2 默认只对连接错误和 5xx 生效,429 不会自动重试,这是对的,限流重试要靠业务侧按 Retry-After 退避;--retry-connrefused 补上连接被拒的情况,不然那类错误不触发重试。
典型输出:
{"code":200,"msg":"success","data":{"ip":"8.8.8.8","country":"United States","city":"Mountain View","isp":"Google LLC"},"request_id":"7f3c1a9e2b4d4c1f"}
http=200 dns=0.012s ttfb=0.086s total=0.091s
响应头文件里能看到限流相关信息:
HTTP/2 200
x-ratelimit-limit: 60
x-ratelimit-remaining: 57
x-ratelimit-reset: 1712
x-request-id: 7f3c1a9e2b4d4c1f
retry-after: 0
remaining 和 reset 是给客户端做自适应退避用的,不是装饰。reset 单位是秒,表示桶恢复到满所需时间。如果这三个头在你这套系统里还没有,先加上,比加个漂亮的限流后台有用得多。
被限流时的响应体我建议统一成:
{"code":429,"msg":"rate limit exceeded","data":{"limit":60,"remaining":0,"reset":2,"retry_after":2}}
顺带一个容易忽略的点:不同接口的 cost 应该按资源消耗加权,而不是一律 1。图片类接口单次返回几百 KB,按请求数计费对带宽不公平。像随机动漫壁纸接口这种返回体积明显大的,把 cost 设成 5 更贴近真实成本,限流曲线也会平滑很多。相关实测数据在API 文章中心里有几篇专门讲过返回体和缓存的关系,可以对照着调。
监控与告警清单#
监控清单:限流与计费必须盯的六项指标
| 指标 | 建议阈值 | 告警动作 |
|---|---|---|
| 令牌桶判定 P99 | > 5ms 持续 3 分钟 | 查 Redis 慢查询、热 key、分片 CPU |
| Redis 命令错误率 | > 0.1% | 触发本地兜底桶,通知值班 |
| 单 key 429 比例 | > 5% | 区分是攻击还是配额配错,前者上黑名单后者改配置 |
| 计费事件堆积量 | Kafka lag > 10 万 | 扩消费者,同时限速上游 |
| 对账差异率 | > 0.5% | 冻结自动结算,转人工核查 |
| 幂等冲突率 | 突增 > 1% | 检查 request_id 生成逻辑,可能重复了 |
前两项是准入层的命门,中间两项是链路健康,后两项是钱。告警通道我个人倾向按严重度分:判定 P99 和对账差异走电话,其余走 IM。全走电话会有告警疲劳,全走 IM 会在半夜漏掉。
风险清单#
风险清单:设计上的已知坑与规避方式
| 风险 | 影响 | 规避方式 |
|---|---|---|
| 单 key 打爆单个分片 | 该 key 限流失效,其余正常 | hash tag 分片 + 本地预取,单 key 上限单独设硬阈值 |
| Redis 主从切换丢令牌数据 | 切换窗口内短暂超发 | 接受超发,靠计费层对账兜底,不做跨机房强一致 |
| 时钟回拨 | 桶时间戳倒退,令牌算成负数 | Lua 里 now < ts 时把 now 拉到 ts,脚本内已处理 |
| 结算任务重跑 | 重复扣费 | 唯一键 + 结算批次号做双重幂等 |
| 客户端重试放大 | 故障时流量翻几倍直接压垮上游 | 服务端返回 Retry-After,客户端指数退避加随机抖动 |
| 跨地域网络中断 | 异地节点限流判定不可用 | 就地降级为本地桶,恢复后按事件补账。Cloudflare Radar 的 Q3 2026 复盘记录了 49 起中断,其中大量来自电力故障,异地容灾不是过度设计 |
总结#
分层之后,每一层都能独立演进。限流阈值改了不用碰计费代码,计费加字段不会影响判定延迟。参数要从压测数据里来,别从同类产品的公开数字里抄,你的接口返回体积和下游耗时跟别人不一样。
正确性靠对账,不靠链路完美。任何声称自己「绝不多扣」的计费系统,如果没有对账任务,只是还没被发现问题而已。
适用范围:
- QPS 在几百到几十万、有明确配额分档的开放 API 网关
- 按次数或按 token 计费的模型调用类接口,允许分钟级延迟结算
- 已经有 Redis 和关系型数据库、能接受最终一致的基础设施
不适用范围:
- 金融级实时扣款,必须强一致且不允许任何超发窗口的场景
- 单机无外部依赖的嵌入式服务,引入 Redis 的运维成本高于收益
- 请求本身就是长事务(超过 30 秒),令牌桶的速率模型对这类负载没有意义
本站实测数据(近 24 小时)#
以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 18:37,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:140 次;成功返回 138 次,鉴权/参数校验拦截 2 次
- 最活跃接口:随机动漫壁纸(94 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:896 次