用户登录

技术文章

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

限流与计费的一致性设计:事件、账本与补偿

一句话回答

限流与计费必须一起设计:应把准入、计量、账本拆成三个只通过事件交互的正交行为,以追加写事件流为唯一事实来源,并用预扣—结算—释放与定时对账把误差收敛到可观测范围。

核心要点 TL;DR
  • 准入、计量、账本三个行为正交拆分,可独立测试、降级与扩容
  • 事件流是唯一事实来源,event_id 由 request_id 派生保证幂等重放
  • 分层限流:边缘粗筛、进程内令牌桶本地判断、Redis 中心校准
  • 计费用预扣、结算、释放三段式,唯一键防止重试造成重复扣费
  • 故障降级次序明确,对账按小时跑且能下钻到具体 request_id

为什么限流和计费必须一起设计#

很多团队先做限流,上线后再补计费,结果两套逻辑各自维护一份计数:网关按 IP 限流,业务按租户计费,窗口不同、精度不同,出问题时谁都说不清。更麻烦的是边界情况:限流放行的请求在上游超时、客户端重试、消息重复投递,都会让“真实用量”偏离“账本里的值”。

近期 Clojure 社区出现的 Pavlov 这类行为编程(Behavioral Programming)库提供了一种可借鉴的组织方式:不要写一个巨大的条件分支来处理请求,而是把系统拆成若干独立行为,每个行为只声明自己关心哪些事件、会发出哪些事件、禁止哪些事件,通过组合而不是嵌套来表达逻辑。限流、计量、扣费恰好是三个可以正交拆开的行为:它们共享同一串事件,但可以独立测试、独立降级、独立扩容。

本文按这个思路给出一套可落地的设计:事件作为唯一事实来源,准入走分层限流,账本用预扣加结算,最后用对账把误差收敛到可观测的范围内。

一、把限流与计费拆成三个正交行为#

先定义事件词汇,再定义行为。请求生命周期只有几个关键节点:收到请求、决定放行、执行完成、记录用量、结算额度。围绕这些节点,三个行为各自只负责一件事:

行为关心的事件发出的事件失败时的策略
Guard 准入RequestReceivedRequestAllowed 或 RequestRejected退回本地令牌桶,允许有限超发
Meter 计量RequestFinishedUsageRecorded写入本地队列,稍后补投
Ledger 账本RequestAllowed、UsageRecordedReserved、Committed、Released预扣无法落库则拒绝请求

拆开的好处很直接。可测试:Guard 只依赖计数存储,用内存实现就能跑单测;Ledger 只依赖数据库,用事务回滚就能验证幂等。可降级:计量队列积压时,账本仍可基于预扣额度工作,只是结算延后。可演进:将来要加“按响应字节计费”,只改 Meter 与 Ledger 的单位换算,完全不碰准入逻辑。

反过来,如果把三件事写在一个请求处理函数里,任何一处异常都会让另外两件事处于不确定状态:准入通过了但没记账,或者记了账但请求其实失败了。

二、事件流是唯一事实来源#

计费系统最怕“两套数据”。解决办法是只保留一份追加写的事件流,账本、报表、告警全部由事件投影(projection)出来。事件必须携带足够的信息,让下游可以幂等重放:

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

event = {
    'event_id': 'evt_7f3c9a',    # 由 request_id 与事件类型派生,全局唯一
    'tenant_id': 'tenant_1024',  # 分区键,保证同租户事件有序
    'route': '/api/get',
    'units': 1,                  # 计量单位:次 / 张 / KB
    'unit': 'call',
    'status': 'ok',
    'ts_ms': 1793000000123,      # 事件发生时间,不是入库时间
    'attempt': 0,
}

三个字段决定成败。event_id 建议由客户端可见的 request_id 加事件类型派生,而不是随机生成——同一个请求被重试或被消息队列重复投递时,写入端靠唯一索引就能直接忽略重复。tenant_id 作为分区键,让同一租户的事件保持有序,便于按租户重算账本。ts_ms 用事件发生时间,否则对账时窗口会整体错位。

