用户登录

技术文章

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

用编译期类型思维设计稳定的 API 限流与计费系统

一句话回答

答案是:把约束尽可能前移。金额、配额、时间窗口写成不可混用的品牌类型,边界在构造器校验,请求状态迁移用枚举穷举,再配合原子扣减脚本与带唯一约束的幂等账本,让非法状态在写出来之前就被拒绝。

核心要点 TL;DR
  • 金额、配额、时间窗口各自独立成类型,单位错了编译期就报错
  • 配额只能由构造器产生,负数、浮点、单位不明的值直接被拒绝
  • 扣减靠原子脚本完成,拒绝与扣减不拆成两步走
  • 幂等靠数据库唯一约束而非先查后插,重复请求自然被吞掉
  • 限流可短暂放宽、计费绝不静默丢失,故障要有明确行为约定

为什么限流与计费总是“看起来没问题,一跑就漏”#

限流和计费是同一枚硬币的两面:限流回答“这次请求能不能过”,计费回答“这次请求该怎么记账”。真正让线上出事的,往往不是令牌桶算法本身,而是单位和边界没有在任何一层被约束住:金额用浮点数存、配额用字符串传、窗口“秒”和“毫秒”混用、幂等键靠调用方自觉。这类缺陷几乎不会在单个接口的单测里暴露,只会在月底账单和客服工单里集中爆发。

近期 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 文章中心,遇到具体问题也可以直接走 技术支持。

常见问题(FAQ)

为什么限流与计费的缺陷很难在单测里发现?
因为问题多出在单位和边界上:金额用浮点存、配额用字符串传、秒与毫秒混用、幂等键靠调用方自觉。单个接口的单测覆盖不到,只会在月底账单和客服工单里集中暴露。
不换语言也能用编译期类型思维吗?
可以。以 TypeScript 为例,用品牌类型把底层 number 包装成 MilliCent、Credit、UnixMs 等不可混用的独立类型,构造时校验正安全整数,即可在编译阶段拦住单位错误与非法赋值。
限流与计费的幂等应该在哪一层保证?
在数据库层保证。靠 (api_key_id, request_id) 与 (usage_id, direction) 的唯一约束,写入用 INSERT ... ON CONFLICT DO NOTHING;应用层先 SELECT 再 INSERT 在并发下一定会漏。
Redis 或账本库故障时应该怎么处理?
原则是限流可短暂放宽、计费不能静默丢失。Redis 不可用时切本地令牌桶并按套餐硬上限 fail-closed;账本写入失败先落本地 WAL 或消息队列异步重投,再由每日对账补偿。
每日对账应该怎么做,什么情况才算通过?
逐租户比对已计量与已出账金额,差异必须精确为 0;返回非 0 时告警并冻结自动扣费。按 usage_id 关联,避免多表 JOIN 造成重复计数。

作者:沐小悠

本文约 18 分钟读完 · 关键词:API限流、API计费、依赖类型、令牌桶、幂等账本

更多文章 GET 示例接口

「」标签下的其他文章