4155 字
21 分钟
AI 聊天产品的积分计费系统:从 token 计价到额度账本

AI 聊天产品通常按 token 成本向用户收积分。看起来只是“每次对话扣一点”,落到工程上却要处理小数单价、多种额度来源、并发扣减、每日过期、幂等发放和对账。这篇文章基于一个实际运行的系统,把整套积分计费和数据库设计完整拆开。

核心设计:不做余额字段#

最直接的做法是在用户表放一个 balance,扣费时 balance = balance - cost。这个方案在单一余额、无过期、无并发的场景下够用,但一旦出现“每日免费额度当天过期”“VIP 月度额度按周期过期”“管理员赠送不过期”并存,单字段就表达不了。

系统的选择是:只做额度批次和流水,余额在查询时聚合计算。

  • 每一次发放都是一行记录,带 granted_credits 和 remaining_credits;
  • 扣费就是原地减少 remaining_credits,减到 0 把状态改成 EXHAUSTED;
  • 过期不是删除,而是把状态改成 EXPIRED,同时写一条负数流水;
  • 所有资金变动都追加到一张只增不改的流水表。

好处是任何时刻的余额都能从批次表推导出来,流水可以独立复算;代价是每次读余额都要做聚合查询,后面会看到它对性能的影响。

数据模型#

金额单位:micro-credit#

所有金额字段都是 BIGINT,单位是 micro-credit,1 积分 = 1000 micro-credit。这样可以用整数表达 0.001 分的精度,同时避免浮点误差。展示给用户时再转换成小数或向上取整。

三张额度批次表#

每日免费额度

字段说明
user_id外键,用户删除时级联删除
local_date按用户时区算出的日期
time_zone发放时的时区快照
granted_credits / remaining_credits发放量与剩余量,micro-credit
statusACTIVE / EXHAUSTED / EXPIRED
granted_at / expires_atexpires_at 是当天本地时间次日 0 点
meta_jsonJSONB,记录发放原因和时区等上下文

关键约束:

UNIQUE INDEX uq_free_daily_credit_grants_user_date
ON free_daily_credit_grants(user_id, local_date);
CREATE INDEX idx_free_daily_credit_grants_user_status_expire
ON free_daily_credit_grants(user_id, status, expires_at);
CREATE INDEX idx_free_daily_credit_grants_status_expire
ON free_daily_credit_grants(status, expires_at);

唯一索引是每日发放的幂等基石:多实例并发发放时,数据库保证同一用户同一天只有一条记录。后两个索引分别服务“算某个用户余额”和“扫描过期批次”。

这里同时存了 local_date 和 expires_at:前者用于按天去重,后者用于精确判断是否过期,避免依赖查询时的时区换算。

管理员赠送

结构类似,但没有过期时间,状态只有 ACTIVE / EXHAUSTED。额外字段:

  • note:赠送备注;
  • created_by_admin_user_id:记录操作人,外键使用 ON DELETE RESTRICT,防止误删管理员后丢失审计关系。

单次赠送上限 10 万积分写在应用层,数据库没有对应的 CHECK 约束。

VIP 周期额度

  • subscription_id:关联订阅,订阅删除时级联删除;
  • plan_code、grant_no:套餐和本计费周期的发放序号;
  • grant_period_start_at / grant_period_end_at:发放窗口;
  • expires_at:等于周期结束时间。

幂等约束:

CREATE UNIQUE INDEX uq_vip_credit_grants_subscription_period
ON vip_credit_grants(subscription_id, grant_period_start_at);

流水表#

credit_ledger_entries 是唯一的事实账本,只追加:

字段说明
user_id / profile_id / session_id / message_id归属信息;会话和消息外键是 ON DELETE SET NULL,删会话不会删账
source_type来源枚举:聊天、记忆抽取、追问、意图识别、标题、天气、风险检测、各类发放与过期
source_ref_id多态引用,可能是消息 ID 或额度 ID,没有外键
provider / model_name实际调用的模型
token_input / token_output / token_reasoning原始 token 数
input_rate / output_rate / reasoning_rate扣费时的单价快照
credits_delta正数发放,负数扣费或过期
balance_after记账完成时的可用余额
meta_json应收、实收、减免、各额度桶的扣减明细
created_at数据库默认时间

索引是 (user_id, created_at DESC)、(session_id, created_at DESC) 和 (source_type, source_ref_id)。

需要注意,(source_type, source_ref_id) 不是唯一索引。数据库层面不防重复扣费,幂等完全依赖调用方保证一次业务只发起一次扣费。