存储上事件表和账本表要分开:事件表只追加,保留 30 到 90 天;账本表是聚合结果,随时可以从事件重算。重算能力是这套设计的底气——计费口径调整时,不需要写脚本修补历史数据,直接重放事件生成新的账本视图即可。

三、准入:分层限流,本地优先#

限流的第一原则是快。放在请求主链路上的限流器如果每次都跨机房访问 Redis,Redis 抖一下,接口就抖一片。可行的做法是三层:

  1. 边缘层:网关按 IP 与路由做粗粒度限流,拦截明显的异常流量,成本最低。
  2. 进程内:每个实例持有一个令牌桶,按实例数从中心领取配额,本地判断,无网络往返。
  3. 中心层:Redis 保存租户级总量,用于跨实例的最终一致性校准。

中心层的令牌桶用一段 Lua 脚本原子完成,避免读改写竞争:

-- KEYS[1]: bucket key
-- ARGV[1]: capacity, ARGV[2]: rate/sec, ARGV[3]: now_ms, ARGV[4]: cost
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local tokens = tonumber(b[1]) or capacity
local ts = tonumber(b[2]) or now

local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(capacity, tokens + delta * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000)
return {allowed, math.floor(tokens)}

进程内预分配的代价是短暂超发:N 个实例各预领一份,实际用量可能略高于配额。这个误差可以通过缩短校准周期(例如 1 秒)并按比例回收剩余额度来控制。对计费类系统来说,“最多超发 1 秒的量”通常比“Redis 故障时全部拒绝”更可接受——前提是超发部分仍然被计量和结算。

拒绝时返回的信息同样重要。除 429 外要带上剩余额度与重试时间,让客户端能做指数退避,而不是无脑重试:

HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1793000060

HTTP/1.1 429 Too Many Requests
Retry-After: 3

窗口算法怎么选:固定窗口实现简单但存在临界突刺,滑动窗口精度高但内存开销大,令牌桶能同时约束速率与突发。对外 API 通常用“令牌桶限速 + 日/月配额”的组合就够了,配额用普通计数器即可。

四、计费:预扣、结算与幂等补偿#

按次计费看似简单,但先执行再扣费,高并发下会余额透支;先扣费再执行,一旦上游失败就必须可靠退回。推荐三段式:预扣(reserve)、结算(commit)、释放(release)。

def handle(request):
    key = f'{request.tenant_id}:{request.request_id}'
    if not ledger.reserve(key, units=estimate(request)):
        return 429, {'error': 'quota_exhausted'}
    try:
        result = call_upstream(request)
    except UpstreamError:
        ledger.release(key)            # 幂等释放,允许客户端重试
        return 502, {'error': 'upstream_failed'}
    ledger.commit(key, units=actual_units(result))
    meter.emit(build_event(request, result))   # 异步,失败进本地队列
    return 200, result

三个动作都以 (tenant_id, request_id, entry_type) 作为唯一键,重复调用直接返回成功,这样网络超时后的重试不会造成重复扣费。释放动作要设置有效期,例如 10 分钟,覆盖客户端最长重试窗口;超过时间仍未结算的预扣由后台任务清理,并计入“悬空预扣”指标。

计量与结算分离还有一个实际好处:POST 示例接口这类请求的用量取决于请求体大小,只有执行完成后才知道真实成本,预扣时按上限估算,结算时按实际值多退少补。账本表可以简化成这样:

CREATE TABLE quota_ledger (
  id          BIGSERIAL PRIMARY KEY,
  tenant_id   TEXT        NOT NULL,
  request_id  TEXT        NOT NULL,
  entry_type  TEXT        NOT NULL,   -- reserve / commit / release
  units       INTEGER     NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, request_id, entry_type)
);

对账任务按小时跑一次:把 usage_event 按租户汇总,与 quota_ledger 中 commit 的汇总比较,差额不为零就告警。差额本身不可怕,可怕的是不知道差额从哪来,所以每条告警都应该能下钻到具体的 request_id。

五、故障与过载时的降级顺序#

