问题:两种做法都是错的
纯事后计量最直观:流结束了,数一下用了多少 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` 是真相,余额字段只是它的物化快照。任何绕过账本直接改余额的代码路径,都是我们不会写的。