大多数关系型 schema 的错误,归根结底都是同一个问题:将本应由数据库保证的规则交给应用程序来执行。那个”在服务层处理”的外键、那个在 INSERT 之前用 SELECT 检查的唯一性规则、那个由 ORM 验证但列本身未加约束的状态字段——每一个都是你从专为执行约束而生的引擎中移走、放入只有在你记得调用时才会运行的代码里的约束。结果就是 schema 本不应允许的数据:孤立行、重复账户、负数价格、无人能解读的时间戳。本文将逐一梳理应用开发者在 Postgres 和 MySQL 上反复犯下的设计错误,分析每个错误的危害,并给出修复方案——每个问题都附有简短的前后对比代码片段。示例在关键处使用 Postgres 18 的现代语法,但其原则适用于任何关系型引擎。
核心要点
- 在数据库层面使用
FOREIGN KEY、NOT NULL、CHECK和UNIQUE约束来保障数据完整性;仅在应用层做校验只有在代码记得调用时才会生效。一个允许你声明关联关系却不生成数据库级外键的 ORM,不过是把完整性缺陷推迟到了生产环境。 - 使用代理主键(
bigint自增列或uuidv7()UUID),并在自然键上单独添加UNIQUE约束,这样品牌更名或邮箱变更只需一个UPDATE,而不是一次数据库迁移。 - 自 PostgreSQL 18(2025 年 9 月 25 日发布)起,你无需再安装扩展来生成时间有序的标识符:
id uuid PRIMARY KEY DEFAULT uuidv7()在保持全局唯一性的同时,索引局部性接近bigint。 - 将时间戳存储为
timestamptz而非timestamp,并为实际参与 JOIN 的外键列和过滤列建立索引——Postgres 不会自动为外键的引用侧建立索引。 - 将 schema 视为活的代码:通过迁移进行版本管理,在其他行引用某条记录时优先使用软删除(
deleted_at timestamptz),并使用COMMENT ON COLUMN为列添加文档说明。
大多数关系型数据库设计错误的根源:规则写在应用代码里,而不是 schema 里
影响最大的错误,是只在应用代码中执行数据规则。服务层的约束只能保护一条代码路径;schema 中的约束则能保护所有路径——你的 API、后台任务、手动执行的 psql 会话、出错的数据迁移,以及下一个对同一数据库编写服务的人。当规则只存在于应用层时,第一个绕过它的写入操作就会永久损坏数据表。
这正是现代 ORM 的陷阱。Prisma 和 Drizzle 等工具可以在应用代码中建模关联关系,却从不生成数据库级外键,而 @default 或 Zod schema 看起来像是在做校验,实则不然。一个允许你声明关联关系却不生成数据库级外键的 ORM,并不是在为你省事,而是把数据完整性缺陷推迟到了生产环境——在那里,最终发现问题的不是你的测试套件,而是会话回放工具,因为失败不会以堆栈跟踪的形式出现。损坏的数据会以另一种方式浮出水面:一条归属于已不存在用户的评论,或者一个重复的订单——用户实际看到的是一个令人困惑的界面。
解决方案是将规则下沉到无法被跳过的层面。例如,PostgreSQL 的检查约束无论哪个客户端写入数据都会拒绝不合规的内容:要求商品价格为正数,可以在表定义中使用 CHECK (price > 0) 约束。
-- Before: nothing stops a negative payment or a NULL email
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint, -- no FK: orphans waiting to happen
amount numeric, -- no CHECK: negatives allowed
email text -- no NOT NULL, no UNIQUE
);
-- After: the engine guarantees the invariants
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
amount numeric NOT NULL CHECK (amount > 0),
email text NOT NULL UNIQUE
);
外键不是性能税——它是防止孤立行的唯一屏障,而孤立行会以”已删除用户的评论”这种形式呈现出来。自 Postgres 18 起,你还可以对繁忙的表分阶段添加约束:ALTER TABLE 现在支持为 NOT NULL 约束设置 NOT VALID 属性,让你无需立即进行全表扫描即可添加约束,之后再在较弱的锁下完成验证。
Discover how at OpenReplay.com.
跳过规范化——或过度规范化
规范化的核心是:每个事实只存储一次。第一范式(1NF)要求没有重复组和多值字段;第三范式(3NF)要求每个非键列只依赖于主键,而不依赖于其他任何东西。对于应用 schema 而言,3NF 是合理的默认选择——具体机制可参考 Postgres 数据定义文档。两种典型的违规形式是:在一列中存储逗号分隔的值,以及使用重复列(如 payment_1 … payment_12)。
在单列中存储逗号分隔的值是一种你会在第一次写出 WHERE … LIKE '%,42,%' 时就后悔的反规范化做法;正确的做法是使用子表或关联表,而不是更复杂的字符串查询。
-- Before: tags as a delimited string, phone numbers as columns
CREATE TABLE users (
id bigint PRIMARY KEY,
tags text, -- 'admin,beta,vip' — unqueryable, unconstrained
phone1 text, phone2 text, phone3 text -- repeating group, runs out
);
-- After: a junction table for tags, a child table for phones
CREATE TABLE user_tags (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
tag text NOT NULL,
PRIMARY KEY (user_id, tag)
);
CREATE TABLE user_phones (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
phone text NOT NULL
);
反向的错误是过度规范化:将一个 address 字段拆分成六张需要 JOIN 的表,或者毫无理由地将一对一关系建模为两张表。每增加一次 JOIN,就增加了查询成本和认知负担。规范化到每个事实只存储一次即可,然后停下来。
将业务字段用作主键
不要将 email、username 或 company_name 等业务字段用作主键;应使用代理键,并在自然值上单独添加 UNIQUE 约束,这样品牌更名或地址变更只需一个 UPDATE,而不是一次迁移。业务值会发生变化,而当主键变化时,所有引用它的外键都必须级联更新——一个本不应作为标识符的值,就此演变成一次全 schema 范围的迁移。
2007 年那套”避免代理键”的建议对应用开发者来说早已过时。现代的默认做法是:代理主键加上对自然键的真实 UNIQUE 约束——既获得稳定的标识符,又保留了唯一性保证。
CREATE TABLE companies (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- stable surrogate identity
name text NOT NULL UNIQUE -- natural key still enforced
);
UUID 与 bigint 的选择
自 Postgres 18 起,你无需再安装扩展来生成时间有序的标识符:Postgres 18 内置了 uuidv7() UUID 生成函数,其值按时间排序,同时还提供了用于显式生成版本 4 UUID 的 uuidv4() 别名。UUIDv7——定义于 RFC 9562(2024 年 5 月,取代了 RFC 4122)——在值中嵌入了时间戳前缀,因此新行会追加到索引的右侧,而不像随机的 v4 UUID 那样分散插入。需要注意的是,对于较旧版本的 Postgres,gen_random_uuid()(v4)自版本 13 起已内置于核心,不再需要 pgcrypto 扩展。
| 键类型 | 存储空间 | 索引局部性 | 全局唯一 | 备注 |
|---|---|---|---|---|
bigint 自增 | 8 字节 | 极佳(顺序写入) | 否 | 最小、最快;会泄露行数;需要集中分配 |
uuidv4() | 16 字节 | 差(随机插入) | 是 | 适合分布式场景;高写入量下索引碎片化 |
uuidv7() | 16 字节 | 良好(时间有序) | 是 | 按时间排序;会泄露创建时间;PG18 的强推默认选项 |
当 ID 只在单一数据库内使用时,默认选择 bigint;当需要在客户端或跨服务生成 ID 时,选择 uuidv7()。
没有明确的索引策略
为实际用于过滤和 JOIN 的列建立索引——尤其是外键列,Postgres 不会自动为其建立索引——但不要对每一列都建索引,因为每个索引都会在每次插入和更新时带来写放大。这是应用 schema 中最常见的性能退化问题,而且在两个方向上都很容易犯错。
这个陷阱是 Postgres 特有的:它会自动为外键的被引用(父)侧建立索引,但不会为引用(子)列建立索引。由于为引用列建索引并非总是必要,且索引方式有很多种,声明外键约束并不会自动在引用列上创建索引。(MySQL 的 InnoDB 会自动创建——所以这个坑是 Postgres 特有的。)
-- payments.user_id is a FK but unindexed: every join and every
-- DELETE on users does a sequential scan of payments
CREATE INDEX ON payments (user_id);
为外键和过滤列建立索引;跳过低选择性列以及很少按该字段查询的表上的索引。
一张表承担过多职责
将一张表强行用于建模多个领域——通用的实体-属性-值(EAV)表,或者一张同时存放告警、审计事件和系统日志的 notifications 表——是用 schema 的清晰度换取一种虚假的灵活性。你会失去类型安全,无法应用有意义的约束,每次读取都变成一场带过滤条件的自连接噩梦。应为每个领域使用独立的表,让每张表拥有自己的列、类型和外键。
-- Before: one bag of everything, typed as text, no real constraints
CREATE TABLE entity_attributes (
entity_id bigint, attr_name text, attr_value text
);
-- After: real tables with real columns and constraints
CREATE TABLE user_alerts (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
level text NOT NULL, body text);
CREATE TABLE audit_events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id bigint REFERENCES users(id),
action text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now());
如果你确实需要灵活的属性,可以在真实表上使用有类型的 jsonb 列——而不是使用字符串类型的 EAV,那会让引擎提供的所有约束都形同虚设。
命名不一致
选择一套命名规范并贯彻始终。对于 Postgres 和大多数 ORM 而言,这意味着:
- 使用
snake_case(未加引号的标识符会折叠为小写); - 表名统一使用复数或单数,一经选定不再变更;
- 时间戳字段使用
created_at/updated_at; - 名称中不包含元数据(如
tbl_users、col_varchar_address); - 不使用空格或引号;以及
- 不将
user、order、group等保留字用作裸标识符。
命名是在建表时最容易做对的事,也是一旦数据和查询依赖于它之后最难更改的事。
将 schema 视为一成不变的产物
schema 是活的代码,而不是一次性的产物。通过版本化、经过审查、可回滚的迁移来管理每一次变更——使用你的 ORM 迁移工具(Prisma Migrate、Drizzle Kit)或独立的迁移运行器——以确保 schema 的历史在各环境中可复现。
历史记录也决定了你应该如何删除数据。当其他行引用某条记录时,优先使用软删除(deleted_at timestamptz)而非硬 DELETE,因为硬删除要么会产生孤立的子行,要么通过 ON DELETE CASCADE 悄无声息地抹去你日后可能被要求提供的历史记录。
ALTER TABLE orders ADD COLUMN deleted_at timestamptz;
-- "Active orders" becomes a filter, not a destructive operation
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL;
存储不带时区的日期时间
将时间戳存储为 timestamptz,而不是 timestamp——不带时区的列会静默地记录服务器当时认为的”现在”,而一旦你的服务器跨越多个地区,这种歧义就变得无法挽回。timestamptz 存储的是一个绝对时间点(归一化为 UTC),并在会话的时区下呈现;timestamp 存储的是没有锚点的挂钟时间。详见 Postgres 日期/时间文档。对于日程和预订场景,Postgres 18 还新增了时态约束:支持通过 WITHOUT OVERLAPS 和 PERIOD 指定不重叠的 PRIMARY KEY、UNIQUE 和外键约束。(时态外键不支持 ON DELETE/ON UPDATE 级联操作,因此不要在此依赖级联。)
没有文档或 schema 测试
没有文档、没有测试的 schema 是一个会悄无声息地累积的错误。魔法值是典型症状:status_code 的值为 1 到 3,某天有人添加了 4 就全乱了,而没有任何记录说明每个数字的含义。具体的补救措施如下:
- 使用
COMMENT ON COLUMN在数据库本身中记录意图; - 在代码仓库中维护一份 ER 图;以及
- 像测试代码一样测试迁移——执行迁移、回滚,并针对边界情况和无效输入进行断言。
COMMENT ON COLUMN orders.status IS
'enum: pending|paid|shipped|cancelled — see app/orders/status.ts';
CHECK (status IN (...)) 约束加上注释,能将一个脆弱的整数字段变成自描述、自执行的列。
结语
本文中的每一个错误,最终都归结为同一个修复方案:让数据库——而不是应用程序,也不是 ORM——成为数据有效性的权威。在编写假设这些约束存在的查询之前,先添加外键、NOT NULL、CHECK 和 UNIQUE 约束。从你写入最频繁的表开始:列出你目前在代码中执行的所有不变量,然后将每一条都移入 schema,让它们无法被绕过。
常见问题
什么时候应该使用 UUID 主键而不是 bigint 自增列?
当 ID 在单一数据库内生成且无需由客户端创建时,使用 bigint 自增列——它只占 8 字节,顺序写入索引,是最快的选项。当需要在客户端或跨多个服务生成 ID 并要求全局唯一性时,使用 UUID,在 Postgres 18 中具体使用 uuidv7()。UUIDv7 因为嵌入了时间戳前缀,索引局部性接近顺序写入,不像随机的 UUIDv4 那样分散。
在 ORM 中声明外键,是否也会在数据库层面创建约束?
不一定。Prisma 和 Drizzle 等工具可以在应用代码中建模关联关系,却不生成数据库级 FOREIGN KEY 约束,因此这段关联关系只存在于 ORM 的理解中,而不存在于数据库引擎里。没有数据库级约束,后台任务、手动查询或其他服务都可以创建 ORM 从未预料到的孤立行。请确认你的迁移实际生成了 REFERENCES 子句,而不是仅凭关联声明就信以为真。
PostgreSQL 中 timestamp 和 timestamptz 有什么区别?
timestamptz 存储归一化为 UTC 的绝对时间点,并在会话时区下呈现;而 timestamp 存储没有时区锚点的挂钟时间。普通的 timestamp 会静默记录服务器认为的本地时间,一旦服务器跨越多个地区,这种歧义就变得无法挽回。应用程序的日期时间字段请使用 timestamptz,这样无论在哪里读取,一个值始终指向同一个明确的时刻。
在 Postgres 中生成 UUID 还需要安装 pgcrypto 或 uuid-ossp 扩展吗?
不需要。生成版本 4 UUID 的 gen_random_uuid() 函数自 PostgreSQL 13 起已内置于核心,无需安装任何扩展。PG18 的 pgcrypto 文档现已将其自身的 gen_random_uuid() 标记为废弃,因为它只是调用了核心函数。自 Postgres 18 起,你还可以直接使用内置的 uuidv7() 生成时间有序的标识符,以及 uuidv4() 别名,无需安装任何扩展。