数据库 Schema 设计:经得起时间考验的模式
范式化规则、命名规范、软删除、审计追踪、多租户模式、版本控制策略,以及我后悔过的 schema 决策。以 PostgreSQL 为主。
Schema 是你和未来自己签订的契约。你添加的每一列、跳过的每一个约束、每一句「以后再清理」——都在不断累积。我曾参与过这样的系统:三年前的一个糟糕 schema 决策,如今每个季度都要花团队整整一个 sprint 来绕过它。
数据库比一切都长命。你的前端框架会换,API 层会重写,部署策略会演进。但数据呢?数据会一直留下来。你在第一天赋予它的形状会永远跟随你,因为迁移一张 2 亿行的表可不像重构一个 React 组件那么简单。
这是我多年来在 schema 设计上的经验总结——那些不会让你日后追悔莫及的决策。以 PostgreSQL 为主,因为那是我使用并信赖的数据库,但其中大多数模式适用于任何关系型数据库。
命名规范:最无聊但最重要的事#
我见过的关于命名规范的争论比关于实际架构决策的还多。以下是我在多年项目切换后最终确定的做法:
所有东西都用 snake_case。 表、列、索引、约束。不用 camelCase,不用 PascalCase。PostgreSQL 会把未加引号的标识符折叠为小写,所以 createdAt 会变成 createdat,除非你到处加双引号。不要和数据库作对。
表名用复数。 一张表存放很多行。users 而不是 user。orders 而不是 order。order_items 而不是 order_item。这在查询中读起来很自然:SELECT * FROM users WHERE ...——你是从一个集合中查询。
外键用 _id 后缀。 user_id、order_id、tenant_id。主键直接叫 id。这毫不含糊。当你做 JOIN 时,users.id = orders.user_id 读起来就像一句话。
时间戳用 _at 后缀。 created_at、updated_at、deleted_at、published_at、expires_at。你总是能一眼看出这是一个时间点。
布尔值用 is_ 前缀。 is_active、is_verified、is_published。有人用 has_ 表示所属关系的布尔值(has_mfa_enabled),但我保持简单,统一用 is_。
非范式化的计数器用 _count 后缀。 comment_count、follower_count。明确表示这是一个缓存的数字,不是实时计算的结果。
关键在于:一致性胜过完美。 我见过团队花好几周争论到底该用 email_address 还是 email 还是 email_addr。选一种模式然后到处执行就好了。最糟糕的命名规范不是某个具体方案,而是执行不一致的方案。
-- 好的:一致、可读、没有意外
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
login_count INTEGER NOT NULL DEFAULT 0,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 差的:大小写不一致、约定混用、名称含义模糊
CREATE TABLE User (
UserID SERIAL PRIMARY KEY,
EmailAddress VARCHAR(255),
name VARCHAR(100),
passwd VARCHAR(255),
active BOOLEAN,
verified INT, -- 这是 0/1?还是验证级别?
lastLogin TIMESTAMP, -- 在 snake_case 的世界里用 camelCase
created TIMESTAMP -- created 什么?什么时候?
);再说一件事:永远不要用保留字作列名。 type、order、user、group、table——加引号都能用,但在 ORM、查询构建器以及各种动态 SQL 生成工具里会坑死你。用 kind 代替 type,用 sort_order 代替 order。未来的你会感谢自己。
标准列:每张表必备#
我的 schema 中,每张表都从同一个骨架开始,没有例外。
CREATE TABLE things (
id BIGSERIAL PRIMARY KEY,
-- ... 业务列 ...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);UUID vs BIGSERIAL 之争#
这是一个产生的争议远大于其实际影响的决策。以下是真正的权衡:
BIGSERIAL(自增整数):
- 8 字节。紧凑。索引和 JOIN 速度快。
- 有序——可以按
id排序获得插入顺序。 - 可预测——用户可以通过递增来猜测其他 ID。
- 在分布式系统中不太适用(需要协调)。
UUID v4(随机):
- 16 字节。索引更大,JOIN 更慢(但很少成为瓶颈)。
- 不可预测——没有信息泄露。
- 在分布式系统中无需协调即可工作。
- B-tree 索引局部性很差——随机 UUID 会导致索引碎片化。
UUID v7(时间排序,RFC 9562):
- 16 字节,但按时间排序,所以 B-tree 局部性很好。
- 足够不可预测,可以对外使用。
- 相对较新,但 PostgreSQL 17+ 有
gen_random_uuid(),也可以通过扩展使用uuid_generate_v7()。
我目前的立场:内部表用 BIGSERIAL,对外暴露的东西用 UUID v7。 如果一个 ID 出现在 URL、API 响应或 webhook 负载中,就用 UUID。如果是用户永远看不到的纯关联表,BIGSERIAL 就行。
-- 面向 API 的资源
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 内部映射表
CREATE TABLE project_members (
id BIGSERIAL PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
user_id UUID NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);时间戳:永远用 TIMESTAMPTZ#
用 TIMESTAMPTZ,不要用 TIMESTAMP。带 "TZ" 的版本以 UTC 存储值,并在读取时根据会话时区进行转换。不带 TZ 的版本会原样存储你给它的值,没有时区上下文——这意味着如果两台不同时区的服务器向同一张表写入数据,你会得到静默的数据损坏。
-- 永远用这个
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- 永远不要用这个
created_at TIMESTAMP NOT NULL DEFAULT NOW()updated_at 触发器#
PostgreSQL 没有 MySQL 的 ON UPDATE CURRENT_TIMESTAMP。你需要一个触发器:
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 对每个有 updated_at 的表应用
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();是的,每张表都需要一个触发器。是的,很繁琐。写一个迁移辅助函数,在添加表时自动创建触发器。这是值得的,因为另一种选择是记住在整个应用程序的每个 UPDATE 查询中设置 updated_at = NOW()——而你一定会忘。
范式化:何时打破规则#
每门计算机课程都会教到第三范式(3NF)。规则如下:
- 第一范式(1NF):每列保存一个原子值。没有数组,没有逗号分隔的列表。
- 第二范式(2NF):每个非键列依赖于整个主键(与复合键相关)。
- 第三范式(3NF):没有传递依赖。如果列 A 决定列 B,B 决定列 C,那么 C 不应该和 A 在同一张表里。
在实践中,第三范式是事务表的最佳平衡点。 你应该从这里开始,只在有具体的、可衡量的理由时才偏离。
以下是一个正确范式化的订单系统:
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);注意 order_items 上的 unit_price_cents。这是刻意的反范式化。我们快照了下单时的价格,因为产品价格之后可能会变。这是最常见也最正确的反范式化理由之一。
何时该反范式化#
报表表。 如果你的分析仪表板需要 JOIN 8 张表才能渲染,就创建一张反范式化的报表表,用后台任务来填充。你的事务 schema 保持干净,报表查询保持快速。
-- 反范式化的报表表,由定时任务填充
CREATE TABLE order_reports (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_email TEXT NOT NULL,
customer_name TEXT NOT NULL,
product_names TEXT[] NOT NULL, -- 这里用数组没问题
item_count INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
order_status TEXT NOT NULL,
ordered_at TIMESTAMPTZ NOT NULL,
report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);物化视图。 PostgreSQL 的 MATERIALIZED VIEW 被严重低估了。它是一个按需刷新的反范式化快照,非常适合仪表板。
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
DATE_TRUNC('month', o.created_at) AS month,
COUNT(DISTINCT o.id) AS order_count,
SUM(o.total_cents) AS revenue_cents,
COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
-- 每晚刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;动态属性的 JSON 列。 当不同行需要不同字段时——产品变体、表单提交、用户偏好——JSONB 列通常比噩梦般的 EAV(实体-属性-值)表要好。后面会详细讨论。
我从不打破的一条规则#
绝不反范式化你的数据源头表。反范式化副本、快照、报表和缓存。权威数据保持范式化。当反范式化副本变旧或损坏时(迟早会的),你可以从范式化的源头重建它。
外键和约束:你写过的最好的代码#
我听过所有跳过外键的借口。「它们会拖慢写入。」「我们在应用层强制执行。」「我们需要灵活性。」
这些都是错的。
外键是你能添加到 schema 中最具影响力的东西。它们能防止整类 bug——竞态条件、部分失败、事务失败后的孤立行——这些是再多的应用代码都无法捕获的。外键是数据库引擎本身的保证,在存储层面强制执行。你的 ORM 的 beforeDelete 钩子只是一个建议。
-- 永远这样做
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
-- "我们在代码里处理"的替代方案:
-- 靠希望。希望不是策略。ON DELETE 策略#
这里是需要细致判断的地方。当你删除父行时会发生什么?
RESTRICT(默认):如果存在子行,删除失败。大多数关系都用这个。你不能删除有订单的客户——这是编码在 schema 中的业务逻辑。
-- 有订单的客户不能被删除
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id)
ON DELETE RESTRICT;CASCADE:子行被自动删除。谨慎且有意识地使用。适用于"从属"关系,即子行离开父行就没有意义。
-- 删除订单会删除其行项——它们单独没有意义
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE;
-- 删除项目会删除其成员关系
ALTER TABLE project_members
ADD CONSTRAINT fk_project_members_project
FOREIGN KEY (project_id) REFERENCES projects(id)
ON DELETE CASCADE;SET NULL:外键列被设置为 NULL。当关系是可选的且子行单独仍有意义时使用。
-- 如果经理离职,其下属仍然存在——只是变成未分配
ALTER TABLE employees
ADD CONSTRAINT fk_employees_manager
FOREIGN KEY (manager_id) REFERENCES employees(id)
ON DELETE SET NULL;我的经验法则:默认用 RESTRICT,组合关系用 CASCADE,可选关联用 SET NULL。 如果不确定,RESTRICT 永远是安全的选择——放松约束比恢复被删除的数据容易得多。
CHECK 约束:廉价的保险#
CHECK 约束在写入时几乎没有成本,却能永远阻止垃圾数据进入你的系统:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
discount_pct NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
weight_kg NUMERIC(10,3) CHECK (weight_kg > 0),
status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);你添加的每一个 CHECK 约束,都意味着你在生产环境中少调试一个数据验证 bug。数据库是最后一道防线,好好利用它。
唯一约束和部分唯一索引#
简单场景下唯一约束很直接:
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);但配合部分索引就变得有趣了——只对特定行生效的唯一约束:
-- 每个用户只能有一个活跃订阅(但可以有很多已取消的)
CREATE UNIQUE INDEX uq_active_subscription
ON subscriptions (user_id)
WHERE status = 'active';
-- 每个用户只能有一个主地址
CREATE UNIQUE INDEX uq_primary_address
ON addresses (user_id)
WHERE is_primary = true;这是 PostgreSQL 的杀手级特性之一。好好用它。
软删除:人人又爱又恨的模式#
软删除的概念很简单:不是 DELETE FROM users WHERE id = 42,而是 UPDATE users SET deleted_at = NOW() WHERE id = 42。行留在数据库中,但在正常查询中被过滤掉。
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 部分索引:高效过滤已删除的行
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;现在你应用中的每个查询都需要 WHERE deleted_at IS NULL。每一个。ORM 对此有所帮助(Prisma 有中间件,Drizzle 有 .where(isNull(deletedAt))),但这是对每次读操作的额外成本。漏掉一次,你就会向用户展示"已删除"的数据。
唯一约束问题#
这是软删除变丑的地方。如果你有 UNIQUE (email) 且用户软删除了账户,他们的邮箱仍然在表里。他们不能用同一个邮箱重新注册。新用户也不能用那个邮箱注册。
解决方案是部分唯一索引:
-- 邮箱必须唯一,但只在非删除用户中
CREATE UNIQUE INDEX uq_users_email_active
ON users (email)
WHERE deleted_at IS NULL;这行得通,但现在你需要对每张可软删除表的每个唯一列都记住这个模式。靠纪律是可以管理的,但这是你选择承担的复杂性。
外键问题#
软删除和外键的交互很糟糕。如果 orders.user_id 引用 users.id 且设置了 ON DELETE RESTRICT,然后你软删除了一个用户……什么都不会发生。外键没有触发,因为你实际上并没有删除那行。从应用的角度看用户"不在了",但在数据库中他仍然存在。
这意味着你的应用代码必须处理被引用实体已被软删除的情况。每次 JOIN、每次查找、每次你跟随外键——你都需要检查被引用表的 deleted_at IS NULL。或者你不检查,然后你的应用显示"订单来自 [已删除用户]",这到底是 bug 还是特性取决于你问的是谁。
我的做法#
我只对恢复是业务需求的面向用户的实体使用软删除——用户账户、项目、文档。那些客服可能需要恢复删除的东西。对其他所有东西,我使用硬删除加审计追踪(下一节会详细讲)。
-- 软删除:面向用户,可恢复
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
-- 硬删除:内部使用,无法从 UI 恢复(但有审计记录)
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 删除后就没了。audit_log 记录了它曾经存在过。审计追踪:知道谁改了什么#
每个非平凡的应用在某个时刻都需要回答"这条记录发生了什么?"。审计追踪就是你在不翻查应用日志的情况下提供这个答案的方式。
独立审计表模式#
最简单的方法:一张 audit_log 表记录每一次变更。
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL, -- 用 text 以兼容 UUID 和 BIGINT 主键
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB, -- INSERT 时为 NULL
new_values JSONB, -- DELETE 时为 NULL
changed_fields TEXT[], -- 哪些列发生了变化(UPDATE 时)
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address INET,
user_agent TEXT
);
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);难点在于填充它。你可以在应用代码中做(显式,但容易遗漏)或用触发器做(自动,但难以传递 changed_by 等上下文信息)。
基于触发器的方案#
触发器自动捕获每一次变更,即使是原始 SQL 或数据库管理员的操作:
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
old_data JSONB;
new_data JSONB;
changed TEXT[];
col TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
old_data := to_jsonb(OLD);
INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
old_data := to_jsonb(OLD);
new_data := to_jsonb(NEW);
-- 找出变更的字段
FOR col IN SELECT key FROM jsonb_each(new_data)
LOOP
IF old_data->col IS DISTINCT FROM new_data->col THEN
changed := array_append(changed, col);
END IF;
END LOOP;
-- 只在确实有变化时记录
IF array_length(changed, 1) > 0 THEN
INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
END IF;
RETURN NEW;
ELSIF TG_OP = 'INSERT' THEN
new_data := to_jsonb(NEW);
INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- 对需要审计的表应用
CREATE TRIGGER audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
CREATE TRIGGER audit_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();缺点是:触发器不知道是哪个应用用户做出的变更。你可以通过会话变量来解决:
-- 在你的应用中,执行查询之前:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
-- 在触发器函数中:
changed_by_id := current_setting('app.current_user_id', true)::UUID;这能用但感觉很脆弱。实践中,我使用混合方案:触发器负责数据捕获,应用代码负责设置会话上下文。
历史表模式#
对于需要完整版本历史的表(不仅是"改了什么"而是"在时间 T 时的状态是什么"),专用的历史表更干净:
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE document_history (
id BIGSERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
version INTEGER NOT NULL,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (document_id, version)
);每次更新 documents 前,把当前状态复制到 document_history 并递增版本号。现在你可以重建文档在任何时间点的状态,显示版本间的差异,甚至恢复旧版本。
代价是存储空间。如果你的 content 列很大且变更频繁,历史表会增长很快。对大多数应用来说这没问题——存储很便宜,需要时也可以把旧版本归档到冷存储。
多租户:三种方案,选你能承受的痛#
多租户是那种一开始加很容易、之后再加几乎不可能的东西。如果你的应用有任何可能服务多个组织,就从第一天就把它建进去。
行级:每张表加 tenant_id#
最常见的方法。每张表有一个 tenant_id 列,每个查询都按它过滤。
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 每个索引都应该包含 tenant_id 以优化查询性能
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);风险在于数据泄露——漏掉一个 WHERE tenant_id = ...,你就会把租户 A 的数据展示给租户 B。PostgreSQL 的行级安全(RLS)消除了这类 bug:
-- 在表上启用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 基于会话变量创建策略
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- 即使是表所有者也强制 RLS
ALTER TABLE projects FORCE ROW LEVEL SECURITY;现在,即使你的应用代码忘了 WHERE tenant_id = ... 子句,PostgreSQL 也会自动添加。这是深度防御,也是在多租户系统中选择 PostgreSQL 最有力的论据之一。
-- 在你应用的连接中间件中:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
-- 现在所有对启用了 RLS 的表的查询都会自动被过滤
SELECT * FROM projects;
-- PostgreSQL 内部添加:WHERE tenant_id = 'tenant-uuid-here'优点:单数据库,运维简单,资源利用高效,管理员跨租户查询方便。 缺点:需要纪律(或 RLS),每个查询都涉及 tenant_id,给租户单独备份/恢复更难。
Schema 级:每个租户一个 Schema#
每个租户获得自己的 PostgreSQL schema。所有 schema 共享同一个数据库,但表通过命名空间隔离。
-- 为每个租户创建 schema
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- 表放在租户的 schema 中
CREATE TABLE tenant_acme.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 设置当前租户的 search_path
SET search_path TO tenant_acme, public;
-- 现在不带限定符的查询会命中租户的 schema
SELECT * FROM projects; -- 查询 tenant_acme.projects优点:强隔离,没有跨租户数据泄露风险,每个租户可以单独备份/恢复,租户可以有 schema 变体。 缺点:Schema 迁移复杂(需要迁移 N 个 schema),连接池管理,PostgreSQL 在约 10,000 个 schema 时有实际限制。
数据库级:每个租户一个数据库#
每个租户获得自己的数据库。最大程度的隔离。
优点:完全隔离,独立扩展,备份/恢复方便,可以将大租户放在专用硬件上。 缺点:连接管理噩梦,跨租户查询不可能,迁移必须运行 N 次,运维开销显著。
选哪个?#
对大多数 SaaS 应用来说:从行级 + RLS 开始。 它运维最简单,RLS 为绝大多数用例提供了足够强的隔离。只有在有合同隔离要求时(企业客户、受监管行业)才转向 schema 级。数据库级方案是当你必须保证物理隔离时才用的——即便如此,也考虑使用托管数据库来分担运维负担。
JSON/JSONB 列:逃生通道#
PostgreSQL 的 JSONB 非常出色。它在关系型系统中给你文档数据库的灵活性。但和任何强大的工具一样,它容易被滥用。
何时该用 JSONB#
用户自定义的动态属性。 一个电商平台,每个产品类别有不同的属性——鞋子有 size 和 color,电子产品有 voltage 和 wattage。与其用 EAV 表或为每个可能的属性建一列:
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
category_id UUID NOT NULL REFERENCES categories(id),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
attributes JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 示例数据:
-- 鞋子:{"size": "10", "color": "black", "material": "leather"}
-- 笔记本电脑:{"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}应用设置和配置。 用户偏好、功能开关、通知设置——形状经常变化且不需要关系完整性的东西。
CREATE TABLE user_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences JSONB NOT NULL DEFAULT '{
"theme": "system",
"notifications": {"email": true, "push": false},
"locale": "en",
"timezone": "UTC"
}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Webhook 负载、API 响应、事件数据。 任何 schema 由外部系统控制且可能在没有通知的情况下发生变化的东西。
JSONB 的 GIN 索引#
没有索引,查询 JSONB 内部需要全表扫描。GIN 索引让它变快:
-- 索引整个 JSONB 列(适用于 @> 包含查询)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
-- 查询:找到所有 color = "black" 的产品
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
-- 查询:找到有这些属性中任何一个的产品
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];对于你经常查询的特定路径,定向索引更高效:
-- 索引一个特定路径
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
-- 现在这就是一个普通的 B-tree 查找
SELECT * FROM products
WHERE attributes->>'color' = 'black';迁移陷阱#
JSONB 的坑在这里:你无法轻松地给 JSON 内部的字段添加 NOT NULL 约束或默认值。 如果你给产品属性添加了一个新的必填字段,你需要回填每一行现有数据。用普通列的话,一次迁移就能原子性地处理。用 JSONB 的话,你得写一个 UPDATE 触及每一行,然后祈祷你的应用代码在回填完成前能优雅地处理缺失字段。
-- 添加新列:干净、原子性、一条语句
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
-- 添加新 JSONB 字段:混乱,需要回填
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- 还得更新应用代码以处理缺失的 weight_kg
-- 还得更新验证逻辑
-- 还得更新每个包含 attributes 的 API 响应我的原则:如果你每周在 WHERE 子句中查询某个 JSONB 字段超过两次,它可能应该是一个列。 JSONB 是很好的逃生通道,但不是好的默认选择。
Schema 迁移:别在凌晨三点搞崩生产环境#
Schema 迁移是理论遇见现实的地方。你的 schema 在纸面上看起来很完美,但现在你需要在工作时间修改一张有 5000 万行的表,还不能有任何停机。
迁移工具#
我用过大多数工具。简短评价:
Drizzle(TypeScript):我目前的最爱。Schema 即代码,从 schema 生成类型安全的查询,干净的迁移 SQL。开发时的 push 命令很快。
Prisma(TypeScript):简单 schema 的开发体验很好。处理高级 PostgreSQL 特性(部分索引、自定义类型、RLS)时有困难。迁移引擎有时会做出令人惊讶的决策。
Flyway(Java/CLI):稳如磐石,久经考验,SQL 优先。如果你写的是原始 SQL 迁移,Flyway 能可靠地跟踪它们。没有魔法,没有意外。
golang-migrate(Go/CLI):类似 Flyway 但更轻量。Go 项目或者你只是想要一个简单的 up/down 迁移运行器时很合适。
零停机问题#
最危险的 schema 变更是那些会锁表的。在 PostgreSQL 中,带默认值的 ALTER TABLE ... ADD COLUMN 过去会在重写期间锁住整张表。从 PostgreSQL 11 开始,简单默认值(常量)只是元数据操作,瞬间完成。但其他操作仍然会锁表:
-- 安全:仅元数据操作,瞬间完成(PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
-- 危险:重写整张表,全表锁
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
-- 危险:扫描整张表进行验证
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);安全添加 NOT NULL 列#
你不能直接给已有数据的表加 NOT NULL——会失败,因为现有行没有值。天真的做法:
-- 这会锁表并重写它。不要在大表上这么做。
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';从 PostgreSQL 11 开始,对于常量默认值这实际上是安全的——它只是元数据操作。但如果你的默认值是函数或者你需要用计算值回填,就使用扩展-收缩模式。
扩展-收缩模式#
这是零停机 schema 变更的黄金标准。分三个阶段:
阶段一:扩展 — 以 nullable 方式添加新列。部署同时写入新旧两列的应用代码。
-- 迁移 1:添加 nullable 列
ALTER TABLE users ADD COLUMN normalized_email TEXT;阶段二:迁移 — 分批回填现有行。你的应用已经在为新数据写入新列了。
-- 迁移 2:分批回填(大表不要用一条语句搞定)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- 重复直到所有行都被回填阶段三:收缩 — 所有行回填完成后,添加 NOT NULL 约束并移除旧列(如适用)。
-- 迁移 3:添加约束(用 NOT VALID 避免全表扫描,然后单独验证)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
CHECK (normalized_email IS NOT NULL) NOT VALID;
-- 迁移 4:验证约束(获取 ShareUpdateExclusiveLock,不是 AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;NOT VALID + VALIDATE CONSTRAINT 这个技巧至关重要。正常添加 CHECK 约束会在持有重锁的情况下扫描整张表。NOT VALID 添加约束但不扫描(只对新写入生效),而 VALIDATE CONSTRAINT 用更轻的锁扫描,不会阻塞读写。
索引创建#
在大表上创建索引默认会阻塞写入。永远使用 CONCURRENTLY:
-- 阻塞写入:不要在线上表这么做
CREATE INDEX idx_users_email ON users (email);
-- 非阻塞:用这个代替
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);CONCURRENTLY 耗时更长且不能在事务中运行,但它不会锁表。在生产环境中这个权衡永远是值得的。
我后悔过的决策#
每个 schema 都有遗憾。以下是我的。
对外部 ID 使用 SERIAL 而非 UUID#
在项目早期,我使用 SERIAL 主键并直接暴露在 URL 中:/users/42、/orders/1337。这泄露了信息(竞争对手可以猜测我们的用户数量),让分页变得可猜测,并且在我们需要合并两个区域的数据库时彻底崩了。切换到 UUID 作为外部 ID 花了好几个月的迁移。
教训:对任何暴露到数据库外部的东西都用 UUID。 内部关联表想用 SERIAL/BIGSERIAL 可以,但永远不要让自增整数出现在 URL 中。
不加约束("以后再加")#
我们上线了一张没有 CHECK 约束的表,因为"我们在快速迭代,以后再加"。两周之内,数据里就有了负价格、空名称,还有一个 order_status 的值是 "oof"——某人在测试时输入的,从没清理。
事后添加约束需要:
- 找到所有无效数据
- 决定如何处理(修复、删除还是保留)
- 编写一个既回填/修复数据又添加约束的迁移
这比第一天就加约束花的时间长多了。从约束开始。需要时再放松。永远不要反过来做。
逗号分隔的字符串而非数组或关联表#
我曾经把标签存成逗号分隔的字符串:"javascript,react,nextjs"。查询简直是噩梦:
-- 这就是查询逗号分隔值的方式。不要这样做。
SELECT * FROM posts WHERE tags LIKE '%react%';
-- 这也会匹配 "react-native" 和 "preact"
-- 我应该做的:
CREATE TABLE post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);或者至少使用 PostgreSQL 原生的数组类型:
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
-- ...
);
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
-- 干净的查询
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];对于不需要自身属性的简单列表,数组是可以接受的。一旦你需要关系上的元数据(比如"谁添加了这个标签"或"什么时候添加的"),你就需要关联表。
使用 "type" 作为列名#
-- 当时觉得没问题
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL, -- "email", "sms", "push"
-- ...
);
-- 然后你在任何 ORM 中试图查询它:
-- notification.type ← 和每种语言的类型系统冲突
-- "type" 在大多数 SQL 方言中是保留字
-- 你到处都得加引号:SELECT "type" FROM notifications用 kind、category 或 notification_type。就是不要用 type。同样,避免用 order(用 sort_order 或 position)、user(用 account 或加前缀)和 group(用 team 或 group_name)。
一开始没加 created_at#
有张表没有 created_at,因为"我们不需要它"。三个月后,我们需要调试一个问题却不知道记录是什么时候创建的。事后添加意味着所有现有行都得到了相同的时间戳(迁移的时间戳),使得历史数据毫无用处。
每张表都要有 created_at。没有例外。成本是一个列。没有它的成本在你需要它之前是未知的。
把业务逻辑放在数据库视图里#
我曾经创建了一个视图链——active_users 过滤 users,premium_active_users 过滤 active_users,还有一个报表视图把它们全都 JOIN 起来。一切运行良好,直到有人修改了 users 表,三个视图全部静默地崩了。查询计划器在优化多层视图时也很吃力。
视图非常适合提供便利和只读访问模式。但作为编码会变化的业务逻辑的地方,它们很糟糕。把业务规则放在应用代码中,在那里它们是版本化的、可测试的、可部署的。视图用于报表快捷方式,而不是架构构建块。
融会贯通#
这就是把这些模式组合在一起时,一个设计良好的 schema 的样子。一个简化的项目管理系统:
-- 扩展
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- 用于 gen_random_uuid()
-- updated_at 触发器函数(可复用)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 租户
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
slug TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
plan TEXT NOT NULL DEFAULT 'free'
CHECK (plan IN ('free', 'pro', 'enterprise')),
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 用户
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
display_name TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
last_login_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_users_email_per_tenant
ON users (tenant_id, email)
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 启用 RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- 项目
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived', 'deleted')),
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- 任务
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (LENGTH(title) > 0),
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo'
CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- 任务评论
CREATE TABLE task_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL CHECK (LENGTH(body) > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- 审计日志(没有 RLS——仅管理员表)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);注意这些模式:
- 每张表都有
tenant_id并启用了 RLS(除了tenants和audit_log)。 - 每张表都有
created_at和updated_at以及触发器。 - 所有主键都用 UUID(面向外部的实体)。
- 状态枚举、长度和范围都有 CHECK 约束。
- 外键有适当的
ON DELETE行为。 - 索引针对访问模式设计(tenant + project,tenant + status)。
- 只有
users使用软删除(需要账户恢复),projects使用基于状态的归档。
最后的思考#
Schema 设计不光鲜。没有人做过题为"我给每张表都加了 CHECK 约束,这为我们节省了六个月的调试时间"的技术演讲。但好的 schema 设计做的恰恰就是这件事——它安静地阻止了问题,以至于你永远都不知道那些问题本来会存在。
这篇文章中的模式并不新颖。它们是多年编写迁移、调试数据损坏、在生产负载下重构 schema 的结果。每一条都存在是因为我,或者和我共事的人,先用了另一种方式然后付出了代价。
从约束开始。使用外键。每张表都加 created_at。选一种命名规范然后严格执行。多租户使用 RLS。谨慎使用 JSONB。在部署之前用生产规模的数据测试你的迁移。
数据库是地基。打好了,建在上面的一切都会变简单。打不好,再多精巧的应用代码也救不了你。