约束
约束(constraint)是数据库对数据必须满足的规则。它不依赖某个应用是否正确执行校验,因此能统一保护来自接口、脚本、导入任务和管理工具的所有写入。
常用约束包括 NOT NULL、CHECK、UNIQUE、PRIMARY KEY 和 FOREIGN KEY。PostgreSQL 还提供排斥约束(exclusion constraint),用于表达“时间段不得重叠”等普通唯一约束难以描述的规则。
本文先讲通用 SQL 约束,再单独标注 PostgreSQL 扩展。
为什么需要数据库约束
应用校验和数据库约束并不冲突:
- 应用校验负责尽早给用户友好的提示;
- 数据库约束负责在最终写入点保证数据一定合法。
只做应用校验会留下并发竞态。例如两个请求都先查询“邮箱不存在”,然后同时插入相同邮箱。只有数据库唯一约束能在并发写入的最终时刻可靠裁决。
命名约束
可以让数据库自动命名,也可以显式命名:
CREATE TABLE products (
product_id BIGINT GENERATED ALWAYS AS IDENTITY,
sku TEXT NOT NULL,
price NUMERIC(12, 2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
CONSTRAINT pk_products PRIMARY KEY (product_id),
CONSTRAINT uq_products_sku UNIQUE (sku),
CONSTRAINT ck_products_price CHECK (price >= 0),
CONSTRAINT ck_products_stock CHECK (stock >= 0)
);
明确名称便于从错误信息定位规则,也便于迁移脚本执行 DROP CONSTRAINT。常见前缀有 pk_、uq_、fk_、ck_ 和 ex_,但最重要的是项目内保持一致。
NOT NULL 约束
NOT NULL 要求列必须有非空值:
email TEXT NOT NULL
它不会拒绝空字符串、数字 0 或只有空格的文本,因为这些都不是 SQL NULL。如果还需禁止空文本,可组合检查约束:
email TEXT NOT NULL,
CONSTRAINT ck_users_email_not_blank
CHECK (BTRIM(email) <> '')
为已有列增加非空限制前,应先查找并处理空值:
SELECT COUNT(*)
FROM users
WHERE email IS NULL;
CHECK 约束
CHECK 要求每行的表达式结果为 TRUE 或 NULL,只有 FALSE 会被拒绝:
CREATE TABLE events (
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
CONSTRAINT ck_events_time_order
CHECK (ends_at IS NULL OR ends_at > starts_at)
);
这意味着仅写 CHECK (price > 0) 不能替代 NOT NULL,因为 price = NULL 时表达式结果是未知值。必填列应显式加 NOT NULL。
检查约束适合:
- 数值范围;
- 状态集合;
- 同一行多列间的关系;
- 文本的简单格式与长度规则。
普通 CHECK 不适合依赖其他行或其他表。数据库无法简单保证被引用数据后来变化时仍满足该表达式。跨表引用使用外键;跨行唯一性使用唯一约束;复杂跨行规则则考虑排斥约束、事务锁或触发器。
UNIQUE 约束
唯一约束保证一列或一组列不出现重复组合:
CONSTRAINT uq_users_email UNIQUE (email)
复合唯一约束:
CONSTRAINT uq_members_tenant_email
UNIQUE (tenant_id, email)
这表示同一租户内邮箱唯一,不要求不同租户之间唯一。
NULL 的唯一性
SQL 中 NULL 表示未知,许多数据库默认允许唯一列出现多个 NULL。PostgreSQL 支持显式把空值视为相同:
CONSTRAINT uq_profiles_phone
UNIQUE NULLS NOT DISTINCT (phone)
这样最多允许一个 NULL。该语法具有数据库与版本差异,跨数据库设计时应测试实际行为。
条件唯一性
“仅未删除记录的邮箱唯一”不能用普通唯一约束直接表达。PostgreSQL 可使用部分唯一索引:
CREATE UNIQUE INDEX uq_users_active_email
ON users (email)
WHERE deleted_at IS NULL;
它提供唯一性保证,但在元数据和外键引用方面不完全等同于标准唯一约束。
PRIMARY KEY 约束
主键唯一标识表中的每一行,相当于“唯一且非空”的候选键被选为主要标识:
CONSTRAINT pk_orders PRIMARY KEY (order_id)
一张表只能有一个主键,但可以是复合主键:
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
CONSTRAINT pk_order_items
PRIMARY KEY (order_id, product_id)
);
主键应稳定、紧凑、不会频繁变化。标识列常用作技术主键,但标识列本身并不会自动建立主键约束。
FOREIGN KEY 约束
外键保证子表中的引用值能在父表的主键或唯一键中找到:
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (order_id),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users (user_id)
);
引用列与目标列的数据类型应兼容。外键通常不会自动为子表引用列创建索引;如果经常按 user_id 查询或删除父记录,应根据查询模式建立索引:
CREATE INDEX idx_orders_user_id ON orders (user_id);
更新和删除动作
FOREIGN KEY (user_id) REFERENCES users (user_id)
ON UPDATE CASCADE
ON DELETE RESTRICT
| 动作 | 含义 |
|---|---|
NO ACTION | 若检查时仍有引用则拒绝,部分数据库可延迟检查 |
RESTRICT | 立即阻止会破坏引用的操作,细节因数据库而异 |
CASCADE | 把父键更新或父行删除传播到子表 |
SET NULL | 把子表引用列设为 NULL,该列必须允许空值 |
SET DEFAULT | 把子表引用列设为默认值,最终值仍必须满足外键 |
级联动作应匹配真实的数据生命周期。订单删除时是否应该删除订单明细,和用户删除时是否应该删除历史订单,是完全不同的业务决策。
复合外键与 MATCH
复合外键要求列按顺序对应:
FOREIGN KEY (tenant_id, user_id)
REFERENCES users (tenant_id, user_id)
默认的 MATCH SIMPLE 在任一引用列为 NULL 时通常不检查该行。MATCH FULL 则要求复合引用要么全部为空,要么全部非空并匹配,能避免“只填了一半”的引用。
排斥约束
排斥约束是 PostgreSQL 特性,它要求任意两行不能让指定比较关系同时成立。典型场景是同一会议室的预订时间不能重叠:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
room_id BIGINT NOT NULL,
during TSTZRANGE NOT NULL,
CONSTRAINT ex_room_bookings_no_overlap
EXCLUDE USING gist (
room_id WITH =,
during WITH &&
)
);
这里 = 表示同一房间,&& 表示时间范围重叠;约束禁止两个条件同时成立。它比“先查询是否冲突、再插入”更能抵抗并发竞态。
立即检查与延迟检查
PostgreSQL 中,部分 UNIQUE、PRIMARY KEY、FOREIGN KEY 和排斥约束可以声明为可延迟:
CONSTRAINT fk_nodes_parent
FOREIGN KEY (parent_id)
REFERENCES nodes (node_id)
DEFERRABLE INITIALLY DEFERRED
立即约束通常在每条语句结束前检查;延迟约束可以推迟到事务提交时检查。也可在事务中调整:
SET CONSTRAINTS fk_nodes_parent DEFERRED;
延迟约束适合循环引用、批量重排唯一值等少数场景,但会让错误更晚出现。不要把它当作逃避正确写入顺序的默认方案。
NOT NULL 和普通 CHECK 不能用这种方式延迟。
给已有大表添加约束
新增约束时,数据库通常要验证所有历史行。PostgreSQL 可先添加未验证的 CHECK 或外键,让新写入先遵守规则:
ALTER TABLE orders
ADD CONSTRAINT ck_orders_total
CHECK (total_amount >= 0) NOT VALID;
清理历史数据后再验证:
ALTER TABLE orders
VALIDATE CONSTRAINT ck_orders_total;
NOT VALID 不代表约束永久不可信,也不应被当作跳过历史脏数据的终点。验证阶段仍会扫描数据,但锁影响可能比直接添加约束更容易安排。具体锁模式应以目标版本文档和压测结果为准。
修改与删除约束
添加约束:
ALTER TABLE products
ADD CONSTRAINT ck_products_stock CHECK (stock >= 0);
删除约束:
ALTER TABLE products
DROP CONSTRAINT ck_products_stock;
修改复杂约束通常采用“新增新约束、验证、切换、删除旧约束”的迁移方式。直接删除保护规则会形成数据违规窗口,应结合事务与锁策略设计。
约束与索引
PostgreSQL 通常会为主键和唯一约束自动创建唯一索引,但不会为外键的引用列自动创建索引。不要再为完全相同的主键或唯一列重复建索引。
索引与约束的职责不同:
- 约束表达数据规则;
- 索引主要提供访问路径,也可作为某些约束的实现基础;
- 普通索引不保证唯一性;
- 表达式索引、部分索引具有更灵活的条件,但不是所有工具都把它们视为约束。
常见误区
CHECK 为未知值时会失败
不会。表达式为 NULL 通常通过,因此必填列还要加 NOT NULL。
外键会自动为子表列建索引
PostgreSQL 不会自动创建。缺少适当索引可能让父表删除或更新时扫描大量子表数据。
唯一约束能保证“先查再写”安全
真正保证安全的是直接尝试写入并处理唯一冲突。应用层预查只能改善提示,不能消除并发竞态。
约束越多越好
约束应表达数据库能够长期、可靠判断的规则。频繁变化、依赖外部系统或需要复杂上下文的规则不适合生硬塞进表约束。
设计检查清单
- 必填列是否使用
NOT NULL,而不是只依赖应用校验; - 业务唯一性是否由
UNIQUE或适当的唯一索引保证; - 每张表是否有稳定主键;
- 跨表引用是否由外键保护,删除动作是否符合生命周期;
CHECK是否只依赖当前行,并正确处理NULL;- 复合键的列顺序和空值语义是否明确;
- 外键列是否需要索引;
- 约束是否有稳定、易定位的名称;
- 大表加约束是否规划了清理、验证、锁和回滚;
- 数据库方言与版本是否支持所用扩展语法。
约束是数据模型的可执行说明书。把稳定规则写成约束,既能防止错误数据,也能让维护者从表结构直接理解系统真正承诺了什么。