PAYATHON 2026

用于计费的高效 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。
  • 如果报表查询较多,可以按时间归档或分区历史表,但实时扣费不需要扫描这些历史数据。
  • 如果需要记录余额不足等失败请求,可以单独保存请求状态,但不能把失败请求写成成功收费流水。

备注:内容仅供参考。