Files
skillhub-052-fullstack-dev/references/db-schema.md
T
2026-07-13 21:35:57 +08:00

23 KiB
Raw Blame History

name, description, license, metadata
name description license metadata
fullstack-dev-db-schema 数据库表结构设计与迁移。适用于创建表、定义 ORM 模型、添加索引或设计关系。涵盖零停机迁移与多租户。 MIT
version sources
1.0.0
PostgreSQL 官方文档
Use The Index, Luke (use-the-index-luke.com)
设计数据密集型应用(Martin Kleppmann
数据库可靠性工程(Laine Campbell & Charity Majors

数据库表结构设计

ORM 无关的关系型数据库表结构设计指南。涵盖数据建模、规范化、索引、迁移、多租户以及常见应用模式。主要面向 PostgreSQL,但原则同样适用于 MySQL/MariaDB。

适用范围

使用该技能的场景:

  • 为新项目或新功能设计表结构
  • 在规范化和反规范化之间做决策
  • 选择需要创建的索引
  • 规划在线数据库的零停机迁移
  • 实现多租户数据隔离
  • 添加审计追踪、软删除或版本控制
  • 诊断因表结构问题导致的慢查询

不适用的场景:

  • 选择使用哪种数据库技术(→ technology-selection
  • PostgreSQL 特定的查询调优(请使用 PostgreSQL 性能文档)
  • ORM 特定的配置(→ django-best-practices 或对应 ORM 的文档)
  • 应用层缓存(→ fullstack-dev-practices

所需上下文

必需 可选
数据库引擎(PostgreSQL / MySQL 预期数据量(行数、增长率)
领域实体及其关系 读写比例
关键访问模式(查询) 多租户需求

快速入门清单

设计新表结构:

  • 识别领域实体——每个实体映射一张表(而非每个类一张表)
  • 主键:公开 ID 使用 UUID,仅内部使用使用 serial/bigserial
  • 外键带有显式的 ON DELETE 行为
  • 默认 NOT NULL——仅在业务逻辑要求时才设为可空
  • 时间戳:每张表都包含 created_at + updated_at
  • 索引:为每个 WHERE、JOIN、ORDER BY 列创建索引
  • 不提前反规范化——从规范化开始,在有测量依据时才反规范化
  • 命名约定保持一致:snake_case,表名使用复数

快速导航

需要… 跳转至
建模实体与关系 1. 数据建模
决定规范化还是反规范化 2. 规范化
选择合适的索引 3. 索引
在在线数据库上安全运行迁移 4. 迁移
设计多租户表结构 5. 多租户
添加软删除/审计追踪 6. 常见模式
对大表进行分区 7. 分区
查看反模式 反模式

核心原则(7 条规则)

1. ✅ 从规范化(3NF)开始——只有在有测量证据时才反规范化
2. ✅ 每张表都有主键、created_at、updated_at
3. ✅ 对外公开的 ID 使用 UUID,内部连接键使用 serial
4. ✅ 默认 NOT NULL——null 是业务决策,而非偷懒的默认值
5. ✅ 为每个用在 WHERE、JOIN、ORDER BY 中的列创建索引
6. ✅ 外键在数据库中强制执行(而非仅在应用代码中)
7. ✅ 迁移是增量的——绝不在生产中一步完成删除/重命名,必须有多步骤计划

1. 数据建模(关键)

表命名

-- ✅ 复数、snake_case
CREATE TABLE orders (...);
CREATE TABLE order_items (...);
CREATE TABLE user_profiles (...);

-- ❌ 单数、大小写混用
CREATE TABLE Order (...);
CREATE TABLE OrderItem (...);
CREATE TABLE tbl_usr_prof (...);    -- 晦涩的缩写

主键

策略 适用场景 优点 缺点
bigserial(自增) 内部表、外键连接 紧凑、连接快 可枚举,不适合公开 ID
uuidv4 随机) 面向公开的资源 不可猜测、全局唯一 体积更大(16 字节)、B-Tree 随机 I/O
uuid v7(按时间排序) 公开 + 需要排序 不可猜测 + 插入友好 较新,生态系统支持较少
text slug URL 友好的资源 人类可读 必须强制唯一性,更新成本高

推荐的默认方案:

CREATE TABLE orders (
    id          bigserial PRIMARY KEY,             -- 内部外键目标
    public_id   uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,  -- API 对外
    -- ...
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

关系

-- 一对多:user → orders
CREATE TABLE orders (
    id         bigserial PRIMARY KEY,
    user_id    bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    -- ...
);
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- 多对多:orders ↔ products(通过连接表)
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) ON DELETE RESTRICT,
    quantity   int NOT NULL CHECK (quantity > 0),
    unit_price numeric(10,2) NOT NULL,
    UNIQUE (order_id, product_id)  -- 防止重复的行项目
);

-- 一对一:user → profile
CREATE TABLE user_profiles (
    user_id    bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    bio        text,
    avatar_url text,
    -- ...
);

ON DELETE 行为

行为 适用场景 示例
CASCADE 子表数据脱离父表无意义 删除订单时同时删除 order_items
RESTRICT 防止意外删除 被 order_items 引用的 products
SET NULL 保留子表数据,清除引用 员工离职时 orders.assigned_to 置空
SET DEFAULT 回退为默认值 较少使用,用于状态列

2. 规范化 vs 反规范化(关键)

从规范化开始(3NF

实际中的范式:

范式 规则 违反示例
1NF 无重复组、原子值 一列中存 tags = "go,python,rust"
2NF 无部分依赖(组合键) order_items.product_name 仅依赖于 product_id
3NF 无传递依赖 orders.customer_city 依赖于 customer_id 而非 order_id

1NF 违反修复:

-- ❌ 标签作为逗号分隔的字符串
CREATE TABLE posts (id serial, tags text);  -- tags = "go,python"

-- ✅ 单独的表(若简单也可用数组或 JSONB)
CREATE TABLE post_tags (
    post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
    tag_id  bigint REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

-- ✅ 替代方案:PostgreSQL 数组(如果标签仅是字符串,无元数据)
CREATE TABLE posts (id serial, tags text[] NOT NULL DEFAULT '{}');
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);

何时反规范化

仅在以下条件同时满足时才反规范化:

  1. 你已经测量到性能问题(EXPLAIN ANALYZE,而非「我觉得慢」)
  2. 反规范化后的数据是读密集型(读写比例 > 100:1
  3. 你接受一致性维护成本(触发器、应用逻辑或物化视图)

安全的反规范化模式:

-- 模式 1:物化视图(计算后、可刷新)
CREATE MATERIALIZED VIEW order_summary AS
SELECT o.id, o.user_id, o.total,
       COUNT(oi.id) AS item_count,
       u.email AS user_email
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN users u ON u.id = o.user_id
GROUP BY o.id, u.email;

REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;  -- 非阻塞

-- 模式 2:缓存聚合列(由应用程序维护)
ALTER TABLE orders ADD COLUMN item_count int NOT NULL DEFAULT 0;
-- 通过触发器或在 order_item 插入/删除时的应用代码来更新

-- 模式 3:JSONB 快照(写入时冻结)
-- 在购买时存储产品信息的副本
CREATE TABLE order_items (
    id          bigserial PRIMARY KEY,
    order_id    bigint NOT NULL REFERENCES orders(id),
    product_id  bigint REFERENCES products(id),
    quantity    int NOT NULL,
    unit_price  numeric(10,2) NOT NULL,      -- 冻结的价格
    product_snapshot jsonb NOT NULL           -- 冻结的名称、描述、图片
);

3. 索引策略(关键)

索引类型(PostgreSQL

类型 适用场景 示例
B-Tree(默认) 等值、范围、ORDER BY WHERE status = 'active'WHERE created_at > '2025-01-01'
Hash 仅等值(很少使用,B-Tree 通常更好) WHERE id = 123(大表,Postgres 10+
GIN 数组、JSONB、全文搜索 WHERE tags @> '{go}'WHERE data->>'key' = 'val'
GiST 几何、范围、最近邻 PostGIS、tsrange、ltree
BRIN 带有自然顺序的超大表 按时间戳排序的时间序列数据

索引决策规则

规则 1:为 WHERE 子句中的每个列创建索引
规则 2:为 JOIN ON 条件中的每个列创建索引
规则 3:为 ORDER BY 中的每个列创建索引(如果与 LIMIT 一起查询)
规则 4:多列 WHERE 使用复合索引(最左前缀规则)
规则 5:仅过滤子集时使用部分索引(例如,仅活跃记录)
规则 6:使用覆盖索引(INCLUDE)以避免回表查询
规则 7:不要单独为低基数列建索引(例如布尔值)

复合索引:列顺序很重要

-- 查询:WHERE user_id = ? AND status = ? ORDER BY created_at DESC
-- ✅ 最优:从左到右匹配查询模式
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at DESC);

-- ❌ 顺序错误:无法高效用于该查询
CREATE INDEX idx_orders_created_user_status
ON orders(created_at DESC, user_id, status);

最左前缀规则: (A, B, C) 上的索引支持对 (A)(A, B)(A, B, C) 的查询,但不支持 (B)(C)(B, C) 的查询。

部分索引(仅索引所需数据)

-- 只有 5% 的订单是 'pending',但频繁查询
CREATE INDEX idx_orders_pending
ON orders(created_at DESC)
WHERE status = 'pending';

-- 只有活跃用户对登录有意义
CREATE INDEX idx_users_active_email
ON users(email)
WHERE is_active = true;

覆盖索引(避免回表查询)

-- 查询只需要 id 和 status,无需读取表行
CREATE INDEX idx_orders_user_covering
ON orders(user_id) INCLUDE (status, total);

-- 现在该查询是仅索引查询:
SELECT status, total FROM orders WHERE user_id = 123;

何时不要建索引

❌ 很少用于 WHERE/JOIN/ORDER BY 的列
❌ 行数 < 1000 的表(顺序扫描更快)
❌ 单独来看基数值很低的列(例如布尔值 is_active)
❌ 写密集的表,索引维护成本超过读收益
❌ 重复索引(检查 pg_stat_user_indexes 中的未使用索引)

4. 零停机迁移(高)

黄金法则

绝不要在一个步骤中做破坏性变更。
始终:添加 → 迁移数据 → 移除旧数据(分多次部署)。

安全的迁移模式

重命名列(3 次部署):

部署 1:添加新列
  ALTER TABLE users ADD COLUMN full_name text;
  UPDATE users SET full_name = name;           -- 回填
  -- 应用同时写入 name 和 full_name

部署 2:将读取切换到新列
  -- 应用从 full_name 读取,仍然同时写入两个字段

部署 3:删除旧列
  ALTER TABLE users DROP COLUMN name;
  -- 应用仅使用 full_name

添加 NOT NULL 列(2 次部署):

-- 部署 1:添加可空列,回填
ALTER TABLE orders ADD COLUMN currency text;              -- 先设为可空
UPDATE orders SET currency = 'USD' WHERE currency IS NULL; -- 回填

-- 部署 2:添加约束(所有行回填完成后)
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
ALTER TABLE orders ALTER COLUMN currency SET DEFAULT 'USD';

无锁添加索引:

-- ✅ CONCURRENTLY:不锁表,可在在线数据库上运行
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);

-- ❌ 不加 CONCURRENTLY:建索引期间锁住写入
CREATE INDEX idx_orders_status ON orders(status);

迁移安全清单

✅ 迁移在生产数据量级上运行时间 < 30 秒
✅ 无排他表锁(索引使用 CONCURRENTLY)
✅ 回滚方案已记录并测试
✅ 回填分批执行(而非一次巨型 UPDATE)
✅ 新列先以可空方式添加,稍后添加约束
✅ 旧列保留到所有代码引用移除为止

❌ 绝不在一次部署中重命名/删除列
❌ 绝不在大表上不做时间测试就执行 ALTER TYPE
❌ 绝不在事务中运行数据回填(大表上会 OOM)

分批回填模板

-- 以 10,000 行为一批进行回填(避免长时间运行的事务)
DO $$
DECLARE
  batch_size int := 10000;
  affected int;
BEGIN
  LOOP
    UPDATE orders
    SET currency = 'USD'
    WHERE id IN (
      SELECT id FROM orders WHERE currency IS NULL LIMIT batch_size
    );
    GET DIAGNOSTICS affected = ROW_COUNT;
    RAISE NOTICE 'Updated % rows', affected;
    EXIT WHEN affected = 0;
    PERFORM pg_sleep(0.1);  -- 短暂暂停以降低负载
  END LOOP;
END $$;

5. 多租户设计(高)

三种方案

方案 隔离性 复杂度 适用场景
行级(共享表 + tenant_id SaaS MVP,租户数 < 1000
每租户独立 Schema 受监管行业,中等规模
每租户独立数据库 企业级,严格数据隔离

行级租户(最常见)

-- 每张表都有 tenant_id
CREATE TABLE orders (
    id         bigserial PRIMARY KEY,
    tenant_id  bigint NOT NULL REFERENCES tenants(id),
    user_id    bigint NOT NULL REFERENCES users(id),
    total      numeric(10,2) NOT NULL,
    -- ...
);

-- 复合索引:租户在前(大多数查询按租户过滤)
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);

-- 行级安全(PostgreSQL
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::bigint);

应用层强制:

// 中间件:在每个请求上设置租户上下文
app.use((req, res, next) => {
  const tenantId = req.headers['x-tenant-id'];
  if (!tenantId) return res.status(400).json({ error: '缺少租户标识' });
  req.tenantId = tenantId;
  next();
});

// 仓库层:始终按租户过滤
async findOrders(tenantId: string, userId: string) {
  return db.order.findMany({
    where: { tenantId, userId },  // ← 每条查询中都有 tenant_id
  });
}

规则

✅ 每张包含租户数据的表都有 tenant_id
✅ tenant_id 作为每个复合索引的第一列
✅ 应用中间件强制租户上下文
✅ 使用 RLS(PostgreSQL)作为纵深防御,而非唯一保护
✅ 用 2 个以上的租户进行测试以验证隔离

❌ 绝不允许在应用代码中进行跨租户查询
❌ 绝不在 WHERE 子句中遗漏 tenant_id(即便在管理工具中)

6. 常见表结构模式(中)

软删除

ALTER TABLE orders ADD COLUMN deleted_at timestamptz;

-- 所有查询过滤已删除记录
CREATE VIEW active_orders AS
SELECT * FROM orders WHERE deleted_at IS NULL;

-- 部分索引:仅索引未删除的行
CREATE INDEX idx_orders_active_status
ON orders(status, created_at DESC)
WHERE deleted_at IS NULL;

ORM 集成:

// Prisma 中间件:自动过滤软删除记录
prisma.$use(async (params, next) => {
  if (params.action === 'findMany' || params.action === 'findFirst') {
    params.args.where = { ...params.args.where, deletedAt: null };
  }
  return next(params);
});

审计追踪

-- 方案 A:每张表上的审计列
ALTER TABLE orders ADD COLUMN created_by bigint REFERENCES users(id);
ALTER TABLE orders ADD COLUMN updated_by bigint REFERENCES users(id);

-- 方案 B:独立的审计日志表(更详细)
CREATE TABLE audit_log (
    id          bigserial PRIMARY KEY,
    table_name  text NOT NULL,
    record_id   bigint NOT NULL,
    action      text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
    old_data    jsonb,
    new_data    jsonb,
    changed_by  bigint REFERENCES users(id),
    changed_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_changed_at ON audit_log(changed_at DESC);

枚举列

-- 方案 APostgreSQL 枚举类型(严格,但 ALTER TYPE 很麻烦)
CREATE TYPE order_status AS ENUM ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled');
ALTER TABLE orders ADD COLUMN status order_status NOT NULL DEFAULT 'pending';

-- 方案 B:文本 + CHECK 约束(更容易迁移)
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending'
  CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled'));

-- 方案 C:查找表(最灵活,适合 UI 驱动的列表)
CREATE TABLE order_statuses (
    id    serial PRIMARY KEY,
    name  text UNIQUE NOT NULL,
    label text NOT NULL      -- 显示名称
);

推荐: 大多数情况下使用方案 B(文本 + CHECK)。如果状态由非开发人员管理,则使用方案 C。

多态关联

-- ❌ 反模式:多态外键(无参照完整性)
CREATE TABLE comments (
    id             bigserial PRIMARY KEY,
    commentable_type text,    -- 'Post' 或 'Photo'
    commentable_id   bigint,  -- 无法建立外键约束!
    body           text
);

-- ✅ 模式 A:单独的外键列(可空)
CREATE TABLE comments (
    id       bigserial PRIMARY KEY,
    post_id  bigint REFERENCES posts(id) ON DELETE CASCADE,
    photo_id bigint REFERENCES photos(id) ON DELETE CASCADE,
    body     text NOT NULL,
    CHECK (
      (post_id IS NOT NULL AND photo_id IS NULL) OR
      (post_id IS NULL AND photo_id IS NOT NULL)
    )
);

-- ✅ 模式 B:独立的表(最清晰,适合不同表结构)
CREATE TABLE post_comments (..., post_id bigint REFERENCES posts(id));
CREATE TABLE photo_comments (..., photo_id bigint REFERENCES photos(id));

JSONB 列(半结构化数据)

-- 好的用途:元数据、设置、灵活属性
CREATE TABLE products (
    id         bigserial PRIMARY KEY,
    name       text NOT NULL,
    price      numeric(10,2) NOT NULL,
    attributes jsonb NOT NULL DEFAULT '{}'  -- 颜色、尺寸、重量…
);

-- JSONB 查询的索引
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);

-- 查询
SELECT * FROM products WHERE attributes->>'color' = 'red';
SELECT * FROM products WHERE attributes @> '{"size": "XL"}';
✅ 对真正灵活/可选的数据(元数据、设置、偏好)使用 JSONB
✅ 在查询 JSONB 列时使用 GIN 索引

❌ 绝不对本应作为列的数据(email、status、price)使用 JSONB
❌ 绝不用 JSONB 来逃避表结构设计(它不是 PostgreSQL 里的 MongoDB

7. 表分区(中)

何时分区

✅ 表行数 > 1 亿且仍在增长
✅ 大多数查询按分区键过滤(日期范围、租户)
✅ 旧数据可按分区删除/归档(高效的 DELETE)

❌ 表行数 < 1000 万(开销不值得)
❌ 查询不按分区键过滤(扫描所有分区)

范围分区(时间序列)

CREATE TABLE events (
    id         bigserial,
    tenant_id  bigint NOT NULL,
    event_type text NOT NULL,
    payload    jsonb,
    created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);

-- 按月分区
CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- 使用 pg_partman 或 cron 自动创建分区

列表分区(多租户)

CREATE TABLE orders (
    id        bigserial,
    tenant_id bigint NOT NULL,
    total     numeric(10,2)
) PARTITION BY LIST (tenant_id);

CREATE TABLE orders_tenant_1 PARTITION OF orders FOR VALUES IN (1);
CREATE TABLE orders_tenant_2 PARTITION OF orders FOR VALUES IN (2);

反模式

# 不要这样做 而应该这样做
1 提前反规范化 从 3NF 开始,有测量依据时再反规范化
2 自增 ID 作为公开 API 标识符 公开用 UUID,内部用 serial
3 无外键约束 始终在数据库中强制执行外键
4 默认可为空 默认 NOT NULL,需要时再设为可空
5 外键列上无索引 为每个外键列创建索引
6 一步完成破坏性迁移 分多次部署完成:添加 → 迁移 → 移除
7 不加 CONCURRENTLYCREATE INDEX 在线表上始终使用 CONCURRENTLY
8 多态外键(commentable_type + commentable_id 单独的外键列或独立的表
9 什么都用 JSONB 灵活数据用 JSONB,结构化数据用列
10 没有 created_at / updated_at 每张表都配有时间戳对
11 一列中存逗号分隔的值 单独的表或 PostgreSQL 数组
12 不带长度验证的 text CHECK 约束或应用层验证

常见问题

问题 1:「查询很慢,但我已经有索引了」

症状: 尽管已有索引,EXPLAIN ANALYZE 仍然显示顺序扫描。

原因:

  1. 索引列顺序错误——复合索引 (A, B)WHERE B = ? 无效
  2. 选择性低——布尔列上的索引(50% 的行匹配),优化器更倾向于顺序扫描
  3. 统计信息过时——运行 ANALYZE table_name;
  4. 类型不匹配——用 integer 参数比较 varchar 列 → 无法使用索引

修复方法: 检查 EXPLAIN (ANALYZE, BUFFERS),确认索引与查询模式匹配,运行 ANALYZE

问题 2:「迁移锁住表数分钟」

症状: 执行期间 ALTER TABLE 阻塞所有写入。

原因: 添加 NOT NULL 约束、更改列类型、或者创建索引时未使用 CONCURRENTLY

修复方法:

-- 无锁添加索引
CREATE INDEX CONCURRENTLY idx_name ON table(col);

-- 无锁添加 NOT NULL 约束(Postgres 12+
ALTER TABLE t ADD CONSTRAINT t_col_nn CHECK (col IS NOT NULL) NOT VALID;
ALTER TABLE t VALIDATE CONSTRAINT t_col_nn;  -- 非阻塞验证

问题 3:「多少个索引算太多?」

经验法则:

  • 读密集表(报表、产品目录):5-10 个索引没问题
  • 写密集表(事件、日志):最多 2-3 个索引
  • 通过 pg_stat_user_indexes 监控——删除 idx_scan = 0 的索引
-- 查找未使用的索引
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;