用编译期类型思维设计稳定的 API 限流与计费系统
答案是:把约束尽可能前移。金额、配额、时间窗口写成不可混用的品牌类型,边界在构造器校验,请求状态迁移用枚举穷举,再配合原子扣减脚本与带唯一约束的幂等账本,让非法状态在写出来之前就被拒绝。
- 金额、配额、时间窗口各自独立成类型,单位错了编译期就报错
- 配额只能由构造器产生,负数、浮点、单位不明的值直接被拒绝
- 扣减靠原子脚本完成,拒绝与扣减不拆成两步走
- 幂等靠数据库唯一约束而非先查后插,重复请求自然被吞掉
- 限流可短暂放宽、计费绝不静默丢失,故障要有明确行为约定
为什么限流与计费总是“看起来没问题,一跑就漏”#
限流和计费是同一枚硬币的两面:限流回答“这次请求能不能过”,计费回答“这次请求该怎么记账”。真正让线上出事的,往往不是令牌桶算法本身,而是单位和边界没有在任何一层被约束住:金额用浮点数存、配额用字符串传、窗口“秒”和“毫秒”混用、幂等键靠调用方自觉。这类缺陷几乎不会在单个接口的单测里暴露,只会在月底账单和客服工单里集中爆发。
近期 Syl 语言关于编译期依赖类型的讨论给了一个很好的类比:如果一个长度已知的向量能在编译期就拒绝越界下标,那么一个按次扣减的配额,也应该在构造阶段就拒绝负数、浮点和单位不明的值。把约束尽可能前移,是这类系统稳定性的第一性原理,也是本文的主线。
启发:让非法状态不可表示#
Syl 这类支持编译期依赖类型的语言,核心能力是让类型本身携带值的信息,从而把一部分运行时检查搬到了编译期。我们不必真的换语言,但可以完整迁移它的三条工程思路:
- 单位进类型:金额、配额、时间窗口各自是独立的类型,互相不能直接相加或赋值,单位错了编译阶段就红。
- 边界在构造器校验:任何配额对象只能通过构造函数产生,构造失败即抛错,系统内部不出现“半合法的配额”。
- 状态迁移穷举:请求的生命周期(已准入 → 已计量 → 已出账 → 已对账)用枚举表示,非法跳转直接拒绝。
类型系统不会替你决定业务规则,但它能保证整套系统只实现了一套规则。
下面按“类型层 → 执行层 → 账本层 → 降级与观测层”自外向内拆解,每一层只解决一个问题,层与层之间靠显式的、可校验的契约连接。
第一层:把单位、金额与配额写进类型#
以 TypeScript 为例,结构化类型系统里 number 是无处不在的,最省事的加固方式是“品牌类型”(branded type):底层仍然是数字,但对外表现为不可混用的独立类型。
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
type Brand<T, U extends string> = T & { readonly __unit: U };
type MilliCent = Brand<number, 'MilliCent'>; // 金额:1 元 = 100000 毫厘
type Credit = Brand<number, 'Credit'>; // 计量:一次请求折算多少额度
type UnixMs = Brand<number, 'UnixMs'>; // 时间:只允许毫秒
interface RateLimit {
readonly limit: Credit; // 窗口内可用额度
readonly windowMs: UnixMs; // 窗口长度,整数毫秒
readonly burst: Credit; // 突发容量,必须 >= limit
}
const positiveInt = (n: number, name: string): number => {
if (!Number.isSafeInteger(n) || n <= 0) {
throw new RangeError(`${name} must be a positive safe integer, got ${n}`);
}
return n;
};
function makeRateLimit(limit: number, windowSec: number, burst: number): RateLimit {
const windowMs = positiveInt(windowSec, 'windowSec') * 1000;
const lim = positiveInt(limit, 'limit');
const bst = positiveInt(burst, 'burst');
if (bst < lim) throw new RangeError('burst must be >= limit');
return {
limit: lim as Credit,
windowMs: windowMs as UnixMs,
burst: bst as Credit,
};
}
这段代码的价值不在于“多写了几个断言”,而在于把三类事故挡在了模块边界之外:把秒传给毫秒参数会编译失败;把金额和额度相加会编译失败;传入 0 或 1.5 会在构造时立刻抛错,而不是等到某天凌晨的扣费任务里才变成负数余额。外部输入(HTTP 参数、配置中心)仍要做一次运行时校验,但校验之后进入系统的就是强类型值,内部代码不必再防御。
| 类型 | 用途 | 必须满足的不变量 |
|---|---|---|
| MilliCent | 账户余额、账单金额 | 整数,禁止浮点运算,统一最小货币单位 |
| Credit | 限流额度、套餐配额 | 正整数,与价格解耦,可随套餐调整 |
| UnixMs | 窗口长度、过期时间 | 整数毫秒,限流判定用单调时钟 |
第二层:限流执行——令牌桶与原子扣减#
类型只解决“参数是否合法”,不解决“并发下是否准确”。多实例部署时,限流必须落在一个原子操作上。常见做法是把令牌桶放进 Redis,用 Lua 保证“读—算—写”不可分割:
-- KEYS[1]: bucket:{api_key_id}:{endpoint}
-- ARGV[1]: capacity ARGV[2]: refill_per_ms ARGV[3]: now_ms ARGV[4]: cost
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local row = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(row[1]) or cap
local ts = tonumber(row[2]) or now
tokens = math.min(cap, tokens + math.max(0, now - ts) * rate)
if tokens < cost then
local wait_ms = math.ceil((cost - tokens) / rate)
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 90000)
return {0, wait_ms} -- 拒绝,并告诉调用方多久后重试
end
redis.call('HSET', KEYS[1], 'tokens', tokens - cost, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 90000)
return {1, 0}
几个容易被忽略的细节:
- 返回
wait_ms而不只是 429,客户端才能做有意义的退避;配合Retry-After头,重试风暴会明显收敛。 - key 的粒度要按“租户 + 维度”设计。全局限流和单 key 限流用不同的桶,避免一个热点 key 把整个租户拖垮。
- 限流判定用单调时钟换算的时间差,墙钟只用于日志展示,防止 NTP 回拨导致令牌被瞬间补满。
- 本地再做一层小容量令牌桶作为预筛,绝大多数超限请求在进程内就被拒掉,不会打到 Redis。
可以把 IP 查询接口 当作例子:这类接口单次成本低、调用频次高,适合“每 key 每秒 N 次 + 突发 B 次”的桶;而 随机动漫壁纸接口 返回的是图片地址,带宽成本远高于计算成本,桶的 cost 就不该恒为 1,而应随响应体积浮动。
第三层:计费账本——幂等事件与分录平衡#
限流放行之后,下一个动作是“记一笔”。这里最大的陷阱是把计费写成 UPDATE balance SET used = used + 1:重试、超时、消息重复投递都会让计数虚高。稳定做法是先写不可变的用量事件,再由事件派生账本分录,用数据库唯一约束兜底幂等。
CREATE TABLE usage_event (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL, -- 调用方或网关生成的幂等键
api_key_id BIGINT NOT NULL REFERENCES api_key(id),
endpoint TEXT NOT NULL,
units BIGINT NOT NULL CHECK (units > 0),
occurred_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_usage_request UNIQUE (api_key_id, request_id)
);
CREATE TABLE ledger_entry (
id BIGSERIAL PRIMARY KEY,
api_key_id BIGINT NOT NULL,
usage_id BIGINT NOT NULL REFERENCES usage_event(id),
direction TEXT NOT NULL CHECK (direction IN ('debit', 'credit')),
amount BIGINT NOT NULL CHECK (amount > 0), -- 单位:毫厘
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_ledger_usage UNIQUE (usage_id, direction)
);
幂等的真正保证是那两个唯一约束,而不是应用层的“先 SELECT 再 INSERT”——后者在并发下一定会漏。写入时用 INSERT ... ON CONFLICT DO NOTHING,重复请求自然被吞掉,既不会多扣费,也不会因为报错让调用方以为失败而继续重试。
调用侧的配合同样重要:每次请求带上稳定的请求 ID。以本站接口为例,重试时复用同一个 ID,服务端就能识别出这是同一次调用:
curl -s -H "X-Request-Id: 8f3c2a1e-6b7d-4f2a-9c1e-2b5d7a9f0c34" \
"https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"
按量计费的接口则要先做单位归一:把响应字节数、图片张数、计算耗时统一折算成 Credit,折算系数放在配置里而不是硬编码在业务代码里。这样调整价格时只改一处,账本里的历史数据仍然自洽。POST 示例接口 适合用来验证这套链路:请求体、幂等键、响应耗时都能直接观察。
最后是每日对账。对账不是“看看总数对不对”,而是逐租户比对“已计量”和“已出账”,差异必须精确为 0:
# 差异为 0 才算当日对账通过;返回非 0 时告警并冻结自动扣费
import psycopg
def reconcile(conn: psycopg.Connection, day: str) -> int:
sql = """
SELECT a.id,
COALESCE(SUM(DISTINCT u.units), 0) AS metered,
COALESCE(SUM(CASE WHEN l.direction = 'debit'
THEN l.amount ELSE 0 END), 0) AS billed
FROM api_key a
LEFT JOIN usage_event u ON u.api_key_id = a.id
AND u.occurred_at >= %s::date
AND u.occurred_at < %s::date + interval '1 day'
LEFT JOIN ledger_entry l ON l.usage_id = u.id
GROUP BY a.id
"""
rows = conn.execute(sql, (day, day)).fetchall()
return sum(1 for r in rows if r[1] != r[2])
生产环境里还要按 usage_id 关联,避免多表 JOIN 造成的重复计数;真实脚本通常拆成两步:先汇总用量,再汇总分录,最后在内存里比对。这一步看似笨拙,却是发现“限流放行了但没记账”“记账了但没扣减”这类断链问题的唯一可靠手段。
第四层:降级、补偿与可观测性#
限流和计费都依赖外部存储,一旦 Redis 或数据库抖动,系统必须有一份明确的行为约定,而不是靠运气。原则是:限流可以短暂放宽,计费绝不能静默丢失。
| 故障场景 | 推荐策略 | 业务影响 |
|---|---|---|
| Redis 超时或不可用 | 切本地令牌桶,按套餐硬上限 fail-closed | 少量 429,不超卖 |
| 账本写入失败 | 写本地 WAL 或消息队列,异步重投 | 出账延迟,需对账补偿 |
| 计费服务超时 | 先放行并携带幂等键补记,事后核对 | 可能多一次补偿任务 |
| 时钟漂移 | 限流用单调时钟,墙钟仅用于展示 | 无 |
配套的观测指标至少要有四组:准入判定 P99 延迟、拒绝率(按 key 维度看热点)、扣减失败率、每日对账差异数。前两个反映限流是否“卡得太死或太松”,后两个反映账本是否健康。再加上一条链路追踪:一个 request_id 能串起准入决策、用量事件、账本分录和最终账单,排障时就不需要靠猜。
测试侧建议做两件事:一是契约测试,固定一组边界值(0、负数、极大值、跨窗口边界)验证构造函数与 Lua 脚本的行为;二是混沌演练,主动把 Redis 打挂五分钟,观察拒绝率和补记队列是否按预期工作。这两项做完,系统才算真正“稳定”,而不只是“平时能跑”。
总结#
把编译期依赖类型的思想迁移到 API 限流与计费,落地下来是四句话:单位进类型,边界在构造器,扣减靠原子脚本,出账靠幂等账本。类型层负责让非法状态写不出来,执行层负责并发下的准确性,账本层负责可对账、可追溯,降级层负责故障时的确定性行为。四层各司其职,系统就具备了可交接、可复盘的稳定性。
如果你正准备给自己的服务接一套限流与计费,不妨先从最简单的接口开始验证这条链路:可以在 API.PLUS 注册账号,免费获取一个 API Key,用 IP 查询接口 跑通“请求 → 计量 → 出账 → 对账”的完整流程,再逐步加上按量和降级策略。更多接入细节与工程实践可以翻阅 API 文章中心,遇到具体问题也可以直接走 技术支持。