用于计费的高效 database design
结论
不必在每次请求到达时扫描并汇总 Rotate_Photo。更合适的做法是:
- 保留不可变的流水表,用于审计和历史查询。
- 在账户表中保存当前可用余额,以整数分为单位,不使用
FLOAT。 - 每次请求都在事务中完成余额检查和扣减,同时写入请求记录与计费流水。
- 使用唯一的
request_id,避免重试造成重复扣费。
这样,实时余额检查只需更新一条账户记录,处理成本不会随历史请求数量增加。
为什么不应实时汇总历史记录
下面的计算方式在逻辑上可以得出余额:
SUM(payments) - COUNT(rotate_requests)
但它不适合高频请求的实时处理。历史数据越多,数据库需要扫描的索引或记录就越多。两个并发请求还可能同时读到相同的余额,并都判定余额充足,最终造成透支。
历史流水适合审计、对账和报表,不应作为每次请求计算余额的数据来源。
金额也不应使用:
FLOAT(5,0)
FLOAT 是近似数值类型,(5,0) 也不能表示小数金额。计费字段可以选择:
- 用
BIGINT保存最小货币单位,例如 1 表示 1 cent。 - 用
DECIMAL(19,4)等定点类型保存金额。
如果始终按美分计费,使用整数通常最简单。
推荐的数据结构
实际系统中,外键最好使用不可变的 customer_id,而不是可能发生变化的 license。
CREATE TABLE billing_account (
customer_id BIGINT NOT NULL,
balance_cents BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL
DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (customer_id),
CONSTRAINT fk_billing_account_customer
FOREIGN KEY (customer_id)
REFERENCES customer(id)
) ENGINE=InnoDB;
旋转请求表用于保留业务历史,并通过客户端或服务器生成的请求编号实现幂等:
CREATE TABLE rotate_photo_request (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
customer_id BIGINT NOT NULL,
cost_cents BIGINT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_rotate_request_id (request_id),
KEY idx_rotate_customer_time (customer_id, created_at),
CONSTRAINT fk_rotate_customer
FOREIGN KEY (customer_id)
REFERENCES customer(id),
CONSTRAINT chk_rotate_cost
CHECK (cost_cents >= 0)
) ENGINE=InnoDB;
还需要一张统一的资金流水表,其中正数表示充值或退款,负数表示消费:
CREATE TABLE billing_ledger (
id BIGINT NOT NULL AUTO_INCREMENT,
customer_id BIGINT NOT NULL,
entry_type VARCHAR(20) NOT NULL,
amount_cents BIGINT NOT NULL,
reference_type VARCHAR(30) NOT NULL,
reference_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_billing_reference (
customer_id,
reference_type,
reference_id
),
KEY idx_ledger_customer_time (customer_id, created_at),
CONSTRAINT fk_ledger_customer
FOREIGN KEY (customer_id)
REFERENCES customer(id)
) ENGINE=InnoDB;
各张表的用途如下:
billing_account.balance_cents保存实时查询所需的余额快照。billing_ledger保存账务事实记录。rotate_photo_request保存业务请求记录。reference_id可以保存对应的request_id。
并发扣费方式
如果每次旋转收费 1 cent,可以通过条件更新完成原子扣款:
START TRANSACTION;
UPDATE billing_account
SET balance_cents = balance_cents - 1
WHERE customer_id = ?
AND balance_cents >= 1;
执行后检查受影响的行数。结果为 0,表示账户不存在或余额不足,此时回滚事务:
ROLLBACK;
结果为 1,则继续写入请求记录和计费流水:
INSERT INTO rotate_photo_request (
request_id,
customer_id,
cost_cents
) VALUES (
?,
?,
1
);
INSERT INTO billing_ledger (
customer_id,
entry_type,
amount_cents,
reference_type,
reference_id
) VALUES (
?,
'CHARGE',
-1,
'ROTATE_PHOTO',
?
);
COMMIT;
整个过程必须使用同一个数据库连接,并放在同一个事务中。
余额条件:
balance_cents >= 1
和余额扣减:
balance_cents = balance_cents - 1
由同一条 UPDATE 完成。InnoDB 会锁定对应的账户行,因此同一客户的并发请求无法同时消费同一份余额。
如果后续插入请求记录或流水失败,事务会回滚,先前的余额扣减也会一并撤销。
防止重复扣费
高并发服务经常遇到超时重试。例如,数据库已经提交事务,但调用方没有及时收到响应,于是再次发送相同请求。
因此,每个业务请求都必须有稳定且唯一的 request_id:
UNIQUE KEY uq_rotate_request_id (request_id)
再次执行同一请求时,插入操作会触发唯一键冲突。由于余额更新和插入操作处于同一事务中,这次事务应当整体回滚,不会再次扣费。
应用程序需要识别重复键错误,查询已有请求,并返回第一次处理的结果。每次重试都必须复用原来的 request_id。如果生成新的 request_id,数据库就无法判断这些重试来自同一个业务请求。
充值也要使用事务
充值时不能只向流水表插入记录,还要同步更新账户余额:
START TRANSACTION;
UPDATE billing_account
SET balance_cents = balance_cents + ?
WHERE customer_id = ?;
INSERT INTO billing_ledger (
customer_id,
entry_type,
amount_cents,
reference_type,
reference_id
) VALUES (
?,
'PAYMENT',
?,
'PAYMENT',
?
);
COMMIT;
支付平台的交易号可以作为 reference_id,再用唯一约束防止支付回调造成重复入账。
如果客户还没有账户记录,可以在创建客户时一并创建 billing_account,避免在扣费流程中处理账户初始化。
余额快照与流水如何配合
账户余额是为提高查询性能而保存的汇总值,流水才是对账依据。可以定期运行类似下面的查询进行核对:
SELECT
customer_id,
SUM(amount_cents) AS calculated_balance_cents
FROM billing_ledger
GROUP BY customer_id;
正常情况下,应满足:
billing_account.balance_cents
=
SUM(billing_ledger.amount_cents)
这类汇总只用于定期对账、异常修复或生成报表,不应在每次旋转请求中执行。
其他注意事项
- 不建议对账务流水使用
ON DELETE CASCADE。删除客户时一并删除收费历史,通常会破坏审计能力。可以改用RESTRICT、软删除,或对个人信息进行匿名化处理。 - 不要把
TIMESTAMP当作记录的唯一标识或排序依据。应使用自增主键、分布式 ID 或业务请求 ID。 - 同一客户的扣款最终会竞争同一条账户记录,这是保证余额正确所需的代价。不同客户的请求仍可并行处理。
- 发生事务死锁或锁等待超时时,应用程序可以进行有限次数的重试,但必须复用同一个
request_id。 - 如果报表查询较多,可以按时间归档或分区历史表,但实时扣费不需要扫描这些历史数据。
- 如果需要记录余额不足等失败请求,可以单独保存请求状态,但不能把失败请求写成成功收费流水。
备注:内容仅供参考。
