PAYATHON 2026

拆分多个资金桶时,哪种 Balance 表设计更合理?

支付阿杰

结论

优先选择 Option B:用 PAYMENTS 保存付款记录,用 PAYMENT_DETAILS 保存付款在各资金桶中的分配明细。

这种设计符合数据规范化要求,也适合“一笔付款通常只涉及 1~3 个资金桶”的实际情况。以后新增资金桶,只需增加类型数据,不必修改表结构、迁移历史数据,或逐一调整依赖代码。

只有当资金桶种类长期固定、字段含义不会变化,而且系统确实依赖简单的宽表查询时,才适合考虑 Option A。

推荐的表结构

可以为资金桶建立独立的类型表,并为明细表设置主键、外键和唯一约束:

CREATE TABLE PAYMENT_TYPES (
    PaymentTypeId INTEGER PRIMARY KEY,
    TypeCode      VARCHAR(50) NOT NULL UNIQUE,
    TypeName      VARCHAR(100) NOT NULL,
    IsActive      BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE PAYMENTS (
    PaymentId   BIGINT PRIMARY KEY,
    AccountId   BIGINT NOT NULL,
    PaymentDate TIMESTAMP NOT NULL,
    TotalPaid   DECIMAL(19, 4) NOT NULL,
    CONSTRAINT CK_PAYMENTS_TOTAL
        CHECK (TotalPaid >= 0)
);

CREATE TABLE PAYMENT_DETAILS (
    PaymentDetailId BIGINT PRIMARY KEY,
    PaymentId       BIGINT NOT NULL,
    PaymentTypeId   INTEGER NOT NULL,
    AmountPaid      DECIMAL(19, 4) NOT NULL,

    CONSTRAINT FK_PAYMENT_DETAILS_PAYMENT
        FOREIGN KEY (PaymentId)
        REFERENCES PAYMENTS (PaymentId),

    CONSTRAINT FK_PAYMENT_DETAILS_TYPE
        FOREIGN KEY (PaymentTypeId)
        REFERENCES PAYMENT_TYPES (PaymentTypeId),

    CONSTRAINT UQ_PAYMENT_DETAILS_TYPE
        UNIQUE (PaymentId, PaymentTypeId),

    CONSTRAINT CK_PAYMENT_DETAILS_AMOUNT
        CHECK (AmountPaid > 0)
);

UNIQUE (PaymentId, PaymentTypeId) 限制同一笔付款的同一种资金桶只能有一条明细。如果业务需要记录多次分配过程,可以取消这一约束,或增加分配批次、顺序等字段。

金额应使用 DECIMAL 或数据库提供的其他精确数值类型,不要使用 FLOAT、REAL 等浮点类型。

为什么 Option B 更合理

1. 避免大量空字段

Option A 会为每一种资金桶设置一列:

PrincipalPaid
InterestPaid
MiscPaid
BadCheckChargesPaid
...

一笔付款通常只涉及其中 1~3 种类型,其他字段大多只能保存 NULL 或 0。Option B 只记录实际发生的分配,表达更直接:

PaymentId | PaymentTypeId | AmountPaid
1001      | PRINCIPAL     | 800.00
1001      | INTEREST      | 120.00

2. 新增资金桶不需要修改表结构

使用 Option A 时,每增加一种资金桶,通常都要:

  • 增加数据库列;
  • 修改插入和更新语句;
  • 修改 ORM 实体;
  • 修改报表和导出逻辑;
  • 重新检查所有使用 SELECT * 或固定字段列表的代码。

使用 Option B,一般只需在 PAYMENT_TYPES 中增加一条记录:

INSERT INTO PAYMENT_TYPES (
    PaymentTypeId,
    TypeCode,
    TypeName
)
VALUES (
    11,
    'LEGAL_FEE',
    'Legal Fee'
);

前提是新增类型沿用现有类型的业务规则。如果某个资金桶有单独的计算、审批或会计规则,仍需编写相应的业务代码。

3. 数据规则更容易约束

Option B 可以用外键限制合法的资金桶类型,并用唯一约束防止重复分配。

对于下面这些规则,Option A 很难直接通过表结构表达:

  • 哪些资金桶当前有效;
  • 哪些资金桶已经停用;
  • 资金桶的显示名称和排序;
  • 某个类型是否允许人工调整;
  • 同一种类型能否在一笔付款中重复出现。

把这些信息放进 PAYMENT_TYPES 更清楚,也便于统一维护。

4. 更适合按类型统计

例如,统计每个账户分配到各资金桶的金额:

SELECT
    p.AccountId,
    pt.TypeCode,
    SUM(pd.AmountPaid) AS TotalAmount
FROM PAYMENTS AS p
JOIN PAYMENT_DETAILS AS pd
    ON pd.PaymentId = p.PaymentId
JOIN PAYMENT_TYPES AS pt
    ON pt.PaymentTypeId = pd.PaymentTypeId
GROUP BY
    p.AccountId,
    pt.TypeCode;

如果报表需要把类型转换成列,可以使用条件聚合:

SELECT
    p.PaymentId,
    p.AccountId,
    SUM(
        CASE
            WHEN pt.TypeCode = 'PRINCIPAL'
            THEN pd.AmountPaid
            ELSE 0
        END
    ) AS PrincipalPaid,
    SUM(
        CASE
            WHEN pt.TypeCode = 'INTEREST'
            THEN pd.AmountPaid
            ELSE 0
        END
    ) AS InterestPaid,
    SUM(
        CASE
            WHEN pt.TypeCode = 'LATE_FEE'
            THEN pd.AmountPaid
            ELSE 0
        END
    ) AS LateFeePaid
FROM PAYMENTS AS p
LEFT JOIN PAYMENT_DETAILS AS pd
    ON pd.PaymentId = p.PaymentId
LEFT JOIN PAYMENT_TYPES AS pt
    ON pt.PaymentTypeId = pd.PaymentTypeId
GROUP BY
    p.PaymentId,
    p.AccountId;

底层数据可以保持规范化,展示层再按需要生成宽表。没有必要为了报表的显示格式,把存储结构也设计成宽表。

TotalPaid 是否应该保存

理论上,TotalPaid 可以通过明细求和得到:

SELECT
    PaymentId,
    SUM(AmountPaid) AS TotalPaid
FROM PAYMENT_DETAILS
GROUP BY PaymentId;

从严格规范化的角度看,PAYMENTS.TotalPaid 属于冗余数据。是否保留,取决于它具体代表什么。

如果 TotalPaid 只是所有资金桶金额的合计,可以不保存,在查询时计算即可,也就不会出现主表总额与明细合计不一致的问题。

如果 TotalPaid 代表付款凭证上的原始金额,而明细记录后续分配结果,则可以保留。此时需要保证:

PAYMENTS.TotalPaid = SUM(PAYMENT_DETAILS.AmountPaid)

付款记录和明细应在同一个事务中写入:

BEGIN;

INSERT INTO PAYMENTS (
    PaymentId,
    AccountId,
    PaymentDate,
    TotalPaid
)
VALUES (
    1001,
    20001,
    CURRENT_TIMESTAMP,
    920.00
);

INSERT INTO PAYMENT_DETAILS (
    PaymentDetailId,
    PaymentId,
    PaymentTypeId,
    AmountPaid
)
VALUES
    (5001, 1001, 1, 800.00),
    (5002, 1001, 2, 120.00);

COMMIT;

普通 CHECK 约束通常无法跨表验证合计金额。具体采用哪种一致性机制,要根据数据库产品决定:

  • 在应用服务中校验,并通过事务提交;
  • 使用存储过程统一写入;
  • 使用延迟约束或约束触发器;
  • 将 TotalPaid 作为不可变的原始付款金额,并定期检查分配差异。

不建议随意用普通触发器维护总额。遇到并发、批量更新和错误恢复时,这类触发器逻辑往往很难排查。

查询效率与索引

Option B 需要多做一次连接,但不代表性能一定更差。每笔付款只有少量明细时,合理的索引通常足以支持常见查询。

建议至少考虑以下索引:

CREATE INDEX IX_PAYMENTS_ACCOUNT_DATE
    ON PAYMENTS (AccountId, PaymentDate);

CREATE INDEX IX_PAYMENT_DETAILS_PAYMENT
    ON PAYMENT_DETAILS (PaymentId);

CREATE INDEX IX_PAYMENT_DETAILS_TYPE
    ON PAYMENT_DETAILS (PaymentTypeId, PaymentId);

如果已经设置:

UNIQUE (PaymentId, PaymentTypeId)

数据库通常会为该约束创建索引,因此单独建立 PaymentId 索引可能造成重复。是否需要保留,应根据具体数据库的索引规则和执行计划判断。

索引要围绕实际查询建立:

  • 按付款加载分配明细时,优先索引 PaymentId;
  • 按资金桶统计时,优先索引 PaymentTypeId;
  • 按账户和日期查询付款时,使用 (AccountId, PaymentDate) 索引。

不能只因为 Option A 少一次 JOIN 就选择它。应结合实际数据量、典型查询和执行计划进行验证。

Balance 也可以采用相同思路

如果账户余额也需要按资金桶拆分,可以建立相应的余额明细表:

CREATE TABLE ACCOUNT_BALANCES (
    AccountId      BIGINT NOT NULL,
    PaymentTypeId  INTEGER NOT NULL,
    BalanceAmount  DECIMAL(19, 4) NOT NULL,

    PRIMARY KEY (AccountId, PaymentTypeId),

    FOREIGN KEY (PaymentTypeId)
        REFERENCES PAYMENT_TYPES (PaymentTypeId)
);

不过,首先要明确 Balance 表示当前状态,还是可以从交易流水计算出的结果。如果系统需要审计、撤销、追溯或按时间重建余额,通常应保存不可变的资金变动流水,再把当前余额作为汇总结果或缓存,而不是只保存一个不断被覆盖的余额值。

Option A 什么时候可以考虑

Option A 并非在所有情况下都不合理。如果同时满足以下条件,可以考虑宽表设计:

  • 资金桶数量很少,而且由法规或核心业务永久固定;
  • 每种资金桶都有不同的业务含义和约束;
  • 绝大多数查询都要同时读取全部类型;
  • 不需要动态增加类型;
  • 真实负载测试已经证明宽表有明显收益。

即使采用 Option A,也不要用 MiscPaid1、MiscPaid2 这类字段扩展业务,否则字段含义很快就会变得模糊。

注意事项

  • PaymentTypeId 应引用类型表,不要依赖应用随意传入数字。
  • 类型记录被使用后,尽量不要物理删除,可以通过 IsActive 停用。
  • 明确金额是否允许为负数。退款、冲销和调整可能需要负值,此时不能简单使用 CHECK (AmountPaid > 0),而应增加交易方向或业务类型。
  • 明确是否允许存在未分配金额。如果允许,就不能强制明细合计始终等于 TotalPaid。
  • 更新分配时应使用事务,避免主表已经写入,但明细只写入了一部分。
  • 历史记录最好保存稳定的类型代码。展示名称发生变化时,不应改变已有数据的含义。
  • 如果报表经常需要宽表,可以使用视图、物化视图或汇总表优化,不必改变规范化的交易表结构。

综合考虑,Option B 更适合作为交易数据的基础模型。它将“付款”和“付款如何分配”分开保存,新增资金桶、控制数据一致性和编写统计查询都会更灵活。只有当资金桶确实固定,并且实际性能测试证明宽表更合适时,才值得采用 Option A。

备注:内容仅供参考。