支付重构里,我最后只信 Redis 那一扣
这次改支付链路,最开始我想错了一个地方。
我以为问题是“Agent 也要扣费”,所以把 Consumer 里那套工作流扣费逻辑搬过来就行。
后来发现不是。
真正的问题是:一次生成请求,到底在哪一刻算占用了用户余额?
这个问题如果不先回答,代码会很快散掉。LLM 在一个地方扣,tool 在另一个地方扣,工作流节点又有一套预扣。每条路都能跑,但每条路都在暗示不同的账务语义。
最后我接受的原则很简单:
Redis 决定请求能不能继续跑。MongoDB 负责把这次消费写成账本。
也就是先过运行期闸门,再补最终账本。
旧结构哪里开始疼
OpenCreator 原来的重扣费场景主要在 Workflow Consumer。
图片、视频、音频这些节点,通常能在调用模型前算出费用。Consumer 会在 Model Gateway 里先走 CreditBalanceCheckServiceV2.check_for_pre_deduction(),Redis 里扣成功后再调用外部模型。
这个结构放在工作流里是顺的。
节点要调用供应商,供应商可能会产生真实成本。余额不够,就别调用。调用失败,就 rollback。
Agent 接进来以后,事情变得没那么整齐。
Agent 一轮对话里有多次 LLM 调用。LLM token 费用要等 usage 回来才知道。tool 也可能返回 cost_usd,比如搜索工具、渲染工具、媒体分析工具。subagent 还可能继续开一条新的执行链。
如果沿着旧思路,把扣费写在 API handler 或 SSE hook 里,肯定会漏路径。
后来这部分被下沉到了 creato/core/executor.py:
async def _check_balance(self) -> None:
ctx = get_billing_context()
if not ctx or not ctx.manager.enabled:
return
await ctx.manager.check_balance_or_raise(ctx.user_id)
async def _bill_llm(self, usage: dict[str, int]) -> None:
ctx = get_billing_context()
if not ctx or not ctx.manager.enabled or not usage:
return
await ctx.manager.bill_llm(ctx.user_id, self.model, usage, ctx.session_id)
async def _bill_tool(self, tool_name: str, cost_usd: float | None) -> None:
ctx = get_billing_context()
if not ctx or not ctx.manager.enabled or cost_usd is None:
return
await ctx.manager.bill_tool(ctx.user_id, tool_name, cost_usd, ctx.session_id)
这个改动不是单纯挪位置。
它把扣费从“某个入口记得做”改成了“只要进入执行引擎,就经过同一个账务边界”。
我一开始低估了 Redis 的角色
很容易把 Redis 叫成缓存。
一旦这么想,就会自然冒出一个方案:MongoDB 是主库,所以先写 MongoDB,再同步 Redis。
这在很多业务里没问题。
但扣费热路径不是这么回事。
用户跑一个工作流,里面可能同时起多个节点。两个请求同时读到余额 100,都觉得自己能扣 80,这种错误不需要大流量才会发生。
这段代码就是危险的:
balance = await redis.get(balance_key)
if int(balance) >= cost:
await redis.decrby(balance_key, cost)
读余额和扣余额是两步。两步之间,世界已经变了。
所以 Redis 在这里不是缓存。
它是运行期余额的裁决点。余额检查、三层积分扣减、扣减明细返回,必须放进同一个 Redis Lua 脚本里。
这也是为什么我最后更愿意说“Redis 那一扣”,而不是“Redis 缓存更新”。
MongoDB 先扣会把账本放进热路径
MongoDB 先扣,看起来最像“正确的财务系统”。
先写账本,再更新运行时缓存。
问题是我们的扣费不是一个字段。
一次消费可能同时影响:
subscription_user_creditsusergift_creditsusage_v2
其中三层积分还有固定顺序:订阅积分先扣,再扣充值积分,最后扣赠送积分。
如果先写 MongoDB,再更新 Redis,有几个窗口很别扭。
MongoDB 扣成功,Redis 更新失败,下一次请求还是看到旧余额。
MongoDB 扣成功,外部模型失败,又要把 usage 和余额集合倒回去。
并发压力也会压到 MongoDB 上。扣费在模型调用前,是热路径,不是后台对账任务。
这不是 MongoDB 不强。
是它不适合当这个闸门。
MongoDB 该做的是记录事实、支持查询、支持对账。它不该负责在每一次模型调用前挡并发。
Redis 先扣也必须付代价
Redis 先扣不是免费午餐。
它换来的最大风险是:
Redis 已经扣了,MongoDB 还没记上。
这比普通缓存不一致严重得多。
用户余额少了,但 usage_v2 没有记录。客服查不到,用户也解释不清。
所以这次重构里,最关键的不是“Redis 先扣”,而是:
Redis 先扣时,必须同时写 pending deduction。
Consumer 里的 CreditBalanceCheckServiceV2 就是这么做的。它先生成 deduction_id,然后在同一个 Lua 脚本里扣余额和写 pending:
local pending_data = cjson.encode({
user_id = user_id,
cost = cost,
sub = subscription_user_credits_deduct_amount,
user = user_credits_deduct_amount,
gift = gift_credits_deduct_amount,
ts = ts
})
redis.call("SET", pending_key, pending_data, "EX", pending_ttl)
pending 不是日志。
它是一张“Redis 已扣,MongoDB 待确认”的临时收据。
进程可以挂,MongoDB 可以短暂失败,队列可以重试。但只要 Redis 扣成功,就必须留下这张收据。
三层积分只能裁决一次
三层积分最烦的地方,不是扣减顺序本身。
烦的是不能算两遍。
Redis 扣一次,MongoDB 后面再自己算一次,听起来只是重复实现,实际是两个不同时间点的世界。
第二次算的时候,订阅可能过期了,赠送积分可能被别的请求扣过了,充值余额也可能已经变化。
所以 Redis Lua 返回的不只是成功失败。
它还要返回这次到底从三个池子各扣了多少:
balance_after
subscription_user_credits_deducted_amount
user_credits_deducted_amount
gift_credits_deducted_amount
后面的 MongoDB 只能消费这个结果,不能重新判断。
Agent 侧的 CreditService.record_usage() 也是这个思路。它写 usage_v2 时带上 deduction_metadata,然后按同一份明细更新三个集合:
{
"source": "agent",
"session_id": "session_xxx",
"deduction_metadata": {
"sub": 100,
"user": 20,
"gift": 0
}
}
这个字段看起来像审计信息。
其实它是账务边界。
状态机比流水线更诚实
我后来不太喜欢把扣费画成一条直线。
直线图会让人以为每一步失败都能用同一种方式处理。
实际不是。

