用户登录

技术文章

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

多租户 API 限流与计费:配额、幂等与对账

一句话回答

多租户 API 限流与计费的关键是分层限流、事件化计量、预扣幂等扣费与日终对账,再用故障降级和客户端退避保证配额不超卖、账本可重放、漂移可告警。

核心要点 TL;DR
  • 限流分接入层、业务配额层与账本层,网关粗筛、配额细算、账本兜底
  • 计量用不可变用量事件,按 request_id 幂等并可重放聚合
  • 扣费采用预扣、执行、补偿三段式,request_id 唯一索引防重复
  • 日终对账比对请求计数、事件行数与扣减额,差异率超0.1%告警
  • 故障降级需明确 fail-open 或 fail-close,并提前演练 Redis 与账本故障

引言:计量能力才是 API 产品的底座#

2026 年 10 月,开发者社区里关于“某国开发者无法注册应用商店商户账号”的讨论再次提醒我们:结算通道与准入资格,往往不掌握在开发者自己手里。对 API 平台而言,这个教训同样成立——如果用量统计依赖上游账单,如果扣费依赖单一支付渠道,那么一次准入变更、一次通道抖动,就足以让毛利和用户信任同时崩掉。

一套稳定的 API 限流与计费系统,本质上要解决三件事:请求进来时能不能拦住、用完之后账算不算得清、出故障时账本会不会漂。本文以 API.PLUS 的接口形态(如 IP 查询接口、随机动漫壁纸接口、POST 示例接口)为参照,给出一套可落地的设计。

一、限流分层:网关粗筛、业务细算、账本兜底#

不少团队把限流写成“一个中间件 + 一个 Redis key”,上线三个月后就会出现两类事故:一是 Redis 抖动导致全站 5xx,二是配额被并发请求击穿,超卖几十倍。稳定的做法是分三层。

  • 接入层(网关):按 IP、API Key 做固定窗口或滑动窗口的粗粒度限流,目标是挡住明显的刷量与攻击,阈值可以设得比真实配额宽 20%~50%。
  • 业务层(配额服务):按套餐、按接口维度做精确配额,用令牌桶控制速率、用计数器控制周期总量。
  • 账本层(计费):真正扣减余额的地方,必须做到幂等与可重放;前两层只是“减少无效请求打到账本”的过滤器。

拒绝请求时不要只返回 429,要带上可执行的元信息:Retry-After、剩余配额、重置时间。客户端拿到这些信息才能做正确的退避,而不是无脑重试把系统打得更惨。

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

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793260800

{"code":429,"message":"rate limit exceeded","retry_after":12}

本地令牌桶 + 全局计数器的组合

纯 Redis 方案每次请求都要一次网络往返,在 QPS(Queries Per Second,每秒请求数) 上万时既慢又脆弱。常见折中是:每个实例维护一个本地令牌桶,按“容量 / 实例数”分配额度,周期性从 Redis 领取补充令牌;Redis 只做总量校准,不做逐请求裁决。这样单实例的判定是纯内存操作,P99(99 分位延迟) 延迟可控在微秒级,代价是短时间内的额度分布不均——对限流来说这是可接受的误差。

// 每个实例的本地桶:每 100ms 从中心领取一次补充
func (b *Bucket) Allow(n int64) bool {
    b.mu.Lock()
    defer b.mu.Unlock()
    b.refill(time.Now())
    if b.tokens >= n {
        b.tokens -= n
        return true
    }
    return false
}

func (b *Bucket) refill(now time.Time) {
    elapsed := now.Sub(b.last).Seconds()
    b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.rate)
    b.last = now
}

二、计量:把每次调用变成一条可重放的事件#

计费系统的第一性原理是:账单必须能从原始事件重新算出来。如果只在数据库里存一个“已用次数”字段,一旦某次扣费失败或重复扣费,你没有任何证据去修复。正确做法是写入不可变的用量事件(usage event),再由事件聚合出余额。

字段类型说明
request_idstring全局唯一,作为幂等键,由网关生成并向客户端透传
api_key_idbigint租户维度,避免用明文 Key 建索引
endpointstring接口标识,用于差异化计价
unitsint计量单位,1 次调用 = 1,图片类可按字节折算
occurred_attimestamp统一 UTC,按分钟对齐,便于时间片聚合
statusenumsuccess / failed / timeout,失败是否计费要写进 SLA

三个原则值得写进团队规范:

  1. 可重放:事件只追加不修改,聚合结果可以丢弃后重建。
  2. 可对齐:时间戳统一 UTC 并按固定时间片对齐,避免跨时区对账出现“差一分钟”的争议。
  3. 可解释:每条账单都能下钻到具体事件列表,用户质疑时拿得出证据。

还要注意迟到事件。网关到账本之间通常有消息队列,网络抖动会让事件延迟数秒甚至数分钟到达。聚合时应使用事件自身的 occurred_at,而不是消费时间,并对“已结算时间片”保留 24 小时的补账窗口。

三、扣费一致性:预扣、幂等与补偿#

不要在执行请求之后再扣费——那时请求已经消耗了上游成本,扣费失败你就亏了。推荐三段式:

  1. 预扣(reserve):请求进入时先冻结额度,写入带 request_id 唯一索引的预扣记录。
  2. 执行:调用上游或本地处理,成功则 commit,失败则 release。
  3. 补偿:定时任务扫描超时未结算的预扣记录,按策略释放或转入人工处理。