配置表#

credit_billing_settings 是单行表,用 singleton_key = 'DEFAULT' 定位。存 24 个费率列:默认费率加 7 个渠道各自的输入、输出、推理单价,以及每日免费额度数量。费率列最初是 INT,后来迁移成 BIGINT,因为内部单位换成了 micro-credit。

用户表里的三个遗留列#

用户表有 credit_balance、credit_granted_total、credit_spent_total 三列,容易让人以为余额存在这里。实际上:

  • credit_balance 从未被写入,也没有读取路径;
  • credit_granted_total 同样未使用;
  • 只有 credit_spent_total 在扣费时累加,是不影响余额的统计冗余。

直接查用户表会看到余额为 0,这是排查时最常见的误判。

余额如何计算#

可用余额等于三张批次表的聚合并求和:

SELECT COALESCE(SUM(remaining_credits), 0)
FROM free_daily_credit_grants
WHERE user_id = ?
AND status = 'ACTIVE'
AND remaining_credits > 0
AND expires_at > now();
SELECT COALESCE(SUM(remaining_credits), 0)
FROM manual_credit_grants
WHERE user_id = ?
AND status = 'ACTIVE'
AND remaining_credits > 0;
SELECT COALESCE(SUM(remaining_credits), 0)
FROM vip_credit_grants
WHERE user_id = ?
AND status = 'ACTIVE'
AND remaining_credits > 0
AND expires_at > now();

系统没有缓存、没有物化余额,所以直接改批次行余额会立刻变化。代价是每次读余额固定 3 条 SQL;一次扣费前后各算一次,加上预检,一次扣费会算三遍余额。

status = 'ACTIVE' 和 expires_at > now() 是双重保险:定时任务会把过期批次标成 EXPIRED,但即使任务延迟,查询也不会把过期额度算进来。

索引 (user_id, status, expires_at) 正好覆盖过滤条件,remaining_credits > 0 是回表后的过滤。

一次扣费在事务里做了什么#

扣费方法是声明式事务,传播级别为默认的 REQUIRED。聊天链路本身没有事务,所以扣费是一个独立事务,和保存回复不在同一个事务里。

事务内的顺序:

  1. 补发当天免费额度:先用 SELECT ... FOR UPDATE 查当天是否已有批次;没有则插入;并发插入冲突时由唯一约束兜底。

  2. 计算扣前余额:执行三张表的聚合。

  3. 锁用户行:SELECT ... FROM users WHERE id = ? FOR UPDATE。

  4. 按顺序扣三个额度桶,每桶都先对候选行加行锁:

    • 免费额度:ORDER BY expires_at ASC, granted_at ASC
    • 管理员赠送:ORDER BY granted_at ASC
    • VIP 额度:ORDER BY expires_at ASC, granted_at ASC

    固定顺序是免费、赠送、VIP,因为免费额度当天过期,要先消耗过期最快的。

  5. 累加 users.credit_spent_total。

  6. 计算扣后余额:再次执行三张表聚合。

  7. 插入一条流水,写入 token、单价快照、扣减明细和 balance_after。

一次扣费大约 12 到 20 条 SQL。一条用户消息可能触发 3 到 5 次模型调用,对应 50 到 100 条 SQL,这是整个系统最主要的数据库压力来源。

并发控制#

系统没有使用乐观锁,全部依赖数据库悲观锁。

  • 用户行锁是同一用户并发扣费的串行化点。
  • 额度行锁保证同一批次不会被两个事务同时扣减。
  • 锁顺序在所有扣费路径上保持一致,正常情况下不会死锁。

数据库隔离级别是 PostgreSQL 默认的 READ COMMITTED。第二个事务在等待 FOR UPDATE 期间,第一个事务提交后会重新检查行是否仍满足 remaining_credits > 0,已扣完的行会被跳过。因此不会出现两个请求各扣一遍同一份额度。

每个批次的扣减量是 min(remaining, remainingCost),扣到 0 就停止,不会把单行扣成负数。

余额不足的两种处理#

  1. 如果事务开始时总余额已经小于等于 0,直接返回 402 INSUFFICIENT_CREDITS,不产生任何扣减。
  2. 如果余额大于 0 但不足以支付本次费用,不报错,而是能扣多少扣多少,剩余部分记为减免。

流水里会记录:

  • requestedCost:本次应收;
  • chargedCost:实际扣减;
  • waivedCost:减免;
  • chargeMode:FULL_CHARGE 或 CAP_TO_ZERO_LAST_USE。

