跳至内容
·15 分钟阅读

数据库 Schema 设计:经得起时间考验的模式

范式化规则、命名规范、软删除、审计追踪、多租户模式、版本控制策略,以及我后悔过的 schema 决策。以 PostgreSQL 为主。

分享:X / TwitterLinkedIn

Schema 是你和未来自己签订的契约。你添加的每一列、跳过的每一个约束、每一句「以后再清理」——都在不断累积。我曾参与过这样的系统:三年前的一个糟糕 schema 决策,如今每个季度都要花团队整整一个 sprint 来绕过它。

数据库比一切都长命。你的前端框架会换,API 层会重写,部署策略会演进。但数据呢?数据会一直留下来。你在第一天赋予它的形状会永远跟随你,因为迁移一张 2 亿行的表可不像重构一个 React 组件那么简单。

这是我多年来在 schema 设计上的经验总结——那些不会让你日后追悔莫及的决策。以 PostgreSQL 为主,因为那是我使用并信赖的数据库,但其中大多数模式适用于任何关系型数据库。

命名规范:最无聊但最重要的事#

我见过的关于命名规范的争论比关于实际架构决策的还多。以下是我在多年项目切换后最终确定的做法:

所有东西都用 snake_case。 表、列、索引、约束。不用 camelCase,不用 PascalCase。PostgreSQL 会把未加引号的标识符折叠为小写,所以 createdAt 会变成 createdat,除非你到处加双引号。不要和数据库作对。

表名用复数。 一张表存放很多行。users 而不是 userorders 而不是 orderorder_items 而不是 order_item。这在查询中读起来很自然:SELECT * FROM users WHERE ...——你是从一个集合中查询。

外键用 _id 后缀。 user_idorder_idtenant_id。主键直接叫 id。这毫不含糊。当你做 JOIN 时,users.id = orders.user_id 读起来就像一句话。

时间戳用 _at 后缀。 created_atupdated_atdeleted_atpublished_atexpires_at。你总是能一眼看出这是一个时间点。

布尔值用 is_ 前缀。 is_activeis_verifiedis_published。有人用 has_ 表示所属关系的布尔值(has_mfa_enabled),但我保持简单,统一用 is_

非范式化的计数器用 _count 后缀。 comment_countfollower_count。明确表示这是一个缓存的数字,不是实时计算的结果。

关键在于:一致性胜过完美。 我见过团队花好几周争论到底该用 email_address 还是 email 还是 email_addr。选一种模式然后到处执行就好了。最糟糕的命名规范不是某个具体方案,而是执行不一致的方案。

sql
-- 好的:一致、可读、没有意外
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 什么?什么时候?
);

再说一件事:永远不要用保留字作列名。 typeorderusergrouptable——加引号都能用,但在 ORM、查询构建器以及各种动态 SQL 生成工具里会坑死你。用 kind 代替 type,用 sort_order 代替 order。未来的你会感谢自己。

标准列:每张表必备#

我的 schema 中,每张表都从同一个骨架开始,没有例外。

sql
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 就行。

sql
-- 面向 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 的版本会原样存储你给它的值,没有时区上下文——这意味着如果两台不同时区的服务器向同一张表写入数据,你会得到静默的数据损坏。

sql
-- 永远用这个
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- 永远不要用这个
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

updated_at 触发器#

PostgreSQL 没有 MySQL 的 ON UPDATE CURRENT_TIMESTAMP。你需要一个触发器:

sql
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 在同一张表里。

在实践中,第三范式是事务表的最佳平衡点。 你应该从这里开始,只在有具体的、可衡量的理由时才偏离。

以下是一个正确范式化的订单系统:

sql
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 保持干净,报表查询保持快速。

sql
-- 反范式化的报表表,由定时任务填充
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 被严重低估了。它是一个按需刷新的反范式化快照,非常适合仪表板。

sql
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 钩子只是一个建议。

sql
-- 永远这样做
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- "我们在代码里处理"的替代方案:
-- 靠希望。希望不是策略。

ON DELETE 策略#

这里是需要细致判断的地方。当你删除父行时会发生什么?