-- 预扣表:request_id 唯一索引保证幂等
CREATE TABLE quota_reservation (
  request_id   VARCHAR(64) PRIMARY KEY,
  api_key_id   BIGINT      NOT NULL,
  units        INT         NOT NULL,
  state        VARCHAR(16) NOT NULL DEFAULT 'reserved',
  created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_state_time (state, created_at)
);

-- 幂等插入:重复请求直接返回 0 行,不产生二次冻结
INSERT INTO quota_reservation (request_id, api_key_id, units)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at = updated_at;

预扣成功后去改余额,两个操作要么放进同一个事务,要么用 outbox 模式保证最终一致。跨库跨服务时后者更现实:把“扣减”写成 outbox 表的一行,由投递器异步写入账本,投递失败就重试,账本侧用 request_id 去重。

日终对账:用漂移率做告警

对账不只是财务的事,更是工程的事。每天跑一次任务,比对三个数字:网关的请求计数、事件表的行数、账本的扣减总额。三者应当一致,允许的差异只来自失败请求的计费策略。把差异率做成指标,超过 0.1% 就告警,通常能提前几天发现“某类接口漏记事件”这种隐蔽问题。

四、故障与降级:先想清楚 fail-open 还是 fail-close#

限流组件本身也是故障源。设计时要明确每个依赖挂掉之后的行为:

  • Redis 不可用:本地令牌桶继续工作,按上次同步的速率放行,同时对外暴露降级标记,让运维可见。
  • 账本写入失败:请求照常返回,事件落本地磁盘队列或 outbox,恢复后重放,绝不丢事件。
  • 配额服务超时:免费用户 fail-close(拒绝),付费用户 fail-open(放行并记账),这是商业上更合理的选择。

这些策略必须提前演练。做法很简单:在预发环境切断 Redis 端口 5 分钟,观察错误率、429 比例和账本漂移。没演练过的降级策略,等于没有。

五、客户端侧:正确消费 429 与配额头#

服务端设计得再好,客户端无脑重试也会把系统打穿。下面是一个带指数退避与抖动的示例,调用 IP 查询接口:

import random, time, requests

def query_ip(ip, api_key, max_retry=5):
    url = "https://www.api.plus/api/get"
    params = {"apikey": api_key, "ip": ip}
    for i in range(max_retry):
        resp = requests.get(url, params=params, timeout=5)
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** i))
            time.sleep(wait + random.uniform(0, 0.5))
            continue
        resp.raise_for_status()
    raise RuntimeError("rate limited after retries")

图片类接口还要考虑缓存与去重,例如 随机动漫壁纸接口 返回的是随机资源,客户端应在本地做短期缓存,避免同一用户反复拉取消耗配额;也可以直接访问 https://www.api.plus/api/dongman?type=json 观察响应结构。写操作则要注意 POST 示例接口 中的幂等键约定,重试时复用同一个 request_id。

更多接口约定与工程实践可以在 API 文章中心 找到;遇到配额或计费争议,直接走 技术支持 提工单,附上 request_id 与 occurred_at,定位会快很多。

六、总结#

回到开头那个话题:外部通道的准入与结算,永远存在你无法控制的风险。能控制的是自己的计量与账本——分层限流挡住无效流量、事件化计量保证可重放、三段式扣费保证不超卖不丢账、日终对账与故障演练保证漂移可见。这四件事做扎实,即使上游结算通道调整、支付渠道变更,你的账单依然可信。

想动手验证本文的限流与配额行为,可以直接在 API.PLUS 注册账号,免费获取一个 API Key,用上面的 Python 示例调用 IP 查询、随机动漫壁纸等接口,观察响应头里的配额字段与 429 行为,再对照自己的业务设计阈值。

常见问题(FAQ)

多租户 API 限流为什么不能只用一个 Redis 中间件?
纯 Redis 逐请求裁决会增加网络往返,Redis 抖动还会放大为全站故障;文章建议本地令牌桶加全局计数器,Redis 只做总量校准,接入层粗筛、业务层精算、账本层兜底。
计费系统为什么要用不可变用量事件,而不是只存已用次数?
账单必须能从原始事件重新算出来。只存已用次数无法修复重复扣费或扣费失败;用量事件只追加不修改,可按 request_id 幂等、按 occurred_at 聚合重放,并下钻解释账单。
扣费应该请求前预扣还是请求后扣,如何保证幂等?
应在请求进入时预扣额度,成功后 commit、失败 release,并用定时任务补偿超时预扣;预扣表用 request_id 唯一索引保证幂等,跨服务可用 outbox 保证最终一致。
日终对账要对比哪些数字,差异率超过多少应告警?
比对网关请求计数、事件表行数、账本扣减总额,允许差异只来自失败请求的计费策略;把差异率做成指标,超过 0.1% 就告警,通常能提前发现漏记事件。
Redis 或账本故障时,API 限流计费应如何降级?
Redis 不可用时本地令牌桶按上次同步速率放行并暴露降级标记;账本写入失败则事件落本地队列或 outbox,恢复后重放;配额服务超时免费用户 fail-close、付费用户 fail-open 并记账。

作者:沐小悠

本文约 12 分钟读完 · 关键词:API限流、API计费、配额管理、幂等扣费、用量对账

更多文章 GET 示例接口

「」标签下的其他文章