域类型(Domain)
域(Domain)是建立在某个已有数据类型之上的、附加了约束的"子类型"。它不改变底层存储与运算,只负责一件事:把一组业务规则(非空、格式、取值范围)打包成一个可复用的名字,让所有用到这个域的列、参数、变量都自动接受同样的校验。
一个典型的痛点:手机号在 users 表、orders 表、sms_log 表里各写了一遍 CHECK (phone ~ '^1[3-9]\d{9}$'),某天规则改成支持国际号,你得改三处,还可能漏一处。域把这条规则收进一个名字里,改一次生效全站。
前置知识
阅读本节前,建议先了解:枚举类型(Enum)
基础概念
域是什么,不是什么
域常常和「自定义类型」「表级 CHECK 约束」混淆,先厘清边界:
| 概念 | 本质 | 存储 | 能否带约束 | 复用粒度 |
|---|---|---|---|---|
| 基类型(Base Type) | 全新数据类型,自己定义 I/O 函数 | 自定义 | 可 | 类型级 |
| 复合类型(Composite) | 多个字段打包 | 行结构 | 否(不能加 CHECK) | 类型级 |
| 枚举类型(Enum) | 固定值域 | 4 字节 | 隐含值域校验 | 类型级 |
| 域(Domain) | 已有类型 + 约束 | 与底层类型完全相同 | 是(CHECK / NOT NULL) | 类型级 |
| 表级 CHECK | 单张表上的规则 | 无 | 是 | 列级(不可跨表复用) |
关键认知:域不是新类型,它底下是什么类型,存储和运算就完全是什么类型。 phone 域基于 text,那它就能用所有 text 函数、能建 btree 索引、能 JOIN 比较,一切照旧,只是写入时会多跑一遍约束。
域解决什么问题
想象一个电商系统里的「金额」:
| 场景 | 不用域 | 用域 |
|---|---|---|
| 定义规则 | 每张表写 CHECK (amount >= 0) | 定义一次 money_amount 域 |
| 新增表 | 复制粘贴约束 | 直接用域 |
| 修改规则(比如上限 100 万) | 逐个 ALTER TABLE | ALTER DOMAIN 一条 |
| 规则遗漏风险 | 高(靠人记) | 低(靠类型系统) |
| 文档性 | 弱(numeric(10,2) 看不出业务含义) | 强(列类型就叫 money_amount) |
域的第二重价值容易被忽略:自文档化。phone phone_number 比 phone text 一眼就能看出这列该存什么。
域与底层类型的运行时开销
| 操作 | 域 | 底层类型 | 差异 |
|---|---|---|---|
| 存储大小 | 完全相同 | 完全相同 | 无 |
| 读取 | 完全相同 | 完全相同 | 无 |
| 写入 | 多跑一次 CHECK | 无 | 极小(微秒级) |
| 索引 | 完全可用 | 完全可用 | 无 |
| 排序 / 比较 | 完全一致 | 完全一致 | 无 |
结论:域的性能代价只在写入路径上的约束检查,可以忽略不计。 它不是"包装类型",运行时没有额外解包动作。
语法与用法
创建域
CREATE DOMAIN name [AS] base_type
[ COLLATE collation ]
[ DEFAULT expression ]
[ CONSTRAINT constraint_name ]
{ NOT NULL | NULL | CHECK (expression) } [...];各部分含义:
| 子句 | 作用 | 是否可省 |
|---|---|---|
AS base_type | 底层类型,可以是任意内置或自定义类型 | 不可省 |
COLLATE | 指定排序规则(一般用于文本域) | 可省 |
DEFAULT | 该域的默认值 | 可省 |
NOT NULL | 域级别非空,所有用该域的列都非空 | 可省 |
CHECK (...) | 值域约束,可写多个 | 可省 |
最小示例
-- 正数金额:基于 numeric(12,2),必须大于 0
CREATE DOMAIN money_amount AS numeric(12, 2)
CHECK (VALUE > 0);
-- 手机号:11 位中国大陆手机号
CREATE DOMAIN phone_number AS text
CHECK (VALUE ~ '^1[3-9]\d{9}$');
-- 邮箱:粗校验,不追求 RFC 完美
CREATE DOMAIN email_address AS text
CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');注意约束表达式里用的是 VALUE 这个伪列,代表"当前被检查的值"。也可以写成 CHECK (VALUE >= 0)。约束不支持子查询、不支持引用其他列——域约束只能看自己这一个值,这是它和表级 CHECK 最大的能力差别。
带命名约束与默认值
CREATE DOMAIN order_no AS text
CONSTRAINT order_no_not_blank CHECK (VALUE <> '')
CONSTRAINT order_no_length CHECK (char_length(VALUE) BETWEEN 16 AND 32)
CONSTRAINT order_no_charset CHECK (VALUE ~ '^[A-Z0-9]+$');
-- 带默认值与非空
CREATE DOMAIN quantity AS integer
DEFAULT 1
NOT NULL
CHECK (VALUE > 0);给约束起名字很重要:报错时会带上约束名,一眼知道违反了哪条规则。
在表里使用域
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
price money_amount NOT NULL,
stock quantity NOT NULL DEFAULT 1,
contact phone_number,
created_at timestamptz NOT NULL DEFAULT now()
);列类型直接写域名即可,用起来和内置类型没有区别。
查看域信息
-- 列出所有域(psql 元命令)
\dD
-- 看某个域的详细信息
\dD+ money_amount
-- 从系统目录查(pg_type 中 typtype = 'd' 即域)
SELECT t.typname AS domain_name,
bt.typname AS base_type,
t.typnotnull AS not_null,
t.typdefault AS default_value,
t.typcollation <> 0 AS has_collation
FROM pg_type t
JOIN pg_type bt ON bt.oid = t.typbasetype
WHERE t.typtype = 'd'
ORDER BY t.typname;pg_type 里与域相关的几个关键列:
| 列 | 含义 |
|---|---|
typtype | 'd' 表示域(domain) |
typbasetype | 底层类型的 oid |
typtypmod | 底层类型的修饰(如 numeric(12,2) 的精度) |
typnotnull | 域是否声明了 NOT NULL |
typdefault | 域的默认值 |
typndims | 底层类型的数组维度(域可以基于数组) |
查看域上的约束
SELECT conname AS constraint_name,
pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE contypid = 'money_amount'::regtype
ORDER BY conname; constraint_name | definition
--------------------+--------------------------------------
money_amount_check | CHECK ((VALUE > (0)::numeric))
(1 row)修改与删除域
-- 增加新约束
ALTER DOMAIN phone_number ADD CONSTRAINT phone_number_cn
CHECK (VALUE ~ '^1[3-9]\d{9}$');
-- 删除约束(必须知道约束名)
ALTER DOMAIN phone_number DROP CONSTRAINT phone_number_cn;
-- 修改默认值
ALTER DOMAIN quantity SET DEFAULT 10;
ALTER DOMAIN quantity DROP DEFAULT;
-- 设置 / 取消非空
ALTER DOMAIN quantity SET NOT NULL;
ALTER DOMAIN quantity DROP NOT NULL;
-- 重命名
ALTER DOMAIN phone_number RENAME TO cn_mobile;
-- 换底层类型(谨慎!可能失败或重写数据)
ALTER DOMAIN quantity SET DATA TYPE bigint;
-- 删除(RESTRICT 遇到依赖会报错,CASCADE 会级联删掉依赖列)
DROP DOMAIN quantity RESTRICT;
DROP DOMAIN quantity CASCADE;ALTER DOMAIN 的能力与边界:
| 操作 | 语法 | 注意 |
|---|---|---|
| 加约束 | ADD CONSTRAINT ... CHECK (...) | 默认会校验存量数据,失败则整体回滚;可跟 NOT VALID 先跳过 |
| 删约束 | DROP CONSTRAINT name | 必须显式给名字 |
| 改默认值 | SET / DROP DEFAULT | 不影响存量行 |
| 改非空 | SET / DROP NOT NULL | SET NOT NULL 会扫表校验存量 |
| 改底层类型 | SET DATA TYPE new_type | 只有"二进制兼容"的改动才免重写;不可逆时可能失败 |
| 重命名 | RENAME TO new_name | 依赖的表定义自动跟随 |
| 改属主 / schema | OWNER TO / SET SCHEMA | 需要权限 |
NOT VALID 的用法值得记一笔,它是大表加约束的救命稻草:
-- 先加上约束但不校验存量数据(瞬间完成,不锁表扫表)
ALTER DOMAIN phone_number ADD CONSTRAINT phone_number_cn
CHECK (VALUE ~ '^1[3-9]\d{9}$') NOT VALID;
-- 稍后在校验窗口再扫描存量(可以提前清理脏数据)
ALTER DOMAIN phone_number VALIDATE CONSTRAINT phone_number_cn;域 vs 表级 CHECK:能力对照
这是选型的关键,务必看清:
| 能力 | 域约束 | 表级 CHECK |
|---|---|---|
| 跨表复用 | 是 | 否 |
| 引用本行其他列 | 否 | 是 |
| 子查询 | 否 | 否 |
| 用于函数参数 / 变量 | 是 | 否 |
出现在 \d 表结构中 | 只显示类型名 | 显示完整表达式 |
| 加约束是否锁表 | 视 NOT VALID | 视 NOT VALID |
| 默认值(DEFAULT) | 域可带默认 | 列级才有默认 |
所以常见组合策略是:能抽象成"单值规则"的放域里(手机号、邮箱、正数),需要跨列关联的放表级 CHECK 里(end_date > start_date)。
详细说明
域和数组成数组:base_type[]
域可以基于数组类型定义,这时 VALUE 是整个数组,可以写聚合式的检查:
-- 非空数组且最多 10 个元素
CREATE DOMAIN tag_list AS text[]
CHECK (array_length(VALUE, 1) BETWEEN 1 AND 10);
-- 域基于数组后,列定义写法有两种,含义不同:
CREATE TABLE a (tags1 tag_list); -- 类型是 tag_list
CREATE TABLE b (tags2 tag_list[]); -- 类型是 tag_list 的数组(二维!)第二种写法会得到一个"域的数组"即二维数组,几乎不会是想要的结果。用数组域时,列类型不要再加 []。
域上的约束是"累积"的
同类型域可以有多个约束,全部都要满足:
CREATE DOMAIN rate AS numeric(5, 4)
CHECK (VALUE >= 0)
CHECK (VALUE <= 1);
SELECT 0.5::rate; -- 通过SELECT 1.5::rate;ERROR: value for domain rate violates check constraint "rate_check"注意报错里的约束名:如果没显式命名,PostgreSQL 自动命名为 {域名}_check;有多个未命名约束时会叫 rate_check1、rate_check2,可读性差,所以建议始终显式命名。
域与类型转换的关系
域到底层类型是隐式(implicit)的,反过来则需要在赋值上下文中才转换:
SELECT '12345678901'::phone_number; -- OK,text → phone_number
SELECT phone_number('12345678901'); -- 也可以函数调用式写法
-- 域到底层类型:自动
SELECT pg_typeof('12345678901'::phone_number::text); pg_typeof
-----------
text
(1 row)反过来,普通 text 赋给 phone_number 列时会走"赋值转换"(assignment cast),所以 INSERT / UPDATE 能直接写字符串字面量,不需要显式 ::phone_number。
域的默认值优先级
一个容易混乱的点:域的 DEFAULT 和列的 DEFAULT 同时存在时,列级覆盖域级。
CREATE DOMAIN qty AS integer DEFAULT 1 CHECK (VALUE > 0);
CREATE TABLE t1 (n qty); -- 默认 1(来自域)
CREATE TABLE t2 (n qty DEFAULT 5); -- 默认 5(列级覆盖)
INSERT INTO t1 DEFAULT VALUES;
INSERT INTO t2 DEFAULT VALUES;
SELECT (SELECT n FROM t1) AS t1_n, (SELECT n FROM t2) AS t2_n; t1_n | t2_n
------+------
1 | 5
(1 row)域与函数、存储过程
域最被低估的用途:作为函数参数与返回类型,让业务规则在 PL/pgSQL 里也生效。
CREATE OR REPLACE FUNCTION charge(amount money_amount) RETURNS text
LANGUAGE plpgsql AS $$
BEGIN
RETURN format('扣款 %s 元', amount);
END;
$$;
SELECT charge(99.90); -- 扣款 99.90 元SELECT charge(-1);ERROR: value for domain money_amount violates check constraint "money_amount_check"因为参数是域类型,-1 在进入函数体之前就被类型系统拦下了。这种"入口校验"比在函数体里手写 IF amount < 0 THEN RAISE 更早、更统一。
DEFAULT 与 NOT NULL 写在域上的坑
把 NOT NULL 放进域里,所有引用它的列都强制非空,看似方便,但灵活性大幅下降:某张表就是想允许 NULL(例如"暂未采集"),你也做不到,只能在该列上写 SET NOT NULL 的反向操作——而这是不允许的。
因此实践建议:
| 规则性质 | 放哪 |
|---|---|
| 值的格式 / 范围 | 域(强烈推荐) |
| 非空 | 列级(推荐),除非业务上确实永远非空 |
| 默认值 | 列级(推荐),域的默认值只适合"全站统一"的场景 |
| 跨列关联 | 表级 CHECK |
域的依赖关系
域被表或函数使用后,就产生了依赖。想删域必须处理这些依赖:
DROP DOMAIN phone_number;ERROR: cannot drop type phone_number because other objects depend on it
DETAIL: column contact of table products depends on type phone_number
HINT: Use DROP ... CASCADE to drop the dependent objects too.pg_depend 里记录了这些依赖关系:
SELECT d.classid::regclass AS dependent_class,
d.objid::regtype AS dependent_object,
d.deptype AS dependency_type
FROM pg_depend d
WHERE d.refobjid = 'phone_number'::regtype
AND d.refclassid = 'pg_type'::regclass;deptype 取值含义:
| 取值 | 含义 |
|---|---|
n | normal,正常依赖,删被依赖者会阻止 |
a | auto,自动依赖(如序列属于列) |
i | internal |
e | extension 成员 |
实战示例
案例一:电商核心域的完整落地
目标:把金额、数量、手机号、邮箱这四类反复出现的规则固化成域,一次性解决全站校验。
步骤 1:定义域
-- 清场(幂等)
DROP TABLE IF EXISTS order_items, orders CASCADE;
CREATE DOMAIN money_amount AS numeric(12, 2)
CONSTRAINT money_amount_positive CHECK (VALUE > 0);
CREATE DOMAIN money_nonneg AS numeric(12, 2)
CONSTRAINT money_nonneg_ok CHECK (VALUE >= 0);
CREATE DOMAIN quantity AS integer
DEFAULT 1 NOT NULL
CONSTRAINT quantity_positive CHECK (VALUE > 0)
CONSTRAINT quantity_reasonable CHECK (VALUE <= 99999);
CREATE DOMAIN phone_number AS text
CONSTRAINT phone_number_cn CHECK (VALUE ~ '^1[3-9]\d{9}$');
CREATE DOMAIN email_address AS text
CONSTRAINT email_address_basic CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$');步骤 2:建表使用域
CREATE TABLE orders (
id bigserial PRIMARY KEY,
order_no text NOT NULL,
phone phone_number NOT NULL,
email email_address,
total money_amount NOT NULL,
discount money_nonneg NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
sku text NOT NULL,
qty quantity NOT NULL,
price money_amount NOT NULL
);步骤 3:写入合法数据
INSERT INTO orders (order_no, phone, email, total, discount) VALUES
('SO20260922001', '13800138000', 'alice@example.com', 299.00, 10.00),
('SO20260922002', '13900139000', 'bob@example.org', 1580.50, 0),
('SO20260922003', '15900159000', NULL, 89.90, 0);
INSERT INTO order_items (order_id, sku, qty, price) VALUES
(1, 'SKU-A100', 2, 100.00),
(1, 'SKU-B200', 1, 99.00),
(2, 'SKU-C300', 5, 316.10),
(3, 'SKU-A100', 1, 89.90);步骤 4:验证非法数据被拦截
-- 错误!金额为负
INSERT INTO orders (order_no, phone, total) VALUES ('SO20260922004', '13800138000', -1);ERROR: value for domain money_amount violates check constraint "money_amount_positive"-- 错误!手机号格式不对
INSERT INTO orders (order_no, phone, total) VALUES ('SO20260922005', '1380013800', 100);ERROR: value for domain phone_number violates check constraint "phone_number_cn"-- 错误!数量为 0
INSERT INTO order_items (order_id, sku, qty, price) VALUES (1, 'SKU-X', 0, 10);ERROR: value for domain quantity violates check constraint "quantity_positive"-- 错误!邮箱格式不对
INSERT INTO orders (order_no, phone, email, total) VALUES ('SO20260922006', '13800138000', 'not-an-email', 10);ERROR: value for domain email_address violates check constraint "email_address_basic"步骤 5:查询带金额计算的视图
CREATE OR REPLACE VIEW v_order_summary AS
SELECT o.id,
o.order_no,
o.phone,
o.total,
o.discount,
(o.total - o.discount)::money_nonneg AS payable,
count(i.id) AS item_kinds,
coalesce(sum(i.qty), 0) AS total_qty
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.order_no, o.phone, o.total, o.discount
ORDER BY o.id;
SELECT * FROM v_order_summary; id | order_no | phone | total | discount | payable | item_kinds | total_qty
----+----------------+-------------+---------+----------+---------+------------+-----------
1 | SO20260922001 | 13800138000 | 299.00 | 10.00 | 289.00 | 2 | 3
2 | SO20260922002 | 13900139000 | 1580.50 | 0.00 | 1580.50 | 1 | 5
3 | SO20260922003 | 15900159000 | 89.90 | 0.00 | 89.90 | 1 | 1
(3 rows)步骤 6:索引照常可用
CREATE INDEX idx_orders_phone ON orders (phone);
CREATE INDEX idx_orders_created ON orders (created_at);
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE phone = '13800138000'; QUERY PLAN
-----------------------------------------------------
Index Scan using idx_orders_phone on orders
Index Cond: (phone = '13800138000'::text)
(2 rows)注意规划器输出里 phone 被当作 text 处理——索引、运算完全按底层类型走,域不产生任何额外开销。
案例二:用域做"跨表统一约束"的集中治理
目标:演示域改一次、全站生效的威力,以及大表加约束的 NOT VALID 路径。
步骤 1:两个业务表共用同一个域
DROP TABLE IF EXISTS members, employees CASCADE;
CREATE DOMAIN cn_mobile AS text
CONSTRAINT cn_mobile_format CHECK (VALUE ~ '^1[3-9]\d{9}$');
CREATE TABLE members (
id bigserial PRIMARY KEY,
name text NOT NULL,
mobile cn_mobile NOT NULL
);
CREATE TABLE employees (
id bigserial PRIMARY KEY,
name text NOT NULL,
mobile cn_mobile NOT NULL,
contact cn_mobile -- 紧急联系人,可以为空
);
INSERT INTO members (name, mobile) VALUES ('张三', '13800138000');
INSERT INTO employees (name, mobile, contact) VALUES ('李四', '13900139000', '13700137000');步骤 2:业务规则变化——需要支持国际号码
-- 第一步:加一条更宽松的约束(先不校验存量)
ALTER DOMAIN cn_mobile ADD CONSTRAINT cn_mobile_intl
CHECK (VALUE ~ '^(\+?86)?1[3-9]\d{9}$' OR VALUE ~ '^\+\d{6,15}$') NOT VALID;
-- 第二步:此时旧约束还在,写国际号仍会被拦INSERT INTO members (name, mobile) VALUES ('王五', '+8613800138000');ERROR: value for domain cn_mobile violates check constraint "cn_mobile_format"步骤 3:删掉旧约束,新约束生效
ALTER DOMAIN cn_mobile DROP CONSTRAINT cn_mobile_format;
-- 现在国际号可以写入
INSERT INTO members (name, mobile) VALUES ('王五', '+8613800138000');
INSERT INTO members (name, mobile) VALUES ('赵六', '+14155552671');步骤 4:校验存量数据
ALTER DOMAIN cn_mobile VALIDATE CONSTRAINT cn_mobile_intl;
SELECT id, name, mobile FROM members ORDER BY id; id | name | mobile
----+------+----------------
1 | 张三 | 13800138000
2 | 王五 | +8613800138000
3 | 赵六 | +14155552671
(3 rows)步骤 5:确认两个表同时生效
-- 直接在 employees 表插入国际号,无需改表定义
INSERT INTO employees (name, mobile) VALUES ('孙七', '+819012345678');
SELECT 'members' AS tbl, count(*) FROM members
UNION ALL
SELECT 'employees', count(*) FROM employees; tbl | count
-----------+-------
members | 3
employees | 2
(2 rows)一次 ALTER DOMAIN,两张表、五列同时获得新规则——这就是域的核心价值。
案例三:域 + 枚举 + 复合类型的组合技
目标:把域嵌进复合类型,做出一个自带全部校验的"收货地址"值对象。
步骤 1:准备基础域
CREATE DOMAIN zip_code AS text
CONSTRAINT zip_code_cn CHECK (VALUE ~ '^\d{6}$');
CREATE DOMAIN phone_number AS text
CONSTRAINT phone_number_cn CHECK (VALUE ~ '^1[3-9]\d{9}$');步骤 2:用域做复合类型的字段
CREATE TYPE receiver AS (
name text,
mobile phone_number, -- 域作为复合字段类型
zip zip_code,
addr text
);
CREATE TABLE shipments (
id bigserial PRIMARY KEY,
to_user receiver NOT NULL,
status text NOT NULL DEFAULT 'created'
);步骤 3:写入并观察校验穿透
INSERT INTO shipments (to_user) VALUES
(ROW('张三', '13800138000', '310000', '杭州市西湖区文一西路 100 号')::receiver);-- 错误!复合字段里的手机号同样被域约束拦截
INSERT INTO shipments (to_user) VALUES
(ROW('李四', '138001380', '310000', '杭州市')::receiver);ERROR: value for domain phone_number violates check constraint "phone_number_cn"-- 错误!邮编不合法
INSERT INTO shipments (to_user) VALUES
(ROW('王五', '13800138000', '31000', '杭州市')::receiver);ERROR: value for domain zip_code violates check constraint "zip_code_cn"步骤 4:字段级读取
SELECT id,
(to_user).name AS receiver_name,
(to_user).mobile AS receiver_mobile,
(to_user).zip AS receiver_zip,
(to_user).addr AS receiver_addr
FROM shipments; id | receiver_name | receiver_mobile | receiver_zip | receiver_addr
----+---------------+-----------------+--------------+-----------------------------
1 | 张三 | 13800138000 | 310000 | 杭州市西湖区文一西路 100 号
(1 row)步骤 5:查看域与复合类型的依赖关系
SELECT t.typname AS composite_type,
a.attname AS field_name,
format_type(a.atttypid, a.atttypmod) AS field_type
FROM pg_type t
JOIN pg_attribute a ON a.attrelid = t.typrelid
WHERE t.typname = 'receiver'
ORDER BY a.attnum; composite_type | field_name | field_type
----------------+------------+-------------
receiver | name | text
receiver | mobile | phone_number
receiver | zip | zip_code
receiver | addr | text
(4 rows)field_type 一栏直接显示了域名——复合类型里嵌域,约束照样穿透生效,format_type 也能正确显示域名。
注意事项
域约束看不到其他列。
CHECK (VALUE > 0)里的VALUE就是全部世界,写CHECK (end_date > start_date)会直接报错。跨列规则只能放表级 CHECK。NOT NULL放域上要三思。 一旦域带NOT NULL,所有引用它的列都失去允许 NULL 的自由,且无法在列上"反向取消"。除非业务上绝对永远非空,否则把非空写在列上。改底层类型可能重写整表。
ALTER DOMAIN d SET DATA TYPE bigint会连锁到所有依赖列,数据量大时锁表时间很长。改前务必在预发环境评估。ALTER DOMAIN ADD CONSTRAINT默认校验存量数据。 存量有脏数据时会直接失败并回滚。大表先用NOT VALID加,清理数据后再VALIDATE CONSTRAINT。域的默认值不如列默认值直观。
\d 表名不会显示域自带的默认值,容易让后来者误以为该列没有默认。团队内应约定"默认值一律写列上"。数组域别写成
域[]。CREATE TABLE t (tags tag_list[])得到的是二维数组,不是一维数组。数组域的列类型直接写tag_list。约束名不显式指定会很难读。 自动名是
{域名}_check,多个约束时变成_check1、_check2。报错信息里看到这个没法定位,所以每个约束都起名。正则表达式写宽了等于没写。
CHECK (VALUE ~ '@')这种邮箱校验会放进一堆垃圾。宁可写得严格一点,也不要用几乎恒真的表达式。域不能用于数组元素类型的"逐元素"校验之外的花样。 域基于数组时,
CHECK里只能对整体数组做判断(array_length、array_position等),不能对每个元素分别套约束。域约束表达式不能带子查询、不能引用序列。 也别在
CHECK里调用有副作用的函数(如nextval),那会导致不可预期的行为。删除域前先查依赖。 直接
DROP DOMAIN遇到依赖会拒绝,用DROP ... CASCADE前先确认会连带删掉哪些列——它会真的把列删掉。域在 ORM 眼里就是底层类型。 Django / SQLAlchemy 等 ORM 反射时看到的是
text、numeric,域的"业务含义"在 ORM 层会丢失。跨语言协作时要在文档里说明。pg_dump会正确处理域。 导出时会先建域再建表,迁移到新库不会丢约束,但要注意导出顺序依赖(域必须在用它的表之前创建)。域和
varchar(n)的长度限制是两回事。CREATE DOMAIN code AS varchar(8)的长度是底层类型修饰,会真正截断/报错;而CHECK (char_length(VALUE) <= 8)才是约束。二者可叠加,别搞混。别把域当作"微服务契约"。 域只在单个数据库实例内有效,跨服务的数据一致性还得靠应用层或消息契约来保证。
最佳实践
只把"稳定、通用、单值可判断"的规则做成域。 手机号、邮箱、金额区间、比例、邮编——这类规则改得少、复用广,收益最大。
约束一律显式命名。 命名规则建议
{域名}_{规则含义},如money_amount_positive、cn_mobile_format。报错时能一眼定位。非空和默认值优先写在列上。 域只负责"值的合法性",不负责"这个字段必不必须填"。职责分离,灵活性更高。
域名用业务语言,不用技术语言。
phone_number比text_pattern_1好一百倍;money_amount比numeric_12_2_positive好。域定义集中放在一个迁移文件里。 例如
db/migrations/001_domains.sql,所有表迁移文件都依赖它,顺序清晰。修改域约束永远走"加新的 → 删旧的 → 校验存量"三步。 直接
DROP再ADD中间有空窗期,并发写入可能溜进脏数据。大表加约束用
NOT VALID+VALIDATE CONSTRAINT两段式。 前者瞬间完成不锁表,后者可以选在业务低峰执行,是生产环境的标配做法。给域写
COMMENT。COMMENT ON DOMAIN email_address IS '邮箱地址,仅做格式粗校验,不验证域名真实性';把"校验的边界"说清楚,避免使用者误以为它是万能的。域与业务校验分工明确。 域只做"格式 / 范围"这类单值、无外部依赖的校验;"这个手机号是否已被注册""这个 SKU 是否在售"属于业务逻辑,必须放应用层或触发器。
域的数量控制在 20~40 个之间。 太少说明没提炼出复用点,太多说明过度设计(比如给每个字段都建一个域)。数量级参考:金额类 3~5 个,文本格式类 10 个左右,数值区间类 5 个左右。
跨环境用同一份 DDL 建域。 开发、测试、生产的域定义必须一致,否则数据导入时会因约束不同而失败。
升级路径要在设计阶段想清楚。 定义域时就问:"这条规则以后放宽/收紧时,我的迁移怎么写?"答案是"加新约束 → 删旧约束 → 校验",设计时确保规则之间不会互相冲突(例如新旧约束不能同时有效)。
下一节
继续学习:类型转换(CAST)
参考链接
- PostgreSQL 官方文档:Domain Types
- PostgreSQL 官方文档:CREATE DOMAIN
- PostgreSQL 官方文档:ALTER DOMAIN
- PostgreSQL 官方文档:DROP DOMAIN