限流与计费别各写一套:一次 API 调用的身份、预占与账本设计
限流和计费若各自计数,出故障时无法区分“被限流”和“记错账”。更稳的做法是共用一条决策链:统一请求身份,限流计数与计费账本分开存储,按“预占—执行—结算—超时回收”推进,账本用请求 ID 做主键实现幂等,并保留悬挂单与差额等对账指标。单实例小流量不必引入这套结构。
- 限流计数允许少量误差,计费计数不可丢、不可重;两者共用身份,但不共用存储。
- 预占、结算、超时回收三段式,把“余额不足”和“业务失败”分开处理。
- 账本主键用请求 ID,重复写入自然被拒,幂等成本最低。
核心判断:限流和计费如果各写一套计数,出问题时你无法区分“这次请求被限了”还是“账记错了”。更稳的做法是让两者共用一条决策链——先确定这次调用的身份,再决定是否放行,最后把结果写成一条可重放的账本记录。
先分清三类数据#
- 身份:租户、密钥、端点、来源 IP。维度越多,存储与聚合成本越高。建议主维度定为“租户 + 密钥 ID”,端点和 IP 只作为辅助阈值。
- 限流计数:允许少量误差,随窗口重置,进程重启后丢失通常可接受。
- 计费计数:不可丢、不可重,需要幂等键和持久账本。
把限流计数当账本用,或把账本当限流计数用,是两类常见的设计错误:前者会导致账单抖动,后者会让每次计费查询都压在限流的关键路径上。
一次请求的决策顺序#
- 认证,得到租户 ID 与密钥 ID。不要用明文密钥本身做计数键。
- 幂等检查:同一请求 ID 已有账本记录,直接返回上次结果。
- 配额预占:按本次调用可能的最大消耗预占额度。
- 执行业务逻辑。
- 结算:把预占改为实际用量,多退少补。
- 返回响应,落用量记录。
预占与结算分开,是为了不把“余额不足”和“业务失败”混为一谈。业务失败要不要计费是产品规则,需要在接入文档中明示,不能由实现临时决定。
计数放在哪里#
单机内存计数最快,但重启即清零、多实例不共享,只适合做粗粒度的自我保护。需要跨实例一致时,把计数放到集中的键值存储,并用脚本或事务把“读—比较—写”合成一个原子操作;具体原子命令与过期语义随版本不同,落地前需按所用版本的官方文档核实。
一个常见折中是:限流用集中计数加短窗口,账本用数据库。两者的降级方向不应相同——限流组件不可用时,可以按“先放过、记入待核对”处理;账本写入失败时,应当拒绝放行,否则就是无账可查的免费调用。
一个最小的账本结构#
以下为示意结构,未执行,字段按实际业务增删:
CREATE TABLE usage_ledger (
request_id VARCHAR(64) PRIMARY KEY, -- 幂等键,由调用方或网关生成
tenant_id VARCHAR(64) NOT NULL,
key_id VARCHAR(64) NOT NULL,
endpoint VARCHAR(128) NOT NULL,
reserved BIGINT NOT NULL, -- 预占额度
settled BIGINT, -- 实际用量,NULL 表示尚未结算
status VARCHAR(16) NOT NULL, -- reserved / settled / released
created_at TIMESTAMP NOT NULL
);
主键放在 request_id 上,重复写入会被唯一约束直接拒绝,这是幂等最省事的实现。把 reserved 与 settled 分开存,对账时能直接看出“预占了但没结算”的悬挂单,不必反推。
结算失败与超时回收#
悬挂单需要回收机制:超过约定时间仍是 reserved 的记录,要么按预占额扣费,要么整笔释放。两种策略对用户的影响不同,属于业务决策,应在文档中写明。回收任务本身也必须幂等,重复执行不能产生第二次扣费。
对账与观测#
- 按租户统计预占与结算的差额,差额持续为正说明结算链路有漏。
- 悬挂单数量与账龄分布。
- 按端点的限流拒绝率。
- 账本写入失败数与重试次数。
拒绝率突然升高,可能是阈值定得太紧,也可能是计数键选错导致所有租户共用同一个桶——后者应在维度设计阶段就排除,而不是等报警后再查。
什么时候不需要这套结构#
单实例、对内、调用量小的服务,一个内存计数器加一行日志就够了。预占、结算、对账的价值随并发量和计费维度上升,过早引入只会增加故障点,还要额外维护回收任务。
API.PLUS 侧的具体配额粒度、计费单位与失败计费规则,本文未取得可核验的官方说明,不作确认,请以其官方文档为准(待核实)。文中表结构仅用于说明设计关系,不代表任何平台的真实实现。