跳至内容
3.5 规范化与有意识的冗余

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_nameproduct 命令
历史快照某时点被接受的独立事实sales_order_item.product_name_snapshot下单时写一次,之后不随 product 改
派生数据可由权威事实确定计算item_subtotalview 查询时计算
缓存列为性能复制派生结果v0 没有需要同步、重建与校验
外部事实副本另一系统权威的本地记录provider reference/statuswebhook、查询和对账

快照不是缓存

购买后 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 · 查看全书目录 · 查看索引中心

最后更新于