RESTRICT(默认):如果存在子行,删除失败。大多数关系都用这个。你不能删除有订单的客户——这是编码在 schema 中的业务逻辑。

sql
-- 有订单的客户不能被删除
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE:子行被自动删除。谨慎且有意识地使用。适用于"从属"关系,即子行离开父行就没有意义。

sql
-- 删除订单会删除其行项——它们单独没有意义
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。当关系是可选的且子行单独仍有意义时使用。

sql
-- 如果经理离职,其下属仍然存在——只是变成未分配
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 约束在写入时几乎没有成本,却能永远阻止垃圾数据进入你的系统:

sql
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。数据库是最后一道防线,好好利用它。

唯一约束和部分唯一索引#

简单场景下唯一约束很直接:

sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

但配合部分索引就变得有趣了——只对特定行生效的唯一约束:

sql
-- 每个用户只能有一个活跃订阅(但可以有很多已取消的)
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。行留在数据库中,但在正常查询中被过滤掉。

sql
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) 且用户软删除了账户,他们的邮箱仍然在表里。他们不能用同一个邮箱重新注册。新用户也不能用那个邮箱注册。

解决方案是部分唯一索引:

sql
-- 邮箱必须唯一,但只在非删除用户中
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 还是特性取决于你问的是谁。

我的做法#

我只对恢复是业务需求的面向用户的实体使用软删除——用户账户、项目、文档。那些客服可能需要恢复删除的东西。对其他所有东西,我使用硬删除加审计追踪(下一节会详细讲)。

sql
-- 软删除:面向用户,可恢复
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 表记录每一次变更。

sql
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 或数据库管理员的操作:

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();

缺点是:触发器不知道是哪个应用用户做出的变更。你可以通过会话变量来解决:

sql
-- 在你的应用中,执行查询之前:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- 在触发器函数中:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

这能用但感觉很脆弱。实践中,我使用混合方案:触发器负责数据捕获,应用代码负责设置会话上下文。

历史表模式#

对于需要完整版本历史的表(不仅是"改了什么"而是"在时间 T 时的状态是什么"),专用的历史表更干净:

sql
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 列,每个查询都按它过滤。

sql
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:

sql
-- 在表上启用 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 最有力的论据之一。

sql
-- 在你应用的连接中间件中:
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 共享同一个数据库,但表通过命名空间隔离。

sql
-- 为每个租户创建 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#

用户自定义的动态属性。 一个电商平台,每个产品类别有不同的属性——鞋子有 sizecolor,电子产品有 voltagewattage。与其用 EAV 表或为每个可能的属性建一列:

sql
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}

应用设置和配置。 用户偏好、功能开关、通知设置——形状经常变化且不需要关系完整性的东西。

sql
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 索引让它变快:

sql
-- 索引整个 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'];

对于你经常查询的特定路径,定向索引更高效:

sql
-- 索引一个特定路径
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- 现在这就是一个普通的 B-tree 查找
SELECT * FROM products
WHERE attributes->>'color' = 'black';

迁移陷阱#

JSONB 的坑在这里:你无法轻松地给 JSON 内部的字段添加 NOT NULL 约束或默认值。 如果你给产品属性添加了一个新的必填字段,你需要回填每一行现有数据。用普通列的话,一次迁移就能原子性地处理。用 JSONB 的话,你得写一个 UPDATE 触及每一行,然后祈祷你的应用代码在回填完成前能优雅地处理缺失字段。

sql
-- 添加新列:干净、原子性、一条语句
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 开始,简单默认值(常量)只是元数据操作,瞬间完成。但其他操作仍然会锁表:

