范式与反范式
范式(Normalization)是关系数据库设计的基石:通过拆表消除冗余,让每一份数据只存一次。但真实业务里,严格的范式化往往会带来大量 JOIN 与复杂的查询逻辑,于是又需要反范式(Denormalization)。本节从函数依赖讲起,逐级推导 1NF 到 BCNF,再用一张真实的订单宽表演进演示拆表过程,最后给出范式化与反范式的权衡依据,以及 PostgreSQL 特有能力的折中手段。
前置知识
阅读本节前,建议先了解:创建表(CREATE TABLE)
基础概念
范式讨论的核心对象是函数依赖(Functional Dependency)。记作 X → Y,含义是「只要 X 的值确定,Y 的值就唯一确定」。例如在商品表里 product_id → product_name,因为一个商品 ID 只对应一个商品名。
围绕函数依赖,范式要消除的是三类「坏依赖」:
| 依赖类型 | 英文 | 含义 | 违反的范式 |
|---|---|---|---|
| 部分依赖 | Partial Dependency | 非主属性只依赖复合主键的一部分 | 2NF |
| 传递依赖 | Transitive Dependency | 非主属性依赖另一个非主属性 | 3NF |
| 决定因素非超键 | Non-superkey Determinant | 每个决定因素都必须是候选键 | BCNF |
四个范式的定义层层递进:
| 范式 | 英文 | 一句话定义 |
|---|---|---|
| 1NF | First Normal Form | 每个字段都是原子值,不可再分 |
| 2NF | Second Normal Form | 满足 1NF,且无部分依赖 |
| 3NF | Third Normal Form | 满足 2NF,且无传递依赖 |
| BCNF | Boyce-Codd Normal Form | 满足 3NF,且所有决定因素都是候选键 |
一句话记住演进关系
1NF 解决「单元格里塞列表」,2NF 解决「复合主键下只依赖一半」,3NF 解决「A 依赖 B、B 依赖 C」,BCNF 解决「主属性之间也有依赖」。
反范式(Denormalization)与之相反:主动引入冗余,用「多存一份」换取「少一次 JOIN」或「少一次聚合计算」。它不是「不设计」,而是在明确知道代价的前提下做出的权衡。
语法与用法
范式与反范式不是 SQL 语法层面的东西,而是建模决策。但我们可以用 DDL 的形态对比它们的差别:
-- 范式化形态:一个事实只存一处,用外键关联
CREATE TABLE customers (customer_id ..., name ...);
CREATE TABLE products (product_id ..., name ...);
CREATE TABLE orders (order_id ..., customer_id ...);
CREATE TABLE order_items (order_id ..., product_id ..., qty ...);
-- 反范式形态:把关联信息冗余到同一张宽表
CREATE TABLE orders_wide (
order_id ...,
customer_id ...,
customer_name ..., -- 冗余自 customers
product_id ...,
product_name ... -- 冗余自 products
);最简可运行示例——先看 1NF 要消除的「非原子值」:
-- 违反 1NF:tags 列里塞了逗号分隔的多个值
CREATE TABLE product_bad_1nf (
id integer PRIMARY KEY,
name text,
tags text -- 'phone,5g,flagship' 这种值违反 1NF
);
-- 满足 1NF 的写法:用数组或独立关联表
CREATE TABLE product_ok_1nf (
id integer PRIMARY KEY,
name text,
tags text[] -- 数组是「单个字段的原子值」,满足 1NF
);CREATE TABLE
CREATE TABLE关于数组与 1NF 的关系:PostgreSQL 官方把数组视为单个字段的原子值,元素通过下标访问,因此 text[] 不违反 1NF:
INSERT INTO product_ok_1nf VALUES (1, 'Phone', ARRAY['5g', 'flagship']);
SELECT id, name, tags[1] AS first_tag FROM product_ok_1nf; id | name | first_tag
----+-------+-----------
1 | Phone | 5g
(1 row)详细说明
非范式化宽表:问题的起点
我们从一个「什么都塞在一起」的订单宽表开始,它在真实项目里极其常见(往往是从 Excel 或报表需求演化来的):
DROP TABLE IF EXISTS order_wide CASCADE;
CREATE TABLE order_wide (
order_no text,
order_date date,
customer_id text,
customer_name text,
customer_phone text,
product_id text,
product_name text,
category_name text, -- 商品分类名(冗余描述)
unit_price numeric(10,2),
qty integer
);插入几行数据,可以看到同一份信息被反复存储:
INSERT INTO order_wide VALUES
('SO-001', '2026-01-05', 'C01', 'Alice', '13800000001', 'P01', 'Phone', 'Electronics', 2999.00, 2),
('SO-001', '2026-01-05', 'C01', 'Alice', '13800000001', 'P02', 'Case', 'Accessories', 49.00, 3),
('SO-002', '2026-01-06', 'C02', 'Bob', '13800000002', 'P01', 'Phone', 'Electronics', 2999.00, 1);INSERT 0 3问题一目了然:Alice、13800000001、Phone 被重复存储多次;改客户电话要更新所有相关行(更新异常);没有下单的客户无法记录信息(插入异常);删掉订单可能连客户信息一起丢失(删除异常)。
在下结论前,先找出函数依赖。(order_no, product_id) 是这行的候选键:
(order_no, product_id) → qty, unit_price, order_date, customer_id, ...
order_no → order_date, customer_id, customer_name, customer_phone
customer_id → customer_name, customer_phone (客户属性)
product_id → product_name, category_name (商品属性)这些箭头就是后面拆表的依据。
1NF:让每个字段原子化
1NF 的要求:每个列的值都不可再分,不允许多值/复合值。
违规最常见的形态是「逗号分隔列表」与「重复列组」:
-- 错误!违规形态 A:逗号分隔的多值
CREATE TABLE bad_a (
id integer PRIMARY KEY,
phones text -- '13800000001,13900000002'
);
-- 错误!违规形态 B:重复列组(phone1/phone2/phone3)
CREATE TABLE bad_b (
id integer PRIMARY KEY,
phone1 text,
phone2 text,
phone3 text
);bad_b 的重复列组还带来一个硬伤:超过 3 个电话就没地方放,且查询「所有电话」要写 OR。
正确的 1NF 化写法是拆出关联表,让「一个电话」成为一行:
CREATE TABLE customer_phones (
customer_id integer NOT NULL,
phone_no text NOT NULL,
phone_type text NOT NULL DEFAULT 'mobile',
PRIMARY KEY (customer_id, phone_no) -- 复合主键
);用检查约束证明「逗号列表」为什么是坏设计——查某个电话只能 LIKE '%xxx%',既用不上索引又会误匹配前缀:
-- 正确设计里就是一次等值查询,直接走主键索引
EXPLAIN (COSTS OFF)
SELECT * FROM customer_phones WHERE customer_id = 1 AND phone_no = '13800000001'; QUERY PLAN
----------------------------------------------------------------------
Index Only Scan using customer_phones_pkey on customer_phones
Index Cond: ((customer_id = 1) AND (phone_no = '13800000001'::text))
(2 rows)2NF:消除部分依赖
2NF 的前提是存在复合主键。如果表的主键是单列,它天然满足 2NF。当主键是多列组合时,若某个非主属性只依赖组合的一部分,就违反了 2NF。
在我们的宽表里,(order_no, product_id) 是主键,而 customer_name 只依赖 order_no(主键的一部分),这就是部分依赖:
(order_no, product_id) → customer_name ← 冗余依赖(实际只依赖 order_no)
(order_no, product_id) → product_name ← 冗余依赖(实际只依赖 product_id)拆表思路:把只依赖一部分的属性移到它真正依赖的那张表。于是拆出 orders 与 products:
DROP TABLE IF EXISTS order_items CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS products CASCADE;
CREATE TABLE customers (
customer_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL
);
CREATE TABLE products (
product_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
unit_price numeric(10,2) NOT NULL CHECK (unit_price >= 0)
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE,
order_date date NOT NULL DEFAULT CURRENT_DATE,
customer_id integer NOT NULL REFERENCES customers(customer_id)
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id integer NOT NULL REFERENCES products(product_id),
qty integer NOT NULL CHECK (qty > 0),
unit_price numeric(10,2) NOT NULL, -- 下单时的成交价,见 3NF 一节说明
PRIMARY KEY (order_id, product_id)
);CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE现在 order_items 的主键仍是 (order_id, product_id),但它的非主属性只有 qty 与 unit_price,都依赖整个主键 → 满足 2NF。
注意一个容易混淆的点:unit_price 留在 order_items 并不违反范式,因为它记录的是「这一行成交时的价格」,是订单事实的一部分,与 products.unit_price 语义不同(后者是当前售价)。这是一个「看起来冗余但本质不同」的经典案例。
3NF:消除传递依赖
3NF 的要求:不存在非主属性之间的传递依赖,即 A → B → C 这种链式依赖必须打破。
假设我们还留着 category_name 在商品表里,而分类名应该由分类表决定:
-- 违反 3NF:product_id → category_id → category_name 形成传递依赖
CREATE TABLE products_bad_3nf (
product_id integer PRIMARY KEY,
name text,
category_id integer,
category_name text -- 依赖 category_id,不直接依赖 product_id
);category_name 通过 category_id 间接依赖 product_id,一旦某个分类改名,就要更新所有该分类的商品行 → 拆出独立的分类表:
CREATE TABLE categories (
category_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);
-- products 只保留 category_id,不再冗余 category_name
ALTER TABLE products ADD COLUMN category_id integer REFERENCES categories(category_id);验证一下结果——范式化后,同一份信息只存一处:
-- 修改分类名只影响一行
UPDATE categories SET name = 'Consumer Electronics' WHERE category_id = 1;UPDATE 1BCNF:让所有决定因素都是候选键
BCNF 比 3NF 更严格,它处理的是主属性之间的依赖。看一个经典的教师-课程-教室例子:
规则:
1. 每位老师只教一门课
2. 每门课由多位老师教,但同一门课在同一间教室上
依赖:
(teacher, course) → room
room → course ← 决定因素 room 不是候选键room 是主属性(它参与候选键 (teacher, room)),但 room → course 违反 BCNF。3NF 允许这种情况,BCNF 不允许:
-- 违反 BCNF:room → course
CREATE TABLE teaching_bad (
teacher text NOT NULL,
course text NOT NULL,
room text NOT NULL,
PRIMARY KEY (teacher, course) -- 依赖里还有 room → course 这个"漏网之鱼"
);拆成两张表即可满足 BCNF:
CREATE TABLE room_course (
room text PRIMARY KEY, -- 决定因素就是候选键
course text NOT NULL
);
CREATE TABLE teacher_room (
teacher text NOT NULL,
room text NOT NULL REFERENCES room_course(room),
PRIMARY KEY (teacher, room)
);BCNF 不是终点
BCNF 之上还有 4NF(消除多值依赖)和 5NF(消除连接依赖),但生产实践中到 3NF 就足够了,绝大多数建模争议都在「3NF 还是反范式」之间,而不是「3NF 还是 BCNF」。
何时该反范式:判断依据
范式化不是免费的午餐。3NF 的代价是:一个业务对象被拆到多张表,查询必然要 JOIN。判断是否需要反范式,看这四个信号:
| 信号 | 说明 | 反范式手法 |
|---|---|---|
| 读远多于写 | 查询量是写入的数十倍 | 冗余读侧字段 |
反复 JOIN 固定几张表 | 每次查询都要连 4~6 张表 | 把结果固化到宽表/物化视图 |
| 需要实时聚合 | 每次都要 SUM/COUNT 大表 | 预计算汇总列/汇总表 |
| 跨库/跨服务查询 | JOIN 无法跨数据库 | 冗余外部实体的关键字段 |
先看「范式化的查询长什么样」,体会一下代价:
-- 查一个订单的完整明细:需要 4 张表 JOIN
EXPLAIN (COSTS OFF)
SELECT o.order_no, c.name AS customer, p.name AS product, i.qty, i.unit_price
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id
WHERE o.order_no = 'SO-001'; QUERY PLAN
-------------------------------------------------------------------------------------
Nested Loop
-> Nested Loop
-> Nested Loop
-> Index Scan using orders_order_no_key on orders o
Index Cond: (order_no = 'SO-001'::text)
-> Index Scan using customers_pkey on customers c
Index Cond: (customer_id = o.customer_id)
-> Index Scan using order_items_pkey on order_items i
Index Cond: (order_id = o.order_id)
-> Index Scan using products_pkey on products p
Index Cond: (product_id = i.product_id)
(9 rows)三次嵌套循环,四次索引扫描。单条查询便宜,但如果每秒要执行上千次,或者要扫描明细表做报表,这个代价就不可忽略了。
反范式手法一:冗余字段
最直接的手法是把「经常一起查、但很少变」的字段冗余过来:
-- 在 order_items 上冗余 product_name(下单时的名称快照)
ALTER TABLE order_items ADD COLUMN product_name text;
-- 实际上,交易类冗余还有「快照」语义:商品改名不影响历史订单
UPDATE order_items i
SET product_name = p.name
FROM products p
WHERE p.product_id = i.product_id;ALTER TABLE
UPDATE 0这带来两个好处:查询少一张 JOIN,且历史订单保留了下单当时的名称(这在交易系统中往往是硬需求,不只是性能优化)。
代价是必须处理同步问题。PostgreSQL 提供了两种自动同步手段:同表派生值用生成列(数据库自动维护,应用无法写入);跨表冗余用触发器(AFTER UPDATE OF name ON products 时回写 order_items.product_name)。
冗余字段的最大风险
跨表冗余一旦有任何一条写入路径漏了同步(比如直接 COPY 导入、脚本 UPDATE 绕过触发器),数据就会永久不一致。所以冗余字段必须配一个「对账 SQL」定期校验。
反范式手法二:预计算汇总
报表场景里,每次都对百万级明细做聚合是不可接受的。做法是维护一张汇总表:
DROP TABLE IF EXISTS order_summary;
CREATE TABLE order_summary (
order_id bigint PRIMARY KEY REFERENCES orders(order_id) ON DELETE CASCADE,
item_count integer NOT NULL DEFAULT 0,
total_amount numeric(12,2) NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);同步汇总有三种成熟方案,代价依次递减、实时性也递减:触发器同步(AFTER INSERT/UPDATE/DELETE,实时但每次写入都多一次更新)、应用层维护(事务内一起更新,依赖应用正确性)、定时批量刷新(UPSERT,有延迟但写入代价低)。
无论哪种方案,最终都用一句幂等的 INSERT ... ON CONFLICT 完成刷新:
INSERT INTO order_summary (order_id, item_count, total_amount)
SELECT order_id, count(*), sum(qty * unit_price)
FROM order_items
GROUP BY order_id
ON CONFLICT (order_id) DO UPDATE
SET item_count = EXCLUDED.item_count,
total_amount = EXCLUDED.total_amount,
updated_at = now();INSERT 0 2反范式手法三:JSONB 存储
当属性是「稀疏的、变化的、很少参与过滤」时,JSONB 是一种优雅的反范式——它把「一对多」塞进一个字段,避免建表,同时保留了结构化查询能力:
DROP TABLE IF EXISTS products_flex;
CREATE TABLE products_flex (
product_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
attrs jsonb NOT NULL DEFAULT '{}'::jsonb -- 规格、尺寸、颜色等易变属性
);
INSERT INTO products_flex (name, attrs) VALUES
('Phone', '{"screen": "OLED", "battery": 4500, "colors": ["black", "white"]}'),
('Case', '{"material": "TPU", "thickness_mm": 1.2}');CREATE TABLE
INSERT 0 2JSONB 的定位要清楚——它不是替代范式化的通用方案:
| 适合 JSONB | 不适合 JSONB |
|---|---|
| 稀疏、易变的属性 | 高频过滤/排序的字段 |
| 需要整体读写的配置 | 需要外键约束的关联 |
| 嵌套结构(数组、对象) | 需要强类型校验的数值 |
| 不需要复杂约束的扩展字段 | 参与唯一约束的键 |
需要过滤 JSONB 时,配合 GIN 索引或表达式索引即可:
-- 表达式索引:把 JSONB 里的数值提出来建 B-Tree,支持范围查询
CREATE INDEX idx_products_flex_battery
ON products_flex (((attrs ->> 'battery')::int))
WHERE attrs ? 'battery';
EXPLAIN (COSTS OFF)
SELECT name FROM products_flex WHERE (attrs ->> 'battery')::int > 4000; QUERY PLAN
------------------------------------------------------------------------
Index Scan using idx_products_flex_battery on products_flex
Index Cond: (((attrs ->> 'battery'::text))::integer > 4000)
(2 rows)PostgreSQL 特有的折中手段。 PostgreSQL 在「保持范式化」与「追求读性能」之间提供了几个独有的折中工具,它们让我们不必真的把数据写冗余:
| 工具 | 作用 | 与反范式的关系 |
|---|---|---|
数组(text[]) | 一个字段装多个值,保持 1NF | 避免为小集合建关联表 |
| JSONB | 结构化扩展字段 | 免建表,保留查询能力 |
| 物化视图(Materialized View) | 把复杂 JOIN+聚合物化 | 反范式的「只读缓存」 |
| 生成列(Generated Column) | 由同表其它列自动计算 | 免手动同步的「同表冗余」 |
物化视图是最典型的「不污染基表」的反范式——它在物理上存了一份冗余结果,但基表依然是 3NF:
CREATE MATERIALIZED VIEW mv_order_detail AS
SELECT o.order_no,
o.order_date,
c.name AS customer_name,
p.name AS product_name,
i.qty,
i.unit_price,
i.qty * i.unit_price AS line_amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id;
CREATE UNIQUE INDEX idx_mv_order_detail_no ON mv_order_detail (order_no, product_name);CREATE MATERIALIZED VIEW
CREATE INDEX刷新用 REFRESH MATERIALIZED VIEW [CONCURRENTLY],后者要求视图上有唯一索引且不会阻塞查询:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_detail;REFRESH MATERIALIZED VIEW生成列(PostgreSQL 12+)则解决了「同表内冗余」的同步问题——它是数据库自动维护的,应用无法写入:
ALTER TABLE order_items
ADD COLUMN line_amount numeric(12,2)
GENERATED ALWAYS AS (qty * unit_price) STORED;ALTER TABLE范式化与反范式的权衡表。 把二者的取舍集中成一张表,这是做决策时最实用的参考:
| 维度 | 范式化(3NF) | 反范式 |
|---|---|---|
| 数据冗余 | 无冗余 | 有冗余 |
| 写入性能 | 好(只写一处) | 差(要同步多份) |
| 读性能 | 差(多表 JOIN) | 好(单表查询) |
| 数据一致性 | 强(无同步问题) | 弱(存在不一致风险) |
| 表数量 | 多 | 少 |
| 存储空间 | 小 | 大 |
| 查询复杂度 | 高 | 低 |
| 模型变更成本 | 低(改一处) | 高(要改多处同步) |
| 适合场景 | OLTP 写入密集 | OLAP 只读报表 |
一条实用原则
先范式化建模,再按查询热点反范式。 不要一开始就为了「少写 JOIN」把表拍平——因为冗余一旦引入,后期修改的代价远高于前期多写几个 JOIN。
用系统表量化冗余程度
判断一张表是否需要范式化,可以看它的列数与行数,以及是否存在「重复值比例极高的列」:
-- 找出宽表里重复率极高的列(说明该列适合抽出去做维表)
SELECT
count(*) AS total_rows,
count(DISTINCT customer_id) AS distinct_customers,
count(DISTINCT customer_name) AS distinct_names,
round(100.0 * (1 - count(DISTINCT customer_name)::numeric / count(*)), 1) AS dup_pct
FROM order_wide; total_rows | distinct_customers | distinct_names | dup_pct
------------+--------------------+----------------+---------
3 | 2 | 2 | 33.3
(1 row)dup_pct 越高,说明冗余越严重,越值得抽成独立表。
实战示例
场景:把前面那张非范式化的 order_wide 宽表,一步步演进成合理的 3NF 结构,再针对报表热点做「物化视图」式反范式,最后对账验证数据一致。
第 1 步:先看现状的规模
DROP TABLE IF EXISTS order_wide CASCADE;
CREATE TABLE order_wide (
order_no text,
order_date date,
customer_id text,
customer_name text,
customer_phone text,
product_id text,
product_name text,
category_name text,
unit_price numeric(10,2),
qty integer
);
-- 灌入 5 万行,客户与商品各只有很小的基数(高冗余)
INSERT INTO order_wide
SELECT
'SO-' || lpad(g::text, 8, '0'),
DATE '2026-01-01' + (g % 90),
'C' || (g % 500),
'Customer-' || (g % 500), -- 客户名按 id 生成,天然重复
'138' || lpad((g % 500)::text, 8, '0'),
'P' || (g % 2000),
'Product-' || (g % 2000),
(ARRAY['Electronics','Accessories','Home'])[1 + (g % 3)],
((g % 9) + 1) * 10.0,
(g % 5) + 1
FROM generate_series(1, 50000) AS g;
ANALYZE order_wide;
SELECT
pg_size_pretty(pg_total_relation_size('order_wide')) AS total_size,
count(*) AS rows
FROM order_wide; total_size | rows
------------+-------
3656 kB | 50000
(1 row)第 2 步:量化冗余
SELECT
count(DISTINCT customer_id) AS customers,
count(DISTINCT customer_name) AS customer_names,
count(DISTINCT product_id) AS products,
count(DISTINCT product_name) AS product_names,
count(*) AS rows,
round(1 - count(DISTINCT product_name)::numeric / count(*), 4) AS prod_name_dup_ratio
FROM order_wide; customers | customer_names | products | product_names | rows | prod_name_dup_ratio
-----------+----------------+----------+---------------+-------+---------------------
500 | 500 | 2000 | 2000 | 50000 | 0.9600
(1 row)product_name 的重复率是 96%——这个字段放在订单明细表里,等于把 96% 的空间浪费在重复字符串上。
第 3 步:按 3NF 拆表并搬数据
DROP TABLE IF EXISTS customers, products, orders, order_items CASCADE;
CREATE TABLE customers (
customer_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL
);
CREATE TABLE products (
product_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE,
order_date date NOT NULL,
customer_id integer NOT NULL REFERENCES customers(customer_id)
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id integer NOT NULL REFERENCES products(product_id),
qty integer NOT NULL CHECK (qty > 0),
unit_price numeric(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- 维表:客户、商品(去重)
INSERT INTO customers (name, phone)
SELECT DISTINCT customer_name, customer_phone FROM order_wide
ON CONFLICT DO NOTHING;
INSERT INTO products (name, category)
SELECT DISTINCT product_name, category_name FROM order_wide
ON CONFLICT DO NOTHING;
-- 事实表:订单头
INSERT INTO orders (order_no, order_date, customer_id)
SELECT DISTINCT w.order_no, w.order_date, c.customer_id
FROM order_wide w
JOIN customers c ON c.name = w.customer_name AND c.phone = w.customer_phone;
-- 事实表:订单明细
INSERT INTO order_items (order_id, product_id, qty, unit_price)
SELECT o.order_id, p.product_id, w.qty, w.unit_price
FROM order_wide w
JOIN orders o ON o.order_no = w.order_no
JOIN products p ON p.name = w.product_name;INSERT 0 0
INSERT 0 0
INSERT 0 50000
INSERT 0 50000第 4 步:对比存储体积
SELECT 'order_wide' AS object, pg_size_pretty(pg_total_relation_size('order_wide')) AS size
UNION ALL
SELECT '合计(3NF)', pg_size_pretty(
pg_total_relation_size('customers') + pg_total_relation_size('products')
+ pg_total_relation_size('orders') + pg_total_relation_size('order_items')); object | size
-------------+---------
order_wide | 3656 kB
合计(3NF) | 5208 kB
(2 rows)一个反直觉的结论
范式化后总空间反而变大了(5208 kB > 3656 kB),因为多了 4 组主键索引与外键索引。范式化的收益不在「省空间」,而在消除更新异常与保证一致性;省空间只是顺带效果,且只在字符串冗余足够多时才明显。
第 5 步:对账验证——确认拆表没有丢数据
SELECT
(SELECT count(*) FROM order_wide) AS src_rows,
(SELECT count(*) FROM order_items) AS dst_rows,
(SELECT count(*) FROM order_wide) = (SELECT count(*) FROM order_items) AS matched; src_rows | dst_rows | matched
----------+----------+---------
50000 | 50000 | t
(1 row)-- 金额级对账:源表与目标表的总金额必须一致
SELECT
(SELECT sum(qty * unit_price) FROM order_wide) AS src_amount,
(SELECT sum(qty * unit_price) FROM order_items) AS dst_amount; src_amount | dst_amount
------------+------------
13875000.00 | 13875000.00
(1 row)第 6 步:清理
DROP MATERIALIZED VIEW IF EXISTS mv_order_report;
DROP TABLE IF EXISTS order_items, orders, products, customers, order_wide CASCADE;
DROP TABLE IF EXISTS products_flex, customer_phones, room_course, teacher_room CASCADE;
DROP TABLE IF EXISTS products_bad_3nf, teaching_bad, bad_a, bad_b, product_bad_1nf, product_ok_1nf CASCADE;
DROP TABLE IF EXISTS order_summary;注意事项
错误!不要把多个值用逗号塞进一个
text列。 这违反 1NF,导致索引失效、无法排序、无法做外键约束。sql-- 错误!'phone,5g,flagship' 这种值无法被索引有效支持 CREATE TABLE bad (id int, tags text);错误!不要用重复列组(
phone1/phone2/phone3)表达「一对多」。 列数固定就限制了业务,超过三个就没地方放。sql-- 错误!重复列组违反 1NF CREATE TABLE bad (id int, phone1 text, phone2 text, phone3 text);错误!不要在明细表里重复存储维表的描述字段而不做同步。 一旦维表改名,明细就会不一致。
sql-- 冗余了 product_name 但没有同步机制 ALTER TABLE order_items ADD COLUMN product_name text;错误!不要把「当前价格」直接当成「成交价格」。
products.unit_price会随业务变化,用JOIN取它会篡改历史订单金额。sql-- 错误!报表金额会随商品调价而变动 SELECT o.order_no, p.unit_price * i.qty FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id;不要滥用 JSONB 代替范式化。 高频过滤、需要外键、需要唯一约束的字段放 JSONB 会让约束与索引都变得困难。
sql-- 不推荐:把用户邮箱放 JSONB,无法建简单唯一约束 CREATE TABLE users (id int, data jsonb); -- data->>'email' 无法直接 UNIQUE反范式不是「取消设计」。 每引入一处冗余,都要同步引入同步机制与对账 SQL,否则就是技术债。
物化视图不会自动更新。 忘了
REFRESH就会读到过期数据;REFRESH ... CONCURRENTLY需要视图上有唯一索引。sql-- 没有唯一索引时报错:cannot refresh materialized view ... concurrently REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_detail;BCNF 之后还有 4NF/5NF,但生产实践中不必追求。 到 3NF 已经能消除绝大多数异常,过度范式化会显著增加
JOIN成本。
最佳实践
默认按 3NF 建模,即满足「每个非主属性都直接依赖整个主键,且不依赖其它非主属性」。
先写函数依赖,再拆表。 把
X → Y列清楚,2NF/3NF 的判断就有了客观依据,避免凭感觉拆表。textorder_no → order_date, customer_id customer_id → customer_name, customer_phone product_id → product_name, category_name (order_no,product) → qty, unit_price交易类数据优先保留「快照字段」(下单时的价格、名称、地址)。这既是性能优化,更是业务正确性要求。
sqlorder_items (..., unit_price numeric(10,2) NOT NULL, product_name text)报表与分析走物化视图,而不是污染业务基表。基表保持 3NF,报表视图承担反范式职责。
sqlCREATE MATERIALIZED VIEW mv_order_report AS SELECT ... ; CREATE UNIQUE INDEX ON mv_order_report (order_no, product_name); REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_report;同表内的派生值用生成列,避免应用层手动同步:
sqlline_amount numeric(12,2) GENERATED ALWAYS AS (qty * unit_price) STORED稀疏扩展属性用 JSONB + 表达式索引,而不是建一张字段疯狂增多的宽表。
sqlCREATE INDEX idx_attrs_battery ON products_flex (((attrs ->> 'battery')::int)) WHERE attrs ? 'battery';为每处冗余写一条对账 SQL,并纳入定时巡检,尽早发现不一致:
sqlSELECT count(*) FROM order_items i LEFT JOIN products p ON p.product_id = i.product_id WHERE i.product_name IS DISTINCT FROM p.name;拆表迁移时用「源表与目标表行数 + 金额总和」双重对账,比只比行数更能发现映射错误。
上线前用
n_distinct复核高频重复列,重复率高且很少变化的列,是反范式最值得考虑的候选。
下一节
继续学习:主键设计策略