限流与计费的一致性设计:事件、账本与补偿
限流与计费必须一起设计:应把准入、计量、账本拆成三个只通过事件交互的正交行为,以追加写事件流为唯一事实来源,并用预扣—结算—释放与定时对账把误差收敛到可观测范围。
- 准入、计量、账本三个行为正交拆分,可独立测试、降级与扩容
- 事件流是唯一事实来源,event_id 由 request_id 派生保证幂等重放
- 分层限流:边缘粗筛、进程内令牌桶本地判断、Redis 中心校准
- 计费用预扣、结算、释放三段式,唯一键防止重试造成重复扣费
- 故障降级次序明确,对账按小时跑且能下钻到具体 request_id
为什么限流和计费必须一起设计#
很多团队先做限流,上线后再补计费,结果两套逻辑各自维护一份计数:网关按 IP 限流,业务按租户计费,窗口不同、精度不同,出问题时谁都说不清。更麻烦的是边界情况:限流放行的请求在上游超时、客户端重试、消息重复投递,都会让“真实用量”偏离“账本里的值”。
近期 Clojure 社区出现的 Pavlov 这类行为编程(Behavioral Programming)库提供了一种可借鉴的组织方式:不要写一个巨大的条件分支来处理请求,而是把系统拆成若干独立行为,每个行为只声明自己关心哪些事件、会发出哪些事件、禁止哪些事件,通过组合而不是嵌套来表达逻辑。限流、计量、扣费恰好是三个可以正交拆开的行为:它们共享同一串事件,但可以独立测试、独立降级、独立扩容。
本文按这个思路给出一套可落地的设计:事件作为唯一事实来源,准入走分层限流,账本用预扣加结算,最后用对账把误差收敛到可观测的范围内。
一、把限流与计费拆成三个正交行为#
先定义事件词汇,再定义行为。请求生命周期只有几个关键节点:收到请求、决定放行、执行完成、记录用量、结算额度。围绕这些节点,三个行为各自只负责一件事:
| 行为 | 关心的事件 | 发出的事件 | 失败时的策略 |
|---|---|---|---|
| Guard 准入 | RequestReceived | RequestAllowed 或 RequestRejected | 退回本地令牌桶,允许有限超发 |
| Meter 计量 | RequestFinished | UsageRecorded | 写入本地队列,稍后补投 |
| Ledger 账本 | RequestAllowed、UsageRecorded | Reserved、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 抖一下,接口就抖一片。可行的做法是三层:
- 边缘层:网关按 IP 与路由做粗粒度限流,拦截明显的异常流量,成本最低。
- 进程内:每个实例持有一个令牌桶,按实例数从中心领取配额,本地判断,无网络往返。
- 中心层: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 查询、随机动漫壁纸等接口验证自己的限流与计量埋点是否符合预期,再逐步把预扣、结算与对账接入生产。