跳转到主要内容

3 正本清源:从业务规则到关系模型

表不是字段清单的容器,关系模型也不是把接口 JSON 原样搬进数据库。建模首先要辨认系统承诺保存哪些事实、谁拥有这些事实、哪些组合状态绝不允许出现;表、键和外键只是把这些判断变成可验证结构。

本章建立 pg36_shop 逻辑模型 v0。它会真实部署到 PostgreSQL,并用正反样例审查,但它有意保留四项未决:金额、时间、状态和标识的可靠物理表达。v0 是通往 ch04 的设计证据,不是可以复制进生产的最终 DDL。

本章目标

完成本章后,读者应当能够:

  • 从业务语言区分实体、事件、状态、命令与不变量;
  • 识别一项事实的权威所有者,而不是让多个服务共享写入责任;
  • 分开内部主键、业务键、外部标识、幂等键和追踪标识;
  • 用主键、唯一约束与外键表达关系的最小完整性;
  • 根据父子生命周期选择 RESTRICTCASCADE 等外键动作;
  • 识别简单约束能维护的行内/引用规则,以及需要事务或流程维护的跨表规则;
  • 用模式、owner 与 runtime role 建立对象边界,不依赖不受控 search_path
  • 区分重复事实、历史快照、派生结果与缓存;
  • 部署五表逻辑模型 v0,生成关系图、状态摘要和四项未决清单。

开始之前

本章假设已经完成 ch01 的 pg36_shop 基线和 ch02 的可重跑工作流。实验仍使用 L1 的 Pigsty default 服务进入当前主库,但建模能力完全属于 PostgreSQL;Pigsty 只提供统一端点、运行环境和后续观测载体。

下载资产:

本章案例

范围限定为五个概念:

