是否需要用 column 存储 digital wallet balance?
结论
交易流水必须保存,作为余额审计和对账的依据。生产环境通常还会单独保存当前余额,方便查询并处理并发扣款。
不要只在 user 表中保存一个可以随意修改的 balance。比较合适的设计是:
- 用
wallet_account保存当前余额; - 用
wallet_transaction记录每次充值、提现和订阅支付; - 在同一个数据库事务中写入流水并更新余额;
- 不直接修改或删除流水,出现错误时新增冲正交易。
余额由流水汇总得出,同时也可以作为受控的缓存值持久化。这两种做法并不矛盾。
为什么不能只保存 balance
如果系统只保存当前余额,发生程序错误、重复回调或人工误操作后,很难查清:
- 余额为什么是这个数?
- 某次充值是否已经到账?
- 订阅费用是否被重复扣除?
- 提现失败后,资金是否已经退回?
- 当前余额能否与支付渠道的记录对应?
所以,完整的交易流水不能省略。
为什么不建议每次都计算全部流水
理论上,余额可以通过下面的查询计算:
SELECT COALESCE(SUM(amount), 0)
FROM wallet_transaction
WHERE wallet_id = :wallet_id
AND status = 'posted';
这里约定充值金额为正数,提现和订阅支付金额为负数。
数据量较小、查询不频繁时,这种方案可以正常工作。但流水不断增加后,每次求和的成本也会提高。仅靠流水求和,也无法直接解决并发扣款问题。
假设钱包余额为 100,两笔金额为 80 的支付同时执行。两个请求可能都先读取到余额 100,各自判断余额充足,随后分别扣款,最终导致超额消费。
生产系统通常会保存当前余额,再通过数据库行锁或原子条件更新来控制并发。
推荐的数据结构
余额应属于钱包账户,而不是直接属于用户。一个用户以后可能拥有不同币种或多个钱包,因此建议单独建立钱包表。
CREATE TABLE wallet_account (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, currency),
CHECK (balance >= 0)
);
交易流水表可以这样设计:
CREATE TABLE wallet_transaction (
id BIGSERIAL PRIMARY KEY,
wallet_id BIGINT NOT NULL REFERENCES wallet_account(id),
transaction_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
reference_id VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (idempotency_key),
CHECK (amount <> 0)
);
字段约定如下:
DEPOSIT的amount为正数;WITHDRAWAL的amount为负数;SUBSCRIPTION_PAYMENT的amount为负数;status = 'posted'表示交易已经正式计入余额;idempotency_key用来防止支付回调或客户端重试造成重复入账。
金额建议按最小货币单位保存为整数。例如,10.25 CNY 应保存为 1025 分。不要用 FLOAT 或 DOUBLE 保存货币金额。如果业务需要支持超过固定小数位的金额,可以改用精度明确的 DECIMAL。
在同一个事务中写流水并更新余额
处理订阅扣款时,可以先锁定钱包记录,再检查并更新余额:
BEGIN;
SELECT balance
FROM wallet_account
WHERE id = :wallet_id
FOR UPDATE;
-- 应用层检查 balance >= :payment_amount
INSERT INTO wallet_transaction (
wallet_id,
transaction_type,
amount,
status,
idempotency_key,
reference_id
) VALUES (
:wallet_id,
'SUBSCRIPTION_PAYMENT',
-:payment_amount,
'posted',
:idempotency_key,
:subscription_id
);
UPDATE wallet_account
SET balance = balance - :payment_amount,
updated_at = CURRENT_TIMESTAMP
WHERE id = :wallet_id;
COMMIT;
另一种做法是使用条件更新,由数据库原子地判断余额是否充足:
BEGIN;
UPDATE wallet_account
SET balance = balance - :payment_amount,
updated_at = CURRENT_TIMESTAMP
WHERE id = :wallet_id
AND balance >= :payment_amount;
应用程序必须检查受影响的行数。结果为 0 时,说明钱包不存在或余额不足。此时必须回滚事务,不能插入成功流水。
INSERT INTO wallet_transaction (
wallet_id,
transaction_type,
amount,
status,
idempotency_key,
reference_id
) VALUES (
:wallet_id,
'SUBSCRIPTION_PAYMENT',
-:payment_amount,
'posted',
:idempotency_key,
:subscription_id
);
COMMIT;
实际代码中,UPDATE 和 INSERT 必须使用同一个数据库连接,并处于同一个事务中。任何一步失败,整个事务都要回滚。
充值和提现的处理
用户发起支付时,不应立即把充值金额计入余额。通常要等支付渠道确认成功后再入账。由于渠道可能重复发送回调,系统必须使用 idempotency_key 或渠道交易号保证幂等。
提现通常不会立即完成,可能经过以下状态:
pending -> processing -> completed
\-> failed
如果用户申请提现后需要立即冻结资金,可以进一步拆分余额:
available_balance BIGINT NOT NULL DEFAULT 0,
pending_balance BIGINT NOT NULL DEFAULT 0
申请提现时,将对应金额从 available_balance 转入 pending_balance。提现成功后扣除冻结金额,失败则把资金退回可用余额。这样可以防止提现处理期间,同一笔钱又被用于订阅支付。
定期校验余额
wallet_account.balance 用于日常查询,但系统仍应定期根据已入账流水重新计算余额并核对:
SELECT
w.id,
w.balance AS stored_balance,
COALESCE(SUM(t.amount), 0) AS calculated_balance
FROM wallet_account w
LEFT JOIN wallet_transaction t
ON t.wallet_id = w.id
AND t.status = 'posted'
GROUP BY w.id, w.balance
HAVING w.balance <> COALESCE(SUM(t.amount), 0);
正常情况下,这个查询不应返回任何记录。如果发现差异,应先查明原因,不要直接用计算结果覆盖余额。
需要特别注意的事项
- 普通业务代码不能直接执行
balance = 某个值,余额只能通过明确的入账或出账操作改变。 - 已入账流水应保持不可变。退款、撤销或纠错时,应新增一笔方向相反的冲正流水。
- 所有可能被重试的请求都需要幂等键。
- 余额更新和流水写入必须放在同一个数据库事务中。
- 不要依赖应用层的“先查询、再更新”来控制并发,应使用行锁或原子条件更新。
- 如果支持多币种,应为每个币种建立独立钱包,不能把不同币种的金额直接相加。
- 如果钱包对应真实资金账户,还要根据适用地区的支付、会计、审计和资金托管要求进行设计。具体要求取决于业务所在地和资金流转方式。
对于简单系统,采用“流水作为事实依据,钱包表保存当前余额”的设计通常已经足够稳妥。如果以后需要更严格的财务审计、多账户转账或平台资金核算,可以再升级为复式记账模型。
备注:内容仅供参考。
