你对同一把 API key 发一百个并发请求。使用次数计数器显示八十七。日志里没有任何错误,没有请求失败,没人抱怨。就是少了十三次增量。
这个问题我遇到过三次,在三个没有一行代码相同的项目里,原因永远一样:这个计数器写的时候,假设只有一个进程在写。
模式:分三步读、加、写
产生它的代码从来看不出危险。在一个转录 API 里,认证一把 key 做两件事:确认它没被吊销,以及更新 useCount 和 lastUsedAt。读这一行,在内存里加一,写回结果。
一次一个用户时它能用。批处理 worker 一起打同一把 key 时就不行了:在读和写之间进来另一个请求,读到同一个值,写回同一个结果。两次使用,一次增量。用 k6 在 10、50 和 100 个虚拟用户下测量,丢失率在 8% 到 14% 之间。
修复只要一条语句:
UPDATE api_key
SET use_count = use_count + 1,
last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;
重要的不只是显而易见的 use_count + 1。而是 revoked_at IS NULL 在同一个 UPDATE 里面。之前吊销检查和自增是两次不同的往返,所以存在一个窗口:有人在校验之后立刻吊销了 key,而这次使用照样被计入。把过滤条件放进去之后,要么这一行满足条件并自增,要么什么都不返回并抛出 401。没有中间状态要处理。
改完之后,并发测试跑了九轮(三个级别乘三次重复),零丢失。
反过来的情况:对同一件事收两次钱
第二个案例正相反。在一个语义搜索里,每次搜索消耗一个配额单位,并写一行记录它的成本:embedding 模型、token 数、rerank 模型、输入和输出 token。
有去重:如果你重复同一次搜索,预留返回 duplicate,复用原来那一行,不再扣配额。
问题是被去重的那次搜索确实执行了。它又调了 embedding,又调了 rerank,供应商又计费了一次。这笔成本无处可去:原来那一行已经记着自己的成本,覆盖它会抹掉第一次的花费。于是它消失了。管理后台显示的搜索成本比实际低。
诱惑是在同一张表里插一行不同状态的记录。坏主意,有两个具体原因:
- 所有配额查询都是不带过滤地数行数的。 多出来的一行会扣掉没人消耗过的配额。
- 查重复也不按状态过滤。 它取的是那个哈希下最新的一行。新行会成为下一次去重的锚点,而它从来不是一个配额单位。
解决方案是一张单独的表 unbilled_search_spend,有同样的用量列,以及一个指向去重所依据的那次预留的引用。这个引用是审计用的,带 ON DELETE set null,不是配额引用。成本能在报表里看到,但不计入已计费搜索的单次成本,而后者才是用来定价的那个数字。
第三个:两个分开读取的余额
第三个是一个图像生成 SaaS。我在订阅额度之上加了每日额度,结果只有每日额度的用户就无法生成了。端点检查 profile.subCredits < estimatedCost 并返回 402,完全忽略了 dailyCredits。额度是存在的,侧边栏也画出来了,而服务器说不够。
这不是并发 bug,而是把一个计数器拆成两个然后忘了其中一个。但根源是一样的:余额不再是一个数字,而变成了一个散落在代码各处、每个地方手工做一遍的运算结果。
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
这一行出现在检查里一次,在计算返回给客户端的剩余余额时又一次。两个必须记得相加的地方。等第三种额度出现的那天,就是三个。
还有一个不是 bug,而是没写下来的策略
第四个案例不同,但属于同一个家族。invoice.paid 的 webhook 在每次续费时覆盖余额:
balanceCents: pricing.balanceCents, // 覆盖了之前的余额
上个月没用完的一切都在续费那天蒸发。在一个余额 1499 分的套餐的约四十个用户中,损失的中位数是 310 分,90 分位到了 890 分。某些情况下接近套餐的 60% 被扔掉。
有意思的是,没人做过这个决定。它不是策略,而是写了赋值而不是加法的后果。现在它会累积,上限三个月:
const nextBalance = Math.min(
current.balanceCents + pricing.balanceCents,
pricing.balanceCents * 3
);
这个上限不是装饰:没有它,一个偶尔使用的账号会攒下十二个月的余额,然后在一个尖峰里花掉,而当你上游按用量给 AI 供应商付钱时,这正是你最不想要的。
规则
任何代表钱的计数器,从第一天起就要带着三样东西写。第一:自增和它的条件活在同一条语句里,SET x = x + 1 WHERE ...,绝不用 read-modify-write。第二:如果有去重,即使配额不扣,真实成本也要有地方落。第三:续费时剩余部分的策略要写在某个地方,哪怕只是一条注释,否则赋值运算符会替你把它写好。
这三件事在一开始做都不花时间。之后再做,三件都要付出一次事故的代价。
