ALTERTABLE orders
ADDCOLUMN customer_name TEXT,ADDCOLUMN customer_tier TEXT;-- Keep the redundant fields correct on insert/updateCREATEORREPLACEFUNCTION sync_order_customer_fields()RETURNSTRIGGERAS $
BEGINSELECT name, tier INTO NEW.customer_name, NEW.customer_tier
FROM customers WHERE customer_id = NEW.customer_id;RETURN NEW;END;
$ LANGUAGE plpgsql;CREATETRIGGER trg_orders_sync_customer
BEFORE INSERTORUPDATEOF customer_id ON orders
FOR EACH ROWEXECUTEFUNCTION sync_order_customer_fields();-- Propagate customer name/tier changes to existing ordersCREATEORREPLACEFUNCTION propagate_customer_changes()RETURNSTRIGGERAS $
BEGINUPDATE orders
SET customer_name = NEW.name,
customer_tier = NEW.tier
WHERE customer_id = NEW.customer_id;RETURN NEW;END;
$ LANGUAGE plpgsql;CREATETRIGGER trg_customer_propagate
AFTERUPDATEOF name, tier ON customers
FOR EACH ROWEXECUTEFUNCTION propagate_customer_changes();技术支持
-- Generated column example (Postgres 12+; same-table expressions)ALTERTABLE products
ADDCOLUMN price_with_tax NUMERIC(12,2) GENERATED ALWAYS AS(price *1.20) STORED;-- Counter maintained by triggers (likes per post)ALTERTABLE posts ADDCOLUMN like_count INTEGERNOTNULLDEFAULT0;CREATEORREPLACEFUNCTION inc_like_count()RETURNSTRIGGERAS $
BEGINUPDATE posts SET like_count = like_count +1WHERE post_id = NEW.post_id;RETURN NEW;END; $ LANGUAGE plpgsql;CREATETRIGGER trg_like_insert
AFTERINSERTON post_likes
FOR EACH ROWEXECUTEFUNCTION inc_like_count();CREATEORREPLACEFUNCTION dec_like_count()RETURNSTRIGGERAS $
BEGINUPDATE posts SET like_count = GREATEST(like_count -1,0)WHERE post_id = OLD.post_id;RETURN OLD;END; $ LANGUAGE plpgsql;CREATETRIGGER trg_like_delete
AFTERDELETEON post_likes
FOR EACH ROWEXECUTEFUNCTION dec_like_count();技术支持
4) 聚合/汇总表
作用: 预计算如下钻,如每日营收、每日活跃用户、每类订单数。
时机: 仪表板重复相同 group-by,直接扫描代价高。
CREATETABLE daily_sales (
sales_day DATEPRIMARYKEY,
gross_amount NUMERIC(14,2)NOTNULL,
order_count INTEGERNOTNULL);-- Incremental upsert for "yesterday" (run hourly/15-min)INSERTINTO daily_sales (sales_day, gross_amount, order_count)SELECT(o.created_at AT TIME ZONE 'UTC')::dateAS sales_day,SUM(oi.quantity * oi.unit_price)AS gross_amount,COUNT(DISTINCT o.order_id)AS order_count
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.created_at >= date_trunc('day',now()-interval'1 day')AND o.created_at < date_trunc('day',now())GROUPBY sales_day
ON CONFLICT (sales_day)DOUPDATESET gross_amount = EXCLUDED.gross_amount,
order_count = EXCLUDED.order_count;技术支持
同步选项: 计划作业(cron/dbt)、流式更新(CDC),或事件驱动聚合器。
5) 物化视图(持久化查询结果)
作用: 将代价高的查询结果以物理表形式存储,您可以为其建立索引。
时机: 结果集代价高、相对稳定,且可接受刷新窗口。
-- Expensive reportCREATE MATERIALIZED VIEW mv_category_sales ASSELECT p.category_id,
date_trunc('day', o.created_at)ASday,SUM(oi.quantity * oi.unit_price)AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
GROUPBY p.category_id, date_trunc('day', o.created_at);-- Make it fast to queryCREATEINDEXON mv_category_sales (category_id,day);-- Refresh patterns-- Full, blocking:
REFRESH MATERIALIZED VIEW mv_category_sales;-- Non-blocking reads (requires unique index on the MV):-- 1) ensure a unique index exists (e.g., (category_id, day))-- 2) then:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_sales;技术支持
CREATETABLE product_cards (
product_id BIGINTPRIMARYKEY,
card_json JSONB NOTNULL);-- Build or rebuild card documentsINSERTINTO product_cards (product_id, card_json)SELECT p.product_id,
jsonb_build_object('id', p.product_id,'name', p.name,'brand', b.name,'price_with_tax', p.price *1.20,'category', c.name
)FROM products p
JOIN brands b ON b.brand_id = p.brand_id
JOIN categories c ON c.category_id = p.category_id
ON CONFLICT (product_id)DOUPDATESET card_json = EXCLUDED.card_json;-- GIN index for JSON queries if neededCREATEINDEX idx_product_cards_gin ON product_cards USING GIN (card_json);技术支持
-- Example parity check: orders_projection vs live joinSELECTCOUNT(*)AS mismatches
FROM orders_projection p
JOIN orders o ON o.order_id = p.order_id
JOIN customers c ON c.customer_id = o.customer_id
WHERE p.customer_name <> c.name
OR p.total_amount <>(SELECTSUM(oi.quantity * oi.unit_price)FROM order_items oi WHERE oi.order_id = o.order_id
);技术支持
我写过很多关于规范化的内容,以及为什么它是确保数据完整性的可靠基础。这并不自相矛盾。我依然认为,良好规范化的模式是大多数事务型系统的正确起点。本文要讨论的是在某些特定场景下有意识地偏离这种“纯粹”,当读取性能比严格遵循范式更重要时,该如何权衡。
反规范化并不是跳过规范化。它是一种在规范化模型之上进行的性能优化:当真实的查询、真实的用户和真实的 SLA 告诉您联接和即时计算太慢或代价太高时才应用。实践中,您用更快的读取和更简单的查询,去交换更多的存储、更复杂的写入,以及额外的一致性维护工作。
本文将向您展示反规范化在哪些地方有用、哪些地方不适用,以及如何在 SQL 数据库中安全地实施。目标不是抛弃良好的设计,而是在工作负载确有需要时,在其之上叠加一些“捷径”。
什么是数据库中的反规范化?
如果您熟悉规范化和数据库设计,先给出一个简短回答:
反规范化是指在已规范化的模式上有意添加冗余数据,以加速读取并简化查询。这是一种有的放矢的性能优化,而不是放弃良好建模的借口!
如果您对数据库设计还比较新手,那么值得拆解一下“规范化”(normalized)、“反规范化”(denormalized)和“非规范化/未规范化”(unnormalized)的含义。网上经常会看到这三个术语,切勿混淆。
规范化:数据被拆分到结构良好的表中,最大限度减少冗余并保护完整性(参见3NF/BCNF)。
反规范化:在该规范化模型之上,通过增加额外列、预计算值或预联接表,有选择地引入冗余,使常见读取更快。在反规范化中,您保留单一事实来源(规范化的表),并为热点路径维护一个或多个更快的表示形式,如仪表板、商品列表、搜索等。您用更多存储 + 更复杂写入,换取更快读取 + 更简单查询。
未规范化:原始、临时拼凑或凌乱的数据,从未经过恰当的结构与约束设计。这并非我们在此要做的。
SQL 示例
下面用一个很小的 SQL 示例,帮助您直观理解规范化与反规范化建模的差异。
规范化建模
为报表路径进行反规范化
在此示例中,您会将 customers 保持为事实来源,并确保
orders.customer_name / orders.customer_tier保持同步(例如通过触发器、CDC 作业或计划回填)。同一事实,两种表示,各自针对不同任务优化。反规范化是糟糕的设计吗?
一个常见误解是,反规范化只是糟糕设计的产物。如果反规范化是可度量的、有范围的、并得到维护,那么并非如此。是的,反规范化往往会违反更高范式(这正是其要义),但它是有意为之,并有方案来维持一致性。糟糕的设计是完全跳过规范化,或随意添加冗余却没有同步策略。
如果您想进一步学习数据库设计,我们的入门课程是一个不错的起点!
规范化 vs 反规范化
下面是并排对比。虽然不算穷尽,但有助于您理解两种方法的权衡。
方面
规范化
反规范化
主要目标
完整性、最小冗余、易于且正确的更新
更快读取、在热点路径上更简单的查询
最适合
写入/更新频繁的 OLTP 系统
读取密集型仪表板、搜索/列表页面、报表/分析
读取性能
通常需要联接;配合合适索引表现良好
更少联接,往往更快且更可预测
写入复杂度
简单:单一事实来源
更高:必须更新/同步冗余副本或聚合
存储
精简
更大(额外列/表、预计算视图)
数据完整性
由设计保证(3NF/BCNF 约束)
需要机制防止漂移(触发器、CDC、作业)
变更速度
列重命名/更新局部化
变更可能在重复数据中扩散
运维开销
较低:活动部件更少
较高:刷新策略、回填、监控
失效模式
N+1 查询、慢联接、缺失索引
陈旧数据、不一致、写入放大
模式演进
可预测、便于重构
冗余表示需要迁移计划
典型示例
订单、客户、交易
预联接的商品列表;汇总销售表;物化/索引视图
为什么以及何时进行反规范化
当真实用户与真实查询被联接、聚合或重复查找所阻塞时,反规范化就会非常有用。一旦确认通过索引、查询调优和缓存仍不足以解决问题,您就可以考虑反规范化,以便在可预测的访问模式上优化快速读取。
反规范化可能有帮助的情况
经典用例
何时不应反规范化,先尝试其他手段
您应当在反规范化能以最小额外运维负担带来最大读取提速之处使用它,且仅在排除更廉价的方案之后。
如果您意识到现在反规范化并不合适,别急!下一节我们将探索反规范化的替代方案。
反规范化的替代方案
反规范化并非万能。在添加冗余之前,请尽可能从数据库引擎与应用中榨取性能。这些修复更易维护,且常能带来同等收益。
1) 索引
复合/覆盖索引: 在 SQL 查询中,将过滤列放在前,然后是
GROUP BY/ORDER BY列。尽量包含选择列表中的列,使引擎可仅用索引响应查询。过滤/部分索引: 只索引热点切片(如
status = 'ACTIVE'),让索引更小更快。表达式/函数索引: 在
LOWER(email)或date_trunc('day', created_at)上建索引,避免计算型扫描。2) 查询调优与分页
避免
SELECT *。只取展示所需字段。当仅需判断存在性时,将不必要的联接替换为
EXISTS/SEMI。下推谓词:尽早过滤,尽晚聚合。
使用键集分页(
WHERE created_at < ? ORDER BY created_at DESC LIMIT 50)以获得稳定、快速的滚动。3) 缓存
4) 只读副本
5) 分区与剪枝
6) 列式/OLAP 存储
7) ORM 卫生
8) 计算/生成列(由数据库维护)
让数据库以生成/计算列或表达式索引维护派生值(如
price_with_tax)。这样即可获得快速读取,无需在应用层编写同步逻辑。反规范化技术
以下是为规范化模型添加可控冗余的常见方法。每种我都会说明其作用、适用时机,以及如何保持同步。
设定:假设有规范化核心表 customers、orders、order_items、products。
1) 扁平化/预联接的“投影”表
作用: 创建一个预先联接所需列的表,以便快速读取(如商品列表或订单仪表板)。
时机: 您的热点路径以可预测方式联接 3 到 5 张表。
如何做(PostgreSQL):
同步选项:
2) 冗余列(复制少量属性)
作用: 将少数经常读取的属性复制到另一张表,避免联接(如 orders.customer_name)。
时机: 您只需要 1 或 2 个值,不想做完整投影表。
如何做(PostgreSQL 触发器示例):
权衡: 读取简单,但当客户更新时写入会扇出。
3) 派生属性(存储计算值)
作用: 持久化那些原本会即时计算的值(如
price_with_tax、latest_comment_at、item_count)。时机: 该计算被频繁执行且是确定性的。
两种常见模式:
4) 聚合/汇总表
作用: 预计算如下钻,如每日营收、每日活跃用户、每类订单数。
时机: 仪表板重复相同 group-by,直接扫描代价高。
同步选项: 计划作业(cron/dbt)、流式更新(CDC),或事件驱动聚合器。
5) 物化视图(持久化查询结果)
作用: 将代价高的查询结果以物理表形式存储,您可以为其建立索引。
时机: 结果集代价高、相对稳定,且可接受刷新窗口。
权衡: 需要管理刷新节奏,因为 Postgres 原生不支持增量刷新。但若使用 dbt 或自定义“仅追加 + 窗口化重建”模式,实现也不难。
6) 预计算 JSON “文档”投影
作用: 存储与您的 API/UI 需要相匹配的反规范化 JSON 块(如商品卡片),并配合定向的 JSON 索引。
时机: 您的 API 反复提供相同结构,且希望一次读取搞定。
同步选项: 在产品/品牌/类别变更时事件驱动重建,或对最近更新的商品进行高频批量刷新。
选择同步策略(速览)
我建议始终在方案中加入可观测性。没有什么比不确定变更是否被正确处理或复制更糟糕的了。它也有助于进行漂移检查与回填脚本的核算。
如何逐步实施反规范化
一如既往,对数据库动刀有风险。我强烈建议您先复制数据库,并在副本上尝试实施反规范化,以确认一切行为符合预期。如果有开发环境就更好了!
流程很简单:度量 → 尽量小改 → 保持正确 → 验证 → 监控 → 循环。
1) 画像并设定成功目标
2) 选择能奏效的最小反规范化
3) 设计同步路径
幂等性是不容妥协的。更新应可安全重复(例如使用
UPSERT进行确定性重算)。相信我,这会让您日后心怀感激。4) 创建、回填与校验
4. 修复不一致;重复执行直至为零(或在约定误差预算内)。
5) 安全切换
6) 监控关键指标
在您选用的可观测性工具中创建一个小型仪表板:
7) 运维与迭代
反规范化的优缺点
我们已在前文的“规范化 vs 反规范化”表格中覆盖细节,这里快速回顾。
您将获得
代价在于
支持反规范化的工具与技术
并非每种数据库都以相同方式支持反规范化。有些提供一流能力来持久化代价高的查询结果,另一些则需要您用作业、触发器或流水线自行实现。
PostgreSQL
Postgres 提供多种方式来物化面向读取优化的形态。
物化视图会持久化查询结果,使读取瞬时完成。您可以选择刷新时机(按计划或按需),甚至可以并发刷新,从而不阻塞读者。
对于轻量复制,生成列与表达式索引可让数据库维护派生值、加速常见过滤,而无需额外的同步逻辑。
当您需要来源与反规范化副本之间的强一致性时,可用触发器保持同步(但请记住,它们会增加写入路径的工作量)。对于事件驱动或批量刷新,您可以将 Postgres 与调度器(如 pg_cron 或外部作业运行器)配对,或使用逻辑复制/CDC 将变更流式传入 worker,以异步更新反规范化表。
如果您想亲手创建数据库并测试反规范化,请查看我们的 PostgreSQL 课程。
SQL Server
SQL Server 大力支持索引视图,这有点像始终在线的物化视图:引擎在每次插入/更新/删除时保持视图同步,因此读取超快。反面是写入需要同时维护基础表与视图,因此重型 OLTP 负载可能变慢。
此外,您还可以使用“持久化计算列”来覆盖单行派生属性。
对于规模化报表,列存储索引是反规范化的替代方案:它们能高效压缩并扫描海量数据,常常让复制数据变得不再必要。
Oracle
Oracle 的物化视图功能成熟,提供使用变更日志仅更新变动部分的
FAST刷新,或当您可接受重建全量时的COMPLETE刷新。借助 Query Rewrite,优化器可在查询底层表时透明地使用您的物化视图,让团队无需修改应用 SQL 也能获得加速。像往常一样,更快的读取意味着其他地方要多做工作:维护日志与刷新视图会增加写入与运维开销。
MySQL / MariaDB
MySQL 没有原生物化视图,因此反规范化通常通过物理表加计划作业或触发器来保持最新。
生成列有助于简单的派生值。这对可预测的仪表板与汇总很奏效,但仍需谨慎规划刷新节奏与触发器复杂度,以免无意中拖累关键写入路径。许多团队会结合只读副本,完全将报表查询卸载出去。
Snowflake / BigQuery(分析层)
列式数据仓库为宽表、易扫描、反规范化的数据而生。您通常会建模为带有可用属性的宽事实表,并依赖分区/聚类来剪枝扫描范围。
两者均支持物化视图与计划任务/查询,以保持聚合数据新鲜,而无需触碰 OLTP 系统。您用存储与刷新成本,换取在规模化下非常可预测、廉价的读取。非常适合仪表板和 BI。
这比我们的 PostgreSQL 课程更进阶一些,因此如果您已对数据库很熟悉,不妨尝试我们的Snowflake 数据建模入门。
dbt(建模与编排)
dbt 被称为反规范化分析的“基础设施即代码”层。您一次性定义模型,选择物化方式(表、视图、增量),让 dbt 处理构建、依赖与测试。增量模型对只需追加并合并新数据的汇总表尤其实用。它绝对是我最喜欢的工具之一!
CDC 与数据流水线
当您希望应用变更可靠地流入反规范化结构时,变更数据捕获(CDC)是不二之选。像 Debezium(自托管)或托管连接器(Fivetran、Airbyte)这类工具,会将 OLTP 的行级变更流式传入 worker 或数仓,以更新投影表、计数器或聚合。通常这是最终一致的(非常适合仪表板和信息流),且较之将一切塞进触发器要更易扩展。请实现幂等更新、监控滞后,并保留迟到或丢失事件的回填路径。
要点总结与结语
反规范化是一种务实的优化,是叠加在规范化设计之上的,而非对其的否定。您以存储与写入复杂度,换取在特定且明确的路径上更快、更简单的读取。
请记住: