PAYATHON 2026

如何设计 Customers 与 Orders 的静态地址存储结构?

支付小周

结论

建议采用带约束的 Option 1:订单只保存 BillingAddressID 和 ShippingAddressID,地址记录则作为不可变的版本快照保存。

客户修改地址时,不应更新原有地址记录,而是插入一条新记录,再把客户的当前地址切换到新版本。历史订单仍引用旧的 AddressID,不会因客户修改地址而变化。

这样设计有两个好处:

  • 多个订单可以共用同一个地址版本,只需存储一次,比 Option 2 的冗余更少。
  • 地址每次变化都会生成新版本,不会覆盖历史订单中的地址。

Option 2 同样可以保证历史数据正确,但每个订单都要重复保存完整的地址字段。Option 3 使用 Blob,不方便查询、校验、建立索引和后续迁移,通常不适合作为关系数据库的主要存储方式。

推荐的数据关系

建议将地址内容与“客户当前使用哪个地址”分开保存:

Customers
    1
    |
    N
CustomerAddress
    |
    N
Address

Orders
    ├── BillingAddressID  ──> Address
    └── ShippingAddressID ──> Address

各表的职责如下:

  • Address 保存不可变的地址版本。
  • CustomerAddress 记录某个客户当前或曾经使用过哪些地址,以及这些地址的用途。
  • Orders 直接引用下单时选定的地址版本。
  • 客户修改地址时,需要新增一条 Address 记录,不能覆盖旧记录。
  • 已被订单引用的地址不能删除。

Billing 和 Shipping 更适合定义为客户与地址之间的用途,而不是地址本身的固有属性。同一个地址完全可以同时用于账单和配送。

表结构示例

以下示例使用 PostgreSQL 风格的 SQL。其他关系数据库可能需要调整自增列和约束语法。

CREATE TABLE Customers (
    CustomerID      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    CustomerName    VARCHAR(200) NOT NULL
);

