New API 扣费链路架构分析
1. 文档范围
- 阶段:方案调研
- 对象:当前
new-api源码中的同步 Relay、用户钱包、API Token 和异步 Task 扣费 - 重点:多 Pod 场景下,Redis Lua 如何执行额度扣减,MySQL 如何持久化余额和统计
- 证据边界:本文基于源码静态分析,未执行多 Pod 压测、故障注入或线上对账
2. 前置背景与部署假设
2.1 业务计费背景
一次请求可能同时包含输入 Token、输出 Token、缓存 Token、图片、音频和工具调用等计费项。它们的价格规则不同,当前实现先读取 usage 明细,再按模型价格、分组倍率和各类附加倍率,统一折算成内部额度值 Quota。
这里要先区分两类 Token:
输入 Token、输出 Token、缓存 Token
-> 本次请求的 usage 明细
API Token
-> 调用网关使用的访问密钥
-> 拥有 remain_quota 和 used_quota
actualQuota 是本次请求按真实 usage 计算出的实际费用换算值。它不是输入 Token、输出 Token 的数量,也不是货币金额,而是 new-api 用来统一扣减用户钱包和 API Token 额度的内部单位。
请求开始前还没有真实输出 usage,因此系统先根据估算输入和 max_tokens 计算 preConsumedQuota,先扣住一笔可能的最大额度。请求完成后,再用真实 usage 计算 actualQuota,得到:
delta = actualQuota - preConsumedQuota
普通倍率计费可以抽象为:
baseInput = PromptTokens
- CachedTokens
- CacheCreationTokens
- ImageTokens
- AudioTokens
promptQuota = baseInput
+ CachedTokens * CacheRatio
+ CacheCreationTokens * CacheCreationRatio
+ ImageTokens * ImageRatio
completionQuota = CompletionTokens * CompletionRatio
actualQuota = (promptQuota + completionQuota)
* ModelRatio
* GroupRatio
+ AudioQuota
+ ToolSurchargeQuota
缓存、图片和音频部分从普通输入中拆出,是为了让它们按各自倍率单独计费,避免重复计算。固定价格模型和 tiered_expr 模式也会在完成价格计算后得到统一的 Quota。
2.1.1 计费示例
假设一次请求返回:
PromptTokens = 1000
CachedTokens = 200
CompletionTokens = 300
ModelRatio = 1
GroupRatio = 1
CacheRatio = 0.1
CompletionRatio = 2
计算过程:
普通输入费用 = (1000 - 200) * 1 = 800
缓存输入费用 = 200 * 0.1 = 20
输出费用 = 300 * 2 = 600
actualQuota = (800 + 20 + 600) * 1 * 1
= 1420
请求开始前,如果估算输入为 1000、max_tokens 为 500:
preConsumedQuota = (1000 + 500) * 1 * 1
= 1500
请求完成后:
delta = actualQuota - preConsumedQuota
= 1420 - 1500
= -80
因此结算阶段返还 80 个 Quota。后面的 Redis Lua 和 MySQL 不再分别处理输入 Token、输出 Token 和缓存 Token,而是处理这个统一的额度差额。
tokens.remain_quota 和 tokens.used_quota 保存的是 Quota 额度汇总;usage 明细和输入、输出、缓存的拆分则保存在消费日志中。
对应源码:
- usage 计费:service/text_quota.go
- 价格参数:relay/helper/price.go
- API Token 模型:model/token.go
- 消费日志:model/log.go
2.2 部署假设
多个 Pod
-> 一个共享 Redis
-> 一个共享 MySQL
所有 Pod 使用同一个 Redis 连接配置和同一个 MySQL 数据库连接配置。
| 组件 | 用途 |
|---|---|
| Redis | 共享额度缓存、Lua 原子预扣、额度增量和热点状态 |
| MySQL | 余额、使用统计、消费日志、任务状态和配置数据 |
BillingSession |
当前请求所在 Pod 内的计费状态 |
| 批量更新队列 | 当前 Pod 内存中的延迟落库队列 |
配置入口:
| 内容 | 源码 |
|---|---|
| Redis 客户端 | common/redis.go |
| Redis 连接 | .env.example 的 REDIS_CONN_STRING |
| 数据库连接 | model/main.go |
3. 整体架构
各组件的作用:
| 组件 | 作用 |
|---|---|
PriceHelper |
将输入、输出、缓存等 usage 按价格规则折算为统一 Quota |
BillingSession |
保存当前请求的预扣、结算和退款状态 |
| Redis Lua | 处理多个 Pod 之间的实时额度竞争 |
| MySQL | 持久化余额、统计、消费日志和任务状态 |
| MySQL 条件更新 | Redis 不可用时提供余额检查和原子扣减 |
4. 扣费链路与核心技术
4.1 三条核心链路
三条链路可以概括为:
| 链路 | 核心行为 |
|---|---|
| 预扣 | 请求开始前,按估算额度检查并扣减钱包和 API Token 额度 |
| 结算 | 请求成功后,用 actualQuota - preConsumedQuota 补扣或返还 |
| 退款 | 上游失败时,退回本次请求已经预扣的额度 |
异步 Task 复用相同模型:提交时预扣,成功时结算,失败时退款。
4.2 Redis Lua 与 MySQL 如何完成并发扣费
4.2.1 实际 Lua 脚本
Reserve Lua 的核心是把“校验缓存、检查余额、修改余额”放在一次 Redis EVAL 中。
用户钱包使用 userQuotaReserveScript:
-- KEYS[1]:用户缓存 Key,例如 user:123
-- ARGV[1]:本次预扣额度 amount
-- ARGV[2]:用户 ID
-- ARGV[3]:缓存结构版本
-- 第一步:确认当前 Hash 属于目标用户,并且缓存字段完整、版本正确。
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
-- 返回 -1:缓存不存在、不完整或版本不匹配,调用方尝试从 MySQL 补缓存。
return -1
end
-- 第二步:读取当前用户钱包余额。
local quota = tonumber(redis.call('HGET', KEYS[1], 'Quota'))
if quota == nil or quota < tonumber(ARGV[1]) then
-- 返回 0:余额不足,不修改任何字段。
return 0
end
-- 第三步:余额充足时原子扣减。
-- HINCRBY 传入负数,相当于 Quota = Quota - amount。
redis.call('HINCRBY', KEYS[1], 'Quota', -tonumber(ARGV[1]))
-- 返回 1:预扣成功。
return 1
参数含义:
| 参数 | 值 |
|---|---|
KEYS[1] |
user:{userId} |
ARGV[1] |
本次需要预扣的 Quota |
ARGV[2] |
用户 ID |
ARGV[3] |
用户缓存结构版本 |
API Token 使用 tokenQuotaReserveScript:
-- KEYS[1]:API Token 缓存 Key,例如 token:{HMAC(tokenKey)}
-- ARGV[1]:本次预扣额度 amount
-- ARGV[2]:API Token ID
-- ARGV[3]:当前时间戳
-- 第一步:确认 Hash 属于目标 API Token,并且额度字段完整。
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or redis.call('HEXISTS', KEYS[1], 'RemainQuota') == 0
or redis.call('HEXISTS', KEYS[1], 'UsedQuota') == 0 then
-- 返回 -1:缓存不存在、字段不完整或 Token 身份不匹配。
return -1
end
-- 第二步:读取 API Token 当前剩余额度。
local remain = tonumber(redis.call('HGET', KEYS[1], 'RemainQuota'))
if remain == nil or remain < tonumber(ARGV[1]) then
-- 返回 0:剩余额度不足,下面三个修改都不会执行。
return 0
end
-- 第三步:在同一次 EVAL 中完成三项修改。
-- 剩余额度减少 amount。
redis.call('HINCRBY', KEYS[1], 'RemainQuota', -tonumber(ARGV[1]))
-- 累计已用额度增加 amount。
redis.call('HINCRBY', KEYS[1], 'UsedQuota', tonumber(ARGV[1]))
-- 记录本次访问时间。
redis.call('HSET', KEYS[1], 'AccessedTime', ARGV[3])
-- 返回 1:预扣成功。
return 1
参数含义:
| 参数 | 值 |
|---|---|
KEYS[1] |
token:{HMAC(tokenKey)} |
ARGV[1] |
本次需要预扣的 Quota |
ARGV[2] |
API Token ID |
ARGV[3] |
当前时间戳 |
脚本返回值:
| 返回值 | 含义 | 后续处理 |
|---|---|---|
1 |
Redis 预扣成功 | 将相同额度增量持久化到 MySQL |
0 |
余额不足 | 返回额度不足,不修改余额 |
-1 |
Hash 缺失、字段不完整或身份不匹配 | 从 MySQL 补齐 Redis 后重试一次 |
| Redis error | Redis 调用失败 | 改走 MySQL 条件更新 |
结算和退款使用 Delta Lua,不再检查余额:
用户:Quota += delta
API Token:
RemainQuota += delta
UsedQuota -= delta
例如退款 80 时,delta = 80;补扣 30 时,调用方传入的余额增量为 delta = -30。
4.2.2 MySQL SQL
Redis 不可用时,MySQL 直接承担“检查余额 + 扣减”的并发控制。
用户钱包预扣:
-- 在一条 UPDATE 中同时完成余额判断和扣减。
UPDATE users
SET quota = quota - :amount
WHERE id = :user_id
-- 条件不成立时不会更新该行,避免并发请求把余额扣成负数。
AND quota >= :amount;
API Token 预扣:
-- remain_quota 和 used_quota 在同一条 UPDATE 中更新。
UPDATE tokens
-- 剩余额度减少,累计已用额度增加。
SET remain_quota = remain_quota - :amount,
used_quota = used_quota + :amount,
accessed_time = :now
WHERE id = :token_id
-- 余额不足时条件不成立,RowsAffected 为 0。
AND remain_quota >= :amount;
数据库通过 RowsAffected 判断结果:
RowsAffected = 1 -> 预扣成功
RowsAffected = 0 -> 余额不足或记录不存在
SQL error -> 数据库操作失败
Redis 预扣成功后,MySQL 不再重复检查余额,而是持久化同一个增量。
用户钱包落库:
-- :delta 表示余额变化量。
-- 预扣时为负数,退款时为正数。
UPDATE users
SET quota = quota + :delta
WHERE id = :user_id;
API Token 落库:
-- :delta 表示 RemainQuota 的变化量。
-- 预扣时 delta < 0,退款时 delta > 0。
UPDATE tokens
SET remain_quota = remain_quota + :delta,
-- UsedQuota 与 RemainQuota 反向变化。
used_quota = used_quota - :delta,
accessed_time = :now
WHERE id = :token_id;
预扣 80 时,:delta = -80:
remain_quota = remain_quota - 80
used_quota = used_quota + 80
直写 MySQL 失败时,代码用 Delta Lua 把 Redis 中刚预扣的额度加回去。批量模式下,增量只进入当前 Pod 的内存队列,调用时无法立即知道后续定时落库是否成功。
4.2.3 两个 Pod 同时预扣示例
假设 API Token 的初始状态为:
RemainQuota = 100
UsedQuota = 0
Pod A 请求预扣 80
Pod B 请求预扣 80
Redis 正常时:
Redis 会顺序执行两个 Lua 脚本。本例假设 Pod A 先执行,它将余额从 100 扣到 20;Pod B 随后读取到的是 20,因此不能再次扣除 80。实际运行中也可能由 Pod B 先成功。
Redis 不可用时:
MySQL 对同一行的条件更新形成并发边界。本例假设 Pod A 先更新;实际运行中任一 Pod 都可能先成功。第一个更新完成后,另一个更新的 remain_quota >= 80 条件不再成立。
这两条路径都能防止同一个余额被并发超扣,但 Redis 路径还需要后续 MySQL 落库,因此 Redis 与 MySQL 之间仍然不存在一个统一事务。
4.2.4 持久化模式
| 模式 | Redis | MySQL |
|---|---|---|
| 直写模式 | Lua 立即修改共享余额 | 当前请求立即执行增量 UPDATE |
| 批量模式 | Lua 立即修改共享余额 | 增量进入当前 Pod 内存队列,定时聚合写入 |
| Redis fallback | 不使用 Redis 余额 | MySQL 条件 UPDATE 直接完成预扣 |
批量模式下,各 Pod 的队列彼此独立;共享 Redis 是实时余额边界,共享 MySQL 是最终持久化存储。
对应源码:
- Redis Lua、条件预扣和持久化入口:model/quota_reserve.go
- 用户余额更新:model/user.go
- API Token 余额更新:model/token.go
- 批量更新队列:model/utils.go
4.3 结算、退款与 BillingSession
BillingSession 保存当前请求的核心状态:
| 状态 | 作用 |
|---|---|
preConsumedQuota |
本次已经预扣的额度 |
tokenConsumed |
API Token 已扣额度 |
fundingSettled |
钱包等资金来源是否已经结算 |
settled |
防止同一会话重复结算 |
refunded |
防止同一会话重复发起退款 |
mu |
保护当前会话状态 |
结算时先调整资金来源,再调整 API Token。退款通过进程内 gopool 异步执行。
BillingSession 的保护范围是当前 Pod 内的同一个对象:
同一个 BillingSession 被重复 Settle
-> settled 拦截
同一个 BillingSession 被重复 Refund
-> refunded 拦截
另一个 Pod 或另一个进程
-> 不共享这个状态
对应源码:
- 请求级计费状态:service/billing_session.go
- 预扣和结算入口:service/billing.go
- 资金来源:service/funding_source.go
5. 方案优点与限制
5.1 两个开关
当前源码中影响扣费路径的主要开关:
| 开关 | 配置方式 | 影响 |
|---|---|---|
RedisEnabled |
配置 REDIS_CONN_STRING;未配置时关闭 |
是否使用共享 Redis Hash 和 Redis Lua 处理额度 |
BatchUpdateEnabled |
配置 BATCH_UPDATE_ENABLED=true |
余额增量是否先进入当前 Pod 的内存队列 |
BATCH_UPDATE_INTERVAL |
配置批量刷新间隔,默认由系统配置读取 | 批量队列多久尝试写入共享 MySQL |
两个开关相互独立,因此形成四种运行模式。RedisEnabled 主要决定实时扣费在哪里完成,BatchUpdateEnabled 主要决定 MySQL 余额增量是直写还是延迟批量写入。
下表描述的是各开关组合下的正常主路径。即使 RedisEnabled=true,如果 Redis 执行失败或缓存无法水合,预扣仍会回退到 MySQL 条件更新。
5.2 四种运行模式
| 模式 | Redis | 批量更新 | 预扣路径 | 结算 / 退款路径 | MySQL 写入压力 | 大流量下的特点 |
|---|---|---|---|---|---|---|
| A | 开 | 关 | Redis Reserve Lua 检查并扣减,随后直接写 MySQL | Redis Delta Lua 异步调整,随后直接写 MySQL | 高 | 余额持久化及时,链路简单;每次预扣、结算和退款都可能触发 MySQL 写入 |
| B | 开 | 开 | Redis Reserve Lua 检查并扣减,额度增量进入当前 Pod 内存队列 | Redis Delta Lua 异步调整,额度增量进入当前 Pod 内存队列 | 较低 | 适合大流量,减少 MySQL 写入;MySQL 延迟、队列积压和 Pod 退出丢失窗口增大 |
| C | 关 | 关 | MySQL 条件 UPDATE 直接检查余额并扣减 |
MySQL 增量 UPDATE 直接执行 |
高 | 不依赖 Redis,余额以 MySQL 为准;所有 Pod 竞争数据库行,数据库压力最大 |
| D | 关 | 开 | MySQL 条件 UPDATE 直接检查余额并扣减 |
增量进入当前 Pod 内存队列,定时写 MySQL | 高 | 预扣仍同步依赖 MySQL,结算和退款可以批量化;Redis 关闭时无法获得 Redis 主路径的高并发能力 |
四种模式的共同点:
多个 Pod
-> 共享 Redis Lua 或共享 MySQL 条件 UPDATE 完成预扣竞争
-> 用户钱包和 API Token 都执行额度变化
-> BillingSession 只管理当前请求内的状态
5.3 各开关组合的处理细节
模式 A:Redis 开启,批量更新关闭
| 项目 | 行为 |
|---|---|
| 并发控制 | Redis Lua 负责余额检查和预扣原子性 |
| MySQL | 预扣成功后直接保存增量,结算和退款也直接更新 |
| 优点 | MySQL 状态相对及时,异常窗口较小,链路容易观察 |
| 代价 | 高流量下 MySQL 写入次数多,余额热点行容易出现锁竞争 |
模式 B:Redis 开启,批量更新开启
| 项目 | 行为 |
|---|---|
| 并发控制 | Redis Lua 负责多个 Pod 的实时余额竞争 |
| MySQL | 预扣、结算和退款的增量先入当前 Pod 队列,再定时写入共享 MySQL |
| 优点 | 正常扣费不需要每次同步等待 MySQL 写入,能够显著减少数据库写压力 |
| 代价 | 队列是进程内存;Pod 重启、崩溃或批量写入失败时存在延迟和未落库窗口 |
| 额外边界 | persistUserQuotaDelta、persistTokenQuotaDelta 在批量模式下入队即返回成功,调用方不能立即知道后续落库结果 |
模式 C:Redis 关闭,批量更新关闭
| 项目 | 行为 |
|---|---|
| 并发控制 | 依赖 MySQL 的条件更新和数据库行级并发控制 |
| MySQL | 预扣、结算和退款全部直接访问 MySQL |
| 优点 | 没有 Redis/MySQL 双写,余额事实来源单一 |
| 代价 | 所有 Pod 的扣费竞争和写入都集中到 MySQL;高流量下连接池、行锁和磁盘 I/O 更容易成为瓶颈 |
模式 D:Redis 关闭,批量更新开启
| 项目 | 行为 |
|---|---|
| 并发控制 | 预扣仍依赖 MySQL 条件更新 |
| MySQL | 预扣同步直写;结算和退款可进入批量队列 |
| 优点 | 结算、退款的 MySQL 写入可以聚合,减少部分写放大 |
| 代价 | 预扣仍然逐请求访问 MySQL,无法像 Redis 主路径那样把实时竞争从数据库转移出去 |
| 风险 | 退款和结算依赖当前 Pod 的内存队列,Pod 退出时可能存在未落库增量 |
5.4 大流量下的当前方案判断
以下判断基于源码静态分析,当前没有多 Pod 压测数据,不使用具体 QPS 结论。
| 维度 | 模式 A:Redis 开 / 批量关 | 模式 B:Redis 开 / 批量开 | 模式 C:Redis 关 / 批量关 | 模式 D:Redis 关 / 批量开 |
|---|---|---|---|---|
| 实时扣费位置 | Redis | Redis | MySQL | MySQL |
| 预扣并发能力 | 较好,Lua 共享原子操作 | 较好,Lua 共享原子操作 | 依赖 MySQL 行更新 | 依赖 MySQL 行更新 |
| MySQL 余额写入 | 每次直写 | 延迟批量写 | 每次直写 | 预扣直写,结算/退款批量写 |
| MySQL 压力 | 高 | 较低 | 很高 | 高 |
| 余额持久化及时性 | 较好 | 较低 | 较好 | 预扣较好,结算/退款较低 |
| Redis 依赖 | 有 | 有 | 无 | 无 |
| Pod 内存丢失窗口 | 无批量队列 | 有 | 无批量队列 | 有 |
| 主要风险 | MySQL 写入和行锁竞争 | Redis/MySQL 双写、队列积压、批量丢失 | MySQL 成为实时热点和瓶颈 | MySQL 仍承受预扣压力,异步增量存在丢失窗口 |
| 更适合的流量特征 | 中等流量,重视落库及时性 | 大流量,允许短暂落库延迟 | Redis 不可用或低流量场景 | Redis 不可用但希望降低结算写入压力 |
5.5 当前方案的共同限制
| 限制 | 影响 |
|---|---|
| Redis 与 MySQL 没有统一事务 | Redis 已扣但 MySQL 未成功写入时,需要依赖补偿 |
| 批量队列位于 Pod 内存 | Pod 退出时,尚未写入 MySQL 的增量可能丢失 |
| 用户钱包与 API Token 分步更新 | 两类额度不是一个统一事务,异常时可能短暂不同步 |
| 退款异步执行 | Pod 退出或任务执行失败时,退款可能延迟 |
| 幂等只在进程内 | settled、refunded 只保护当前 Pod 内的 BillingSession |
used_quota 不是不可变流水 |
仅靠余额汇总字段无法完成完整的请求级对账 |
| Redis 共享热点 | 热点用户或热点 API Token 的 Lua 操作会集中到单个 Redis Key |
5.6 当前方案结论
| 结论项 | 对当前 new-api 实现的判断 |
|---|---|
| 默认主路径 | Redis 开启时,Redis Lua 负责实时预扣;批量开关决定 MySQL 是直写还是延迟写 |
| 大流量模式 | RedisEnabled=true 且 BatchUpdateEnabled=true 能降低 MySQL 写入压力,但必须接受批量延迟和 Pod 内存队列风险 |
| 及时落库模式 | RedisEnabled=true 且 BatchUpdateEnabled=false 更容易观察和对账,但 MySQL 写压力更高 |
| Redis 关闭模式 | 预扣退回 MySQL 条件更新;批量开关只能影响后续增量落库,不能消除预扣对 MySQL 的依赖 |
| 架构定位 | 当前实现更接近高并发网关额度系统,具备额度控制能力,但不等同于严格账务流水系统 |

浙公网安备 33010602011771号