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 |
status | ACTIVE / EXHAUSTED / EXPIRED |
granted_at / expires_at | expires_at 是当天本地时间次日 0 点 |
meta_json | JSONB,记录发放原因和时区等上下文 |
关键约束:
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_grantsWHERE user_id = ? AND status = 'ACTIVE' AND remaining_credits > 0 AND expires_at > now();
SELECT COALESCE(SUM(remaining_credits), 0)FROM manual_credit_grantsWHERE user_id = ? AND status = 'ACTIVE' AND remaining_credits > 0;
SELECT COALESCE(SUM(remaining_credits), 0)FROM vip_credit_grantsWHERE 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。聊天链路本身没有事务,所以扣费是一个独立事务,和保存回复不在同一个事务里。
事务内的顺序:
-
补发当天免费额度:先用
SELECT ... FOR UPDATE查当天是否已有批次;没有则插入;并发插入冲突时由唯一约束兜底。 -
计算扣前余额:执行三张表的聚合。
-
锁用户行:
SELECT ... FROM users WHERE id = ? FOR UPDATE。 -
按顺序扣三个额度桶,每桶都先对候选行加行锁:
- 免费额度:
ORDER BY expires_at ASC, granted_at ASC - 管理员赠送:
ORDER BY granted_at ASC - VIP 额度:
ORDER BY expires_at ASC, granted_at ASC
固定顺序是免费、赠送、VIP,因为免费额度当天过期,要先消耗过期最快的。
- 免费额度:
-
累加
users.credit_spent_total。 -
计算扣后余额:再次执行三张表聚合。
-
插入一条流水,写入 token、单价快照、扣减明细和
balance_after。
一次扣费大约 12 到 20 条 SQL。一条用户消息可能触发 3 到 5 次模型调用,对应 50 到 100 条 SQL,这是整个系统最主要的数据库压力来源。
并发控制
系统没有使用乐观锁,全部依赖数据库悲观锁。
- 用户行锁是同一用户并发扣费的串行化点。
- 额度行锁保证同一批次不会被两个事务同时扣减。
- 锁顺序在所有扣费路径上保持一致,正常情况下不会死锁。
数据库隔离级别是 PostgreSQL 默认的 READ COMMITTED。第二个事务在等待 FOR UPDATE 期间,第一个事务提交后会重新检查行是否仍满足 remaining_credits > 0,已扣完的行会被跳过。因此不会出现两个请求各扣一遍同一份额度。
每个批次的扣减量是 min(remaining, remainingCost),扣到 0 就停止,不会把单行扣成负数。
余额不足的两种处理
- 如果事务开始时总余额已经小于等于 0,直接返回 402
INSUFFICIENT_CREDITS,不产生任何扣减。 - 如果余额大于 0 但不足以支付本次费用,不报错,而是能扣多少扣多少,剩余部分记为减免。
流水里会记录:
requestedCost:本次应收;chargedCost:实际扣减;waivedCost:减免;chargeMode:FULL_CHARGE或CAP_TO_ZERO_LAST_USE。
也就是说,系统没有“预留”或“冻结”机制,预检只判断余额是否大于 0,所以最后一笔超额是设计允许的行为。实际日志中能看到“应收 2114、实收 645、减免 1469”的记录,用户回复照常返回,余额归零,下一条消息才被拦截。
额度发放与过期
每日免费额度的三条触发路径
- 注册成功时立即发放;
- 登录、打开页面、发消息、扣费前都会顺手检查并补发;
- 定时任务每分钟全量扫描所有用户。
扫描的实现是先把所有用户的 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。
事务边界与失败语义
模型调用、保存回复、扣费是三段独立事务:
- 调用模型,不占数据库事务;
- 保存 assistant 消息,独立事务;
- 扣费,独立事务。
由此产生几种行为:
- 扣费失败但不是 402(例如数据库异常):回复已经保存、token 已经消耗,但没有流水,系统不会补偿。
- 扣费返回 402:调用方显式删除刚保存的 assistant 消息,再把 402 抛给前端。这是唯一的补偿动作。
- 扣费事务内部:额度扣减、用户统计、流水插入在同一事务,流水写失败会整体回滚,不会出现“扣了额度却没记账”。
进程在模型调用完成、扣费之前崩溃,这次调用就是免费的,回复可能已保存也可能没有。批次表是唯一事实源,崩溃不会造成余额漂移,定时任务会在下一轮补发或过期。
多实例调度的隐患
发放和过期任务都用事务包住整个循环,循环体里 catch 单个用户的异常后继续。
在 PostgreSQL 中,一旦某条插入触发唯一约束冲突,当前事务会被标记为 aborted,后续 SQL 都会报 “current transaction is aborted”,catch 并不能让事务恢复。
因此多实例并发扫描,或者用户发消息正好和扫描撞上时,可能出现:单个用户冲突导致整个扫描事务回滚,这一轮其他用户也没发成。下一分钟重跑通常能恢复,但这是真实存在的风险。
更稳妥的做法是让每个用户使用独立事务(REQUIRES_NEW),或者使用 INSERT ... ON CONFLICT DO NOTHING,或者引入分布式调度锁。
配置与缓存
- 配置只在表为空时用环境变量播种一次。已有数据后会完全忽略环境变量。
- 读路径走 JVM 内存缓存,后台更新后刷新数据库并清除本实例缓存。
- 多实例部署时,其他实例不会失效,会继续使用旧单价,直到重启。
- 流水里保存了每笔的单价快照,所以改价只影响之后的新扣费,历史账目仍可复算。
已知问题
按影响排序:
- 推理 token 重复计费。多数模型服务的
completion_tokens已经包含reasoning_tokens,代码又在输出和推理上各算一次。只要推理单价大于 0,推理部分就会被收两次。正确做法是把推理单价配置为 0,或者把输出单价定义为“总 completion 单价”。 - 免费额度流水的
balance_after漏算管理员赠送。免费额度发放和过期时只累加免费与 VIP 额度,没有加管理员赠送。有赠送的用户会看到流水里的“扣后余额”低于真实余额;账户接口用的是完整三桶求和,所以展示余额是对的,但对账时会对不上。 - 用户表三个积分列是死字段。从未写入,任何直接查用户表的报表或运维脚本都会得到错误余额。
- 多实例缓存不失效。后台改价后其他实例继续用旧价,属于静默不一致。
- 定时任务没有分布式锁,且是全表扫描加单事务循环。用户量增长后既慢又容易互相踩,冲突时可能整轮回滚。
- 流水幂等完全依赖调用方。
(source_type, source_ref_id)没有唯一约束,任何重试路径都可能产生重复扣费记录。 - 删除用户会级联删除流水。
user_id是ON DELETE CASCADE,删用户会丢掉全部计费历史,对账和审计场景要特别注意。 - 时间类型不统一。多数表使用无时区的
TIMESTAMP,部分新表使用TIMESTAMPTZ,依赖 JVM 或连接时区设置,跨时区部署时容易出现偏移。
排查用的 SQL
某个用户的完整账本:
SELECT source_type, billing_channel, model_name, token_input, token_output, token_reasoning, credits_delta, balance_after, meta_json, created_atFROM credit_ledger_entriesWHERE user_id = '<uuid>'ORDER BY created_at DESCLIMIT 50;某次会话的扣费:
SELECT source_type, credits_delta, meta_jsonFROM credit_ledger_entriesWHERE 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_microFROM credit_ledger_entriesWHERE user_id = '<uuid>';正常情况下,流水求和应该等于当前可用余额。对不上时优先检查:
- 是否存在状态非
ACTIVE但remaining_credits仍大于 0 的脏数据; - 是否有绕过流水直接更新批次表的操作;
balance_after的口径问题只影响单行快照,不影响credits_delta求和。
容量与优化方向
当前的查询特征:
- 每次读余额固定 3 条聚合;
- 一次扣费约 12 到 20 条 SQL,包含 3 次余额聚合、1 次用户行锁、最多 3 次额度行锁查询和若干逐行更新;
- 额度扣减是逐行
UPDATE,没有批量更新。用户积累多个未用完的小批次时,单次扣费会更新多行; - 每日扫描是全表用户加每人至少 2 条 SQL,分钟级频率,十万用户规模后会明显吃数据库;
- 每条消息 3 到 5 次模型调用,每次都完整走一遍扣费,是写放大的主要来源。
如果要优化,优先级大致是:
- 合并扣费流程里的多次余额聚合;
- 在用户表增加物化余额列,用触发器或事务内维护;
- 定时任务改成游标分页加单用户事务;
- 引入分布式调度锁,避免多实例重复扫描。
这套设计的核心取舍很明确:用“批次 + 流水”换取可追溯和多种过期策略,代价是读余额和扣费路径上的聚合开销。对账、审计和运营调整都因此变得简单,但性能优化必须围绕这些聚合查询来做。