这张图里最重要的分支是 MongoDB 写失败。
如果外部模型没有产生结果,rollback Redis 是合理的。用户没有拿到产物,供应商也没有成功交付。
但如果外部模型已经成功,用户拿到了结果,MongoDB 写 usage_v2 失败,这时不能退款。
这不是技术洁癖问题。
这是产品语义问题:结果已经交付,消费就已经发生。MongoDB 写失败应该进入重试补账,而不是把 Redis 余额退回去。
很多账务 bug 都会卡在这里:把“模型失败”和“记账失败”混成同一种失败。
它们不是同一种失败。
Agent 和 Workflow 不应该强行统一扣费时间
这次我也放弃了另一个执念:所有扣费都统一成同一种模式。
Workflow 里的图片、视频、音频模型,很多费用在调用前能算出来。这里适合预扣。
余额不够,就别调用供应商。
调用失败,就 rollback。
Agent LLM 不一样。
LLM token 费用要等 usage 回来才知道。这里更合理的是先检查最低余额门槛,再按真实 usage 扣费。
tool 也不一样。
tool 如果返回了 cost_usd,才把 USD 成本换成积分。tool 执行失败,或者没有成本,就不扣。
所以统一的不是“扣费时间点”。
统一的是边界:
| 场景 | 扣费时间点 | 账务边界 |
|---|---|---|
| Workflow 固定价模型 | 调供应商前 | Redis 预扣,MongoDB 后记账 |
| Agent LLM | usage 返回后 | Redis 扣真实 token 费用,MongoDB 记录 usage |
| Agent tool | tool 成功返回 cost_usd 后 |
Redis 扣真实工具成本,MongoDB 记录 usage |
业务事实不同,扣费时间点就应该不同。
强行统一,只会把某些路径写得很别扭。
这次留下的代价
这个方案不是没有代价。
第一,pending deduction 需要被当成一等公民。
它不能只是 Redis 里一条没人看的 key。至少要能查,能对账,能知道哪些 pending 卡住了。
第二,usage_v2 需要幂等键。
现在链路里已经有 deduction_id 这个天然候选。后续如果把队列重试和补账任务做完整,usage_v2 应该围绕它加唯一约束。
否则 MongoDB 写超时后,重试可能插出两条 usage,甚至重复扣 MongoDB 余额。
第三,Redis 和 MongoDB 的差异会短暂存在。
这不是 bug,而是这个设计承认的中间态。关键是这段中间态必须可见、可恢复、可解释。
比起假装强一致,我更愿意把中间态摆在台面上。
我会守住的几条规矩
检查余额和扣减必须在同一个 Redis 原子动作里。
三层积分的扣减顺序只能在 Redis 里裁决一次,MongoDB 不重新计算。
Redis 扣成功时,pending deduction 必须同时写入。
模型没有结果,才 rollback。
模型有结果但 MongoDB 写失败,只能重试补账,不能退款。
Agent 扣费跟 Executor 走,不跟某个 API handler 或 SSE hook 走。
usage_v2 最后要围绕 deduction_id 做幂等。
扣费链路最怕的不是步骤多。
最怕的是失败以后不知道自己停在哪一步。