← 回首页

工程日志

流式请求的配额,到底该在什么时候扣

请求开始时不知道会消耗多少,结束时钱已经花出去了。这是计量系统里最容易做错的一处,我们用两阶段解决它。

最后更新
2026-08-06
作者
网关与计量

问题:两种做法都是错的

纯事后计量最直观:流结束了,数一下用了多少 token,扣掉。问题是它挡不住超额。一个额度只剩 100 CU 的用户可以同时发起十个大请求,每个都在开始时通过检查,结束时一起把额度打成负数。

纯事前扣费按 `max_tokens` 扣满则走向另一个极端:用户设了 `max_tokens: 8000` 但实际只生成了 200 token,被按 8000 扣。这会逼用户把 max_tokens 设得很小,而那会让长回答被截断——我们用计费逻辑损害了输出质量。

解法:预扣与提交

标准解法是两阶段。请求进来时按最坏情况预扣一笔,流结束时用真实用量提交,多退少补。

1. 鉴权   api_key → user → subscription → 三窗口上限
2. 预扣   Redis Lua 原子脚本(没配 Redis 时走 Postgres 顾问锁):
            check(5h, week, month) && hold(estimate) && zadd(reservation, TTL=15min)
          estimate = max_tokens × 输出系数 + tokens_in × 输入系数
3. 转发   选上游 → SSE 流式透传
4. 落库   写 usage_events(append-only)——**先落库**
5. 提交   真实 usage → 解占用、按真实用量加计数器、多退少补
6. 对账   定时任务用 usage_events 重算计数器,先记漂移再修缓存

第 2 步必须是原子的。三个窗口的检查和扣减如果分成多次往返,两个并发请求就能同时通过检查。Lua 脚本在 Redis 里单线程执行,天然解决这个问题。

没有配 Redis 时这一步走 Postgres 的事务级顾问锁,同一用户的请求在锁上排队。结论完全一样,代价是每个请求多两次聚合扫描。两条路由一组测试逐项比对,确保「配了 Redis」不会悄悄改变业务规则。

第 4 步和第 5 步的顺序也是正确性的一部分:真相先落库,计数器后跟上。反过来的话,落库失败会留下一笔谁也对不上的用量;而按这个顺序,计数器这一步失败最多让缓存暂时偏小,下一次重灌会从 usage_events 读到含这笔的真值,自己补回来。

真正麻烦的是边界情况

主流程只有二十行,难的是它出错时会发生什么。我们踩过的几个:

  • 客户端中途断开——最常见。收尾逻辑必须挂在 `finally` 上,而不是流的正常结束回调上,否则预扣会一直挂着。
  • 网关进程崩溃——`finally` 也救不了。所以 reservation 带 15 分钟 TTL:最坏情况是用户的额度被短暂占用,而不是永久损失。这是我们能接受的失败方向。
  • 上游返回的 usage 与我们数的不一致——以上游为准,差异单独记一条 metric。持续偏差说明我们的 tokenizer 假设有问题,需要人去看。
  • Redis 挂了——退回 Postgres 顾问锁那条路,并记一次降级。不是放行:放行等于短时间内谁都能超额,而超额那部分写进 append-only 的用量表之后收不回来。慢是可以观测、可以扩容的,放行是事后才发现的资损。

Redis 是快,Postgres 才是真相

Redis 计数器是为了快,但它会漂:崩溃、TTL 提前过期、Lua 脚本 bug,任何一个都会让计数偏离真实值。所以真相在 Postgres 的 `usage_events`——append-only,永不更新。

对账任务用事件表重算计数器。发现差异时先把它写进一张只追加的漂移表,然后才把缓存修掉——修的是缓存,不是账。留一条记录是因为差额本身是排查线索:它是事后唯一能回答「那几分钟用户是不是被多放行了」的东西。

这和余额对账刻意不同:那一条只报不改。 区别不在于哪张表更重要,在于「修」这个动作会不会把证据一起擦掉。账本是真相,改它就销毁了线索;计数器是缓存,停在错值上会继续做错的准入判断,而线索已经记在漂移表里了。

这条原则在整个计费系统里通用——余额也一样,`balance_ledger` 是真相,余额字段只是它的物化快照。任何绕过账本直接改余额的代码路径,都是我们不会写的。