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;把相关字段放在同一行也不自动违反规范化。评审问:
- 这列描述的是哪一个事实?
- 由哪组键决定?
- 更新它是否需要同步其他行?
- 删除一行会不会意外删除另一类事实?
- 同名字段是同一事实,还是不同时间/契约的快照?
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_procedure 和 reconciliation_query,团队实际上选择了“永远相信它不会错”。
三种一致性责任
同一行、同一事务
例如 line_total = unit_price * quantity。若确有保存价值,可用生成列或同一 SQL 写入并约束;最容易提供强一致。ch04 决定。
跨行、同一数据库
例如 order total 是所有 line 之和。触发器、事务命令或物化汇总都要处理:
- insert/update/delete line;
- 批量语句;
- 并发修改;
- 事务回滚;
- 历史数据回填;
- 触发器禁用与恢复;
- 重新计算和差异检测。
没有性能证据时,查询时聚合通常更简单。
跨系统、最终一致
例如搜索索引、缓存、分析仓库。必须定义:
- 事件或 CDC 的投递语义;
- 可接受延迟;
- 重复、乱序与丢失处理;
- 全量重建;
- 源端删除与保留;
- 差异告警和人工修复。
“异步同步”不是一致性设计的完整句子。
一个冗余决策示例
假设订单列表 P99 因聚合 line 变慢,提出在 order 保存 item_count:
- 先用执行计划和负载证明聚合是瓶颈;
- 定义源为
sales_order_item; - 决定由同一订单命令事务更新;
- 禁止其他入口直接改计数;
- 提供:
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.*);- 建立回填与告警;
- 记录若优化收益消失则删除缓存列。
v0 没有 item_count 列,view 已提供正确基线,未来优化才能有对照。
本节验收
- 能为每列写出决定它的键;
- 宽表中的更新、插入、删除异常可以用具体事实解释;
- 快照、派生、缓存和外部副本不再统称“冗余”;
- 历史快照不会随当前主数据刷新;
- 新缓存有权威源、写者、窗口、重建与对账;
- 没有性能证据时不提前存跨行聚合。
参考资料
上一节:模式、所有权与对象边界 · 返回本章目录 · 下一节:实战:建立逻辑模型 v0 · 查看全书目录 · 查看索引中心