PAYATHON 2026

是否需要用 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 = 某个值,余额只能通过明确的入账或出账操作改变。
  • 已入账流水应保持不可变。退款、撤销或纠错时,应新增一笔方向相反的冲正流水。
  • 所有可能被重试的请求都需要幂等键。
  • 余额更新和流水写入必须放在同一个数据库事务中。
  • 不要依赖应用层的“先查询、再更新”来控制并发,应使用行锁或原子条件更新。
  • 如果支持多币种,应为每个币种建立独立钱包,不能把不同币种的金额直接相加。
  • 如果钱包对应真实资金账户,还要根据适用地区的支付、会计、审计和资金托管要求进行设计。具体要求取决于业务所在地和资金流转方式。

对于简单系统,采用“流水作为事实依据,钱包表保存当前余额”的设计通常已经足够稳妥。如果以后需要更严格的财务审计、多账户转账或平台资金核算,可以再升级为复式记账模型。

备注:内容仅供参考。