数据库设计中是否要列出所有可能的 FK?
结论
通常没有必要在 ORDER、INVOICE 和 PAYMENT 中重复保存 CUSTOMER_ID。
如果业务关系始终是:
CUSTOMER → ORDER → INVOICE → PAYMENT
而且每条下级记录只属于一条上级记录,就可以沿外键关系通过 JOIN 找到对应的客户。各表只需保存直接关系:
ORDER.CUSTOMER_IDINVOICE.ORDER_IDPAYMENT.INVOICE_ID
如果在 INVOICE 和 PAYMENT 中再次保存 CUSTOMER_ID,这些字段就是冗余数据,容易引发数据不一致。
推荐的表结构
CREATE TABLE CUSTOMER (
CUSTOMER_ID BIGINT PRIMARY KEY,
CUSTOMER_NAME VARCHAR(200) NOT NULL
);
CREATE TABLE ORDERS (
ORDER_ID BIGINT PRIMARY KEY,
CUSTOMER_ID BIGINT NOT NULL,
FOREIGN KEY (CUSTOMER_ID)
REFERENCES CUSTOMER (CUSTOMER_ID)
);
CREATE TABLE INVOICE (
INVOICE_ID BIGINT PRIMARY KEY,
ORDER_ID BIGINT NOT NULL,
FOREIGN KEY (ORDER_ID)
REFERENCES ORDERS (ORDER_ID)
);
CREATE TABLE PAYMENT (
PAYMENT_ID BIGINT PRIMARY KEY,
INVOICE_ID BIGINT NOT NULL,
AMOUNT DECIMAL(18, 2) NOT NULL,
FOREIGN KEY (INVOICE_ID)
REFERENCES INVOICE (INVOICE_ID)
);
这些外键描述的是每张表与直接上级之间的关系,不需要列出所有能够间接关联到的实体。
通过查询获取客户
查询订单所属的客户:
SELECT
o.ORDER_ID,
c.CUSTOMER_ID,
c.CUSTOMER_NAME
FROM ORDERS AS o
JOIN CUSTOMER AS c
ON c.CUSTOMER_ID = o.CUSTOMER_ID;
查询发票所属的客户:
SELECT
i.INVOICE_ID,
c.CUSTOMER_ID,
c.CUSTOMER_NAME
FROM INVOICE AS i
JOIN ORDERS AS o
ON o.ORDER_ID = i.ORDER_ID
JOIN CUSTOMER AS c
ON c.CUSTOMER_ID = o.CUSTOMER_ID;
查询付款所属的客户:
SELECT
p.PAYMENT_ID,
p.AMOUNT,
i.INVOICE_ID,
o.ORDER_ID,
c.CUSTOMER_ID,
c.CUSTOMER_NAME
FROM PAYMENT AS p
JOIN INVOICE AS i
ON i.INVOICE_ID = p.INVOICE_ID
JOIN ORDERS AS o
ON o.ORDER_ID = i.ORDER_ID
JOIN CUSTOMER AS c
ON c.CUSTOMER_ID = o.CUSTOMER_ID;
如果经常使用这类查询,可以创建视图,业务代码就不必反复编写相同的连接逻辑:
CREATE VIEW PAYMENT_CUSTOMER AS
SELECT
p.PAYMENT_ID,
p.AMOUNT,
i.INVOICE_ID,
o.ORDER_ID,
c.CUSTOMER_ID,
c.CUSTOMER_NAME
FROM PAYMENT AS p
JOIN INVOICE AS i
ON i.INVOICE_ID = p.INVOICE_ID
JOIN ORDERS AS o
ON o.ORDER_ID = i.ORDER_ID
JOIN CUSTOMER AS c
ON c.CUSTOMER_ID = o.CUSTOMER_ID;
之后可以直接查询:
SELECT *
FROM PAYMENT_CUSTOMER
WHERE CUSTOMER_ID = 1001;
为什么不建议重复保存 CUSTOMER_ID
假设 INVOICE 同时保存了 ORDER_ID 和 CUSTOMER_ID:
INVOICE.ORDER_ID = 500
INVOICE.CUSTOMER_ID = 20
但订单 500 实际属于客户 10,数据库里就会出现两个相互冲突的答案:
ORDER 认为发票属于客户 10
INVOICE 认为发票属于客户 20
普通外键只能分别保证客户和订单存在,却不能自动保证该订单属于同一个客户。应用程序还要维护额外的一致性规则,更新和排错也会更加复杂。
只要 CUSTOMER_ID 能够由 ORDER_ID 唯一推导出来,就不必在 INVOICE 中重复保存。PAYMENT 也是同样的道理。
什么时候应该直接保存 CUSTOMER_ID
如果实际业务不符合这条严格的关联链,就不能简单依赖逐级关联,需要按真实关系重新设计。
发票不一定对应单个订单
如果一张发票可以包含多个订单,INVOICE.ORDER_ID 就不再合适。可以让发票直接属于客户,再用关联表记录发票包含的订单:
CREATE TABLE INVOICE (
INVOICE_ID BIGINT PRIMARY KEY,
CUSTOMER_ID BIGINT NOT NULL,
FOREIGN KEY (CUSTOMER_ID)
REFERENCES CUSTOMER (CUSTOMER_ID)
);
CREATE TABLE INVOICE_ORDER (
INVOICE_ID BIGINT NOT NULL,
ORDER_ID BIGINT NOT NULL,
PRIMARY KEY (INVOICE_ID, ORDER_ID),
FOREIGN KEY (INVOICE_ID)
REFERENCES INVOICE (INVOICE_ID),
FOREIGN KEY (ORDER_ID)
REFERENCES ORDERS (ORDER_ID)
);
此时,INVOICE.CUSTOMER_ID 表达的是发票直接开给哪个客户这一独立业务事实,并不是为了查询方便而复制的数据。
一笔付款可以支付多张发票
实际业务中可能存在合并付款、预付款或部分付款。遇到这些情况,不应让 PAYMENT 只保存一个 INVOICE_ID,而应增加付款分配表:
CREATE TABLE PAYMENT (
PAYMENT_ID BIGINT PRIMARY KEY,
CUSTOMER_ID BIGINT NOT NULL,
AMOUNT DECIMAL(18, 2) NOT NULL,
FOREIGN KEY (CUSTOMER_ID)
REFERENCES CUSTOMER (CUSTOMER_ID)
);
CREATE TABLE PAYMENT_ALLOCATION (
PAYMENT_ID BIGINT NOT NULL,
INVOICE_ID BIGINT NOT NULL,
ALLOCATED_AMOUNT DECIMAL(18, 2) NOT NULL,
PRIMARY KEY (PAYMENT_ID, INVOICE_ID),
FOREIGN KEY (PAYMENT_ID)
REFERENCES PAYMENT (PAYMENT_ID),
FOREIGN KEY (INVOICE_ID)
REFERENCES INVOICE (INVOICE_ID)
);
这里的 PAYMENT.CUSTOMER_ID 表示付款人或付款账户所属的客户,也不是单纯缓存查询结果。
需要保留历史快照
客户名称、账单地址或税务信息可能会变化,但历史发票通常需要保留开票时的数据。此时可以在发票中保存客户名称、地址等快照字段。
这些字段是历史凭证的一部分,不能直接由当前 CUSTOMER 表中的数据替代。是否保存 CUSTOMER_ID,仍然取决于发票和客户之间是否存在直接的业务关系。
有明确的性能需求
数据量很大,而且实际测量表明连接查询确实已经成为瓶颈时,可以考虑冗余保存 CUSTOMER_ID。这种做法属于反规范化,必须同时处理一致性问题,例如:
- 禁止随意修改冗余字段;
- 在事务中同步更新;
- 使用触发器或受控写入流程维护一致性;
- 定期检查冗余值与关联链是否一致。
不要因为担心多表 JOIN 可能很慢,就提前增加冗余字段。正确的索引通常足以满足一般查询需求。
索引注意事项
主键通常会自动创建索引,但数据库是否自动为外键列创建索引,取决于具体产品。对于经常用于连接和筛选的列,应检查其索引情况,并按需显式创建索引:
CREATE INDEX IDX_ORDERS_CUSTOMER_ID
ON ORDERS (CUSTOMER_ID);
CREATE INDEX IDX_INVOICE_ORDER_ID
ON INVOICE (ORDER_ID);
CREATE INDEX IDX_PAYMENT_INVOICE_ID
ON PAYMENT (INVOICE_ID);
如果经常按客户查询其全部付款,数据库可以利用这些索引完成连接。是否还需要其他组合索引,要根据实际查询、数据量和执行计划来判断。
设计时的判断原则
设计时,不要只看哪些表需要显示客户,而要判断:
客户与这条记录之间,是否存在需要单独维护的直接业务关系?
如果客户只能通过订单唯一确定,使用关联查询即可。如果发票或付款与客户之间有独立含义,或者这些记录并不是沿关联链严格归属,就应直接建模这种关系。
外键用于表达并约束真实关系。它不是为了减少 JOIN,也不需要把所有能够间接关联到的主键复制到每张表中。
备注:内容仅供参考。