稳定性设计的核心在于“故障时系统仍按可预期的方向退化”,而不是“保证不出故障”。给三个行为排一个明确的降级顺序,比临时拍脑袋强:

  • Redis 不可用:退回进程内令牌桶,允许有限超发,同时标记为降级状态并打点。
  • 账本库不可写:拒绝新的预扣请求,返回 503 并带 Retry-After。对计费系统而言,未知的免费流量比短暂不可用危险得多。
  • 计量队列积压:写入本地磁盘队列并限制长度,超过阈值时采样丢弃、记录丢弃数量,保证进程不被撑爆。
  • 对账延迟:结算是异步的,短时间延迟不影响可用性,但“结算延迟 P99(99 分位延迟)”必须作为一级指标。

需要监控的指标不多,但每个都要能定位问题:限流命中率(区分正常限流与异常突增)、重复事件数、悬空预扣数量、账本差额、结算延迟分位数。这些指标稳定之后,限流与计费就不再是黑盒。

六、在真实接口上落地计量单位#

设计再漂亮,落到具体接口上仍要回答一个问题:一个单位到底是多少。API.PLUS 上不同类型的接口,计量方式并不相同:

  • IP 查询接口:按次计费,一次请求一个单位,响应体很小,适合固定权重。
  • 随机动漫壁纸接口:按张或按流量计费,单次响应可能达到数百 KB,成本与次数不成正比。
  • POST 示例接口:按请求体大小计费,需要读取 body 后再估算成本。

接入时先确认接口行为,再做限流与计费埋点:

curl -s 'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
curl -s 'https://www.api.plus/api/dongman?type=json'

把不同接口的权重写进配置而不是硬编码,后续调整计费口径时只改配置、重放事件即可。字段含义与返回结构可以先看 IP 查询接口文档 和 随机动漫壁纸接口文档,更多调用细节在 API 文章中心,遇到额度异常或对接问题可以直接走 技术支持。

七、总结#

一套稳定的限流与计费系统,本质上解决三件事:准入决策要快且可降级,用量记录要唯一且可重放,额度结算要幂等且可对账。借用行为编程的思路,把准入、计量、账本拆成三个只通过事件交互的行为,每个行为都能单独测试、单独降级,故障时系统的退化方向就是可预期的。

落地顺序建议:先定事件结构与幂等键,再实现令牌桶与配额,最后补预扣结算和对账任务。不要反过来——先写扣费逻辑再补限流,通常会在第一次流量高峰时付出代价。

需要真实接口练手的话,可以在 API.PLUS 注册账号获取免费 API Key,用 IP 查询、随机动漫壁纸等接口验证自己的限流与计量埋点是否符合预期,再逐步把预扣、结算与对账接入生产。

常见问题(FAQ)

为什么限流和计费不能先做限流、后补计费?
两套逻辑各自维护计数,会出现网关按 IP、业务按租户,窗口与精度都不一致;遇到上游超时、客户端重试、消息重复投递时,真实用量偏离账本且无法归因。所以应共享同一串事件一起设计。
event_id 为什么建议派生而不是随机生成?
由客户端可见的 request_id 加事件类型派生,同一请求被重试或被队列重复投递时,写入端靠唯一索引即可直接忽略重复,从而保证下游可以幂等重放。
进程内令牌桶预分配配额会造成超发吗?
会短暂超发,N 个实例各预领一份,实际用量可能略高于配额。可通过缩短校准周期(如 1 秒)并按比例回收剩余额度来控制,且超发部分仍被计量结算,通常比 Redis 故障时全部拒绝更可接受。
预扣成功但上游调用失败,额度怎么处理?
调用幂等的 release 释放额度,重复调用直接返回成功。释放要设有效期(如 10 分钟)覆盖最长重试窗口,超时仍未结算的预扣由后台任务清理,并计入悬空预扣指标。
Redis 或账本库不可用时应如何降级?
Redis 不可用则退回进程内令牌桶,允许有限超发并标记降级、打点;账本库不可写则拒绝新的预扣请求并返回 503 带 Retry-After,因为未知的免费流量比短暂不可用更危险。

作者:沐小悠

本文约 15 分钟读完 · 关键词:API 限流、API 计费、令牌桶、事件溯源、行为编程

更多文章 GET 示例接口

「」标签下的其他文章