API 限流与计费稳定性设计:状态、时钟与对账
限流可以近似、计费必须精确:用令牌桶加隔离舱做限流,用不可变事件加幂等键做计费,两边靠分钟级对账把数字对齐,并补齐限流器故障时的降级策略。
- 限流可近似、计费须精确,二者读写的其实是同一份调用状态
- 全局配额放共享存储,用 Lua 脚本一次完成读取、补充、扣减、回写
- 计费事件先行:不可变事件加可复现指纹幂等键,账本异步聚合
- 时钟回拨、读改写竞态、超时重发是数字对不上的三个主要来源
- 限流器故障时读接口 fail-open、计费 fail-close,并预设超时预算
为什么把限流和计费放在一起谈#
限流做的是「少放你进来」,计费做的是「多记你一笔」,方向相反,但它们读写的其实是同一份状态:某个 Key 在某个时间窗里调用了多少次。这份状态一旦放错位置,两边会同时出问题——限流算不准,后端被突发流量打穿;账本记不准,客户月底对账时发现数字对不上,你要花几天翻日志自证。
最近社区里关于独立开发者和 AI 冲击的讨论很多,一个共同的观察是:小团队没有人力做人工运维和人工对账,系统必须自己证明自己是对的。本文从一个 API 网关的实际形态出发,讲清楚限流与计费在稳定性上的几个关键决策。文中的压测对象可以用 IP 查询接口 或 随机动漫壁纸接口,它们响应轻、无副作用,适合反复打。
一、先划边界:限流可以近似,计费必须精确#
这两条原则决定了很多实现细节的取舍:
- 限流允许近似。多放行百分之一不会出事,少放行百分之一也不会出事,所以可以用本地缓存加定期同步,也可以在 Redis 抖动的瞬间用进程内计数兜底。
- 计费不允许近似。少记一笔是收入损失,多记一笔是客诉,重复记一笔是数据事故。所以计费路径上宁可慢一点、重试多一点,也不能「估算一下」。
- 两边必须能对齐。限流的放行次数和计费的成功次数,应该能在分钟级别对上;对不上就说明中间有丢失或重复。
落地时建议分三层:边缘层(CDN 或 WAF(Web Application Firewall,Web 应用防火墙) 做粗粒度 IP 级拦截)、网关层(按 API Key 做配额)、业务层(对昂贵资源做二次校验,比如图片生成、批量导出)。三层用不同的数据结构和不同的超时预算,任何一层失效都不应该让请求无限排队。
二、限流的三层结构:本地桶、分布式配额、隔离舱#
进程内的令牌桶只能防住单机突发,真正的全局配额必须放在共享存储里。下面这个 Lua 脚本可以直接跑在 Redis 上,用 Hash 存令牌数与上次补充时间,一次 EVAL 完成读取、补充、扣减、回写,避免「读-改-写」的竞态:
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
-- KEYS[1] 桶 key;ARGV:容量、每秒补充速率、当前毫秒、本次消耗
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local d = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts = tonumber(d[2])
if tokens == nil then tokens = cap; ts = now end
local delta = math.max(0, now - ts) / 1000
tokens = math.min(cap, 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(cap / rate * 1000) + 1000)
return { allowed, math.floor(tokens * 100) / 100 }几个容易踩的点:
- 时间参数
now不要用应用服务器的时钟,改用 Redis 的TIME命令或网关统一授时,否则多台机器时钟漂移会让同一个桶被反复「补满」。 - TTL 只用于回收冷 Key,不要让 TTL 参与限流计算。TTL 过期后桶会重置为满,这是可接受的近似,但要保证过期时间远大于业务窗口。
- 按租户做哈希分片,避免一个大客户把共享桶打满,必要时给高价值客户独立的桶,也就是隔离舱模式。
三、计费:事件先行,账本后置#
计费最忌讳的写法是在请求处理函数里直接执行 UPDATE balance = balance - cost。一旦请求重试、网关超时重发,或者进程在扣减后崩溃,账目就再也对不上了。更稳的做法是拆成两阶段:
- 计量事件:每次调用产生一条不可变事件,带唯一
event_id,只追加不修改。写入失败就返回 5xx 让调用方重试,重试靠event_id去重。 - 账本聚合:异步消费者按 Key 把事件聚合成余额或账单,聚合本身幂等,可以从任意位置重放。
事件的幂等键不要用随机 UUID,而要用「可复现的指纹」,这样超时重试才能命中同一条记录:
import hashlib, time
def meter_event(apikey, endpoint, cost=1, ts=None):
ts = ts or int(time.time() * 1000)
# 秒级窗口 + Key + 端点,同一秒内的重试会落到同一个 event_id
fp = f'{apikey}:{endpoint}:{ts // 1000}'
return {
'event_id': hashlib.sha256(fp.encode()).hexdigest()[:32],
'apikey': apikey,
'endpoint': endpoint,
'cost': cost,
'occurred_at': ts,
}对应的写入表只需要主键去重,不需要分布式事务:
CREATE TABLE usage_event (
event_id CHAR(32) NOT NULL,
apikey VARCHAR(64) NOT NULL,
endpoint VARCHAR(128) NOT NULL,
cost INT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (event_id),
KEY idx_key_time (apikey, occurred_at)
);指纹的粒度是个权衡:窗口太长会吞掉正常的重复调用,太短则重试命中不了。实践中秒级窗口配合「同一秒内同一端点视为一次」已经够用;如果接口本身允许高并发,就把请求 ID 一起放进指纹。
四、时钟与并发:数字对不上的三个真凶#
- 墙上时钟回拨。NTP 校准把时间往回调,滑动窗口里会出现「未来窗口」,桶里的令牌突然变多。解决办法是限流用 Redis 或单调时钟,打标签用墙上时钟,两者分工明确。
- 读-改-写竞态。用 GET 再 SET 实现计数,在并发下必然丢更新。解决办法是 Lua 脚本或
INCR这类原子命令。 - 超时导致的重复计量。网关等不到上游响应就重试,事件写了两遍,账本扣了两笔。解决办法是幂等键加强唯一索引,写入侧用
INSERT ... ON DUPLICATE KEY兜住。
还有一类隐性问题是跨步骤失败:限流扣减成功但计费写入失败,或反之。不要试图用两阶段提交解决它,改成「先记事件、后扣余额」,中间状态用补偿任务收敛,定时跑批比对差异并告警,比强一致的耦合更稳。
五、降级:限流器本身挂了怎么办#
这是最容易被忽略的场景。Redis 集群抖动、连接池耗尽、脚本执行超时,都会让限流检查变成新的故障点。几个可用的策略:
- 读接口 fail-open,写与扣费 fail-close。像 IP 查询这类只读接口,限流器不可用时放行不会造成资损;而计费路径宁可返回 503 让调用方重试,也不要默默不记账。
- 本地兜底计数。每个实例维护一个进程内令牌桶,作为 Redis 不可用时的二级保护,容量设成全局配额的 1/N(N 为实例数),宁可整体少放行。
- 超时预算。限流检查设 5ms 上限,计费事件写入设 20ms 上限,超过就走降级分支,别让一个慢依赖拖垮整条请求链路。
可以用一条命令验证降级策略是否生效,同时观察状态码与耗时:
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'把 YOUR_KEY 换成自己的 Key,连续打几百次,观察拒绝比例是否和配置的配额一致。图片类接口也可以用来验证缓存与限流的配合,见 随机动漫壁纸接口;如果是 POST 场景,参考 POST 示例接口,注意 POST 的幂等键要放在 body 或请求头里,而不是依赖 URL。
六、对账与观测:让系统自己证明自己是对的#
对账不是财务的事,是工程的事。最小可用的对账只要三个数字,按分钟聚合:
| 指标 | 来源 | 异常含义 |
|---|---|---|
| 放行次数 | 网关访问日志 | 与计量事件数差异大于 0.5% 说明有请求没记账 |
| 计量事件数 | usage_event 表 | 与账本聚合数不一致说明消费者丢消息 |
| 拒绝次数 | 限流器返回值 | 突增说明配额配置错误或正在被刷量 |
再补两个工程指标:限流检查的 P99 延迟(超过 10ms 说明 Redis 侧有问题)、单 Key 命中限额的 Top 20(既能发现异常调用方,也能作为和客户沟通扩容的依据)。对账任务本身要幂等、能重跑,跑完把差异写进差异表而不是直接改账,人工确认后再补偿。
更多接口契约与工程实践可以翻 API 文章中心,如果对账口径和文档描述不一致,也可以走 技术支持 一起排查。
七、总结#
一个稳定的限流与计费系统,本质上是在处理「近似」与「精确」的边界:限流侧用令牌桶加隔离舱换性能和可用性,允许近似;计费侧用不可变事件加幂等键换正确性,拒绝近似;两边之间用分钟级对账把数字对齐。落地顺序建议是:先把事件表和幂等键做对,再上分布式限流,最后补降级与对账。顺序反了,你会花大量时间在解释「为什么账单多了一笔」。
想直接验证上面这些思路,可以在 API.PLUS 注册并获取免费 API Key,用 IP 查询或动漫壁纸接口作为压测对象,配合 IP 查询接口文档 先跑通一条从限流到计费的完整链路。