多租户 API 限流与计费:配额、幂等与对账
多租户 API 限流与计费的关键是分层限流、事件化计量、预扣幂等扣费与日终对账,再用故障降级和客户端退避保证配额不超卖、账本可重放、漂移可告警。
- 限流分接入层、业务配额层与账本层,网关粗筛、配额细算、账本兜底
- 计量用不可变用量事件,按 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_id | string | 全局唯一,作为幂等键,由网关生成并向客户端透传 |
| api_key_id | bigint | 租户维度,避免用明文 Key 建索引 |
| endpoint | string | 接口标识,用于差异化计价 |
| units | int | 计量单位,1 次调用 = 1,图片类可按字节折算 |
| occurred_at | timestamp | 统一 UTC,按分钟对齐,便于时间片聚合 |
| status | enum | success / failed / timeout,失败是否计费要写进 SLA |
三个原则值得写进团队规范:
- 可重放:事件只追加不修改,聚合结果可以丢弃后重建。
- 可对齐:时间戳统一 UTC 并按固定时间片对齐,避免跨时区对账出现“差一分钟”的争议。
- 可解释:每条账单都能下钻到具体事件列表,用户质疑时拿得出证据。
还要注意迟到事件。网关到账本之间通常有消息队列,网络抖动会让事件延迟数秒甚至数分钟到达。聚合时应使用事件自身的 occurred_at,而不是消费时间,并对“已结算时间片”保留 24 小时的补账窗口。
三、扣费一致性:预扣、幂等与补偿#
不要在执行请求之后再扣费——那时请求已经消耗了上游成本,扣费失败你就亏了。推荐三段式:
- 预扣(reserve):请求进入时先冻结额度,写入带
request_id唯一索引的预扣记录。 - 执行:调用上游或本地处理,成功则 commit,失败则 release。
- 补偿:定时任务扫描超时未结算的预扣记录,按策略释放或转入人工处理。
-- 预扣表: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 行为,再对照自己的业务设计阈值。