关于 postgres-schema-design
postgres-schema-design 是一项 PostgreSQL 表结构设计参考技能,面向需要规划数据类型、约束和索引的开发者。内容涵盖常见设计原则、数据库特性及不同负载下的注意事项。
主要功能
- 01表、主键与规范化设计原则
- 02数据类型选择和 PostgreSQL 注意事项
- 03约束与外键索引建议
- 04索引类型及查询路径设计
- 05分区与不同写入负载的设计
适用场景
- 01规划新表的字段和主键
- 02为查询模式选择索引
- 03设计时间序列或高写入量表
- 04检查约束及分区方案
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ davila7/ claude-code-templates --skill postgres-schema-design安装后在 Claude Code 中输入/postgres-schema-design确认已加载。
附带文件LICENSE.txt
详细介绍
PostgreSQL 表设计
核心规则
- 为引用表(用户、订单等)定义 PRIMARY KEY。时间序列/事件/日志数据不一定需要主键。使用主键时,优先选择
BIGINT GENERATED ALWAYS AS IDENTITY;仅在需要全局唯一性或不透明性时使用UUID。 - 先规范化(达到 3NF),以消除数据冗余和更新异常;仅在经过验证的高投资回报读取场景中,且已证明连接性能存在问题时才反规范化。过早反规范化会增加维护负担。
- 在语义上确有必要的地方添加 NOT NULL;常用值使用 DEFAULT。
- 针对实际查询的访问路径创建索引:主键/唯一约束(自动创建)、外键列(手动创建!)、常用筛选/排序字段以及连接键。
- 事件时间优先使用 TIMESTAMPTZ;金额使用 NUMERIC;字符串使用 TEXT;整数值使用 BIGINT,浮点数使用 DOUBLE PRECISION(或在需要精确十进制运算时使用
NUMERIC)。
PostgreSQL“注意事项”
- 标识符:未加引号的标识符会转换为小写。避免使用带引号/大小写混合的名称。约定:表名和列名使用
snake_case。 - 唯一约束 + NULL:UNIQUE 允许存在多个 NULL。使用
UNIQUE (...) NULLS NOT DISTINCT(PG15+)可将 NULL 限制为一个。 - 外键索引:PostgreSQL 不会自动为外键列创建索引。请手动添加。
- 不会静默转换:长度/精度溢出会报错(不会截断)。例如,向
NUMERIC(2,0)插入 999 会失败并报错,而某些数据库会静默截断或舍入。 - 序列/identity 会有间隙(这是正常现象,无需“修复”)。回滚、崩溃和并发事务都会导致 ID 序列出现间隙(1、2、5、6……)。这是预期行为,不要试图让 ID 连续。
- 堆存储:默认情况下,主键不会使数据聚簇(不同于 SQL Server/MySQL InnoDB);
CLUSTER是一次性重组,后续插入不会自动维护聚簇。除非显式执行聚簇操作,否则磁盘上的行顺序就是插入顺序。 - MVCC:更新/删除会留下死元组,由 vacuum 处理。设计时应避免频繁改动宽行。
数据类型
- ID:优先使用
BIGINT GENERATED ALWAYS AS IDENTITY(GENERATED BY DEFAULT也可以);在合并、联合或分布式系统中使用,或需要不透明 ID 时,使用UUID。使用uuidv7()(PG18+ 首选)或gen_random_uuid()(较旧的 PG 版本)生成。 - 整数:除非存储空间至关重要,否则优先使用
BIGINT;较小范围使用INTEGER;除非有限制,否则避免使用SMALLINT。 - 浮点数:除非存储空间至关重要,否则优先使用
DOUBLE PRECISION,而非REAL。精确十进制运算使用NUMERIC。 - 字符串:优先使用
TEXT;需要长度限制时,使用CHECK (LENGTH(col) <= n),而不是VARCHAR(n);避免使用CHAR(n)。二进制数据使用BYTEA。较大的字符串或二进制数据(默认阈值 >2KB)会自动压缩并存储在 TOAST 中。TOAST 存储方式:PLAIN(不使用 TOAST)、EXTENDED(压缩并存储在行外)、EXTERNAL(存储在行外,不压缩)、MAIN(压缩,并尽可能保留在行内)。默认的EXTENDED通常最优。使用ALTER TABLE tbl ALTER COLUMN col SET STORAGE strategy控制存储方式,使用ALTER TABLE tbl SET (toast_tuple_target = 4096)设置阈值。大小写不敏感:处理区域设置或重音时,使用非确定性排序规则;处理纯 ASCII 时,使用基于LOWER(col)的表达式索引(除非列需要大小写不敏感的 PK/FK/UNIQUE,否则优先选用)或CITEXT。 - 金额:使用
NUMERIC(p,s)(绝不使用浮点数)。 - 时间:时间戳使用
TIMESTAMPTZ;仅含日期的值使用DATE;时长使用INTERVAL。避免使用TIMESTAMP(不含时区)。事务开始时间使用now(),当前实际时间使用clock_timestamp()。 - 布尔值:使用
BOOLEAN,并添加NOT NULL约束,除非需要三态值。 - 枚举:对于数量少且稳定的集合(例如美国各州、一周中的各天),使用
CREATE TYPE ... AS ENUM。对于由业务逻辑驱动且不断变化的值(例如订单状态),使用 TEXT(或 INT)+ CHECK,或使用查找表。 - 数组:使用
TEXT[]、INTEGER[]等。适用于需要查询元素的有序列表。对于包含(@>、<@)和重叠(&&)查询,使用 GIN 索引。访问方式:arr[1](从 1 开始索引)、arr[1:3](切片)。适用于标签、类别;避免用于关系,应改用联结表。字面量语法:'{val1,val2}'或ARRAY[val1,val2]。 - 范围类型:使用
daterange、numrange、tstzrange表示区间。支持重叠(&&)、包含(@>)等运算符。使用 GiST 索引。适用于排程、版本控制和数值范围。选择一种边界方案并始终保持一致;默认优先使用[)(包含下界,不包含上界)。 - 网络类型:IP 地址使用
INET,网络范围使用CIDR,MAC 地址使用MACADDR。支持网络运算符(<<、>>、&&)。 - 几何类型:2D 空间数据使用
POINT、LINE、POLYGON、CIRCLE。使用 GiST 索引。高级空间功能可考虑使用 PostGIS。 - 文本搜索:全文搜索文档使用
TSVECTOR,搜索查询使用TSQUERY。使用 GIN 为tsvector建立索引。始终指定语言:to_tsvector('english', col)和to_tsquery('english', 'query')。绝不使用单参数版本。此要求适用于索引表达式和查询。 - 域类型:使用
CREATE DOMAIN email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+$')创建可复用且带验证的自定义类型。可在多个表中强制执行约束。 - 复合类型:使用
CREATE TYPE address AS (street TEXT, city TEXT, zip TEXT)表示列中的结构化数据。使用(col).field语法访问。 - JSONB:优先于 JSON;使用 GIN 索引。仅用于可选或半结构化属性。只有在必须保留内容的原始顺序时,才使用 JSON。
- 向量类型:使用
pgvector提供的vector类型进行嵌入向量相似度搜索。
不要使用以下数据类型
- 不要使用
timestamp(不带时区);应使用timestamptz。 - 不要使用
char(n)或varchar(n);应使用text。 - 不要使用
money类型;应使用numeric。 - 不要使用
timetz类型;应使用timestamptz。 - 不要使用
timestamptz(0)或任何其他精度声明;应使用timestamptz。 - 不要使用
serial类型;应使用generated always as identity。
表类型
- 常规表:默认类型;完全持久化,并记录日志。
- 临时表:限定于会话范围,自动删除,不记录日志。适合临时工作,速度更快。
- 非日志表:持久化,但无法在崩溃后保证安全。写入速度更快;适合缓存或暂存。
行级安全性
使用 ALTER TABLE tbl ENABLE ROW LEVEL SECURITY 启用。创建策略:CREATE POLICY user_access ON orders FOR SELECT TO app_users USING (user_id = current_user_id())。内置基于用户的行级访问控制。
约束
- 主键:隐式包含 UNIQUE + NOT NULL;会创建 B-tree 索引。
- 外键:指定
ON DELETE/UPDATE操作(CASCADE、RESTRICT、SET NULL、SET DEFAULT)。在引用列上显式添加索引,以加快联接并避免删除或更新父记录时出现锁定问题。对于循环外键依赖,使用DEFERRABLE INITIALLY DEFERRED,在事务结束时检查。 - 唯一约束:会创建 B-tree 索引;除非使用
NULLS NOT DISTINCT(PG15+),否则允许多个 NULL。标准行为:允许(1, NULL)和(1, NULL)。使用NULLS NOT DISTINCT时,只允许一个(1, NULL)。除非确实需要重复的 NULL,否则优先使用NULLS NOT DISTINCT。 - 检查约束:行级约束;NULL 值会通过检查(三值逻辑)。示例:
CHECK (price > 0)允许价格为 NULL。结合NOT NULL可强制要求:price NUMERIC NOT NULL CHECK (price > 0)。 - 排除约束:使用运算符防止值重叠。
EXCLUDE USING gist (room_id WITH =, booking_period WITH &&)可防止房间被重复预订。需要合适的索引类型(通常是 GiST)。
索引
- B-tree:等值和范围查询的默认索引(
=、<、>、BETWEEN、ORDER BY) - 复合索引:顺序很重要——如果对最左前缀进行等值查询,就会使用该索引(
WHERE a = ? AND b > ?会使用(a,b)上的索引,但WHERE b = ?不会)。将选择性最高或最常用于筛选的列放在前面。 - 覆盖索引:
CREATE INDEX ON tbl (id) INCLUDE (name, email)- 包含非键列,以便仅通过索引扫描,而无需访问表。 - 部分索引:适用于热点子集(
WHERE status = 'active'→CREATE INDEX ON tbl (user_id) WHERE status = 'active')。任何包含status = 'active'的查询都可以使用此索引。 - 表达式索引:适用于计算后的搜索键(
CREATE INDEX ON tbl (LOWER(email)))。表达式必须与 WHERE 子句完全匹配:WHERE LOWER(email) = '[email protected]'。 - GIN:JSONB 包含/存在性查询、数组(
@>、?)、全文搜索(@@) - GiST:范围、几何数据、排除约束
- BRIN:适用于超大型、自然排序的数据(时间序列),存储开销极小。当磁盘上的行顺序与索引列相关时(按插入顺序排列或执行
CLUSTER后),效果良好。
分区
- 适用于超大表(>1 亿行),且查询始终按分区键(通常是时间/日期)过滤的场景。
- 另一种用途:适用于数据维护任务要求定期清理或批量替换数据的表。
- RANGE:常见于时间序列(
PARTITION BY RANGE (created_at))。创建分区:CREATE TABLE logs_2024_01 PARTITION OF logs FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')。TimescaleDB 通过保留策略和压缩实现基于时间或基于 ID 的分区自动化。 - LIST:用于离散值(
PARTITION BY LIST (region))。示例:FOR VALUES IN ('us-east', 'us-west')。 - HASH:在没有自然键时用于均匀分布(
PARTITION BY HASH (user_id))。按模数创建 N 个分区。 - 约束排除:需要分区上存在
CHECK约束,查询规划器才能进行剪枝。声明式分区(PG10+)会自动创建。 - 优先使用声明式分区或超表。不要使用表继承。
- 限制:没有全局 UNIQUE 约束——需将分区键包含在 PK/UNIQUE 中。不支持来自分区表的外键;请使用触发器。
特殊注意事项
更新密集的表
- 分离热/冷列——将频繁更新的列放入单独的表,以尽量减少膨胀。
- 使用
fillfactor=90为可避免索引维护的 HOT 更新留出空间。 - 避免更新已建索引的列——这会阻止有益的 HOT 更新。
- 按更新模式分区——将频繁更新的行放入与稳定数据不同的分区。
插入密集型工作负载
- 尽量减少索引——只创建查询所需的索引;每个索引都会拖慢插入。
- 使用
COPY或多行INSERT代替单行插入。 - UNLOGGED 表用于可重建的暂存数据——写入快得多。
- 延迟创建索引用于批量加载——删除索引、加载数据、重建索引。
- 按时间/哈希分区以分散负载。TimescaleDB 可自动完成插入密集型数据的分区和压缩。
- 使用自然键作为主键,例如 (timestamp, device_id),如果强制全局唯一性很重要——许多插入密集型表根本不需要主键。
- 如果确实需要代理键,优先使用
BIGINT GENERATED ALWAYS AS IDENTITY而非UUID。
友好的 Upsert 设计
- 冲突目标列上需要 UNIQUE 索引——
ON CONFLICT (col1, col2)需要精确匹配的唯一索引(部分索引无效)。 - 使用
EXCLUDED.column引用本应插入的值;仅更新实际发生变化的列,以减少写入开销。 DO NOTHING更快于DO UPDATE,当不需要实际更新时。
安全的架构演进
- 事务性 DDL:大多数 DDL 操作可在事务中运行并回滚——
BEGIN; ALTER TABLE...; ROLLBACK;可安全测试。 - 并发创建索引:
CREATE INDEX CONCURRENTLY避免阻塞写入,但不能在事务中运行。 - 易变默认值会导致重写:添加带有易变默认值(例如
now()、gen_random_uuid())的NOT NULL列会重写整个表。非易变默认值则很快。 - 先删除约束再删除列:先
ALTER TABLE DROP CONSTRAINT再DROP COLUMN,以避免依赖问题。 - 函数签名变更:使用不同参数执行
CREATE OR REPLACE会创建重载,而非替换。如果不需要重载,请 DROP 旧版本。
生成列
... GENERATED ALWAYS AS (<expr>) STORED用于可计算、可索引的字段。PG18+ 新增VIRTUAL列(读取时计算,不存储)。
扩展
pgcrypto:用于密码哈希的crypt()。uuid-ossp:可替代的 UUID 函数;新项目优先使用pgcrypto。pg_trgm:使用%运算符和similarity()函数进行模糊文本搜索。使用 GIN 建立索引,以加速LIKE '%pattern%'查询。citext:不区分大小写的文本类型。除非需要不区分大小写的约束,否则优先在LOWER(col)上建立表达式索引。btree_gin/btree_gist:支持混合类型索引(例如,在 JSONB 和文本列上建立 GIN 索引)。hstore:键值对;其用途大多已被 JSONB 取代,但适用于简单的字符串映射。timescaledb:时间序列必备工具,支持自动分区、数据保留、压缩和连续聚合。可自托管,也可在 Tiger Cloud 上使用。postgis:提供超越基本几何类型的全面地理空间支持,是基于位置的应用必备工具。pgvector:用于嵌入向量的相似性搜索。pgaudit:记录所有数据库活动的审计日志。
JSONB 指南
- 优先为
JSONB使用 GIN 索引。 - 默认用法:
CREATE INDEX ON tbl USING GIN (jsonb_col);→ 可加速:- 包含
jsonb_col @> '{"k":"v"}' - 键存在性
jsonb_col ? 'k'、任意/所有键?\|、?& - 嵌套文档的路径包含
- 析取查询
jsonb_col @> ANY(ARRAY['{"status":"active"}', '{"status":"pending"}'])
- 包含
- 对于大量使用
@>的工作负载,可考虑使用运算符类jsonb_path_ops,以建立更小、速度更快的仅支持包含查询的索引:CREATE INDEX ON tbl USING GIN (jsonb_col jsonb_path_ops);- 权衡:不再支持键存在性查询(
?、?|、?&),仅支持包含查询(@>)。
- 对特定标量字段进行相等/范围查询:提取该字段并使用 B-tree 建立索引(生成列或表达式索引):
ALTER TABLE tbl ADD COLUMN price INT GENERATED ALWAYS AS ((jsonb_col->>'price')::INT) STORED;CREATE INDEX ON tbl (price);- 优先使用
WHERE price BETWEEN 100 AND 500(使用 B-tree)这类查询,而不是在未建索引时使用WHERE (jsonb_col->>'price')::INT BETWEEN 100 AND 500。
- JSONB 中的数组:使用 GIN +
@>进行包含查询(例如,查询标签)。如果只进行包含查询,可考虑使用jsonb_path_ops。 - 核心关系应存放在表中;可选或可变属性则使用 JSONB。
- 使用约束限制列中允许的 JSONB 值,例如
config JSONB NOT NULL CHECK(jsonb_typeof(config) = 'object')
示例
用户
CREATE TABLE users (
user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON users (LOWER(email));
CREATE INDEX ON users (created_at);
订单
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','PAID','CANCELED')),
total NUMERIC(10,2) NOT NULL CHECK (total > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON orders (user_id);
CREATE INDEX ON orders (created_at);
JSONB
CREATE TABLE profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(user_id),
attrs JSONB NOT NULL DEFAULT '{}',
theme TEXT GENERATED ALWAYS AS (attrs->>'theme') STORED
);
CREATE INDEX profiles_attrs_gin ON profiles USING GIN (attrs);
评论
支持 Markdown