Files
2026-07-13 21:35:57 +08:00

706 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: fullstack-dev-db-schema
description: "数据库表结构设计与迁移。适用于创建表、定义 ORM 模型、添加索引或设计关系。涵盖零停机迁移与多租户。"
license: MIT
metadata:
version: "1.0.0"
sources:
- 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. 数据建模](#1-数据建模关键) |
| 决定规范化还是反规范化 | [2. 规范化](#2-规范化-vs-反规范化关键) |
| 选择合适的索引 | [3. 索引](#3-索引策略关键) |
| 在在线数据库上安全运行迁移 | [4. 迁移](#4-零停机迁移高) |
| 设计多租户表结构 | [5. 多租户](#5-多租户设计高) |
| 添加软删除/审计追踪 | [6. 常见模式](#6-常见表结构模式中) |
| 对大表进行分区 | [7. 分区](#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. 数据建模(关键)
### 表命名
```sql
-- ✅ 复数、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 友好的资源 | 人类可读 | 必须强制唯一性,更新成本高 |
**推荐的默认方案:**
```sql
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()
);
```
### 关系
```sql
-- 一对多: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 违反修复:**
```sql
-- ❌ 标签作为逗号分隔的字符串
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. 你接受**一致性维护成本**(触发器、应用逻辑或物化视图)
**安全的反规范化模式:**
```sql
-- 模式 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:不要单独为低基数列建索引(例如布尔值)
```
### 复合索引:列顺序很重要
```sql
-- 查询: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)` 的查询。
### 部分索引(仅索引所需数据)
```sql
-- 只有 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;
```
### 覆盖索引(避免回表查询)
```sql
-- 查询只需要 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 次部署):**
```sql
-- 部署 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';
```
**无锁添加索引:**
```sql
-- ✅ 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)
```
### 分批回填模板
```sql
-- 以 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** | 中 | 中 | 受监管行业,中等规模 |
| **每租户独立数据库** | 高 | 高 | 企业级,严格数据隔离 |
### 行级租户(最常见)
```sql
-- 每张表都有 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);
```
**应用层强制:**
```typescript
// 中间件:在每个请求上设置租户上下文
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. 常见表结构模式(中)
### 软删除
```sql
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 集成:**
```typescript
// Prisma 中间件:自动过滤软删除记录
prisma.$use(async (params, next) => {
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args.where = { ...params.args.where, deletedAt: null };
}
return next(params);
});
```
### 审计追踪
```sql
-- 方案 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);
```
### 枚举列
```sql
-- 方案 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。
### 多态关联
```sql
-- ❌ 反模式:多态外键(无参照完整性)
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 列(半结构化数据)
```sql
-- 好的用途:元数据、设置、灵活属性
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 万(开销不值得)
❌ 查询不按分区键过滤(扫描所有分区)
```
### 范围分区(时间序列)
```sql
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 自动创建分区
```
### 列表分区(多租户)
```sql
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` 仍然显示顺序扫描。
**原因:**
1. **索引列顺序错误**——复合索引 `(A, B)``WHERE B = ?` 无效
2. **选择性低**——布尔列上的索引(50% 的行匹配),优化器更倾向于顺序扫描
3. **统计信息过时**——运行 `ANALYZE table_name;`
4. **类型不匹配**——用 `integer` 参数比较 `varchar` 列 → 无法使用索引
**修复方法:** 检查 `EXPLAIN (ANALYZE, BUFFERS)`,确认索引与查询模式匹配,运行 `ANALYZE`
### 问题 2:「迁移锁住表数分钟」
**症状:** 执行期间 `ALTER TABLE` 阻塞所有写入。
**原因:** 添加 NOT NULL 约束、更改列类型、或者创建索引时未使用 `CONCURRENTLY`
**修复方法:**
```sql
-- 无锁添加索引
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` 的索引
```sql
-- 查找未使用的索引
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;