跳到主要内容

约束

约束(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 不会自动创建。缺少适当索引可能让父表删除或更新时扫描大量子表数据。

唯一约束能保证“先查再写”安全​

真正保证安全的是直接尝试写入并处理唯一冲突。应用层预查只能改善提示,不能消除并发竞态。

约束越多越好​

约束应表达数据库能够长期、可靠判断的规则。频繁变化、依赖外部系统或需要复杂上下文的规则不适合生硬塞进表约束。

设计检查清单​

  1. 必填列是否使用 NOT NULL,而不是只依赖应用校验;
  2. 业务唯一性是否由 UNIQUE 或适当的唯一索引保证;
  3. 每张表是否有稳定主键;
  4. 跨表引用是否由外键保护,删除动作是否符合生命周期;
  5. CHECK 是否只依赖当前行,并正确处理 NULL;
  6. 复合键的列顺序和空值语义是否明确;
  7. 外键列是否需要索引;
  8. 约束是否有稳定、易定位的名称;
  9. 大表加约束是否规划了清理、验证、锁和回滚;
  10. 数据库方言与版本是否支持所用扩展语法。

约束是数据模型的可执行说明书。把稳定规则写成约束,既能防止错误数据,也能让维护者从表结构直接理解系统真正承诺了什么。