--- 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 -- 方案 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。 ### 多态关联 ```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;