也就是说,系统没有“预留”或“冻结”机制,预检只判断余额是否大于 0,所以最后一笔超额是设计允许的行为。实际日志中能看到“应收 2114、实收 645、减免 1469”的记录,用户回复照常返回,余额归零,下一条消息才被拦截。

额度发放与过期#

每日免费额度的三条触发路径#

  1. 注册成功时立即发放;
  2. 登录、打开页面、发消息、扣费前都会顺手检查并补发;
  3. 定时任务每分钟全量扫描所有用户。

扫描的实现是先把所有用户的 id 和时区读出来,再逐个用户查询或插入。没有分页,也没有游标,每个用户至少两条 SQL,每分钟执行一次。用户规模上来后,这是明确的扩展性瓶颈。

多实例部署时每个实例都会扫描,代码里没有分布式调度锁,重复执行完全依赖唯一索引兜底。

过期处理#

  • 免费额度:找到 status = ACTIVE AND expires_at <= now() 的批次,逐条加行锁,状态改为 EXPIRED,并写一条负数流水。
  • VIP 额度:同样的机制,按周期结束时间处理。
  • 管理员赠送:永不过期。

升级 VIP 时清零免费额度#

用户开通 VIP 时,系统会把当前所有 ACTIVE 的免费批次清零并标记为 EXPIRED,同时写一条 FREE_DAILY_CLEAR_ON_VIP_UPGRADE 负数流水。之后该用户不再发放每日免费额度。

计费输入:token 从哪里来#

  • 非流式调用:直接从响应体的 usage 字段读取。
  • 流式调用:请求里带 stream_options.include_usage = true,模型服务会在最后一个 chunk 返回 usage,客户端边收流边保存最后拿到的值。
  • 三个 token 字段都会做非负清洗,null 按 0 处理。
  • 如果模型服务完全不返回 usage(部分兼容网关会忽略 include_usage),这次调用会跳过扣费,日志里记录 usageMissing=true。这是目前唯一的系统性漏计费入口。

单价换算规则:

cost = ceil(input × inputRate / 1000)
+ ceil(output × outputRate / 1000)
+ ceil(reasoning × reasoningRate / 1000)

rate 的单位是 micro-credit 每 1000 token。默认 1 积分每 1000 token 对应 rate = 1000。三段分别向上取整后相加,所以每段至少产生 1 micro-credit。

事务边界与失败语义#

模型调用、保存回复、扣费是三段独立事务:

  1. 调用模型,不占数据库事务;
  2. 保存 assistant 消息,独立事务;
  3. 扣费,独立事务。

由此产生几种行为:

  • 扣费失败但不是 402(例如数据库异常):回复已经保存、token 已经消耗,但没有流水,系统不会补偿。
  • 扣费返回 402:调用方显式删除刚保存的 assistant 消息,再把 402 抛给前端。这是唯一的补偿动作。
  • 扣费事务内部:额度扣减、用户统计、流水插入在同一事务,流水写失败会整体回滚,不会出现“扣了额度却没记账”。

进程在模型调用完成、扣费之前崩溃,这次调用就是免费的,回复可能已保存也可能没有。批次表是唯一事实源,崩溃不会造成余额漂移,定时任务会在下一轮补发或过期。

多实例调度的隐患#

发放和过期任务都用事务包住整个循环,循环体里 catch 单个用户的异常后继续。

在 PostgreSQL 中,一旦某条插入触发唯一约束冲突,当前事务会被标记为 aborted,后续 SQL 都会报 “current transaction is aborted”,catch 并不能让事务恢复。

因此多实例并发扫描,或者用户发消息正好和扫描撞上时,可能出现:单个用户冲突导致整个扫描事务回滚,这一轮其他用户也没发成。下一分钟重跑通常能恢复,但这是真实存在的风险。

更稳妥的做法是让每个用户使用独立事务(REQUIRES_NEW),或者使用 INSERT ... ON CONFLICT DO NOTHING,或者引入分布式调度锁。

配置与缓存#

  • 配置只在表为空时用环境变量播种一次。已有数据后会完全忽略环境变量。
  • 读路径走 JVM 内存缓存,后台更新后刷新数据库并清除本实例缓存。
  • 多实例部署时,其他实例不会失效,会继续使用旧单价,直到重启。
  • 流水里保存了每笔的单价快照,所以改价只影响之后的新扣费,历史账目仍可复算。

已知问题#

