使用 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。
备注:内容仅供参考。
