用户登录

技术文章

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

设计稳定的 API 限流与计费系统:分层、幂等与对账

一句话回答

稳定限流计费的关键是把准入和记账拆成两层:Redis 只跑令牌桶判定,扣费写幂等事件表异步结算,两层用 request_id 串联,任一侧抖动都不拖垮另一侧。

核心要点 TL;DR
  • 准入层用 Redis 令牌桶,判定 P99 控制在 2ms 内
  • 计费走幂等事件表,request_id 唯一索引挡重复
  • 限流允许近似,计费必须精确,用对账兜底

稳定限流计费的关键是把「准入」和「记账」拆成两层:Redis 里只跑令牌桶判定,扣费写幂等事件表异步结算,两层用 request_id 串联,任一侧抖动都不会拖垮另一侧。

说人话:限流是地铁闸机,只负责按固定速率放人;计费是账本,只记录已经发生的事。闸机不能等账本盖完章再开,账本也不能靠闸机实时算钱。

为什么限流和计费必须拆成两层?#

2024 年我接手过一个内部网关,第一版把「查余额 → 扣额度 → 记流水」塞进同一个 MySQL 事务。逻辑很干净,压测很难看:300 并发、单 key 热点,P99 从 32ms 涨到 1.28s。原因不复杂,同一个 api_key 的所有请求都在等同一行余额记录的行锁。

拆开之后每一层的约束完全不同,把它们绑在一起就是拿最慢的一环定义整体性能。

对比表:三层职责与失败策略

层级职责存储延迟预算失败策略
准入层令牌桶(Token Bucket)判定放行或 429Redis 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 里怎样才算原子?#

踩过的三个坑,按出现频率排:

  1. 用 GET 再 SET 两步操作。20 个 worker 打 100 QPS 的目标,10 秒内实际放行 1873 次,超发 87%。
  2. 用应用服务器的 time.time() 补算令牌。多机时钟漂移 30~60ms,桶容量算出来忽大忽小,同一份配置在两台机器上表现不一致。
  3. 桶 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 / released15 分钟未迁移由清扫任务置 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,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

令牌桶和固定窗口限流该选哪个?
选令牌桶。固定窗口在边界会放行接近两倍流量,短时突发放大会直接打穿后端。Cloudflare 2026 Q3 报告里短促低频攻击环比涨 208%,边界效应被放大得更明显。
Redis 挂了限流应该放行还是拒绝?
放行并打点告警。限流是保护手段不是业务规则,一律拒绝等于把 Redis 变成全局单点。放行后靠后端并发上限和计费对账兜底,宁可短暂超发也不要整站不可用。
预扣额度为什么不能直接减余额行?
余额行锁会把同一 api_key 的请求全部串行化,300 并发下 P99 能涨到秒级。改成写事件行,结算层按分钟聚合后批量更新余额,单 key 写入 QPS 可从千级提到万级。

作者:沐小悠

本文约 28 分钟读完 · 关键词:API限流、令牌桶、Redis原子操作、计费系统、幂等键

更多文章 GET 示例接口

「」标签下的其他文章