PAYATHON 2026

使用 One-To-Many join table 避免 nullable columns

支付小周

结论

与其为每种活动分别建立 Payment join table,更合适的方案是增加一张统一的活动父表,例如 Activities,并让 Payments 通过非空外键引用这张表。

Sales、Loans、Rents 和 Services 都是 Activities 的子类型,共用同一套活动标识。这个结构不会产生大量 nullable columns,也能通过普通 Foreign Key 维护 Referential integrity。以后增加活动类型时,也不需要修改 Payments 表。

推荐的数据模型

表之间的关系如下:

Activities 1 ─── N Payments
     1
     ├── 0..1 Sales
     ├── 0..1 Loans
     ├── 0..1 Rents
     └── 0..1 Services

Activities 保存各类活动共有的标识和属性,具体业务表只保存该类型独有的数据。

下面以 PostgreSQL 为例:

CREATE TYPE activity_kind AS ENUM (
    'sale',
    'loan',
    'rent',
    'service'
);

CREATE TABLE activities (
    activity_id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    activity_kind activity_kind NOT NULL,
    created_at    timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,

    UNIQUE (activity_id, activity_kind)
);

CREATE TABLE payments (
    payment_id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    activity_id bigint NOT NULL,
    amount      numeric(18, 2) NOT NULL,
    paid_at     timestamp,
    
    CONSTRAINT fk_payment_activity
        FOREIGN KEY (activity_id)
        REFERENCES activities (activity_id)
);

每笔 Payment 只有一个 activity_id。数据库可以直接保证:

  • Payment 必须属于一项已经存在的活动;
  • 一项活动可以有零笔或多笔 Payments;
  • Payment 不会同时指向 Loan 和 Rent;
  • 增加活动类型时,不需要给 payments 添加新列。

建模具体活动类型

各业务表可以直接将 activity_id 作为主键,并用它引用 activities:

CREATE TABLE sales (
    activity_id bigint PRIMARY KEY,
    activity_kind activity_kind NOT NULL DEFAULT 'sale',
    customer_id bigint NOT NULL,
    total_amount numeric(18, 2) NOT NULL,

    CONSTRAINT chk_sales_kind
        CHECK (activity_kind = 'sale'),

    CONSTRAINT fk_sales_activity
        FOREIGN KEY (activity_id, activity_kind)
        REFERENCES activities (activity_id, activity_kind)
);

CREATE TABLE loans (
    activity_id bigint PRIMARY KEY,
    activity_kind activity_kind NOT NULL DEFAULT 'loan',
    borrower_id bigint NOT NULL,
    principal numeric(18, 2) NOT NULL,

    CONSTRAINT chk_loans_kind
        CHECK (activity_kind = 'loan'),

    CONSTRAINT fk_loans_activity
        FOREIGN KEY (activity_id, activity_kind)
        REFERENCES activities (activity_id, activity_kind)
);

这里使用复合 Foreign Key,是为了防止将 activity_kind = 'loan' 的活动误插入 sales。

创建活动时,需要在同一个事务中写入父表及对应的子类型表:

BEGIN;

INSERT INTO activities (activity_kind)
VALUES ('loan')
RETURNING activity_id;

-- 假设返回 activity_id = 1001

INSERT INTO loans (
    activity_id,
    borrower_id,
    principal
)
VALUES (
    1001,
    42,
    50000.00
);

COMMIT;

之后可以添加 Payment:

INSERT INTO payments (
    activity_id,
    amount,
    paid_at
)
VALUES (
    1001,
    2500.00,
    CURRENT_TIMESTAMP
);

查询某笔 Loan 对应的 Payments 也很简单:

SELECT
    l.activity_id,
    l.borrower_id,
    p.payment_id,
    p.amount,
    p.paid_at
FROM loans AS l
LEFT JOIN payments AS p
    ON p.activity_id = l.activity_id
WHERE l.activity_id = 1001;

为什么不建议使用多组 nullable Foreign Keys

下面的结构虽然可用,但不利于扩展:

CREATE TABLE payments (
    payment_id bigint PRIMARY KEY,
    loan_id bigint NULL,
    sale_id bigint NULL,
    rent_id bigint NULL,
    service_id bigint NULL
);

在 PostgreSQL 中,可以通过 CHECK 保证恰好有一个外键非空:

CHECK (
    num_nonnulls(
        loan_id,
        sale_id,
        rent_id,
        service_id
    ) = 1
)

问题在于,每增加一种活动,都要修改表结构和约束。查询所有 Payments 时,也会出现大量条件判断或 COALESCE。所以,这种方案一般只适用于活动类型很少、类型固定且基本不会扩展的系统。

为什么 join table 也不能完全解决问题

分别创建 LoanPayments、RentPayments 等表,虽然可以消除 NULL,却不能保证一笔 Payment 只出现在其中一张关系表里。

即使每张表都给 payment_id 添加 UNIQUE:

CREATE TABLE loan_payments (
    loan_id bigint NOT NULL REFERENCES loans(activity_id),
    payment_id bigint NOT NULL UNIQUE REFERENCES payments(payment_id),
    PRIMARY KEY (loan_id, payment_id)
);

数据库仍可能允许同一个 payment_id 同时出现在 loan_payments 和 rent_payments 中,也可能允许它不出现在任何一张表中。若要跨多张表保证它”恰好出现一次”,通常还需要 trigger 或额外的全局约束机制,整体设计反而更复杂。

只有在一笔 Payment 确实可以分配给多个活动时,join table 才符合业务语义。这时可以将它命名为 payment_allocations,并在表中保存分摊金额:

CREATE TABLE payment_allocations (
    payment_id bigint NOT NULL REFERENCES payments(payment_id),
    activity_id bigint NOT NULL REFERENCES activities(activity_id),
    allocated_amount numeric(18, 2) NOT NULL,
    PRIMARY KEY (payment_id, activity_id)
);

这属于 Many-To-Many 的”支付分摊”模型,已经不同于”一笔 Payment 只属于一项活动”的原始前提。

注意事项

不要使用 activity_type + activity_id 直接引用多张彼此独立的表,例如:

activity_type = 'loan'
activity_id   = 1001

普通 Foreign Key 无法按照 activity_type 动态选择要引用 loans、sales 还是其他表。这种 polymorphic association 会削弱数据库层面的 Referential integrity。

如果 Payment 在创建初期可以暂时不确定所属活动,应将其明确建模为草稿或待匹配状态,例如存入单独的 staging table。不要为了适应临时流程,长期将正式表中的 activity_id 设为 nullable。

对于这个场景,使用 Activities 父表配合各业务子类型表,结构相对简洁,便于扩展,也能保留 Referential integrity。

备注:内容仅供参考。