sql
-- 安全:仅元数据操作,瞬间完成(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——会失败,因为现有行没有值。天真的做法:

sql
-- 这会锁表并重写它。不要在大表上这么做。
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

从 PostgreSQL 11 开始,对于常量默认值这实际上是安全的——它只是元数据操作。但如果你的默认值是函数或者你需要用计算值回填,就使用扩展-收缩模式。

扩展-收缩模式#

这是零停机 schema 变更的黄金标准。分三个阶段:

阶段一:扩展 — 以 nullable 方式添加新列。部署同时写入新旧两列的应用代码。

sql
-- 迁移 1:添加 nullable 列
ALTER TABLE users ADD COLUMN normalized_email TEXT;

阶段二:迁移 — 分批回填现有行。你的应用已经在为新数据写入新列了。

sql
-- 迁移 2:分批回填(大表不要用一条语句搞定)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- 重复直到所有行都被回填

阶段三:收缩 — 所有行回填完成后,添加 NOT NULL 约束并移除旧列(如适用)。

sql
-- 迁移 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

sql
-- 阻塞写入:不要在线上表这么做
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"——某人在测试时输入的,从没清理。

事后添加约束需要:

  1. 找到所有无效数据
  2. 决定如何处理(修复、删除还是保留)
  3. 编写一个既回填/修复数据又添加约束的迁移

这比第一天就加约束花的时间长多了。从约束开始。需要时再放松。永远不要反过来做。

逗号分隔的字符串而非数组或关联表#

我曾经把标签存成逗号分隔的字符串:"javascript,react,nextjs"。查询简直是噩梦:

sql
-- 这就是查询逗号分隔值的方式。不要这样做。
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 原生的数组类型:

sql
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" 作为列名#

sql
-- 当时觉得没问题
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- 然后你在任何 ORM 中试图查询它:
-- notification.type  ← 和每种语言的类型系统冲突
-- "type" 在大多数 SQL 方言中是保留字
-- 你到处都得加引号:SELECT "type" FROM notifications

kindcategorynotification_type。就是不要用 type。同样,避免用 order(用 sort_orderposition)、user(用 account 或加前缀)和 group(用 teamgroup_name)。

一开始没加 created_at#

有张表没有 created_at,因为"我们不需要它"。三个月后,我们需要调试一个问题却不知道记录是什么时候创建的。事后添加意味着所有现有行都得到了相同的时间戳(迁移的时间戳),使得历史数据毫无用处。

每张表都要有 created_at。没有例外。成本是一个列。没有它的成本在你需要它之前是未知的。

把业务逻辑放在数据库视图里#

我曾经创建了一个视图链——active_users 过滤 userspremium_active_users 过滤 active_users,还有一个报表视图把它们全都 JOIN 起来。一切运行良好,直到有人修改了 users 表,三个视图全部静默地崩了。查询计划器在优化多层视图时也很吃力。

视图非常适合提供便利和只读访问模式。但作为编码会变化的业务逻辑的地方,它们很糟糕。把业务规则放在应用代码中,在那里它们是版本化的、可测试的、可部署的。视图用于报表快捷方式,而不是架构构建块。

融会贯通#

这就是把这些模式组合在一起时,一个设计良好的 schema 的样子。一个简化的项目管理系统:

sql
-- 扩展
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(除了 tenantsaudit_log)。
  • 每张表都有 created_atupdated_at 以及触发器。
  • 所有主键都用 UUID(面向外部的实体)。
  • 状态枚举、长度和范围都有 CHECK 约束。
  • 外键有适当的 ON DELETE 行为。
  • 索引针对访问模式设计(tenant + project,tenant + status)。
  • 只有 users 使用软删除(需要账户恢复),projects 使用基于状态的归档。

最后的思考#

Schema 设计不光鲜。没有人做过题为"我给每张表都加了 CHECK 约束,这为我们节省了六个月的调试时间"的技术演讲。但好的 schema 设计做的恰恰就是这件事——它安静地阻止了问题,以至于你永远都不知道那些问题本来会存在。

这篇文章中的模式并不新颖。它们是多年编写迁移、调试数据损坏、在生产负载下重构 schema 的结果。每一条都存在是因为我,或者和我共事的人,先用了另一种方式然后付出了代价。

从约束开始。使用外键。每张表都加 created_at。选一种命名规范然后严格执行。多租户使用 RLS。谨慎使用 JSONB。在部署之前用生产规模的数据测试你的迁移。

数据库是地基。打好了,建在上面的一切都会变简单。打不好,再多精巧的应用代码也救不了你。

相关文章