PAYATHON 2026

数据库设计中是否要列出所有可能的 FK?

支付小周

结论

通常没有必要在 ORDER、INVOICE 和 PAYMENT 中重复保存 CUSTOMER_ID。

如果业务关系始终是:

CUSTOMER → ORDER → INVOICE → PAYMENT

而且每条下级记录只属于一条上级记录,就可以沿外键关系通过 JOIN 找到对应的客户。各表只需保存直接关系:

  • ORDER.CUSTOMER_ID
  • INVOICE.ORDER_ID
  • PAYMENT.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,也不需要把所有能够间接关联到的主键复制到每张表中。

备注:内容仅供参考。