按影响排序:

  1. 推理 token 重复计费。多数模型服务的 completion_tokens 已经包含 reasoning_tokens,代码又在输出和推理上各算一次。只要推理单价大于 0,推理部分就会被收两次。正确做法是把推理单价配置为 0,或者把输出单价定义为“总 completion 单价”。
  2. 免费额度流水的 balance_after 漏算管理员赠送。免费额度发放和过期时只累加免费与 VIP 额度,没有加管理员赠送。有赠送的用户会看到流水里的“扣后余额”低于真实余额;账户接口用的是完整三桶求和,所以展示余额是对的,但对账时会对不上。
  3. 用户表三个积分列是死字段。从未写入,任何直接查用户表的报表或运维脚本都会得到错误余额。
  4. 多实例缓存不失效。后台改价后其他实例继续用旧价,属于静默不一致。
  5. 定时任务没有分布式锁,且是全表扫描加单事务循环。用户量增长后既慢又容易互相踩,冲突时可能整轮回滚。
  6. 流水幂等完全依赖调用方。(source_type, source_ref_id) 没有唯一约束,任何重试路径都可能产生重复扣费记录。
  7. 删除用户会级联删除流水。user_id 是 ON DELETE CASCADE,删用户会丢掉全部计费历史,对账和审计场景要特别注意。
  8. 时间类型不统一。多数表使用无时区的 TIMESTAMP,部分新表使用 TIMESTAMPTZ,依赖 JVM 或连接时区设置,跨时区部署时容易出现偏移。

排查用的 SQL#

某个用户的完整账本:

SELECT source_type, billing_channel, model_name,
token_input, token_output, token_reasoning,
credits_delta, balance_after, meta_json, created_at
FROM credit_ledger_entries
WHERE user_id = '<uuid>'
ORDER BY created_at DESC
LIMIT 50;

某次会话的扣费:

SELECT source_type, credits_delta, meta_json
FROM credit_ledger_entries
WHERE session_id = '<uuid>'
ORDER BY created_at;

余额拆分(结果单位是 micro-credit,除以 1000 才是展示值):

SELECT
(SELECT COALESCE(SUM(remaining_credits), 0) FROM free_daily_credit_grants
WHERE user_id = $1 AND status = 'ACTIVE'
AND remaining_credits > 0 AND expires_at > now()) AS free_micro,
(SELECT COALESCE(SUM(remaining_credits), 0) FROM manual_credit_grants
WHERE user_id = $1 AND status = 'ACTIVE'
AND remaining_credits > 0) AS manual_micro,
(SELECT COALESCE(SUM(remaining_credits), 0) FROM vip_credit_grants
WHERE user_id = $1 AND status = 'ACTIVE'
AND remaining_credits > 0 AND expires_at > now()) AS vip_micro;

用流水和余额对账:

SELECT COALESCE(SUM(credits_delta), 0) AS ledger_sum_micro
FROM credit_ledger_entries
WHERE user_id = '<uuid>';

正常情况下,流水求和应该等于当前可用余额。对不上时优先检查:

  • 是否存在状态非 ACTIVE 但 remaining_credits 仍大于 0 的脏数据;
  • 是否有绕过流水直接更新批次表的操作;
  • balance_after 的口径问题只影响单行快照,不影响 credits_delta 求和。

容量与优化方向#

当前的查询特征:

  • 每次读余额固定 3 条聚合;
  • 一次扣费约 12 到 20 条 SQL,包含 3 次余额聚合、1 次用户行锁、最多 3 次额度行锁查询和若干逐行更新;
  • 额度扣减是逐行 UPDATE,没有批量更新。用户积累多个未用完的小批次时,单次扣费会更新多行;
  • 每日扫描是全表用户加每人至少 2 条 SQL,分钟级频率,十万用户规模后会明显吃数据库;
  • 每条消息 3 到 5 次模型调用,每次都完整走一遍扣费,是写放大的主要来源。

如果要优化,优先级大致是:

  1. 合并扣费流程里的多次余额聚合;
  2. 在用户表增加物化余额列,用触发器或事务内维护;
  3. 定时任务改成游标分页加单用户事务;
  4. 引入分布式调度锁,避免多实例重复扫描。

这套设计的核心取舍很明确:用“批次 + 流水”换取可追溯和多种过期策略,代价是读余额和扣费路径上的聚合开销。对账、审计和运营调整都因此变得简单,但性能优化必须围绕这些聚合查询来做。

AI 聊天产品的积分计费系统:从 token 计价到额度账本
https://blog.xqcherry.top/posts/ai/ai-chat-credit-billing/
作者
xqcherry
发布于
2026-10-23
许可协议
CC BY-NC-SA 4.0