niuzj

支付重构里,我最后只信 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_credits
  • user
  • gift_credits
  • usage_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
  }
}

这个字段看起来像审计信息。

其实它是账务边界。

状态机比流水线更诚实

我后来不太喜欢把扣费画成一条直线。

直线图会让人以为每一步失败都能用同一种方式处理。

实际不是。

扣费状态机:Redis 边界和 MongoDB 边界

这张图里最重要的分支是 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 做幂等。

扣费链路最怕的不是步骤多。

最怕的是失败以后不知道自己停在哪一步。