erDiagram
  CUSTOMER ||--o{ SALES_ORDER : places
  SALES_ORDER ||--|{ SALES_ORDER_ITEM : contains
  PRODUCT ||--o{ SALES_ORDER_ITEM : snapshotted_as
  SALES_ORDER ||--o{ PAYMENT : receives
  • customer 保存当前客户档案;
  • product 保存当前商品目录事实;
  • sales_order 保存被接受的下单命令及买家快照;
  • sales_order_item 保存订单组成与购买时商品快照;
  • payment 保存支付尝试与外部提供方标识。

发货、库存、退款、优惠、税务和多币种不是被遗忘,而是明确排除在 v0 之外。一个小而闭合的模型比一个列很多却没有边界的“万能订单表”更适合演进。

教学路径

flowchart LR
  A["业务语言<br/>事实与不变量"] --> B["标识<br/>主键与业务键"]
  B --> C["关系<br/>外键与生命周期"]
  C --> D["对象边界<br/>schema 与 owner"]
  D --> E["规范化<br/>快照与冗余"]
  E --> F["v0 实战<br/>部署 + 反例"]
  F --> G["ch04<br/>可靠物理模式"]

本章目录

3.1 从业务语言提取数据库事实

先写事实句、所有权和失败条件,再决定表。

3.2 标识、主键与业务键

同一行可以同时拥有多个不同目的的标识;只有一个承担内部引用主键。

3.3 关系与引用完整性

把基数与生命周期写进外键,同时承认普通 CHECK 无法维护跨行、跨表真相。

3.4 模式、所有权与对象边界

shopshop_apishop_private 分别承载规范事实、查询接口与内部实现;schema 名称本身不自动构成安全边界。

3.5 规范化与有意识的冗余

商品当前名称只保存一次,订单行上的名称则是购买时快照;两者字面重复,事实语义不同。

3.6 实战:建立逻辑模型 v0

v0 强制无争议的键、引用和正值规则,同时用事务内反例证明任意状态、任意金额小数位和“paid 但没有行/付款”仍可能穿透。

章节产物

运行 task.sh all 后,证据目录至少包括:

文件 证明什么
manifest.txt 客户端/服务端版本与十个输入文件哈希
setup.stdout 五表、两个辅助 schema 与一个查询 view 成功建立
verify.txt 行数、权限、引用与关系摘要符合 v0
review.txt 应拒绝的三类错误被拒绝,三项开放规则仍可穿透
review.stderr 预期 unique、foreign key、check violation 被明确捕获

基线数据摘要为:

model_version=ch03-v0
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
open_decision_count=4
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

章节验收

  1. 能把“用户下单”改写成至少五条可判断真假的事实;
  2. 能解释 order_idorder_norequest_keytrace_id 为什么不能互换;
  3. 能为每条外键说明父子生命周期与删除动作;
  4. 能指出“订单至少一行”“paid 必须足额付款”为什么不是普通行级 CHECK
  5. 能区分订单行商品名称快照与无依据缓存;
  6. 能证明 runtime role 不是对象 owner,且无权使用 shop_private
  7. 能从负向实验读出 v0 的已闭合与未闭合边界;
  8. 不把 v0 宣称为可靠物理模式。

下一章 ch04《量体裁衣:数据类型、约束与可靠数据表达》 将逐项关闭金额、时间、状态和标识决策,并用类型、约束、反例与分区 ADR 产出可靠 DDL。

参考资料


上一章:手到擒来:psql 与可复现工作流 · 返回上卷导读 · 下一章:量体裁衣:数据类型、约束与可靠数据表达 · 查看全书目录 · 查看索引中心

3.1 从业务语言提取数据库事实

建模会议最容易从“订单表有哪些字段”开始,最后得到一个容纳所有词汇的大表,却没人能说明哪种状态算错。本节反过来:先把业务陈述改写成可判断真假的事实,再找出必须永久成立的不变量。

3.1.1 实体、事件、状态与业务不变量

四类词回答不同问题:

概念 问题 pg36_shop 例子 常见误区
实体 哪个事物拥有持续身份? customer、product、sales order 看到名词就机械建表
事件 在什么时刻发生了什么? order placed、payment attempted 只保留当前状态,失去历史事实
状态 某实体当前处于什么条件? order is placed/paid/cancelled 把一个 text 字段当成完整状态机
不变量 哪些命题在每次提交后都必须为真? order 指向已有 customer 只写“通常”“应该”,没有失败语义

表与业务概念不是一一映射。一个实体可能需要多个关系保存当前事实和历史;多个小值对象也可能嵌入同一关系。判断依据是身份、生命周期、基数、更新原子性和查询责任,而不是面向对象类图。

把叙事改写成事实句

“Alice 买了咖啡和杯子并支付成功”至少包含:

  1. customer CUST-ALICE 在下单时存在;
  2. order ORD-20260729-0001 属于该 customer;
  3. order 接受了两个不同 line;
  4. 每个 line 记录数量与购买时单价;
  5. line 引用的 product 在接受订单时存在;
  6. payment provider 接受了一个金额为 167.80 的尝试;
  7. provider reference 在该 provider 范围内唯一;
  8. captured payment 总额与订单接受金额相符;
  9. “paid” 状态只有在满足支付规则后才能出现。

前七项可由本章五个关系直接表达;第八、九项是跨表和状态转换规则,v0 会故意暴露它们尚未关闭。

不变量要写出四个维度

不要只写“订单号唯一”,而要写:

规则:order_no 在 pg36_shop 订单域内唯一。
时点:每条 INSERT/UPDATE 语句结束时成立。
权威:PostgreSQL unique constraint。
违反:整条语句失败;调用方收到 unique_violation。

再看“订单至少有一行”:

规则:进入 accepted/paid 等已接受状态的订单至少有一个 line。
时点:状态转换事务提交时成立。
权威:订单命令边界;实现方式待 ch04/ch13 决定。
违反:状态转换失败,订单不得部分提交。

这条规则不能要求每次刚插入 order 行后就成立,否则同一事务尚未来得及插入第一个 line。时点是模型的一部分。

事件和状态不要互相伪装

payment 在 v0 中代表一次有身份的支付尝试,包含 provider reference、request fingerprint、状态和发生时间。它不是完整的支付事件流;若未来要回答每次授权、捕获、撤销和退款的顺序,需要追加事件关系或对账记录,而不是在一行上反复覆盖后声称历史仍然存在。

同样,order_status = 'paid' 只是一个断言。只有定义允许值、转换、终态、并发规则和金额条件后,它才成为可依赖状态机。ch04 负责值域表达,ch10 处理并发转换,ch13 讨论数据库逻辑边界。

3.1.2 命令模型、查询模型与数据所有权

命令模型回答“怎样接受一个合法变化”,查询模型回答“消费者怎样读取所需形状”。它们可以共享一套规范事实,但不必共享一张宽表。

规范事实与读取形状

v0 的命令侧关系是:

  • customer:当前客户档案;
  • product:当前商品目录;
  • sales_order:订单头、客户引用、幂等输入和下单快照;
  • sales_order_item:组成关系与购买时商品快照;
  • payment:支付尝试与外部标识。

查询侧提供 shop_api.order_summary

SELECT
    order_id,
    order_no,
    order_status,
    item_count,
    item_subtotal,
    captured_amount
FROM shop_api.order_summary
ORDER BY order_id;

item_count 与金额汇总按需计算,不在订单头重复保存。现在两行三项数据,普通 view 足够;未来是否物化、缓存或拆到读取服务,要由查询量、延迟和新鲜度目标证明。

这不是要求每个系统都采用 CQRS。核心原则更朴素:写模型首先维护事实与不变量,读接口可以投影、连接和聚合;不要为了一个列表页面,让五处写入共同维护一张含所有派生字段的表。

数据所有权不是数据库 owner

“谁拥有数据”至少有三层含义:

层次 pg36_shop 例子 责任
业务权威 订单域拥有已接受订单事实 决定语义、修改接口与生命周期
PostgreSQL 对象 owner pg36_owner 执行 DDL、授权、迁移
运行角色 pg36_apppg36_ro 按最小权限执行命令或读取

三者不能混为“这个服务有数据库密码,所以它拥有一切”。

业务事实清单把本章范围写成:

事实 本章权威 外部依赖
当前客户档案 pg36_shop 身份系统可能提供外部标识
当前商品目录 pg36_shop 教学范围 真实组织可能有独立商品服务
已接受订单与购买快照 pg36_shop 下单客户端只提交命令
本地支付尝试记录 支付边界 provider 才是资金处理外部权威

数据库能保证 provider reference 在本地不重复,却不能证明第三方真的扣款。外部响应必须经过认证、重试、对账与补偿;跨系统一致性不能伪装成一个本地外键。

一个事实只应有一个写入责任

多个服务可以消费订单摘要,但不应绕过订单命令随意更新 order_status。否则每个写者都带着不同规则,数据库最后只能保存“谁最后提交”的结果。

若组织确实需要多写者,必须共享同一数据库不变量、并发协议与发布契约。更常见的选择是一个权威写入口,其他服务通过 API、消息或受控数据库接口提出命令。

所有权还包括删除与保留。客户档案删除不意味着历史订单必须消失;商品下架不意味着订单快照应级联删除。关系动作要服从业务生命周期,而不是代码生成器默认值。

3.1.3 哪些规则必须由数据库兜底

“都放应用”与“都放数据库”同样偷懒。选择执行层时逐条问:

  1. 违反后是否会形成永久无效数据?
  2. 是否可能由并发写者同时触发?
  3. 是否存在多个写入入口、批处理或人工 SQL?
  4. PostgreSQL 能否在正确时点原子判断?
  5. 规则是否依赖外部系统或人工裁决?
  6. 错误需要怎样映射给调用方?

数据库应兜底的最小集合

规则 v0 PostgreSQL 表达 原因
每行有稳定身份 PRIMARY KEY 所有写入口共享
SKU/order_no 等业务键不重复 UNIQUE 并发下应用“先查再插”会竞态
order 必须有 customer FOREIGN KEY 防止孤儿引用
line 必须有 order 与 product 两条 FK 关系事实必须真实
line_no、quantity 为正 CHECK 单行、确定、无外部依赖
关键列必须存在 NOT NULL 让“未知”成为显式建模决定

应用仍应提前验证并返回友好错误,但数据库约束是最后防线。它覆盖后台任务、迁移脚本、并发请求和未来尚未出现的写者。

普通约束不适合什么

PostgreSQL 不支持让 CHECK 引用该行之外的表数据并承诺持续一致。下面的想法是错误方向:

-- 不要这样设计跨表 CHECK
CHECK (
  amount <= (
    SELECT sum(unit_price * quantity)
    FROM shop.sales_order_item
    WHERE order_id = payment.order_id
  )
)

CHECK 按新行或更新行验证,并假设表达式对同一行输入保持不变。其他表以后变化时,它不会自动重检;dump/restore 顺序也可能让这种伪约束失败。

跨表规则的候选实现包括:

  • 同一事务中的原子命令与显式锁;
  • 唯一、外键、排他约束等真正受支持的关系约束;
  • 受严格设计的触发器或延迟约束触发器;
  • 由状态转换把“草稿不完整”和“已接受必须完整”分开;
  • 外部工作流的对账、补偿和人工裁决。

选择触发器不自动让规则正确;并发、递归、批量导入、错误语义和恢复都要验证。ch10 与 ch13 会继续。

v0 的刻意空缺

本章负向实验会证明以下状态仍能提交到事务内:

  • numeric 接受 9 位小数;
  • order_status 接受 teleported
  • 一个 status 为 paid 的 order 可以没有 line 和 payment。

实验随后 ROLLBACK,不会污染基线。暴露空缺比用 prose 声称“以后应用会注意”更诚实;它们进入四项未决登记并在 ch04 验收。

本节产物

为每条业务规则建立最小登记:

rule_id
fact / invariant
scope
validation_time
authoritative_owner
enforcement_layer
error_semantics
evidence_query
open_questions

如果一条规则没有权威 owner 或验证时点,先不要写 DDL。技术不能替组织替你决定事实。

本节验收

  • 能把一个业务故事拆成实体、事件、状态和至少五条事实;
  • 每条不变量都写出范围、时点、权威与违反结果;
  • 命令模型与查询投影职责分开;
  • 业务 owner、对象 owner 与 runtime role 不再混用;
  • 能解释哪些规则适合约束,哪些需要事务或外部协调;
  • 所有未闭合规则进入登记,而不是藏在代码注释。

参考资料


返回本章目录 · 下一节:标识、主键与业务键 · 查看全书目录 · 查看索引中心

3.2 标识、主键与业务键

“这个对象叫什么”没有一个通用答案。一行可能同时需要数据库内部引用、业务沟通、外部系统对账、请求去重和链路追踪标识。把它们都塞进一个 id,会让任何一次格式或业务规则变化沿所有外键扩散。

3.2.1 自然键、代理键与外部标识

三类键各有职责:

类型 定义 pg36_shop 例子 设计问题
自然/业务键 业务已经赋予的唯一事实 SKU、order_no、customer_ref 作用域、稳定性、大小写、回收规则
代理键 数据库模型人为引入的内部行身份 customer_id、product_id、order_id 类型、生成位置、生命周期
外部标识 另一个权威系统赋予 provider_payment_ref 必须连同提供方/租户保存作用域

使用代理主键不意味着可以丢掉业务唯一约束:

CREATE TABLE shop.product (
    product_id bigint PRIMARY KEY,
    sku text NOT NULL UNIQUE,
    ...
);

product_id 让内部外键紧凑、稳定;sku 的唯一约束防止数据库保存两个业务上同一商品。若只保留代理键,两行不同 ID、同一 SKU 会同时“技术合法”。

哪些自然属性不适合做主键

客户 email 在 v0 中唯一,但仍不作为主键:

  • 用户可能改邮箱;
  • 大小写与规范化规则尚未决定;
  • 邮箱可能由身份系统合并或重新分配;
  • 它包含个人信息,会传播进子表、日志和缓存;
  • 所有引用表都被迫携带一个较宽可变字符串。

因此使用 customer_id 作内部身份、customer_ref 作业务引用、email 作当前可联系属性并暂时唯一。ch04 会审查文本比较与大小写语义。

SKU 也可能变更,但订单行需要“购买时看到的 SKU”。v0 同时保存:

product_id      -> 当前商品实体
sku_snapshot    -> 下单时业务快照

商品改 SKU 不应重写历史订单。两个字段字面上曾经相同,语义和生命周期不同。

外部标识必须带命名空间

支付提供方的 pay-ref-1001 只在 provider 自己的命名空间内有意义:

UNIQUE (provider, provider_payment_ref)

若系统是多租户,还可能需要 (tenant_id, provider, provider_payment_ref)。不要根据测试数据“看起来全局唯一”省掉作用域;权威方没有承诺的唯一性不是事实。

外部标识也不是认证凭据。能够猜到 order_no、bigint ID 或 provider reference,不应赋予读取和修改权限。

3.2.2 主键稳定性、键宽度与传播范围

主键一旦被外键、消息、缓存、URL 和数据仓库引用,就变成传播最广的设计决定之一。评审至少看:

  1. 稳定性:业务是否会要求修改它?
  2. 作用域:数据库、租户、服务还是全球唯一?
  3. 宽度:每个引用、索引和 join 要携带多少字节?
  4. 生成:数据库、应用还是外部权威负责?
  5. 顺序性:是否暴露规模,是否影响写入局部性?
  6. 展示:客户支持与 API 是否需要可读标识?

v0 选择 bigint 只是为了让关系可运行,值由 seed 显式提供。ch04 会在 identity、UUID 与应用生成之间做正式决定。

不把可变业务键传播到所有关系

关系传播图:

父关系 内部主键 业务/外部键 子关系保存什么
customer customer_id customer_ref、email order 保存 customer_id,另保存下单 email 快照
product product_id SKU line 保存 product_id 与 SKU/name 快照
sales_order order_id order_no line/payment 保存 order_id
payment payment_id provider reference 对账通过 provider + reference 查找

内部关系使用稳定代理键,边界接口仍可用 order_no、customer_ref 等业务标识。这样修改展示格式不会要求重写每个外键。

复合主键何时合理

sales_order_item 使用:

PRIMARY KEY (order_id, line_no)

line 的身份只在一张订单内成立,没有脱离 order 的独立生命周期。复合键准确表达“订单中的第 N 行”,并让重复 line_no 直接失败。

若未来 line 需要在多个系统独立引用、跨订单移动或拥有大量子关系,可以再评估独立 order_item_id;不要因为所有表模板都含 id 就提前添加。

复合键也有传播成本。子表若引用 order item,必须携带两列,唯一索引和 join 也更宽。模型应在语义准确与操作成本之间明确取舍。

主键更新通常意味着身份混乱

v0 外键显式使用 ON UPDATE RESTRICT。内部主键原则上不可变;如果业务要求“把 customer_id 从 1 改为 2”,更可能是在合并实体,需要迁移引用、冲突决策、审计和补偿,而不是普通级联更新。

ON UPDATE CASCADE 是可用机制,但不应替代身份语义。一个能级联修改的键仍会影响锁、索引、复制和外部消费者。

键宽度与写入局部性的物理代价在 ch04/ch09 量化,本节先冻结职责。

3.2.3 幂等键、去重键与审计标识

网络超时后,客户端不知道服务器是否提交,最安全的重试依赖幂等协议,而不是“希望第一次没成功”。幂等键表达:

在约定作用域和保留期内,同一个 key 代表同一个逻辑命令。

v0 对下单使用:

UNIQUE (customer_id, request_key)

对支付使用:

UNIQUE (provider, idempotency_key)

作用域不同,因为两类命令的权威和重试边界不同。

唯一键只挡重复,不验证同一请求

如果攻击者或客户端错误地用相同 key 发送不同商品列表,UNIQUE 只会告诉你已有一行。它不会判断新旧 payload 是否等价。因此 v0 还保存 request_fingerprint

key 相同 + fingerprint 相同 -> 返回既有结果
key 相同 + fingerprint 不同 -> 明确冲突,不能当成功
key 不同                     -> 尝试新命令

典型流程:

INSERT INTO shop.sales_order (...)
VALUES (...)
ON CONFLICT (customer_id, request_key) DO NOTHING
RETURNING order_id, request_fingerprint;

若没有返回行,在同一事务的后续语句读取既有记录并比较 fingerprint。并发语义、锁等待和 ON CONFLICT 快照细节会在 ch10 展开;本节只确定协议事实。

fingerprint 的序列化必须规范:字段顺序、编码、NULL、数值与 JSON 规范化都要固定。直接 md5(raw_http_body) 可能让语义相同但格式不同的请求被判为冲突,也可能遗漏不应忽略的字段。本章 hash 只是确定性样例。

去重键与幂等键

“去重”常从已有数据推测两条记录像不像,例如相同 email 与时间窗口;“幂等”是调用方和服务事先约定同一命令身份。前者可能需要概率与人工判断,后者应有确定作用域和唯一约束。不要把模糊相似度当支付幂等。

trace ID 不应唯一

created_by_trace_idpayment.trace_id 用于把数据库事实关联到日志、消息和调用链。同一个 trace 可能创建 order、多个 line 和 payment,因此它们通常不是唯一键,也不决定重试结果。

审计标识还不能替代审计内容。至少要知道动作、主体、时间、目标和结果;单独一串 trace ID 只有在外部日志仍可用时才有意义。

保留期与删除

幂等键如果被删除并重用,迟到重试可能创建第二笔业务。设计时必须定义:

  • key 由谁生成;
  • 在什么作用域唯一;
  • 保留多久;
  • 过期后迟到请求怎样处理;
  • 数据归档/分区是否仍保留去重索引;
  • 跨地域或多主写入怎样协调。

本章不删除订单和支付,因此 key 与事实同生命周期。

本节验收

  • 能为每个标识写出权威、作用域、稳定性、生成方与是否公开;
  • 代理主键与业务唯一约束同时存在;
  • 外部标识包含 provider/tenant 等真实命名空间;
  • 幂等唯一键有 fingerprint 冲突语义;
  • trace ID 不被误设为主键或唯一键;
  • bigint 只是 v0 选择,生成策略明确留给 ch04。

参考资料


上一节:从业务语言提取数据库事实 · 返回本章目录 · 下一节:关系与引用完整性 · 查看全书目录 · 查看索引中心

3.3 关系与引用完整性

外键不是为了让 ER 图好看,而是让“这条引用指向真实对象”在并发与所有写入入口下持续成立。建模还要回答可选性、基数、父子生命周期和删除后果;只写两列同名 ID 并没有建立关系。

3.3.1 一对一、一对多与多对多

关系基数要落成列、NOT NULLUNIQUE 和 foreign key 的组合。

一对多:外键放在“多”侧

一个 customer 可以有零到多个 order,每个 order 必须属于一个 customer:

customer(customer_id PRIMARY KEY)

sales_order(
  order_id PRIMARY KEY,
  customer_id NOT NULL
    REFERENCES customer(customer_id)
)

NOT NULL 关闭“订单暂时没有客户”的可能,FK 关闭“客户 ID 不存在”的可能。父侧仍可以暂时没有订单;普通 FK 不保证至少存在一个子行。

一对一:外键再加唯一

假设每个 customer 至多有一份独立 profile:

CREATE TABLE customer_profile (
    customer_id bigint PRIMARY KEY
        REFERENCES shop.customer(customer_id),
    ...
);

让 FK 本身成为子表主键即可保证每个 customer 最多一行 profile。若 profile 与 customer 总是同时创建、同生命周期且没有独立权限/更新原因,拆表反而可能增加 join 与一致性成本;“一对一”不是自动拆表指令。

多对多:关联关系也是事实

订单与商品看似多对多:一个 order 有多个 product,一个 product 出现在多个 order。sales_order_item 不是只有两列的机械桥表,因为这段关系还有自己的事实:

line_no
quantity
purchase-time SKU/name
purchase-time unit_price

它是有属性的关联实体,主键为 (order_id, line_no),另用 product_id 指向当前商品身份。

如果只是“用户收藏商品”,关联表可能是:

PRIMARY KEY (customer_id, product_id)

一旦需要收藏时间、来源、排序或状态,这些也是关系本身的属性。

可选性要说出业务含义

可空 FK 表示“关系可能不存在或未知”,两者不是同一语义。例如 payment 可以没有 provider reference 吗?

  • 若记录代表已发送给 provider 的尝试,reference 应 NOT NULL
  • 若要先创建本地 pending 请求,再异步获得 reference,需要单独状态和可空时点;
  • 若 provider 永远不返回 reference,应使用另一稳定外部键,而不是把 NULL 解释成所有情况。

v0 选择 provider reference NOT NULL,把“尚未调用 provider”的命令放在 payment 行创建之前。这是范围选择,不是通用支付设计。

3.3.2 外键动作与生命周期

ON DELETE/ON UPDATE 不是语法偏好,而是父事实改变时子事实怎样存活。

动作 父行删除时 适用直觉 风险
NO ACTION 默认在约束检查时拒绝 引用必须先处理;可与 deferred constraint 配合 名称易被误读为“什么都不做”
RESTRICT 立即拒绝相关删除 父子身份都应保留 清理必须显式按顺序
CASCADE 自动删除子行 子行完全是父的组成部分 一次误删放大成整棵树
SET NULL 清空可空 FK 子事实可独立存活且“原父已无”有意义 丢失直接引用,列必须允许 NULL
SET DEFAULT 写入默认值 存在真实“默认父”且 FK 仍成立 默认哨兵行常掩盖业务错误

NO ACTION 是默认值,在可延迟约束中可以等到稍后检查;RESTRICT 不允许把该引用动作延后。v0 约束都是非 deferrable,本章仍显式写 RESTRICT,让生命周期决定可见。

v0 的动作说明

父 → 子 动作 业务理由
customer → sales_order ON DELETE RESTRICT 历史订单不能因档案删除消失
product → sales_order_item ON DELETE RESTRICT 订单保留对原商品身份的引用
sales_order → sales_order_item ON DELETE CASCADE line 没有脱离 order 的独立身份
sales_order → payment ON DELETE RESTRICT 支付尝试是审计/对账事实,不随订单静默删除

这里存在一个值得审查的张力:order line 级联、payment 限制,意味着有 payment 的 order 不能删除;没有 payment 的 order 删除会带走 line。若业务要求订单一经接受永不物理删除,可以把 order 删除权限整体收紧,而不依赖 FK 动作区分。

CASCADE 不能代替授权和保留政策。对根表执行一条 DELETE 前,仍要预览作用域、锁和子行数量。

更新动作

v0 使用 ON UPDATE RESTRICT,因为内部身份不应作为普通业务修改。业务键如 email、SKU、order_no 不承担外键,因此可以在各自规则下演进而不级联所有关系;历史快照保持原值。

FK 的物理边界

PostgreSQL 会为主键和 unique constraint 创建唯一 B-tree 索引,但不会自动为外键的引用列创建索引。删除或更新父行时,数据库需要在子表检查引用;大表缺少合适索引会产生昂贵扫描与锁等待。

本章只冻结逻辑 FK。ch09 会根据查询和父表变更路径设计引用侧索引;生产建模评审不能永远把它留空。

外键也会参与并发锁定。插入子行和删除父行竞争时,正确性由 PostgreSQL 保证,但延迟、死锁顺序和批量操作仍需设计。

3.3.3 聚合边界与跨表不变量

聚合边界回答“哪些事实必须在一个命令和事务里一起保持一致”。这里不要求套用某种领域驱动设计术语,而是给事务边界一个业务理由。

order 与 line

下单命令至少涉及:

  1. 创建 order 头;
  2. 创建一到多条 line;
  3. 固化商品标识、名称和单价快照;
  4. 计算请求 fingerprint;
  5. 将 order 置为已接受初始状态。

这些动作应在同一事务内完成。外键保证 line 不会指向不存在的 order,但它不能保证每个 order 至少有一条 line。可行策略是:

  • 先以 draft 状态创建不完整 order;
  • 插入 line;
  • 在同一事务中验证至少一行;
  • 只有验证通过才转为 placed
  • 对外查询不把 draft 当已接受订单。

状态值和转换机制在后续闭合。

payment 是相邻边界

支付 provider 是外部系统,网络调用不能加入 PostgreSQL 本地事务。订单与支付记录通过 order_id 关联,但“外部扣款 + 本地状态”需要幂等、重试和对账,而不是保持一个数据库事务数秒等待第三方。

本地可以原子记录一次 provider 响应并更新订单状态;若提交结果未知,依赖 provider reference 与幂等键恢复。资金真相还要与 provider 对账。

跨表不变量要有检测查询

即使暂时没有强制机制,也要能发现违反:

SELECT
    order_id,
    order_status,
    item_count,
    item_subtotal,
    captured_amount
FROM shop_api.order_summary
WHERE (order_status = 'paid' AND item_count = 0)
   OR (order_status = 'paid' AND captured_amount <> item_subtotal)
ORDER BY order_id;

基线应返回零行。review.sql 在事务内插入一个没有 line/payment 的 paid order,上述查询就能发现它,然后回滚。

检测查询不是强制约束。它缩短发现时间,却仍允许错误状态短暂或永久存在。对“绝不能提交”的规则,应设计原子命令、锁与约束/触发器;对外部最终一致规则,则定义容忍窗口、告警和补偿。

不把总额重复写进 order v0

item_subtotal 可由 line 快照的 unit_price * quantity 推导。若 v0 又在 order 保存 total,就产生两个可独立更新的事实。没有性能证据和同步责任前,view 按需计算。

未来若订单金额是法律/支付契约中的独立快照,可能需要保存经明确舍入、币种和折扣规则计算的 accepted total。那时它不是随便的缓存,而是新的权威事实;ch04 的金额决策必须先完成。

聚合评审表

规则 单表约束 本地事务 外部协调
quantity > 0 不需要额外
line 引用真实 product FK 不需要额外
order 至少一行后才 placed
paid 金额等于 accepted total provider 对账
provider 实际扣款一次 本地只能记账

本节验收

  • 每条关系都写出基数、可选性和父子生命周期;
  • 一对一用 unique FK 表达,而不是双方互相引用;
  • 多对多关联的自身属性没有塞回任一父表;
  • 每个 FK 动作都有业务理由;
  • 知道引用侧索引不会由 FK 自动创建;
  • 跨表不变量有检测查询、强制层和外部协调边界。

参考资料


上一节:标识、主键与业务键 · 返回本章目录 · 下一节:模式、所有权与对象边界 · 查看全书目录 · 查看索引中心

3.4 模式、所有权与对象边界

关系模型还需要命名与权限边界。PostgreSQL schema 是数据库内的 namespace,也参与权限解析;它不是独立数据库、租户隔离或微服务边界的自动实现。边界成立要靠所有权、USAGE、对象权限和受控名称解析共同支持。

3.4.1 业务模式、接口模式与内部模式

v0 使用三个 schema:

schema 内容 谁需要 USAGE 承诺
shop customer、product、order、line、payment 规范关系 app、只读角色 业务事实与命令模型
shop_api order_summary 等显式查询接口 app、只读角色 面向消费者的读取形状
shop_private 未来内部 helper、staging 或实现对象 owner 无外部兼容承诺

public 仍存在,但 ch01 已撤销 PUBLICCREATE。本书不把业务对象默认堆进 public,以免命名、权限与扩展对象混杂。

schema 是 namespace

同一数据库可以同时存在:

shop.customer
crm.customer
archive.customer

未限定的 customersearch_path 决定。显式 shop.customer 同时表达对象和边界,迁移、审计和安全敏感 SQL 应优先使用。

schema 不能提供:

  • 独立 WAL、备份或故障域;
  • 独立连接、资源隔离或主要版本;
  • 自动跨租户行隔离;
  • 自动阻止拥有更高数据库权限的角色访问。

需要这些性质时,应评估数据库、集群、RLS、资源治理或服务边界,而不是给 schema 换一个更宏大的名字。

接口 view 不是天然安全 view

shop_api.order_summary 把五表投影为读取形状。v0 的 app 和只读角色本来就有底表 SELECT,因此该 view 只表达接口与派生逻辑,不承担隐藏敏感行的安全责任。

PostgreSQL 普通 view 默认按 view owner 的底层权限检查。若想用 view 作为安全边界,还要审查 security_barriersecurity_invoker、函数是否 leakproof、RLS 与调用者可创建对象的权限。ch23 专门处理;本章绝不因为“只 grant 了 view”就宣称数据已隔离。

接口演进需要兼容契约

CREATE OR REPLACE VIEW 不能任意改变既有列名称、顺序和类型;新查询必须保留现有列,最多在末尾增加列。消费者依赖哪些列、空值和排序,应在 ch12 的服务契约中管理。

本章 view 查询不承诺默认排序。调用方必须显式 ORDER BY

3.4.2 对象所有者与运行角色分离

对象 owner 可以修改、删除对象和转授权限,是结构控制身份;应用只应拥有完成运行任务所需的 DML。v0 角色:

角色 LOGIN 责任
pg36_owner 拥有 schema、table、view,执行受控迁移
pg36_app 读取与修改规范业务关系
pg36_ro 只读规范关系和接口 view
dbuser_dba L1 管理入口;经授权 SET ROLE pg36_owner

DDL 脚本先以管理员认证,再:

SET ROLE pg36_owner;

于是新对象直接归 pg36_owner,而不是先由个人管理员拥有再批量改 owner。NOLOGIN owner 没有可泄露的直接登录凭据。

owner 权力不是普通 ACL

对象 owner 隐含拥有改变、删除和授权对象的能力。即使撤销 owner 的普通 SELECT,owner 仍能重新 grant。因此安全设计不能把“owner 账号”当作日常应用角色。

超级用户又能绕过绝大多数权限边界。L1 的 dbuser_dba 只用于教学管理;生产迁移应采用受审计、短时授权和明确发布流程。

显式权限与默认权限

setup 对当前五表执行:

GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE shop.customer, shop.product, shop.sales_order,
         shop.sales_order_item, shop.payment
TO pg36_app;

GRANT SELECT ON TABLE ...
TO pg36_ro;

并为 owner 在 shop_api 的未来关系设置默认 SELECT。ALTER DEFAULT PRIVILEGES 只影响以后由指定 owner 创建的对象,不会回填现有对象,也不会自动适用于另一创建角色或 schema。

验证实际边界:

SELECT
    has_table_privilege(
      'pg36_app', 'shop.sales_order', 'INSERT'
    ) AS app_can_insert,
    has_table_privilege(
      'pg36_ro', 'shop.sales_order', 'SELECT'
    ) AS ro_can_select,
    has_table_privilege(
      'pg36_ro', 'shop.sales_order', 'UPDATE'
    ) AS ro_can_update,
    has_schema_privilege(
      'pg36_app', 'shop_private', 'USAGE'
    ) AS app_can_use_private;

期望 true, true, false, false。权限函数是当前状态证据,仍要结合角色成员关系、owner 和 RLS 解释。

v0 给 app 直接表 DML 是教学范围,未来 ch12 可能把部分命令收敛到更窄接口;权限演进必须与应用发布一起设计。

3.4.3 避免依赖不受控的 search_path

search_path 是名称解析规则,也是信任列表。若某角色能在路径靠前的 schema 中创建对象,未限定函数、操作符或关系名可能解析到攻击者对象。

本书运行角色默认:

ALTER ROLE pg36_app IN DATABASE pg36_shop
SET search_path = pg_catalog, shop;

迁移脚本每次又显式:

SET search_path = pg_catalog, shop;

随后验证 current_schemas(false)。双重设置让正常会话有安全默认,也让脚本不依赖数据库外部配置。

为什么 pg_catalog 放在前面

即使没有写入路径,PostgreSQL 也会隐式搜索 pg_catalog;显式把它放在前面,能防止同名用户对象抢在系统函数/操作符之前解析。临时 schema 对关系与类型还有特殊搜索规则,安全敏感代码仍应写出限定名。

v0 DDL 使用:

pg_catalog.md5(...)
shop.sales_order
shop_api.order_summary

不是所有普通查询都必须把每个内置函数写全,但迁移、SECURITY DEFINER 代码和生成 SQL 应采用更严格限定。

不把 $user, public 当无害默认

默认 search path 通常包含 "$user", public。若同名用户 schema 存在,或者 PUBLIC 仍能在 public 创建对象,解析结果可能与预期不同。是否危险取决于权限,但最简单的基线是:

  • 撤销不必要的 public CREATE;
  • runtime 不拥有路径中的 schema;
  • 路径只含受信 namespace;
  • 关键对象使用显式限定;
  • 每个函数设置安全 search path,不继承调用者环境。

函数安全在 ch13/ch23 展开。

schema 权限分两层

拥有 schema USAGE 才能解析其中对象;仍需相应表/view 权限才能访问。反过来,表上有 SELECT 但无 schema USAGE,也不能通过普通限定名访问。

shop_private 对 app 撤销 USAGE,且其中未来对象不授予 runtime。owner 和超级用户仍可访问,所以它是实现边界,不是对管理员的保密区。

本节验收

  • 每个 schema 有内容、消费者与兼容承诺;
  • 不把 schema 宣称为独立数据库或租户隔离;
  • owner 是 NOLOGIN,runtime 不拥有业务对象;
  • 当前权限与 default privileges 分开验证;
  • app/ro 不能使用 shop_private
  • search path 只含受信 schema,迁移对象显式限定;
  • 不把普通 view 当成未经审查的安全屏障。

参考资料


上一节:关系与引用完整性 · 返回本章目录 · 下一节:规范化与有意识的冗余 · 查看全书目录 · 查看索引中心

3.5 规范化与有意识的冗余

规范化不是把任何重复字符串都拆掉,而是让每个事实由正确的键决定并只有一个权威写入位置。冗余也不是禁词:历史快照、独立契约事实与有证据的缓存都可能重复字节,但必须有明确语义和一致性责任。

3.5.1 函数依赖与重复事实

函数依赖 X → Y 表示:在模型承诺的范围内,给定 X 就唯一决定 Y。它是业务语义,不是根据当前十行样例猜出的相关性。

v0 中:

customer_id              → customer_ref, current email, display_name
product_id               → current SKU, current name, current price
order_id                 → order_no, customer_id, placed_at, current status
(order_id, line_no)      → product_id, snapshot fields, quantity
(provider, provider_ref) → payment_id, order_id, amount, status

这些依赖帮助识别事实应该放在哪里。

一张宽表的异常

假设每个订单行重复:

customer_current_email
product_current_name
product_current_price
order_status
line_quantity

若这些字段都声称是“当前值”,会产生:

  • 更新异常:商品改名必须更新所有历史 order line;
  • 插入异常:没有订单时无法保存新 product;
  • 删除异常:删除最后一条 line 可能丢掉 product 事实;
  • 矛盾状态:同一 product_id 在不同 line 上显示两个当前价格。

拆成 customer、product、order、line 后,当前商品事实只由 product_id 决定并保存在一处。

规范化不是按表数评分

把每个字段放一张表会制造无意义 join;把相关字段放在同一行也不自动违反规范化。评审问:

  1. 这列描述的是哪一个事实?
  2. 由哪组键决定?
  3. 更新它是否需要同步其他行?
  4. 删除一行会不会意外删除另一类事实?
  5. 同名字段是同一事实,还是不同时间/契约的快照?

PostgreSQL 数组、JSONB 和复合类型也不是天然“不规范”。若值是一个有边界整体、无需独立引用与约束,嵌入可能正确;若其中元素有独立身份、基数和查询生命周期,把它藏进 JSON 只会把关系规则移到应用。ch04 再讨论半结构化边界。

用唯一和外键验证依赖

关系理论上的依赖需要实际约束支撑。product_id 主键保证一行身份,SKU unique 保证另一业务候选键,order line 复合主键保证每个行号只有一条事实。

但数据库看到的约束只是模型声明。若业务允许 SKU 在多个市场重复,单列 unique 就过强;真正键可能是 (market_id, sku)。建模错误不能靠更快索引修复。

3.5.2 派生数据、快照数据与缓存列

字节重复前先判断它属于哪一类:

类别 含义 v0 例子 更新责任
规范事实 当前权威值 product.product_name product 命令
历史快照 某时点被接受的独立事实 sales_order_item.product_name_snapshot 下单时写一次,之后不随 product 改
派生数据 可由权威事实确定计算 item_subtotal view 查询时计算
缓存列 为性能复制派生结果 v0 没有 需要同步、重建与校验
外部事实副本 另一系统权威的本地记录 provider reference/status webhook、查询和对账

快照不是缓存

购买后 product 从 “Coffee Beans” 改名为 “Summer Coffee”,历史 order line 仍应显示用户购买时接受的名称与单价。它们不是等待刷新到最新值的缓存,而是订单契约的一部分。

同理,buyer_email 保存下单时联系快照,customer 表保存当前 email。两者相同只是初始状态:

BEGIN;

UPDATE shop.customer
SET email = 'alice.new@example.test'
WHERE customer_id = 1;

SELECT o.buyer_email, c.email AS current_email
FROM shop.sales_order AS o
JOIN shop.customer AS c USING (customer_id)
WHERE o.order_id = 1001;

ROLLBACK;

示例在事务末回滚。查询中历史与当前值分离是预期,不应启动“修复任务”把订单快照更新掉。

派生值先计算

订单行小计:

unit_price * quantity

订单 item subtotal:

SELECT sum(unit_price * quantity)
FROM shop.sales_order_item
WHERE order_id = $1;

v0 由 shop_api.order_summary 计算,不在 order 存第二份。金额舍入和币种尚未决定,因此现在存 total 还会提前固化错误语义。

ch04 可能使用生成列保存纯行内派生结果;跨行聚合无法用普通生成列维护。物化 view、缓存表或应用缓存需要性能证据。

外部副本要承认权威差异

本地 payment_status='captured' 表示系统记录了 provider 响应,不自动等于资金最终结算。对账可能发现撤销、拒付或漏单。字段名称和文档应说明它是本地观察还是最终财务事实。

3.5.3 接受冗余前先定义一致性责任

增加缓存列前,设计文档至少回答:

canonical_source:
copied_value:
why_needed:
writer:
update_trigger:
consistency_window:
transaction_boundary:
failure_behavior:
rebuild_procedure:
reconciliation_query:
monitoring:
removal_condition:

如果没有 rebuild_procedurereconciliation_query,团队实际上选择了“永远相信它不会错”。

三种一致性责任

同一行、同一事务

例如 line_total = unit_price * quantity。若确有保存价值,可用生成列或同一 SQL 写入并约束;最容易提供强一致。ch04 决定。

跨行、同一数据库

例如 order total 是所有 line 之和。触发器、事务命令或物化汇总都要处理:

  • insert/update/delete line;
  • 批量语句;
  • 并发修改;
  • 事务回滚;
  • 历史数据回填;
  • 触发器禁用与恢复;
  • 重新计算和差异检测。

没有性能证据时,查询时聚合通常更简单。

跨系统、最终一致

例如搜索索引、缓存、分析仓库。必须定义:

  • 事件或 CDC 的投递语义;
  • 可接受延迟;
  • 重复、乱序与丢失处理;
  • 全量重建;
  • 源端删除与保留;
  • 差异告警和人工修复。

“异步同步”不是一致性设计的完整句子。

一个冗余决策示例

假设订单列表 P99 因聚合 line 变慢,提出在 order 保存 item_count

  1. 先用执行计划和负载证明聚合是瓶颈;
  2. 定义源为 sales_order_item
  3. 决定由同一订单命令事务更新;
  4. 禁止其他入口直接改计数;
  5. 提供:
SELECT o.order_id, o.item_count, count(i.*) AS actual_count
FROM shop.sales_order AS o
LEFT JOIN shop.sales_order_item AS i USING (order_id)
GROUP BY o.order_id, o.item_count
HAVING o.item_count <> count(i.*);
  1. 建立回填与告警;
  2. 记录若优化收益消失则删除缓存列。

v0 没有 item_count 列,view 已提供正确基线,未来优化才能有对照。

本节验收

  • 能为每列写出决定它的键;
  • 宽表中的更新、插入、删除异常可以用具体事实解释;
  • 快照、派生、缓存和外部副本不再统称“冗余”;
  • 历史快照不会随当前主数据刷新;
  • 新缓存有权威源、写者、窗口、重建与对账;
  • 没有性能证据时不提前存跨行聚合。

参考资料


上一节:模式、所有权与对象边界 · 返回本章目录 · 下一节:实战:建立逻辑模型 v0 · 查看全书目录 · 查看索引中心

3.6 实战:建立逻辑模型 v0

本实验把前五节的判断落入真实 PostgreSQL。它不是“写一遍 CREATE TABLE 就算建模完成”,而是同时交付业务事实、DDL、样例、正反规则、关系图和未决登记。

风险:

  • setupR1·可逆变更,创建五表、两个 schema 和一个 view;
  • seedR1·可逆变更,会清空并重建五表的教学数据;
  • verifyR0·观察
  • reviewR2·破坏性演练,事务内插入反例,最后强制回滚;
  • resetR2·破坏性演练,删除全部 ch03 对象,要求双重令牌。

3.6.1 用户、商品、订单、订单项与支付

先审阅业务事实清单。五表各自只保存有明确所有权的事实:

关系 主键 业务/外部键 关键关系 快照或派生
customer customer_id customer_ref、email 当前档案
product product_id SKU 当前目录
sales_order order_id order_no、customer-scoped request key customer buyer email 快照
sales_order_item order_id + line_no order、product SKU/name/unit price 快照
payment payment_id provider reference、provider-scoped idempotency key order provider 响应记录

完整关系图:

erDiagram
  CUSTOMER ||--o{ SALES_ORDER : places
  SALES_ORDER ||--|{ SALES_ORDER_ITEM : contains
  PRODUCT ||--o{ SALES_ORDER_ITEM : snapshotted_as
  SALES_ORDER ||--o{ PAYMENT : receives

  CUSTOMER {
    bigint customer_id PK
    text customer_ref UK
    text email UK
  }
  PRODUCT {
    bigint product_id PK
    text sku UK
    numeric current_unit_price
  }
  SALES_ORDER {
    bigint order_id PK
    text order_no UK
    bigint customer_id FK
    text request_key
    text order_status
  }
  SALES_ORDER_ITEM {
    bigint order_id PK,FK
    integer line_no PK
    bigint product_id FK
    text sku_snapshot
    numeric unit_price
    integer quantity
  }
  PAYMENT {
    bigint payment_id PK
    bigint order_id FK
    text provider
    text provider_payment_ref
    text payment_status
    numeric amount
  }

可下载源文件是 model.mmd

v0 已经决定什么

  • 所有关系有主键;
  • customer ref、email、SKU、order no 有业务唯一约束;
  • order request key 在 customer 范围唯一;
  • payment provider ref 与 idempotency key 在 provider 范围唯一;
  • 所有引用由 FK 维护;
  • line_no、quantity、price/amount 的基本正值规则由 CHECK 维护;
  • order line 保存购买时商品快照;
  • runtime 与 owner 分离;
  • 查询摘要由 view 派生,不重复写入 order。

v0 故意没有决定什么

DDL 使用手工 bigint、无指定 precision/scale 的 numerictext 状态和 timestamptz。它们让逻辑关系可以运行,但没有回答:

  • ID 由 identity、UUID 还是应用生成;
  • 金额是否用 minor units、如何表达币种和舍入;
  • 状态允许值与转换;
  • 业务时间 zone、precision、clock source;
  • paid/order-line 等跨表不变量如何原子强制。

因此所有对象 comment 和输出都标记 ch03-v0

3.6.2 在 Pigsty L1 的真实数据库中部署并用样例规则审查

沿用 ch02 私有 service file:

export PGSERVICEFILE="$PWD/pg_service.conf"
export PGSERVICE=pg36-admin
psql -X -w "service=$PGSERVICE" -c '\conninfo'

确认目标是 L1 的 pg36_shop 主库。下载 ch03 文件到同一目录并运行:

chmod +x task.sh
export PG36_EVIDENCE_DIR="$PWD/evidence/ch03/all-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh all

综合顺序:

manifest → setup → seed → verify → review → review rollback

Pigsty 5436 负责把管理连接送到当前主库 PostgreSQL;应用 DDL 由版本化 SQL 迁移负责,不应塞进 Pigsty 集群拓扑配置。Pigsty 可以声明 database、role 和 service,业务表模式仍属于应用发布物。

setup 的漂移保护

setup.sql使用 CREATE ... IF NOT EXISTS 支持重入,但随后从 pg_attributepg_constraint 验证:

  • 五表的列名、类型、NOT NULL 与列集合精确匹配;
  • 21 个命名 PK/UK/FK/CHECK 存在、类型正确且已验证;
  • 没有额外用户约束;
  • 相关对象 owner 是 pg36_owner

若人为增加 product.drift_probe,setup 在事务中返回:

ERROR: logical model has unexpected columns: product.drift_probe

psql 状态为 3,不会用“relation already exists”掩盖漂移。不要在有价值环境为了测试随意改表;这项负向验证只在可销毁 L1 做。

seed 与状态摘要

seed.sql用固定 ID、金额与 UTC 时间生成:

  • 2 customer;
  • 3 product;
  • 2 order;
  • 3 line;
  • 2 payment。

verify.sql检查行数、孤儿、order 1001 subtotal/captured amount、app/ro 权限和 private schema 边界。基线输出:

status=ok
model_version=ch03-v0
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
open_decision_count=4
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

连续执行两次 all,摘要相同。setup 的 NOTICE 属于“对象已存在并经过后续验证”,不是静默成功。

规则审查必须有正反两面

review.sql在一个事务内先尝试三类非法写入:

反例 预期 SQLSTATE 类别
重复 SKU-COFFEE unique_violation
line 引用不存在 product foreign_key_violation
quantity = 0 check_violation

脚本只捕获预期异常,若错误类型不同或写入意外成功,整个 review 失败。

随后插入三项当前 DDL允许、业务尚未批准的状态:

arbitrary_money_scale_still_allowed=true
arbitrary_order_status_still_allowed=true
paid_without_items_or_payment_still_possible=true

事务末 ROLLBACK,再次 verify 仍得到原校验和。这组输出是 v0 的边界证据:约束有效,但模型尚未完整。

分步运行:

./task.sh setup
./task.sh seed
./task.sh verify
./task.sh review

每次给 evidence 新目录,保留失败现场。

3.6.3 产出金额、时间、状态、标识四项未决清单

未决登记不是随手记下的待办列表,而是 ch04 的输入合同:

决策域 已知事实 仍需决定 关闭证据
金额 line 保存购买价;payment 保存尝试金额 表示、币种、scale、rounding、refund 非法精度/币种有明确失败
时间 下单与支付发生时间是瞬间 业务 zone、precision、clock、范围 DST/客户端 zone 往返样例
状态 order/payment 是不同状态域 允许值、转换、终态、实现 非法值与非法转换分别失败
标识 internal/business/idempotency/provider/trace 含义不同 类型、生成方、公开性、顺序 并发生成与重复用例

决策不是选一个类型名

“金额用 numeric”仍缺少:

  • 是否每行携带 currency;
  • 同币种 scale;
  • 税费、折扣与汇率何时舍入;
  • 负数表示退款还是另建事实;
  • API/JSON 如何序列化;
  • index 和聚合代价。

“时间用 timestamptz”仍缺少:

  • 字段表示发生瞬间还是业务日;
  • 哪个时钟产生;
  • 允许多远未来/过去;
  • 展示用哪个 zone;
  • 精度与外部系统对齐。

“状态用 enum”也没有定义转换;“ID 用 UUID”也没有定义版本、生成位置和暴露范围。ch04 必须把语义、DDL、错误与验证一起交付。

关闭条件

每项 decision 只有同时具备以下内容才从 open 变成 accepted:

  1. 业务语义与反例;
  2. PostgreSQL 表达;
  3. 约束/生成与并发行为;
  4. 旧数据迁移;
  5. API 与错误契约;
  6. 验证查询;
  7. 回退或前滚路径;
  8. 版本适用范围。

只在会议中口头说“应该两位小数”不算关闭。

3.6.4 生成逻辑关系图并链接 ch04 的可靠版本

图必须能与真实目录互证。列出所有 FK:

SELECT
    c.conname,
    c.conrelid::regclass AS child_relation,
    c.confrelid::regclass AS parent_relation,
    pg_catalog.pg_get_constraintdef(c.oid, true) AS definition
FROM pg_catalog.pg_constraint AS c
WHERE c.contype = 'f'
  AND c.connamespace = 'shop'::regnamespace
ORDER BY (c.conrelid::regclass)::text, c.conname;

应得到四条边:

sales_order.customer_id        -> customer.customer_id
sales_order_item.order_id      -> sales_order.order_id
sales_order_item.product_id    -> product.product_id
payment.order_id               -> sales_order.order_id

Mermaid 中 SALES_ORDER ||--|{ SALES_ORDER_ITEM 表达已接受订单应至少一行,但当前 FK 目录只能保证每个 line 有 order,不能反向保证 order 有 line。图表达目标模型,目录查询表达现有强制能力;两者差异必须进入未决登记,而不是让图冒充约束。

v0 到可靠版本的交接

ch04 将建立 v1,至少产生:

schema-v1.sql
migrate-v0-to-v1.sql
verify-v1.sql
negative-cases.sql
partition-adr.md

v1 关系图需要标出类型/状态决策变化,并保留 v0 作为迁移起点。不能直接改写 ch03 文件让读者失去演进过程。

reset 边界

默认 all 不清理。如果必须回到 ch02:

export PG36_RESET_TOKEN=RESET_CH03_MODEL
export PG36_EVIDENCE_DIR="$PWD/evidence/ch03/reset-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh reset
unset PG36_RESET_TOKEN

SQL 内部还要求 confirm_reset=RESET_CH03_MODEL。reset 只删除:

  • 五个 ch03 表;
  • shop_api.order_summary
  • 空的 shop_apishop_private schema。

它不删除 pg36_shopshop、角色或 ch02 fixture。schema 使用默认 RESTRICT 删除;若出现未知额外对象,事务失败并整体回滚,避免把他人对象级联带走。

本章最终验收

  • 业务事实清单有 owner、命令和不变量;
  • 五表职责没有重叠的当前权威事实;
  • 每类标识的作用域与用途明确;
  • 四条 FK 与关系图互证;
  • owner/runtime/schema 权限符合预期;
  • setup 重跑收敛,额外列会返回状态 3
  • seed 摘要与 checksum 匹配;
  • 三类非法写入触发准确约束;
  • 三项开放规则被实验性证明且完全回滚;
  • 四项 decision register 已链接 ch04 验收;
  • 团队明确 v0 不是生产 DDL。

满足这些条件后进入 ch04《量体裁衣:数据类型、约束与可靠数据表达》

参考资料


上一节:规范化与有意识的冗余 · 返回本章目录 · 下一章:量体裁衣:数据类型、约束与可靠数据表达 · 查看全书目录 · 查看索引中心