API 限流与计费系统的可交接稳定性设计
文章提出用策略外置把阈值写进版本库、先定计量口径再写计费实现、用不可变计费事件与三条对账线暴露差异,并把降级策略和演练制度化,让限流计费系统在人员变动后仍可交接、可定位、可追溯。
- 限流阈值与口径写入带 owner 的声明式配置,随代码评审和回滚
- 计费事件用请求派生幂等键,拒绝也记录但 units 为 0
- 账本只追加,余额由预扣与结算推导,写入靠唯一约束兜底
- 建立网关日志到计费事件、小时聚合、出账金额的三条对账线
- 把降级策略写成表,并定期演练账本重放与故障注入
限流和计费是典型的横切关注点:它们不属于任何业务模块,却要在每一次请求上生效。很多团队的第一版都能跑起来,真正的问题出现在人换了一茬之后——新同事不敢改阈值,账单出现差异时找不到凭证,凌晨告警只有一个人知道怎么处理。2021 年那篇讨论工程师离队的文章之所以反复被提起,是因为它说的并不是“多写文档”,而是:系统的判断依据应该留在系统里,而不是留在某个人的记忆里。
这篇文章从“可交接”的角度,讲一套 API 限流与计费系统的落地做法:策略外置、口径先行、账本对账、降级演练。示例接口统一用 API.PLUS 的真实地址,方便边读边验证。
一、交接视角:限流计费为什么总在人员变动时出问题#
限流与计费的隐性知识主要有三类,恰好都是代码注释里最容易被省略的部分:
- 口径:什么算一次调用?失败请求计不计费?一个请求内部调了两次上游算 1 还是 2?
- 阈值:为什么默认是每秒 5 次、突发 20 次?这个数字来自压测、客户合同还是拍脑袋?
- 例外:内部巡检 Key 为什么能跑到 100 QPS(Queries Per Second,每秒请求数)?白名单是谁批的,什么时候复核?
当这三类知识只存在于口头约定中,系统会呈现出一种“脆弱稳定”:平时看起来没事,一旦有人离职、调岗或者只是休假两周,任何一次阈值调整都变成高风险操作。更糟的是,账单争议发生时,团队无法回答“这一笔为什么收费”,只能靠翻访问日志拼凑。
下面的设计围绕一个目标:让任何一个具备常规后端经验的工程师,在无人交接的情况下也能看懂、改得动、出问题能定位。
二、策略外置:把阈值和口径写进版本库#
限流规则不要散落在代码常量、配置中心和环境变量里。建议用一份带 owner 和复核日期的声明式配置,随代码一起走 review 和回滚流程。
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
# policies/api-rate.yaml
# owner: platform-team review: 双人评审 expire: 2027-03-01
version: 3
defaults:
algorithm: token_bucket
rate_per_sec: 5
burst: 20
quota:
window: 1d
units: 10000
routes:
- match: /api/get
rate_per_sec: 10
burst: 40
cost: 1
- match: /api/dongman
rate_per_sec: 3
burst: 10
cost: 1
overrides:
- api_key: key_internal_ops
rate_per_sec: 100
note: 内部巡检专用,季度复核规则加载后,决策逻辑本身应该保持很短,短到一眼能看完:
def decide(ctx, policy, store, now):
rule = policy.match(ctx.api_key_id, ctx.route)
allowed, retry_after = token_bucket(store, ctx.bucket_key, rule, now)
if not allowed:
return Decision(allow=False, reason='rate_limited', retry_after=retry_after)
if quota_exceeded(store, ctx.api_key_id, rule.quota):
return Decision(allow=False, reason='quota_exceeded')
return Decision(allow=True, cost=rule.cost)
def token_bucket(store, key, rule, now):
state = store.hgetall(key) or {'tokens': rule.burst, 'ts': now}
tokens = min(rule.burst, float(state['tokens']) + (now - float(state['ts'])) * rule.rate_per_sec)
if tokens < 1:
retry_after = max(1, int((1 - tokens) / rule.rate_per_sec))
store.hset(key, mapping={'tokens': tokens, 'ts': now})
return False, retry_after
store.hset(key, mapping={'tokens': tokens - 1, 'ts': now})
return True, 0关键在于 Decision 对象必须被完整记录:谁、哪条路由、命中哪条规则、拒绝原因、建议重试时间。这既是排障入口,也是计费事件的输入。交接时最有用的不是“限流实现说明”,而是“昨天这条 Key 为什么被拒”能在一个查询里回答。
三、先定计量口径,再写计费实现#
计费系统的稳定性,八成取决于口径是否被写死成数据结构。建议把每一次被计量的调用建模成一条不可变的计费事件:
event = {
'event_id': make_id('evt', ctx.request_id, ctx.route), # 幂等键由请求派生
'request_id': ctx.request_id,
'api_key_id': ctx.api_key_id,
'route': ctx.route,
'units': decision.cost,
'occurred_at': now_iso(),
'decision': 'allow' if decision.allow else decision.reason,
}三个细节值得强调:
- 幂等键由请求派生,而不是随机生成。网关重试、消息重复投递都不会造成重复扣费。
- 拒绝也要记录,但
units为 0。这样对账时“日志里有、账本里没有”就不会被误判为漏计。 - 账本 append-only,余额通过预扣加结算推导,而不是在事件写入时直接更新余额字段。预扣保证超额不放过,结算保证最终一致。
写入侧依赖数据库唯一约束来兜底,比在应用层判断是否存在更可靠:
INSERT INTO billing_event (event_id, request_id, api_key_id, route, units, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (event_id) DO NOTHING;四、账本与对账:让差异自己暴露#
不要等客户来问账单。至少准备三条对账线:网关访问日志与计费事件、计费事件与小时聚合、聚合结果与出账金额。第一条最能发现“代码路径漏埋点”的问题:
SELECT date_trunc('hour', g.ts) AS h,
count(*) AS gateway_calls,
count(b.event_id) AS billed_events
FROM gateway_log g
LEFT JOIN billing_event b ON b.request_id = g.request_id
WHERE g.ts >= now() - interval '24 hours'
GROUP BY 1
HAVING count(*) <> count(b.event_id)
ORDER BY 1 DESC;第二条线把事件汇总成可出账的用量,聚合本身也必须幂等,否则重放就会翻倍:
INSERT INTO usage_hourly (api_key_id, hour, units)
SELECT api_key_id, date_trunc('hour', occurred_at), sum(units)
FROM billing_event
WHERE occurred_at >= now() - interval '2 hours'
GROUP BY 1, 2
ON CONFLICT (api_key_id, hour)
DO UPDATE SET units = EXCLUDED.units;配套的指标不需要很多,但每一项都要有人负责:限流命中率、429 占比、计费写入延迟 p99(99 分位延迟)、未结算事件数、账本与聚合的差额。告警文案里直接写排查入口,比写“请检查系统”有用得多。
五、降级与演练:算不准的时候也要算得清#
限流和计费依赖存储与队列,这些组件一定会出问题。提前把降级策略写成表,交接时它比任何架构图都直接:
| 故障点 | 默认策略 | 必须打的标记 | 恢复动作 |
|---|---|---|---|
| 限流存储不可用 | 本地令牌桶兜底放行 | decision=degraded | 按访问日志补记超额用量 |
| 计费写入失败 | 落本地 WAL 队列 | billing_state=pending | 重放队列,依赖幂等去重 |
| 聚合延迟超阈值 | 预扣额度继续服务 | ledger_lag>5m 告警 | 追平后解锁并核对差额 |
选择放行还是拒绝,应按接口价值分级:查询类接口可以短暂 fail-open,写操作和计费口径敏感的接口更适合 fail-closed。无论选哪种,都要在响应头里留下痕迹,例如 X-RateLimit-Degraded: 1,让调用方自己也能感知。
演练同样要制度化:每月做一次账本重放,验证重放前后总额一致;每季度做一次存储故障注入,确认降级路径真的生效而不是“写了没测”。这类演练的脚本本身就是最好的交接文档。
六、用真实接口验证限流与计数#
联调阶段建议直接拿线上接口跑一遍完整链路,既验证计数是否与响应一一对应,也验证拒绝时的头部是否可读。以 IP 查询和随机动漫壁纸两个接口为例:
# 单次调用,确认计费事件与响应一一对应
curl -s 'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
# 连续快速调用,观察 429 与 Retry-After
for i in $(seq 1 30); do curl -s -o /dev/null -w '%{http_code} ' 'https://www.api.plus/api/dongman?type=json'; done写路径的计量容易漏埋点,可以用 POST 示例接口 单独验证一次请求体解析失败时是否被正确计为 0 单位。字段定义和返回结构参考 IP 查询接口 与 随机动漫壁纸接口 的文档;如果想把限流策略和缓存键一起设计,可以顺带翻一下 API 文章中心 里关于缓存键与响应契约的几篇。遇到头部字段或状态码不一致的情况,通过 技术支持 反馈通常比自行猜测更快。
七、总结#
稳定的限流与计费系统,本质上是把三类知识固化下来:阈值写进版本库、口径写进事件结构、异常处理写进降级表和演练脚本。做到这三点,系统就不再依赖某个具体的人,人员变动只是换了个维护者,而不是一次事故。
如果你还在搭第一版,建议先用真实流量跑通最小闭环:一次调用、一条事件、一次对账。可以在 API.PLUS 注册账号获取免费 API Key,用 IP 查询或随机动漫壁纸接口做联调样本,把计量、限流、对账三段链路先串起来,再考虑复杂的配额分层与阶梯计费。