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

LLMS 索引： [llms.txt](/llms.txt)

---

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

## 3.1.1 实体、事件、状态与业务不变量 {#item-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 会故意暴露它们尚未关闭。

### 不变量要写出四个维度

不要只写“订单号唯一”，而要写：

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

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

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

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

### 事件和状态不要互相伪装

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

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

## 3.1.2 命令模型、查询模型与数据所有权 {#item-3-1-2}

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

### 规范事实与读取形状

v0 的命令侧关系是：

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

查询侧提供 `shop_api.order_summary`：

```sql
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_app`、`pg36_ro` | 按最小权限执行命令或读取 |

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

[业务事实清单](/labs/ch03/requirements.md)把本章范围写成：

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

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

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

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

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

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

## 3.1.3 哪些规则必须由数据库兜底 {#item-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` 引用该行之外的表数据并承诺持续一致。下面的想法是错误方向：

```sql
-- 不要这样设计跨表 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 验收。

### 本节产物

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

```text
rule_id
fact / invariant
scope
validation_time
authoritative_owner
enforcement_layer
error_semantics
evidence_query
open_questions
```

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

### 本节验收

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

## 参考资料

- [PostgreSQL 18：约束](https://www.postgresql.org/docs/18/ddl-constraints.html)
- [PostgreSQL 18：检查约束的跨行限制](https://www.postgresql.org/docs/18/ddl-constraints.html#DDL-CONSTRAINTS-CHECK-CONSTRAINTS)
- [PostgreSQL 18：事务](https://www.postgresql.org/docs/18/tutorial-transactions.html)

---

[返回本章目录](../) · [下一节：标识、主键与业务键](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
