Skip to content

域类型(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 TABLEALTER DOMAIN 一条
规则遗漏风险高(靠人记)低(靠类型系统)
文档性弱(numeric(10,2) 看不出业务含义)强(列类型就叫 money_amount)

域的第二重价值容易被忽略:自文档化。phone phone_number 比 phone text 一眼就能看出这列该存什么。

域与底层类型的运行时开销 ​

操作域底层类型差异
存储大小完全相同完全相同无
读取完全相同完全相同无
写入多跑一次 CHECK无极小(微秒级)
索引完全可用完全可用无
排序 / 比较完全一致完全一致无

结论:域的性能代价只在写入路径上的约束检查,可以忽略不计。 它不是"包装类型",运行时没有额外解包动作。

语法与用法 ​

创建域 ​

sql
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 (...)值域约束,可写多个可省

最小示例 ​

sql
-- 正数金额:基于 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 最大的能力差别。

带命名约束与默认值 ​

sql
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);

给约束起名字很重要:报错时会带上约束名,一眼知道违反了哪条规则。

在表里使用域 ​

sql
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()
);

列类型直接写域名即可,用起来和内置类型没有区别。

查看域信息 ​

sql
-- 列出所有域(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底层类型的数组维度(域可以基于数组)

查看域上的约束 ​

sql
SELECT conname     AS constraint_name,
       pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE contypid = 'money_amount'::regtype
ORDER BY conname;
text
  constraint_name   |              definition
--------------------+--------------------------------------
 money_amount_check | CHECK ((VALUE > (0)::numeric))
(1 row)

修改与删除域 ​

sql
-- 增加新约束
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 NULLSET NOT NULL 会扫表校验存量
改底层类型SET DATA TYPE new_type只有"二进制兼容"的改动才免重写;不可逆时可能失败
重命名RENAME TO new_name依赖的表定义自动跟随
改属主 / schemaOWNER TO / SET SCHEMA需要权限

NOT VALID 的用法值得记一笔,它是大表加约束的救命稻草:

sql
-- 先加上约束但不校验存量数据(瞬间完成,不锁表扫表)
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 是整个数组,可以写聚合式的检查:

sql
-- 非空数组且最多 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 的数组(二维!)

第二种写法会得到一个"域的数组"即二维数组,几乎不会是想要的结果。用数组域时,列类型不要再加 []。

域上的约束是"累积"的 ​

同类型域可以有多个约束,全部都要满足:

sql
CREATE DOMAIN rate AS numeric(5, 4)
  CHECK (VALUE >= 0)
  CHECK (VALUE <= 1);

SELECT 0.5::rate;    -- 通过
text
SELECT 1.5::rate;
text
ERROR:  value for domain rate violates check constraint "rate_check"

注意报错里的约束名:如果没显式命名,PostgreSQL 自动命名为 {域名}_check;有多个未命名约束时会叫 rate_check1、rate_check2,可读性差,所以建议始终显式命名。

域与类型转换的关系 ​

域到底层类型是隐式(implicit)的,反过来则需要在赋值上下文中才转换:

sql
SELECT '12345678901'::phone_number;         -- OK,text → phone_number
SELECT phone_number('12345678901');         -- 也可以函数调用式写法

-- 域到底层类型:自动
SELECT pg_typeof('12345678901'::phone_number::text);
text
 pg_typeof
-----------
 text
(1 row)

反过来,普通 text 赋给 phone_number 列时会走"赋值转换"(assignment cast),所以 INSERT / UPDATE 能直接写字符串字面量,不需要显式 ::phone_number。

域的默认值优先级 ​

一个容易混乱的点:域的 DEFAULT 和列的 DEFAULT 同时存在时,列级覆盖域级。

sql
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;
text
 t1_n | t2_n
------+------
    1 |    5
(1 row)

域与函数、存储过程 ​

域最被低估的用途:作为函数参数与返回类型,让业务规则在 PL/pgSQL 里也生效。

sql
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 元
text
SELECT charge(-1);
text
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

域的依赖关系 ​

域被表或函数使用后,就产生了依赖。想删域必须处理这些依赖:

sql
DROP DOMAIN phone_number;
text
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 里记录了这些依赖关系:

sql
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 取值含义:

取值含义
nnormal,正常依赖,删被依赖者会阻止
aauto,自动依赖(如序列属于列)
iinternal
eextension 成员

实战示例 ​

案例一:电商核心域的完整落地 ​

目标:把金额、数量、手机号、邮箱这四类反复出现的规则固化成域,一次性解决全站校验。

步骤 1:定义域

sql
-- 清场(幂等)
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:建表使用域

sql
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:写入合法数据

sql
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:验证非法数据被拦截

sql
-- 错误!金额为负
INSERT INTO orders (order_no, phone, total) VALUES ('SO20260922004', '13800138000', -1);
text
ERROR:  value for domain money_amount violates check constraint "money_amount_positive"
sql
-- 错误!手机号格式不对
INSERT INTO orders (order_no, phone, total) VALUES ('SO20260922005', '1380013800', 100);
text
ERROR:  value for domain phone_number violates check constraint "phone_number_cn"
sql
-- 错误!数量为 0
INSERT INTO order_items (order_id, sku, qty, price) VALUES (1, 'SKU-X', 0, 10);
text
ERROR:  value for domain quantity violates check constraint "quantity_positive"
sql
-- 错误!邮箱格式不对
INSERT INTO orders (order_no, phone, email, total) VALUES ('SO20260922006', '13800138000', 'not-an-email', 10);
text
ERROR:  value for domain email_address violates check constraint "email_address_basic"

步骤 5:查询带金额计算的视图

sql
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;
text
 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:索引照常可用

sql
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';
text
                     QUERY PLAN
-----------------------------------------------------
 Index Scan using idx_orders_phone on orders
   Index Cond: (phone = '13800138000'::text)
(2 rows)

注意规划器输出里 phone 被当作 text 处理——索引、运算完全按底层类型走,域不产生任何额外开销。

案例二:用域做"跨表统一约束"的集中治理 ​

目标:演示域改一次、全站生效的威力,以及大表加约束的 NOT VALID 路径。

步骤 1:两个业务表共用同一个域

sql
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:业务规则变化——需要支持国际号码

sql
-- 第一步:加一条更宽松的约束(先不校验存量)
ALTER DOMAIN cn_mobile ADD CONSTRAINT cn_mobile_intl
  CHECK (VALUE ~ '^(\+?86)?1[3-9]\d{9}$' OR VALUE ~ '^\+\d{6,15}$') NOT VALID;

-- 第二步:此时旧约束还在,写国际号仍会被拦
text
INSERT INTO members (name, mobile) VALUES ('王五', '+8613800138000');
text
ERROR:  value for domain cn_mobile violates check constraint "cn_mobile_format"

步骤 3:删掉旧约束,新约束生效

sql
ALTER DOMAIN cn_mobile DROP CONSTRAINT cn_mobile_format;

-- 现在国际号可以写入
INSERT INTO members (name, mobile) VALUES ('王五', '+8613800138000');
INSERT INTO members (name, mobile) VALUES ('赵六', '+14155552671');

步骤 4:校验存量数据

sql
ALTER DOMAIN cn_mobile VALIDATE CONSTRAINT cn_mobile_intl;
SELECT id, name, mobile FROM members ORDER BY id;
text
 id | name |     mobile
----+------+----------------
  1 | 张三 | 13800138000
  2 | 王五 | +8613800138000
  3 | 赵六 | +14155552671
(3 rows)

步骤 5:确认两个表同时生效

sql
-- 直接在 employees 表插入国际号,无需改表定义
INSERT INTO employees (name, mobile) VALUES ('孙七', '+819012345678');
SELECT 'members' AS tbl, count(*) FROM members
UNION ALL
SELECT 'employees', count(*) FROM employees;
text
   tbl     | count
-----------+-------
 members   |     3
 employees |     2
(2 rows)

一次 ALTER DOMAIN,两张表、五列同时获得新规则——这就是域的核心价值。

案例三:域 + 枚举 + 复合类型的组合技 ​

目标:把域嵌进复合类型,做出一个自带全部校验的"收货地址"值对象。

步骤 1:准备基础域

sql
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:用域做复合类型的字段

sql
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:写入并观察校验穿透

sql
INSERT INTO shipments (to_user) VALUES
  (ROW('张三', '13800138000', '310000', '杭州市西湖区文一西路 100 号')::receiver);
text
-- 错误!复合字段里的手机号同样被域约束拦截
INSERT INTO shipments (to_user) VALUES
  (ROW('李四', '138001380', '310000', '杭州市')::receiver);
text
ERROR:  value for domain phone_number violates check constraint "phone_number_cn"
sql
-- 错误!邮编不合法
INSERT INTO shipments (to_user) VALUES
  (ROW('王五', '13800138000', '31000', '杭州市')::receiver);
text
ERROR:  value for domain zip_code violates check constraint "zip_code_cn"

步骤 4:字段级读取

sql
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;
text
 id | receiver_name | receiver_mobile | receiver_zip |        receiver_addr
----+---------------+-----------------+--------------+-----------------------------
  1 | 张三          | 13800138000     | 310000       | 杭州市西湖区文一西路 100 号
(1 row)

步骤 5:查看域与复合类型的依赖关系

sql
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;
text
 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 也能正确显示域名。

注意事项 ​

  1. 域约束看不到其他列。 CHECK (VALUE > 0) 里的 VALUE 就是全部世界,写 CHECK (end_date > start_date) 会直接报错。跨列规则只能放表级 CHECK。

  2. NOT NULL 放域上要三思。 一旦域带 NOT NULL,所有引用它的列都失去允许 NULL 的自由,且无法在列上"反向取消"。除非业务上绝对永远非空,否则把非空写在列上。

  3. 改底层类型可能重写整表。 ALTER DOMAIN d SET DATA TYPE bigint 会连锁到所有依赖列,数据量大时锁表时间很长。改前务必在预发环境评估。

  4. ALTER DOMAIN ADD CONSTRAINT 默认校验存量数据。 存量有脏数据时会直接失败并回滚。大表先用 NOT VALID 加,清理数据后再 VALIDATE CONSTRAINT。

  5. 域的默认值不如列默认值直观。 \d 表名 不会显示域自带的默认值,容易让后来者误以为该列没有默认。团队内应约定"默认值一律写列上"。

  6. 数组域别写成 域[]。 CREATE TABLE t (tags tag_list[]) 得到的是二维数组,不是一维数组。数组域的列类型直接写 tag_list。

  7. 约束名不显式指定会很难读。 自动名是 {域名}_check,多个约束时变成 _check1、_check2。报错信息里看到这个没法定位,所以每个约束都起名。

  8. 正则表达式写宽了等于没写。 CHECK (VALUE ~ '@') 这种邮箱校验会放进一堆垃圾。宁可写得严格一点,也不要用几乎恒真的表达式。

  9. 域不能用于数组元素类型的"逐元素"校验之外的花样。 域基于数组时,CHECK 里只能对整体数组做判断(array_length、array_position 等),不能对每个元素分别套约束。

  10. 域约束表达式不能带子查询、不能引用序列。 也别在 CHECK 里调用有副作用的函数(如 nextval),那会导致不可预期的行为。

  11. 删除域前先查依赖。 直接 DROP DOMAIN 遇到依赖会拒绝,用 DROP ... CASCADE 前先确认会连带删掉哪些列——它会真的把列删掉。

  12. 域在 ORM 眼里就是底层类型。 Django / SQLAlchemy 等 ORM 反射时看到的是 text、numeric,域的"业务含义"在 ORM 层会丢失。跨语言协作时要在文档里说明。

  13. pg_dump 会正确处理域。 导出时会先建域再建表,迁移到新库不会丢约束,但要注意导出顺序依赖(域必须在用它的表之前创建)。

  14. 域和 varchar(n) 的长度限制是两回事。 CREATE DOMAIN code AS varchar(8) 的长度是底层类型修饰,会真正截断/报错;而 CHECK (char_length(VALUE) <= 8) 才是约束。二者可叠加,别搞混。

  15. 别把域当作"微服务契约"。 域只在单个数据库实例内有效,跨服务的数据一致性还得靠应用层或消息契约来保证。

最佳实践 ​

  1. 只把"稳定、通用、单值可判断"的规则做成域。 手机号、邮箱、金额区间、比例、邮编——这类规则改得少、复用广,收益最大。

  2. 约束一律显式命名。 命名规则建议 {域名}_{规则含义},如 money_amount_positive、cn_mobile_format。报错时能一眼定位。

  3. 非空和默认值优先写在列上。 域只负责"值的合法性",不负责"这个字段必不必须填"。职责分离,灵活性更高。

  4. 域名用业务语言,不用技术语言。 phone_number 比 text_pattern_1 好一百倍;money_amount 比 numeric_12_2_positive 好。

  5. 域定义集中放在一个迁移文件里。 例如 db/migrations/001_domains.sql,所有表迁移文件都依赖它,顺序清晰。

  6. 修改域约束永远走"加新的 → 删旧的 → 校验存量"三步。 直接 DROP 再 ADD 中间有空窗期,并发写入可能溜进脏数据。

  7. 大表加约束用 NOT VALID + VALIDATE CONSTRAINT 两段式。 前者瞬间完成不锁表,后者可以选在业务低峰执行,是生产环境的标配做法。

  8. 给域写 COMMENT。 COMMENT ON DOMAIN email_address IS '邮箱地址,仅做格式粗校验,不验证域名真实性'; 把"校验的边界"说清楚,避免使用者误以为它是万能的。

  9. 域与业务校验分工明确。 域只做"格式 / 范围"这类单值、无外部依赖的校验;"这个手机号是否已被注册""这个 SKU 是否在售"属于业务逻辑,必须放应用层或触发器。

  10. 域的数量控制在 20~40 个之间。 太少说明没提炼出复用点,太多说明过度设计(比如给每个字段都建一个域)。数量级参考:金额类 3~5 个,文本格式类 10 个左右,数值区间类 5 个左右。

  11. 跨环境用同一份 DDL 建域。 开发、测试、生产的域定义必须一致,否则数据导入时会因约束不同而失败。

  12. 升级路径要在设计阶段想清楚。 定义域时就问:"这条规则以后放宽/收紧时,我的迁移怎么写?"答案是"加新约束 → 删旧约束 → 校验",设计时确保规则之间不会互相冲突(例如新旧约束不能同时有效)。

下一节

继续学习:类型转换(CAST)

参考链接 ​