用户登录

技术文章

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

API 限流与计费系统的可交接稳定性设计

一句话回答

文章提出用策略外置把阈值写进版本库、先定计量口径再写计费实现、用不可变计费事件与三条对账线暴露差异,并把降级策略和演练制度化,让限流计费系统在人员变动后仍可交接、可定位、可追溯。

核心要点 TL;DR
  • 限流阈值与口径写入带 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 查询或随机动漫壁纸接口做联调样本,把计量、限流、对账三段链路先串起来,再考虑复杂的配额分层与阶梯计费。

常见问题(FAQ)

限流阈值和计量口径应该放在哪里,为什么?
放在随代码走评审与回滚的声明式配置里,并标注 owner、复核日期和例外说明。这样阈值调整有版本记录,交接时新同事能看到依据,而不是依赖口头约定或某个人的记忆。
失败或被拒绝的请求要不要记录计费事件?
要记录,但 units 记为 0。文章强调拒绝也要留下事件,这样对账时“日志里有、账本里没有”不会被误判为漏计,同时保留拒绝原因便于排障。
如何避免网关重试造成重复扣费?
计费事件的幂等键由 request_id、route 等请求信息派生,而不是随机生成;写入时依赖数据库唯一约束 ON CONFLICT DO NOTHING 兜底,重复投递不会重复扣费。
限流存储不可用时应该放行还是拒绝?
文章建议按接口价值分级:查询类接口可短暂 fail-open,用本地令牌桶兜底并标记 degraded;写操作和计费敏感接口更适合 fail-closed。无论哪种都要在响应头留下降级痕迹,并事后补记核对。
怎样验证限流与计费链路真的可用?
联调时用真实接口跑完整链路,确认每次调用与计费事件一一对应,并连续快速调用观察 429 与 Retry-After;同时每月做账本重放、每季度做存储故障注入,验证降级路径确实生效。

作者:沐小悠

本文约 14 分钟读完 · 关键词:API 限流、API 计费、令牌桶、配额对账、可观测性

更多文章 GET 示例接口

「」标签下的其他文章