规范化与反规范化决策
数据库设计没有绝对的对错,只有适合当前场景的权衡。规范化(Normalization)减少冗余、保证一致性;反规范化(Denormalization)牺牲一些冗余换取查询性能。了解何时该用哪种策略,是区分经验工程师和初级工程师的重要标志。
为什么要规范化
规范化的目标是消除数据冗余,防止更新异常(Update Anomaly)。
未规范化的问题:
-- ❌ 未规范化:用户地址存在 orders 表中
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
user_name TEXT, -- 冗余:用户改名,历史订单 user_name 不一致
user_email TEXT, -- 冗余:同上
city TEXT, -- 问题:是下单时的地址还是当前地址?
address TEXT,
total NUMERIC(10, 2)
);
第三范式(3NF)后的设计:
-- ✅ 规范化:用户信息在 users 表
-- 订单在 orders 表(只存 user_id 外键)
-- 收货地址独立为 addresses 表(历史快照)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE addresses (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
city TEXT NOT NULL,
address TEXT NOT NULL,
is_default BOOLEAN DEFAULT false
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
shipping_address_snapshot JSONB, -- 下单时的地址快照(见后)
total NUMERIC(12, 2) NOT NULL
);
快照模式:规范化 + 历史记录
某些数据需要"当时的状态",而不是"当前的状态"。典型场景:订单的商品价格、收货地址。
-- 订单明细:存下单时的价格(不能只存 product_id,价格可能变化)
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
product_name TEXT NOT NULL, -- ← 快照:下单时的商品名
unit_price NUMERIC(10, 2) NOT NULL, -- ← 快照:下单时的价格
quantity INTEGER NOT NULL
);
-- 订单:存下单时的收货地址快照
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
shipping_address JSONB NOT NULL, -- ← 地址快照
total NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- shipping_address 的格式:
-- {
-- "name": "Alice",
-- "phone": "+60-12-345-6789",
-- "line1": "123 Jalan Bukit Bintang",
-- "city": "Kuala Lumpur",
-- "postcode": "50450",
-- "state": "Kuala Lumpur"
-- }
何时考虑反规范化
反规范化是一种有意识的权衡,为了提升读性能而接受一定的数据冗余。常见场景:
场景一:统计计数字段
-- ❌ 每次查 follower 数都要 COUNT(*)(慢)
SELECT COUNT(*) FROM followers WHERE user_id = 123;
-- ✅ 反规范化:在 users 表增加计数列
ALTER TABLE users ADD COLUMN follower_count INTEGER NOT NULL DEFAULT 0;
-- 用触发器保持同步
CREATE OR REPLACE FUNCTION update_follower_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET follower_count = follower_count + 1 WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET follower_count = follower_count - 1 WHERE id = OLD.user_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER maintain_follower_count
AFTER INSERT OR DELETE ON followers
FOR EACH ROW EXECUTE FUNCTION update_follower_count();
场景二:预聚合查询
-- ❌ 每次加载用户主页都聚合:
SELECT SUM(amount) FROM orders WHERE user_id = 123 AND status = 'paid';
-- ✅ 物化视图(PostgreSQL 原生支持)
CREATE MATERIALIZED VIEW user_order_stats AS
SELECT
user_id,
COUNT(*) AS total_orders,
SUM(total_amount) AS total_spent,
MAX(created_at) AS last_order_at
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
CREATE INDEX ON user_order_stats (user_id);
-- 定期刷新(或在事务后刷新)
REFRESH MATERIALIZED VIEW CONCURRENTLY user_order_stats;
场景三:避免深层 JOIN
-- ❌ 每次查评论都要 JOIN users 获取作者名
SELECT c.content, u.name AS author_name
FROM comments c
JOIN users u ON c.user_id = u.id;
-- ✅ 在 comments 表冗余存储 author_name(接受用户改名后历史评论不同步)
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id),
user_id BIGINT NOT NULL REFERENCES users(id),
author_name TEXT NOT NULL, -- ← 冗余,但避免了 JOIN
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
反规范化决策框架
graph TD
A["某个查询很慢"] --> B{"是否频繁执行?"}
B --> |否| C["先优化索引\n(第 03 章)"]
B --> |是| D{"是聚合/统计?"}
D --> |是| E["考虑物化视图\n或冗余计数列"]
D --> |否| F{"是深层 JOIN?"}
F --> |是| G["考虑冗余列\n(接受更新复杂性)"]
F --> |否| H["分析 EXPLAIN ANALYZE\n(第 03 章)"]
E --> I["评估:更新频率 vs 读取频率\n更新多 → 物化视图定期刷新\n写少读多 → 触发器实时维护"]
style C fill:#e8f5e9,stroke:#2e7d32
style I fill:#fff3e0,stroke:#ef6c00
规范化 vs 反规范化 原则总结
| 场景 | 推荐策略 |
|---|---|
| 数据完整性优先(金融、用户信息) | 3NF 规范化 |
| 历史时间点数据(订单价格/地址) | 快照模式(冗余 + JSONB) |
| 高频统计(帖子点赞数、关注数) | 冗余计数列 + 触发器 |
| 复杂聚合报表 | 物化视图 |
| 避免高频 JOIN 的读密集查询 | 谨慎冗余(明确接受更新复杂性) |
本节记录清单
- [ ] 审查你的 Schema:是否有重复存储用户名/邮箱/产品名的地方?
- [ ] 为需要历史记录的字段(价格、地址)改用快照模式
- [ ] 评估高频统计字段是否值得用冗余计数列替代实时 COUNT
- [ ] 考虑为复杂报表查询创建物化视图
下一章:索引策略与查询优化——Schema 设计好了,现在学如何用索引让查询飞起来——以及如何用 EXPLAIN ANALYZE 找到真正的性能瓶颈。