# 规范化与有意识的冗余

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

---

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

## 3.5.1 函数依赖与重复事实 {#item-3-5-1}

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

v0 中：

```text
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
```

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

### 一张宽表的异常

假设每个订单行重复：

```text
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 派生数据、快照数据与缓存列 {#item-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。两者相同只是初始状态：

```sql
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;
```

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

### 派生值先计算

订单行小计：

```sql
unit_price * quantity
```

订单 item subtotal：

```sql
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 接受冗余前先定义一致性责任 {#item-3-5-3}

增加缓存列前，设计文档至少回答：

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

如果没有 `rebuild_procedure` 和 `reconciliation_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. 提供：

```sql
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.*);
```

6. 建立回填与告警；
7. 记录若优化收益消失则删除缓存列。

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

### 本节验收

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

## 参考资料

- [PostgreSQL 18：唯一约束与主键](https://www.postgresql.org/docs/18/ddl-constraints.html)
- [PostgreSQL 18：生成列](https://www.postgresql.org/docs/18/ddl-generated-columns.html)
- [PostgreSQL 18：CREATE MATERIALIZED VIEW](https://www.postgresql.org/docs/18/sql-creatematerializedview.html)

---

[上一节：模式、所有权与对象边界](../04/) · [返回本章目录](../) · [下一节：实战：建立逻辑模型 v0](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
