23 KiB
23 KiB
name, description, license, metadata
| name | description | license | metadata | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| fullstack-dev-db-schema | 数据库表结构设计与迁移。适用于创建表、定义 ORM 模型、添加索引或设计关系。涵盖零停机迁移与多租户。 | MIT |
|
数据库表结构设计
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 |
uuid(v4 随机) |
面向公开的资源 | 不可猜测、全局唯一 | 体积更大(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);
何时反规范化
仅在以下条件同时满足时才反规范化:
- 你已经测量到性能问题(EXPLAIN ANALYZE,而非「我觉得慢」)
- 反规范化后的数据是读密集型(读写比例 > 100:1)
- 你接受一致性维护成本(触发器、应用逻辑或物化视图)
安全的反规范化模式:
-- 模式 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);
枚举列
-- 方案 A:PostgreSQL 枚举类型(严格,但 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 | 不加 CONCURRENTLY 的 CREATE INDEX |
在线表上始终使用 CONCURRENTLY |
| 8 | 多态外键(commentable_type + commentable_id) |
单独的外键列或独立的表 |
| 9 | 什么都用 JSONB | 灵活数据用 JSONB,结构化数据用列 |
| 10 | 没有 created_at / updated_at |
每张表都配有时间戳对 |
| 11 | 一列中存逗号分隔的值 | 单独的表或 PostgreSQL 数组 |
| 12 | 不带长度验证的 text |
CHECK 约束或应用层验证 |
常见问题
问题 1:「查询很慢,但我已经有索引了」
症状: 尽管已有索引,EXPLAIN ANALYZE 仍然显示顺序扫描。
原因:
- 索引列顺序错误——复合索引
(A, B)对WHERE B = ?无效 - 选择性低——布尔列上的索引(50% 的行匹配),优化器更倾向于顺序扫描
- 统计信息过时——运行
ANALYZE table_name; - 类型不匹配——用
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;