CREATE TABLE Address (
    AddressID       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    AddressLine1    VARCHAR(200) NOT NULL,
    AddressLine2    VARCHAR(200),
    City            VARCHAR(100) NOT NULL,
    State           VARCHAR(100),
    CountryCode     CHAR(2) NOT NULL,
    PostCode        VARCHAR(30),
    CreatedAt       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE CustomerAddress (
    CustomerID      BIGINT NOT NULL,
    AddressID       BIGINT NOT NULL,
    AddressType     VARCHAR(20) NOT NULL,
    IsCurrent       BOOLEAN NOT NULL DEFAULT TRUE,

    PRIMARY KEY (CustomerID, AddressID, AddressType),

    CONSTRAINT FK_CustomerAddress_Customer
        FOREIGN KEY (CustomerID)
        REFERENCES Customers (CustomerID),

    CONSTRAINT FK_CustomerAddress_Address
        FOREIGN KEY (AddressID)
        REFERENCES Address (AddressID),

    CONSTRAINT CK_CustomerAddress_Type
        CHECK (AddressType IN ('Billing', 'Shipping'))
);

CREATE TABLE Orders (
    OrderID             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    CustomerID          BIGINT NOT NULL,
    BillingAddressID    BIGINT NOT NULL,
    ShippingAddressID   BIGINT NOT NULL,
    CreatedAt           TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT FK_Orders_Customer
        FOREIGN KEY (CustomerID)
        REFERENCES Customers (CustomerID),

    CONSTRAINT FK_Orders_BillingAddress
        FOREIGN KEY (BillingAddressID)
        REFERENCES Address (AddressID)
        ON DELETE RESTRICT,

    CONSTRAINT FK_Orders_ShippingAddress
        FOREIGN KEY (ShippingAddressID)
        REFERENCES Address (AddressID)
        ON DELETE RESTRICT
);

ON DELETE RESTRICT 能防止删除仍被订单引用的地址,但无法阻止通过 UPDATE 修改地址字段。地址不可变的规则还需要由权限、触发器或应用服务来保证。

下单流程

创建订单时,应在同一个事务中确定地址版本并写入订单:

BEGIN;

INSERT INTO Address (
    AddressLine1,
    AddressLine2,
    City,
    State,
    CountryCode,
    PostCode
)
VALUES (
    '88 Example Road',
    NULL,
    'Shanghai',
    'Shanghai',
    'CN',
    '200000'
)
RETURNING AddressID;

取得返回的 AddressID 后,将它写入客户地址关系和订单:

INSERT INTO CustomerAddress (
    CustomerID,
    AddressID,
    AddressType,
    IsCurrent
)
VALUES (
    1001,
    5010,
    'Shipping',
    TRUE
);

INSERT INTO Orders (
    CustomerID,
    BillingAddressID,
    ShippingAddressID
)
VALUES (
    1001,
    5009,
    5010
);

COMMIT;

如果客户选择的地址已经存在,而且内容完全相同,可以直接复用原来的 AddressID,无需再次插入。

客户修改地址时的处理

假设某个订单引用了 AddressID = 5010,之后客户修改了门牌号。此时不要执行:

UPDATE Address
SET AddressLine1 = '99 New Road'
WHERE AddressID = 5010;

否则,所有引用该地址的历史订单都会随之改变。

正确的处理方式是创建一个新地址版本,再切换客户的当前地址:

BEGIN;

UPDATE CustomerAddress
SET IsCurrent = FALSE
WHERE CustomerID = 1001
  AND AddressType = 'Shipping'
  AND IsCurrent = TRUE;

INSERT INTO Address (
    AddressLine1,
    AddressLine2,
    City,
    State,
    CountryCode,
    PostCode
)
VALUES (
    '99 New Road',
    NULL,
    'Shanghai',
    'Shanghai',
    'CN',
    '200000'
)
RETURNING AddressID;

然后使用返回的新 AddressID 创建当前地址关系:

INSERT INTO CustomerAddress (
    CustomerID,
    AddressID,
    AddressType,
    IsCurrent
)
VALUES (
    1001,
    5011,
    'Shipping',
    TRUE
);

COMMIT;

这样,旧订单仍引用 5010,新订单则使用 5011。

如何强制地址不可变

只靠开发约定容易发生误操作,至少应采取以下一种措施:

  1. 撤销业务账号对 Address 表的 UPDATE 权限,只允许插入和查询。
  2. 所有地址修改都通过“创建新版本”的服务接口完成。
  3. 使用数据库触发器,拒绝修改已经被订单引用的地址。
  4. 禁止物理删除地址,改用状态字段或匿名化流程。

例如,可以在 PostgreSQL 中阻止修改已经被订单使用的地址:

CREATE OR REPLACE FUNCTION prevent_order_address_update()
RETURNS TRIGGER AS $$
BEGIN
    IF EXISTS (
        SELECT 1
        FROM Orders
        WHERE BillingAddressID = OLD.AddressID
           OR ShippingAddressID = OLD.AddressID
    ) THEN
        RAISE EXCEPTION
            'Address % is referenced by an order and cannot be updated',
            OLD.AddressID;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER TR_Address_PreventReferencedUpdate
BEFORE UPDATE ON Address
FOR EACH ROW
EXECUTE FUNCTION prevent_order_address_update();

如果希望规则更简单、更严格,也可以完全禁止更新 Address,无论该地址是否已经被订单引用。

三种方案的取舍

Option 1:订单引用不可变地址

这通常是最合适的方案。

优点:

  • 地址结构清晰,可以按城市、国家和邮编查询或建立索引。
  • 多个订单可以复用同一个地址版本。
  • 地址变化时只需新增一个版本。
  • Billing 和 Shipping 可以指向同一个 AddressID。

这个方案的前提是严格执行“地址版本不可变”的规则。如果允许直接修改 Address,Option 1 就无法保证历史数据正确。

Option 2:地址字段直接复制到订单

这是常见的订单快照设计,同样可靠,而且每条订单记录都能自包含所需的地址信息。

它适合以下场景:

  • 每张订单都必须有独立快照。
  • 地址可能经过订单专属的格式化或人工修正。
  • 不希望订单依赖客户地址表的生命周期。
  • 可以接受订单数量和地址字段带来的存储成本。

它的主要问题不是正确性,而是字段较多、重复数据较多。不过,关系数据库中的文本重复通常没有想象中昂贵。如果业务更重视审计独立性,Option 2 仍然是合理选择。

也可以把重复字段放到独立的 OrderAddress 表中:

CREATE TABLE OrderAddress (
    OrderAddressID  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    OrderID         BIGINT NOT NULL,
    AddressRole     VARCHAR(20) NOT NULL,
    AddressLine1    VARCHAR(200) NOT NULL,
    AddressLine2    VARCHAR(200),
    City            VARCHAR(100) NOT NULL,
    State           VARCHAR(100),
    CountryCode     CHAR(2) NOT NULL,
    PostCode        VARCHAR(30),

    CONSTRAINT FK_OrderAddress_Order
        FOREIGN KEY (OrderID)
        REFERENCES Orders (OrderID),

    CONSTRAINT UQ_OrderAddress_Role
        UNIQUE (OrderID, AddressRole),

    CONSTRAINT CK_OrderAddress_Role
        CHECK (AddressRole IN ('Billing', 'Shipping'))
);

这种设计仍属于订单快照,只是不会让 Orders 表变得过宽。

Option 3:Blob

一般不建议采用。

Blob、JSON 或拼接字符串虽然写入方便,但存在以下问题:

  • 很难按国家、城市或邮编查询。
  • 无法分别约束各个字段,也不方便单独建立索引。
  • 地址格式调整和数据迁移更麻烦。
  • 容易把展示格式和原始数据混在一起。

如果确实需要保留下单时打印在面单或发票上的完整文本,可以额外保存一份格式化快照,但它不应替代结构化字段。

需要注意的细节

  • 邮编不要存成数值。邮编可能包含字母、空格或前导零,应使用字符串类型。
  • 国家建议保存稳定的国家代码,例如 ISO 3166-1 alpha-2。具体采用哪套代码,需要根据系统约定确定。
  • 收件人姓名、公司名和联系电话也可能属于订单配送快照,不能默认只从客户的当前资料中读取。
  • 账单地址和配送地址可以相同,因此应允许两个外键指向同一个 AddressID。
  • 如果需要保证订单地址确实属于该客户,应在创建订单的业务事务中校验 CustomerAddress,也可以使用额外的关联表和复合外键加强约束。
  • 历史订单需要长期保存,但这不等于可以无限期保留个人信息。实际的保留和匿名化策略应符合所在地区的隐私、税务和审计要求。

核心原则是:订单引用“下单时的地址版本”,而不是“客户当前地址”。只要旧版本不能被修改或随意删除,历史订单就能保持准确。

备注:内容仅供参考。