NOT NULL / CHECK / DEFAULT
NOT NULL、CHECK、DEFAULT 是三种「不建索引」的列级约束:它们不帮你加速查询,但决定了数据能不能被写进来。本节讲清 NOT NULL 与 CHECK 的语义等价与性能差异、CHECK 里 NULL 放行的关键陷阱、NOT VALID 在线加约束的技巧,以及 DEFAULT 的求值时机与 PostgreSQL 11 之后加列不再重写全表的优化。
前置知识
阅读本节前,建议先了解:UNIQUE 唯一约束
基础概念
这三种约束的共同点是:只在写入/更新时校验,不产生任何索引。这也是它们与 UNIQUE / PRIMARY KEY 最本质的区别——后两者必须维护一个 B-tree 索引,而它们只需要存一段元数据。
| 概念 | 术语 | 说明 |
|---|---|---|
| 非空约束 | NOT NULL | 列值不得为 NULL,是元数据级检查 |
| 非空(无约束条目) | NOT NULL(无 pg_constraint 行) | 18 之前只存在 pg_attribute.attnotnull |
| 检查约束 | CHECK | 每行必须让布尔表达式为真 |
| 默认值 | DEFAULT | 插入未提供该列时的填充表达式 |
| 未校验约束 | NOT VALID | 只对新数据生效,跳过历史数据校验 |
| 校验约束 | VALIDATE CONSTRAINT | 补做历史数据校验,不阻塞读写 |
| 三值逻辑 | Three-Valued Logic | 布尔结果可为 TRUE / FALSE / UNKNOWN |
| 不继承检查 | NO INHERIT | 子表不继承父表的该检查约束 |
三者在系统表里的可见性不同:
| 约束 | 是否出现在 pg_constraint | contype | 是否可 NOT VALID | 是否可 DEFERRABLE |
|---|---|---|---|---|
NOT NULL | PostgreSQL 18 起有记录 | n | 否 | 否 |
CHECK | 是(列级/表级都会生成) | c | ✅ 是 | ❌ 否 |
DEFAULT | 否(不是约束) | — | — | — |
DEFAULT 严格来说不是约束,它只存在于 pg_attribute.adsrc(由 pg_get_expr(adbin, adrelid) 还原)里,在 pg_constraint 中完全查不到。
NOT NULL / CHECK → pg_constraint 中有记录(NOT NULL 需 PostgreSQL 18+)
DEFAULT → 只在 pg_attrdef(pg_attribute 侧)中,不属于约束语法与用法
-- 列约束写法
column_name data_type [ CONSTRAINT constraint_name ]
{ NOT NULL [ NO INHERIT ]
| NULL
| DEFAULT default_expr
| CHECK ( expression ) [ NO INHERIT ] }
-- 表约束写法(CHECK 可以引用多列,NOT NULL/DEFAULT 不行)
[ CONSTRAINT constraint_name ] CHECK ( expression ) [ NO INHERIT ]
-- 已有表上增删
ALTER TABLE table_name ALTER COLUMN column_name { SET | DROP } NOT NULL;
ALTER TABLE table_name ALTER COLUMN column_name { SET | DROP } DEFAULT expression;
ALTER TABLE table_name ADD [ CONSTRAINT constraint_name ] CHECK ( expression ) [ NOT VALID ];
ALTER TABLE table_name VALIDATE CONSTRAINT constraint_name;
ALTER TABLE table_name DROP CONSTRAINT constraint_name [ CASCADE ];最简可运行示例:
-- 三种约束一次写全
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL, -- 非空
nickname text NOT NULL DEFAULT '', -- 非空 + 默认值
age integer CHECK (age >= 0 AND age <= 200),-- 检查(跨列亦可)
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
-- 表约束:跨列检查
CONSTRAINT ck_users_age_lt_200 CHECK (age IS NULL OR age < 200)
);CREATE TABLE详细说明
NOT NULL 与 CHECK (col IS NOT NULL) 的语义等价
从「拦不拦得住 NULL」这个角度看,两者完全等价:
CREATE TABLE t_nn_a (
id bigint PRIMARY KEY,
v text NOT NULL -- 写法 A:内置约束
);
CREATE TABLE t_nn_b (
id bigint PRIMARY KEY,
v text CHECK (v IS NOT NULL) -- 写法 B:CHECK 表达同样语义
);两种写法对 NULL 的拒绝行为一致:
-- 两种表都会报错,错误信息略有不同
INSERT INTO t_nn_a (id, v) VALUES (1, NULL);ERROR: null value in column "v" of relation "t_nn_a" violates not-null constraint
DETAIL: Failing row contains (1, null).INSERT INTO t_nn_b (id, v) VALUES (1, NULL);ERROR: new row for relation "t_nn_b" violates check constraint "t_nn_b_v_check"
DETAIL: Failing row contains (1, null).真正的差别在于代价与能力:
| 对比项 | NOT NULL | CHECK (col IS NOT NULL) |
|---|---|---|
| 检查方式 | 元数据标记,写入时直接看 attnotnull | 每行求值一个布尔表达式 |
| CPU 开销 | 极低 | 每次写入都要执行表达式 |
| 优化器利用 | 能直接推断「该列无 NULL」,可做外连接消除等改写 | 不能用于优化器推断 |
能否被 DEFERRABLE | 否 | 否 |
能否 NOT VALID | 否 | ✅ 是 |
NOT NULL 列与 CHECK 并存 | 建议只留 NOT NULL | — |
优化器推断这一条很实际:只有 NOT NULL 能让优化器确定「这一列一定不为空」,进而把 LEFT JOIN ... WHERE t.col IS NULL 改写成反连接短路。用 CHECK 写的等价约束起不到这个作用。
从 PostgreSQL 18 起,NOT NULL 在 pg_constraint 里也有了独立记录(contype = 'n'),这解决了老版本里「非空约束在约束巡检中隐身」的问题:
-- PostgreSQL 18+
SELECT conname, contype, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conrelid = 'users'::regclass
ORDER BY contype; conname | contype | def
-----------------+---------+--------------------
users_pkey | p | PRIMARY KEY (id)
users_email_not_null | n | NOT NULL email
ck_users_age_lt_200 | c | CHECK ((age IS NULL) OR (age < 200))
(3 rows)在 18 之前,非空信息只能从 pg_attribute 侧读:
-- 兼容所有版本:从 pg_attribute 读非空标记
SELECT attname, attnotnull
FROM pg_attribute
WHERE attrelid = 'users'::regclass AND attnum > 0 AND NOT attisdropped
ORDER BY attnum; attname | attnotnull
------------+------------
id | t
email | t
nickname | t
age | f
status | t
created_at | t
(6 rows)NOT NULL 为什么比 CHECK 更高效
NOT NULL 的开销几乎为零:它是元数据层面的标记。写入一行时,执行器只看一下 attnotnull 标志与传入值是否为 NULL,不需要构造表达式、不需要函数调用、不需要类型转换。
-- 用 EXPLAIN 观察:NOT NULL 不会出现在 Filter 里,也不会产生额外表达式求值
EXPLAIN (ANALYZE, COSTS OFF)
INSERT INTO t_nn_a (id, v) VALUES (1, 'x'); QUERY PLAN
----------------------------------------------
Insert on t_nn_a (actual time=0.021 rows=0)
Buffers: shared hit=2
Planning Time: 0.028 ms
Execution Time: 0.045 msCHECK (v IS NOT NULL) 每次插入都要走一遍表达式求值器(ExecEvalExpr),在高频写入场景下,几十万 TPS 的差距往往就来自这类「看起来无所谓」的表达式。
因此实践结论只有一条:要表达非空就用 NOT NULL,不要用 CHECK (col IS NOT NULL) 代替。只有当需要在非空之外再加一层业务条件、或者需要 NOT VALID 能力时,才用 CHECK。
CHECK 的语义:TRUE 通过,FALSE 拒绝,UNKNOWN 放行(关键陷阱)
这是 CHECK 最容易踩的坑。标准规定:CHECK 约束在表达式结果为 FALSE 时违反,结果为 TRUE 或 NULL(UNKNOWN)时通过。
CREATE TABLE accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint,
age integer,
balance numeric(12,2) CHECK (balance >= 0),
CONSTRAINT ck_accounts_age CHECK (age > 0) -- 看起来要求 age 为正数
);直觉上 CHECK (age > 0) 应该拒绝 age 为空的行,实际不会:
-- 成功!age IS NULL 时 age > 0 的结果是 UNKNOWN,而非 FALSE
INSERT INTO accounts (user_id, age, balance) VALUES (1, NULL, 100.00);INSERT 0 1-- 而负数确实被拦住
INSERT INTO accounts (user_id, age, balance) VALUES (1, -5, 100.00);ERROR: new row for relation "accounts" violates check constraint "ck_accounts_age"
DETAIL: Failing row contains (2, 1, -5, 100.00).同理,balance >= 0 也无法拦住 balance 为 NULL 的行:
-- 成功!balance IS NULL → UNKNOWN → 放行
INSERT INTO accounts (user_id, age, balance) VALUES (1, 20, NULL);INSERT 0 1如果「必须为正数」是业务硬要求,就必须把非空与检查一起写:
CREATE TABLE accounts_strict (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
age integer NOT NULL CHECK (age > 0), -- 先非空,再检查
balance numeric(12,2) NOT NULL CHECK (balance >= 0)
);-- 现在 NULL 会被 NOT NULL 拦下
INSERT INTO accounts_strict (user_id, age, balance) VALUES (1, NULL, 100.00);ERROR: null value in column "age" of relation "accounts_strict" violates not-null constraint
DETAIL: Failing row contains (2, 1, null, 100.00).也可以把非空条件写进同一个 CHECK(用 IS NOT NULL AND),但推荐拆开写:NOT NULL 更高效,报错信息也更明确。
-- 可行但不推荐:把非空塞进 CHECK
CONSTRAINT ck_age CHECK (age IS NOT NULL AND age > 0)
-- 推荐:职责分离
age integer NOT NULL CHECK (age > 0)CHECK 的三条硬性限制
CHECK 表达式的能力边界必须记牢,否则会遇到一堆「语法上没问题、运行时被判非法」的困惑。
限制一:不能包含子查询(Subquery)
-- 错误!CHECK 中不允许子查询
CREATE TABLE bad_check_sub (
id bigint PRIMARY KEY,
user_id bigint,
CONSTRAINT ck_bad_sub CHECK (user_id IN (SELECT id FROM users))
);ERROR: cannot use subquery in check constraint限制二:不能使用非 IMMUTABLE 函数
约束在行写入时求值,如果表达式结果会随时间或环境变化,约束就失去了确定含义。
-- 错误!now() 是 STABLE,不是 IMMUTABLE
CREATE TABLE bad_check_now (
id bigint PRIMARY KEY,
created_at timestamptz,
CONSTRAINT ck_bad_now CHECK (created_at <= now())
);ERROR: functions in check constraint must be marked IMMUTABLE限制三:不能引用其他表的列、也不能引用同一表/同一行的其他行
-- 错误!跨表引用
CREATE TABLE bad_check_cross (
id bigint PRIMARY KEY,
user_id bigint,
CONSTRAINT ck_bad_cross CHECK (user_id = users.id)
);ERROR: cross-database references are not implemented: "demo.users.id"CHECK 只能引用同一行内的列,而且必须是所在表的列。这条限制划定了 CHECK 的适用面:
| 想表达的需求 | CHECK 能做吗 | 正确方案 |
|---|---|---|
值域范围(age > 0) | ✅ | CHECK |
枚举取值(status IN (...)) | ✅ | CHECK |
跨列比较(end_at > start_at) | ✅ | CHECK |
| 引用其他表的值 | ❌ | 外键 FOREIGN KEY |
| 引用同一表其他行 | ❌ | EXCLUDE 排他约束或触发器 |
| 依赖当前时间 | ❌ | 触发器 / 应用层 |
跨列检查是允许的,这常被误以为不行:
CREATE TABLE reservations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
CONSTRAINT ck_period_ok CHECK (ends_at > starts_at) -- 合法:同一行的两列
);-- 时间倒置会被拦下
INSERT INTO reservations (room_id, starts_at, ends_at)
VALUES (1, '2026-09-24 10:00+08', '2026-09-24 09:00+08');ERROR: new row for relation "reservations" violates check constraint "ck_period_ok"
DETAIL: Failing row contains (1, 1, 2026-09-24 10:00:00+08, 2026-09-24 09:00:00+08).NOT VALID:在线加 CHECK 约束的关键技巧
在大表上直接 ADD CONSTRAINT ... CHECK (...) 会扫描整表校验历史数据,期间持有 ACCESS EXCLUSIVE 锁——线上等同于短暂停服。对 CHECK 而言,有一条优雅的在线路径:NOT VALID。
-- 第 1 步:只对新增/修改的行生效,跳过历史数据(极快,只改元数据)
SET lock_timeout = '3s';
ALTER TABLE orders
ADD CONSTRAINT ck_orders_amount CHECK (amount >= 0) NOT VALID;
RESET lock_timeout;ALTER TABLE此时约束已生效,但只作用于新数据:
SELECT conname, convalidated, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conrelid = 'orders'::regclass AND contype = 'c'; conname | convalidated | def
------------------+--------------+----------------------------------
ck_orders_amount | f | CHECK ((amount >= 0)) NOT VALID
(1 row)-- 新插入的负数已经被拦住
INSERT INTO orders (user_id, order_no, amount) VALUES (1, 'SO-X', -1.00);ERROR: new row for relation "orders" violates check constraint "ck_orders_amount"
DETAIL: Failing row contains (1, 1, SO-X, -1.00, ...).-- 第 2 步:校验历史数据。只取 SHARE UPDATE EXCLUSIVE 锁,不阻塞读写
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_amount;ALTER TABLE-- 校验完成后 convalidated 变成 t
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'orders'::regclass AND contype = 'c'; conname | convalidated
------------------+--------------
ck_orders_amount | t
(1 row)如果历史数据里有违规行,VALIDATE 会直接报错(注意这里只报「存在违规」,不像外键那样给出具体键值):
-- 错误!历史数据中存在 amount < 0 的行
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_amount;ERROR: check constraint "ck_orders_amount" of relation "orders" is violated by some row先定位再修复:
-- 找出所有违规行并修复
SELECT count(*) FROM orders WHERE amount < 0;
UPDATE orders SET amount = 0 WHERE amount < 0;
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_amount;还有一个容易忽略的点:NOT VALID 的 CHECK 约束可以被查询优化器部分利用。优化器不敢信任历史数据,但可以基于它做「新数据一定满足」的推断,因此不会完全忽略它。
NO INHERIT:不向下继承的检查约束
CHECK 默认会被子表继承。NO INHERIT 可以切断这一点:
CREATE TABLE base_orders (
id bigint PRIMARY KEY,
status text NOT NULL,
CONSTRAINT ck_base_status CHECK (status IN ('new', 'paid')) NO INHERIT
);
CREATE TABLE child_orders (
note text
) INHERITS (base_orders);NO INHERIT 的含义是:子表不会继承这条约束,因此子表可以插入父表不允许的值:
-- 父表:被约束拦住
INSERT INTO base_orders (id, status) VALUES (1, 'refunded');ERROR: new row for relation "base_orders" violates check constraint "ck_base_status"
DETAIL: Failing row contains (1, refunded).-- 子表:没有继承约束,允许(这是 NO INHERIT 的语义)
INSERT INTO child_orders (id, status, note) VALUES (2, 'refunded', '子表专有状态');INSERT 0 1反之,如果去掉 NO INHERIT,子表会继承约束,上面这条插入就会失败。
-- 查看约束的继承标记
SELECT conname, conislocal, coninhcount
FROM pg_constraint
WHERE conrelid = 'child_orders'::regclass; conname | conislocal | coninhcount
-------------------+------------+-------------
child_orders_pkey | t | 0
(1 row)DEFAULT 的求值时机与表达式限制
DEFAULT 的规则很简单:只在 INSERT 未提供该列时求值一次,且只对新行生效。
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
tags text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(), -- 每次插入都重新求值
seq bigint NOT NULL DEFAULT nextval('events_seq')
);关键点在于 now() 与 nextval() 这类函数是逐行求值的,不是「建表时算一次」:
INSERT INTO events (payload) VALUES ('{"a":1}'), ('{"b":2}');
SELECT id, created_at, seq FROM events ORDER BY id; id | created_at | seq
----+-------------------------------+-----
1 | 2026-09-24 10:12:33.40123+08 | 1
2 | 2026-09-24 10:12:33.40124+08 | 2
(2 rows)两行的 created_at 有微秒级差异、seq 各不相同——证明是每行各求值一次。如果是「语句级只求值一次」,两行值会完全相同。
关于函数易变性(Volatility)与 DEFAULT 的关系,容易产生误解:
| 易变性 | 举例 | 能否用于 DEFAULT |
|---|---|---|
IMMUTABLE | '{}'::jsonb、upper('a') | ✅ 可以 |
STABLE | now()、current_user | ✅ 可以(但同一语句内值相同) |
VOLATILE | random()、nextval()、clock_timestamp() | ✅ 可以,且每行重新求值 |
很多人以为 VOLATILE 函数不能出现在 DEFAULT 里,实际上是可以的——因为 DEFAULT 是逐行求值的,不存在「结果被缓存导致语义错误」的问题。真正限制 VOLATILE 的是 CHECK 约束(要求 IMMUTABLE)与索引表达式(要求 IMMUTABLE)。
-- 合法:random() 是 VOLATILE,但 DEFAULT 逐行求值,语义没问题
CREATE TABLE demo_rand (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
val double precision NOT NULL DEFAULT random()
);
INSERT INTO demo_rand DEFAULT VALUES;
INSERT INTO demo_rand DEFAULT VALUES;
SELECT id, round(val::numeric, 4) AS val FROM demo_rand; id | val
----+--------
1 | 0.4231
2 | 0.8872
(2 rows)注意 now() 是 STABLE(事务级时间戳),同一事务内多次调用返回相同值;想拿「真实当前时刻」要用 clock_timestamp()(VOLATILE):
BEGIN;
SELECT now() = now() AS same_in_tx; -- t
SELECT clock_timestamp() = clock_timestamp() AS same_now; -- f
COMMIT;DEFAULT 与 NOT NULL 的组合
「非空 + 默认值」是最常见的搭配,但要小心二者独立生效的顺序:
CREATE TABLE cfg (
key text NOT NULL,
value text NOT NULL DEFAULT ''
);DEFAULT 只在你没有提到该列时生效。显式写 NULL 不行:
-- 正确:未提及 value,取默认值 ''
INSERT INTO cfg (key) VALUES ('a');
-- 错误!显式写 NULL,DEFAULT 不生效,被 NOT NULL 拦下
INSERT INTO cfg (key, value) VALUES ('b', NULL);ERROR: null value in column "value" of relation "cfg" violates not-null constraint
DETAIL: Failing row contains (b, null).ON CONFLICT DO UPDATE 里的 EXCLUDED 也遵循同样规则:
-- 冲突时不覆盖 value(保留原值)
INSERT INTO cfg (key, value) VALUES ('a', '')
ON CONFLICT (key) DO NOTHING;ALTER COLUMN:SET / DROP DEFAULT 与 SET NOT NULL
DEFAULT 的增删只改元数据,速度极快,不会重写历史行:
-- 加上默认值:只对将来插入的行生效,已有行的值不变
ALTER TABLE cfg ALTER COLUMN value SET DEFAULT 'unknown';
-- 去掉默认值:将来插入缺列时会得到 NULL(若同时有 NOT NULL 则会报错)
ALTER TABLE cfg ALTER COLUMN value DROP DEFAULT;这一点务必记住:SET DEFAULT 不会回溯填充已有行。已经存在的 NULL 需要自己 UPDATE:
-- 已有行不会被自动填充,需要显式回填
UPDATE cfg SET value = 'unknown' WHERE value IS NULL;SET NOT NULL 则一定要扫全表校验(除非已有等价的 CHECK 或版本优化):
-- 大表上会全表扫描并持 ACCESS EXCLUSIVE 锁
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;PostgreSQL 12+ 有一处优化:如果表上存在一个已被验证(convalidated = true)的 CHECK (col IS NOT NULL) 约束,SET NOT NULL 可以跳过全表扫描,直接借用该约束的结论。这是历史项目在无法停机时加非空的经典手法。
-- 第 1 步:先加一条 NOT VALID 的等价 CHECK(很快)
ALTER TABLE orders ADD CONSTRAINT ck_orders_user_id_nn CHECK (user_id IS NOT NULL) NOT VALID;
-- 第 2 步:后台校验(不阻塞读写)
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_user_id_nn;
-- 第 3 步:此时 SET NOT NULL 可跳过全表扫描(PostgreSQL 12+)
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;
-- 第 4 步:非空已落地,可删掉这条「垫脚」约束
ALTER TABLE orders DROP CONSTRAINT ck_orders_user_id_nn;注意
只在 PostgreSQL 12 及以上版本才会自动利用已有的已验证 CHECK 约束来跳过 SET NOT NULL 的全表扫描;更早版本仍会扫描整表。
ADD COLUMN ... DEFAULT 不再重写全表(PostgreSQL 11+)
这是 PostgreSQL 11 引入的一个重要优化,也是最常被引用、却常被误述的一条。
PostgreSQL 11 之前:ALTER TABLE ... ADD COLUMN c type DEFAULT expr 会物理重写整张表,把默认值逐行写入每一行。对亿级大表来说,这意味着长时间持锁 + 磁盘空间翻倍。
PostgreSQL 11 及以后:如果 DEFAULT 是非易变(non-volatile)的,PostgreSQL 只把默认值记录在元数据(pg_attribute.attmissingval)中,不重写任何数据行。读取时若发现某行「这一列不存在」,就用元数据里的默认值补上。
-- PostgreSQL 11+:只改元数据,秒级完成,即使表有上亿行
ALTER TABLE orders ADD COLUMN channel text NOT NULL DEFAULT 'web';ALTER TABLE验证这次变更确实是「元数据级」的:
-- 查看是否存在 attmissingval(非 NULL 说明列是通过元数据默认值补上的)
SELECT attname, atthasmissing, attmissingval
FROM pg_attribute
WHERE attrelid = 'orders'::regclass AND attname = 'channel'; attname | atthasmissing | attmissingval
---------+---------------+---------------
channel | t | {web}
(1 row)atthasmissing = t 就是「没有重写表」的证据。触发一次行更新后,该行会物理写入真实值:
-- 更新后该行物理落盘,但列的元数据默认值对未更新行依然有效
UPDATE orders SET channel = 'app' WHERE id = 1;需要重写全表的例外情况(这些仍会造新表):
| 情况 | 是否重写 | 原因 |
|---|---|---|
DEFAULT 是常量或 IMMUTABLE 表达式 | ❌ 不重写 | 可写入 attmissingval |
DEFAULT 是 now()、random()、nextval() 等 | ✅ 重写 | 每行应得到不同/当时的值,无法用统一默认值代替 |
| 同时修改列类型 | ✅ 重写 | 类型转换需要逐行计算 |
SET NOT NULL(无 CHECK 垫脚) | ❌ 不重写但全表扫描 | 只读校验 |
加 STORAGE / COMPRESSION 变更 | ✅ 视情况 | 需重写以应用新策略 |
-- 会重写全表:VOLATILE 默认值
-- 大表上不要这样做
ALTER TABLE orders ADD COLUMN created_at2 timestamptz DEFAULT clock_timestamp();
-- 安全写法:先加可空列(不重写),再后台回填
ALTER TABLE orders ADD COLUMN created_at2 timestamptz;
UPDATE orders SET created_at2 = now() WHERE created_at2 IS NULL; -- 分批执行
ALTER TABLE orders ALTER COLUMN created_at2 SET NOT NULL;查看与维护约束
CHECK 约束统一在 pg_constraint 中查看,pg_get_constraintdef() 可以把内部表达式还原成可读 SQL:
SELECT
c.conname AS constraint_name,
c.contype AS type,
c.convalidated AS validated,
c.connoinherit AS no_inherit,
pg_get_constraintdef(c.oid) AS definition
FROM pg_constraint c
WHERE c.conrelid = 'orders'::regclass
ORDER BY c.contype, c.conname; constraint_name | type | validated | no_inherit | definition
-------------------------+------+-----------+------------+-------------------------------------------
ck_orders_amount | c | t | f | CHECK ((amount >= 0))
orders_pkey | p | t | f | PRIMARY KEY (id)
uq_orders_no | u | t | f | UNIQUE (order_no)
fk_orders_user | f | t | f | FOREIGN KEY (user_id) REFERENCES users(id)
(4 rows)contype 的完整取值表(这是运维巡检时最常用的对照):
contype | 约束类型 | 阶段 |
|---|---|---|
c | 检查(Check) | — |
f | 外键(Foreign Key) | — |
p | 主键(Primary Key) | — |
u | 唯一(Unique) | — |
x | 排他(Exclude) | — |
n | 非空(Not Null) | PostgreSQL 18+ |
也可以用 \d 一屏看全(Check constraints 段):
\d orders Table "public.orders"
Column | Type | Nullable | Default
------------+--------------------------+----------+-----------------------------------
id | bigint | not null | generated by default as identity
user_id | bigint | not null |
order_no | text | not null |
amount | numeric(12,2) | not null |
Indexes:
"orders_pkey" PRIMARY KEY, btree (id)
"uq_orders_no" UNIQUE CONSTRAINT, btree (order_no)
Check constraints:
"ck_orders_amount" CHECK (amount >= 0)
Foreign-key constraints:
"fk_orders_user" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT删除约束用 DROP CONSTRAINT,删掉后再也拦不住可疑数据:
ALTER TABLE orders DROP CONSTRAINT ck_orders_amount;实战示例
场景:给已有 200 万行的 orders 表在线补上 NOT NULL 与 CHECK 约束,全程不阻塞读写,最后验证约束确实生效。
第 1 步:准备数据(含少量脏数据)
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint,
order_no text,
amount numeric(12,2),
status text,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (user_id, order_no, amount, status)
SELECT
CASE WHEN g % 50000 = 0 THEN NULL ELSE (g % 1000) + 1 END, -- 少量 NULL
'SO-' || lpad(g::text, 10, '0'),
CASE WHEN g % 70000 = 0 THEN -1.00 ELSE (random() * 1000)::numeric(12,2) END, -- 少量负数
(ARRAY['pending', 'paid', 'shipped', 'bad_status'])[1 + (g % 4)],
now() - (g || ' seconds')::interval
FROM generate_series(1, 2000000) AS g;
ANALYZE orders;
SELECT pg_size_pretty(pg_relation_size('orders')) AS table_size,
count(*) FILTER (WHERE user_id IS NULL) AS null_user,
count(*) FILTER (WHERE amount < 0) AS bad_amount,
count(*) FILTER (WHERE status NOT IN ('pending', 'paid', 'shipped')) AS bad_status
FROM orders; table_size | null_user | bad_amount | bad_status
------------+-----------+------------+------------
155 MB | 40 | 28 | 500000
(1 row)第 2 步:直接加约束会怎样(不要在生产执行)
-- 会全表校验并持 ACCESS EXCLUSIVE 锁,200 万行期间业务全部阻塞
ALTER TABLE orders ADD CONSTRAINT ck_orders_status
CHECK (status IN ('pending', 'paid', 'shipped'));ERROR: check constraint "ck_orders_status" of relation "orders" is violated by some row连约束都加不上——历史数据里有 50 万行非法 status。
第 3 步:清洗脏数据
-- 3.1 非法状态归入 pending
UPDATE orders SET status = 'pending'
WHERE status NOT IN ('pending', 'paid', 'shipped');
-- 3.2 负数金额归零
UPDATE orders SET amount = 0 WHERE amount < 0;
-- 3.3 空 user_id 归入「未知用户」,为后续 NOT NULL 做准备
INSERT INTO users (id, email) VALUES (999999, 'unknown@example.com') ON CONFLICT DO NOTHING;
UPDATE orders SET user_id = 999999 WHERE user_id IS NULL;
-- 复核
SELECT count(*) FILTER (WHERE status NOT IN ('pending', 'paid', 'shipped')) AS bad_status,
count(*) FILTER (WHERE amount < 0) AS bad_amount,
count(*) FILTER (WHERE user_id IS NULL) AS null_user
FROM orders; bad_status | bad_amount | null_user
------------+------------+-----------
0 | 0 | 0
(1 row)第 4 步:用 NOT VALID 在线加 CHECK(不阻塞)
SET lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT ck_orders_status
CHECK (status IN ('pending', 'paid', 'shipped')) NOT VALID;
ALTER TABLE orders ADD CONSTRAINT ck_orders_amount
CHECK (amount >= 0) NOT VALID;
RESET lock_timeout;ALTER TABLE
ALTER TABLE第 5 步:后台补校验(不阻塞读写)
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_status;
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_amount;
SELECT conname, convalidated
FROM pg_constraint
WHERE conrelid = 'orders'::regclass AND contype = 'c'
ORDER BY conname; conname | convalidated
-------------------+--------------
ck_orders_amount | t
ck_orders_status | t
(2 rows)第 6 步:用「CHECK 垫脚」方式在线加 NOT NULL
-- 6.1 先加等价 CHECK 并校验
ALTER TABLE orders ADD CONSTRAINT ck_orders_user_id_nn
CHECK (user_id IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_user_id_nn;
-- 6.2 PostgreSQL 12+:SET NOT NULL 复用已验证的 CHECK,跳过全表扫描
SET lock_timeout = '3s';
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;
RESET lock_timeout;
-- 6.3 删掉垫脚 CHECK,留着会带来重复检查开销
ALTER TABLE orders DROP CONSTRAINT ck_orders_user_id_nn;ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE第 7 步:验证约束真的生效
-- 7.1 非法状态被拦
INSERT INTO orders (user_id, order_no, amount, status) VALUES (1, 'SO-BAD-1', 10.00, 'bad_status');ERROR: new row for relation "orders" violates check constraint "ck_orders_status"
DETAIL: Failing row contains (2000001, 1, SO-BAD-1, 10.00, bad_status, ...).-- 7.2 负数金额被拦
INSERT INTO orders (user_id, order_no, amount, status) VALUES (1, 'SO-BAD-2', -1.00, 'paid');ERROR: new row for relation "orders" violates check constraint "ck_orders_amount"
DETAIL: Failing row contains (2000001, 1, SO-BAD-2, -1.00, paid, ...).-- 7.3 NULL user_id 被拦
INSERT INTO orders (user_id, order_no, amount, status) VALUES (NULL, 'SO-BAD-3', 10.00, 'paid');ERROR: null value in column "user_id" of relation "orders" violates not-null constraint
DETAIL: Failing row contains (2000001, null, SO-BAD-3, 10.00, paid, ...).第 8 步:查看最终约束与「元数据默认值」优化
ALTER TABLE orders ADD COLUMN channel text NOT NULL DEFAULT 'web';
SELECT attname, atthasmissing, attmissingval
FROM pg_attribute
WHERE attrelid = 'orders'::regclass AND attname = 'channel'; attname | atthasmissing | attmissingval
---------+---------------+---------------
channel | t | {web}
(1 row)atthasmissing = t 说明这次 ADD COLUMN ... DEFAULT 只改了元数据,200 万行没有被重写。
第 9 步:清理
DROP TABLE IF EXISTS orders;注意事项
CHECK遇到NULL会放行。 表达式结果为 UNKNOWN(NULL)不构成违反,这是最高频的误解。
-- 危险!age IS NULL 时放行
CONSTRAINT ck_age CHECK (age > 0)
-- 正确
age integer NOT NULL CHECK (age > 0)CHECK里不能有子查询、不能引用其他表、不能引用其他行。
-- 错误!cannot use subquery in check constraint
CHECK (user_id IN (SELECT id FROM users))CHECK里不能用非IMMUTABLE函数(区别于DEFAULT,后者允许VOLATILE)。
-- 错误!functions in check constraint must be marked IMMUTABLE
CHECK (created_at <= now())- 用
NOT NULL而不是CHECK (col IS NOT NULL)。 前者是元数据检查、开销近似为零,且能被优化器用于推断。
-- 不推荐
v text CHECK (v IS NOT NULL)
-- 推荐
v text NOT NULLADD CONSTRAINT ... CHECK默认扫全表校验并持ACCESS EXCLUSIVE锁。 大表必须用NOT VALID+VALIDATE CONSTRAINT。
-- 大表不要直接执行
ALTER TABLE orders ADD CONSTRAINT ck_x CHECK (amount >= 0);
-- 正确:两步走
ALTER TABLE orders ADD CONSTRAINT ck_x CHECK (amount >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT ck_x;DEFAULT不会作用于已存在的行。ALTER COLUMN ... SET DEFAULT只影响将来的插入。
ALTER TABLE cfg ALTER COLUMN value SET DEFAULT 'unknown';
-- 已有 NULL 行不会变化,需要自己回填
UPDATE cfg SET value = 'unknown' WHERE value IS NULL;ADD COLUMN ... DEFAULT仅在 PostgreSQL 11+ 且默认值为非易变表达式时才不重写表。
-- PostgreSQL 11+:元数据变更,秒级完成
ALTER TABLE orders ADD COLUMN channel text NOT NULL DEFAULT 'web';
-- 仍会重写:VOLATILE 默认值
ALTER TABLE orders ADD COLUMN ts timestamptz DEFAULT clock_timestamp();SET NOT NULL默认会全表扫描。 仅当存在已验证的CHECK (col IS NOT NULL)时(PostgreSQL 12+)才能跳过。
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;SET NOT NULL会失败于存量NULL行。 必须先修数据。
-- 错误!column "user_id" of relation "orders" contains null values
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;NO INHERIT会让子表不继承父表的CHECK,可能让子表绕过父表的业务规则。
CONSTRAINT ck_base_status CHECK (status IN ('new', 'paid')) NO INHERITDEFAULT不属于约束,不会出现在pg_constraint里。 巡检「默认值一致性」要查pg_attrdef/pg_get_expr(adbin, adrelid)。
SELECT a.attname, pg_get_expr(d.adbin, d.adrelid) AS default_expr
FROM pg_attrdef d
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE d.adrelid = 'orders'::regclass;NOT VALID的约束只拦新数据,不保证历史数据合法。 上线后必须补VALIDATE,否则这个约束是「半生效」状态。
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'orders'::regclass AND NOT convalidated;- 删除
CHECK约束会立即失去防护,应用层若依赖它兜底,删之前先确认代码已补校验。
ALTER TABLE orders DROP CONSTRAINT ck_orders_amount;最佳实践
- 非空一律用
NOT NULL,与业务检查分开写。 一个约束只表达一件事,报错信息也更好定位。
age integer NOT NULL CHECK (age > 0)- 枚举与值域用
CHECK显式表达,别只在应用层拦。 数据库是最后一道防线。
CONSTRAINT ck_users_status CHECK (status IN ('active', 'disabled', 'banned'))- 跨列一致性用表级
CHECK(时间区间、金额关系),这是CHECK最有价值的能力。
CONSTRAINT ck_period_ok CHECK (ends_at > starts_at)- 给所有约束显式命名(
ck_<表>_<语义>),自动名(t_col_check)在迁移脚本与巡检 SQL 里极难引用。
CONSTRAINT ck_orders_amount CHECK (amount >= 0)- 大表加约束一律走
NOT VALID+VALIDATE CONSTRAINT,把全表扫描从持锁阶段剥离出去。
SET lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT ck_orders_amount CHECK (amount >= 0) NOT VALID;
RESET lock_timeout;
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_amount;- 大表加
NOT NULL用「CHECK ... NOT VALID→VALIDATE→SET NOT NULL→DROP CHECK」四步法(PostgreSQL 12+)。
ALTER TABLE orders ADD CONSTRAINT ck_o_user_nn CHECK (user_id IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT ck_o_user_nn;
ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT ck_o_user_nn;DEFAULT只用常量或IMMUTABLE表达式,这样ADD COLUMN ... DEFAULT才能走「不重写表」的快路径。
status text NOT NULL DEFAULT 'active'
created_at timestamptz NOT NULL DEFAULT now()- 注意
now()与clock_timestamp()的语义差异:前者是事务时间戳(同事务内恒定),后者是真实时钟。
created_at timestamptz NOT NULL DEFAULT now() -- 事务一致,推荐
updated_at timestamptz NOT NULL DEFAULT clock_timestamp() -- 需要真实时刻才用- 在线变更脚本统一设置
lock_timeout,避免ALTER TABLE在锁队列里堵住后续所有查询。
SET lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT ck_x CHECK (amount >= 0) NOT VALID;
RESET lock_timeout;- 上线后巡检未校验约束,把
NOT VALID的残留当成技术债定期清理。
SELECT conrelid::regclass AS tbl, conname, contype
FROM pg_constraint
WHERE NOT convalidated
ORDER BY 1, 2;CHECK约束数量要克制。 每个约束都是每次写入的一层表达式求值,宽表上堆十几个大范围CHECK会明显拖慢写入。
下一节
继续学习:EXCLUDE 排他约束