枚举类型(Enum)
枚举类型(Enum Type)是一组有限、固定、有序的字符串值集合。它把业务值域直接固化进 schema,写入非法值时数据库立刻报错,而不是等到应用层才发现脏数据。订单状态、审核状态、会员等级这类"状态机字段"是最典型的应用场景。
前置知识
阅读本节前,建议先了解:复合类型(Composite Type)
基础概念
为什么用枚举而不是 text + CHECK
三种常见做法的对比:
| 方案 | 写入校验 | 值域集中管理 | 排序语义 | 存储开销 | 改值域成本 |
|---|---|---|---|---|---|
text + CHECK | 有,但分散在每张表 | 差(约束散落) | 按字母序 | 变长,含重复字符串 | 改 CHECK 需扫表 |
varchar 无约束 | 无 | 无 | 按字母序 | 变长 | 无 |
| 枚举类型 | 强,类型系统级 | 好(一个类型一处定义) | 按声明顺序 | 4 字节 | 加值容易,删值很难 |
枚举的核心优势有三条:
- 类型级校验。 不只是这一张表,任何接受该类型的函数、变量、视图都会校验。
- 业务顺序排序。
ORDER BY status直接按pending → paid → shipped的声明顺序,不用写CASE WHEN。 - 固定 4 字节存储。 值本身存在
pg_enum里,表里只存 4 字节的引用(oid),比重复存字符串省空间。
枚举的存储模型
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'done', 'cancelled');这条语句在系统目录里产生了两个层次:
| 层次 | 位置 | 内容 |
|---|---|---|
| 类型定义 | pg_type 一行 | 类型名、长度 4、typtype = 'e' |
| 枚举值 | pg_enum 多行 | 每个值的标签(enumlabel)与排序号(enumsortorder) |
-- 表里存的到底是什么?看存储层面
SELECT 'paid'::order_status AS value,
pg_column_size('paid'::order_status) AS bytes,
pg_column_size('pending'::order_status) AS bytes2; value | bytes | bytes2
-------+-------+--------
paid | 4 | 4
(1 row)-- 对比 text 的占用(长度不同大小不同)
SELECT pg_column_size('paid'::text) AS as_text, -- 5(含 1 字节头)
pg_column_size('pending'::text) AS as_text2; -- 8 as_text | as_text2
---------+----------
5 | 8
(2 rows)-- 查看枚举值与排序号
SELECT enumlabel AS label, enumsortorder AS ord
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder; label | ord
----------+-----
pending | 1
paid | 2
shipped | 3
done | 4
cancelled| 5
(5 rows)术语提示
枚举类型(Enum Type)、枚举标签(Enum Label)、排序号(Sort Order)、值域(Value Domain)、状态机(State Machine)、前向兼容(Forward Compatibility)。
枚举的排序规则
这是枚举最容易被误解的地方:排序按声明顺序,不是字母序。
SELECT status
FROM (VALUES ('done'::order_status),
('pending'::order_status),
('shipped'::order_status),
('paid'::order_status)) AS t(status)
ORDER BY status; status
-----------
pending
paid
shipped
done
(4 rows)-- 对比:同样的值以 text 存储时,走的是字母序
SELECT status
FROM (VALUES ('done'), ('pending'), ('shipped'), ('paid')) AS t(status)
ORDER BY status; status
-----------
done
paid
pending
shipped
(4 rows)枚举转成 text 后排序会改变
ORDER BY status::text 会退化成字母序。一旦用了强制类型转换,业务顺序就丢了——这是很隐蔽的 bug 来源。
枚举 vs CHECK 约束 vs 外键表
选型时把三种方案摊开看:
| 对比项 | 枚举类型 | CHECK 约束 | 小整数 + 外键表 |
|---|---|---|---|
| 值域定义位置 | 类型(全局复用) | 每张表(或每个 ALTER) | 独立表(可含名称、描述) |
| 写入非法值 | 报 invalid input value for enum | 报 violates check constraint | 报外键冲突 |
| 增加新值 | ALTER TYPE ... ADD VALUE | ALTER TABLE ... DROP/ADD CONSTRAINT(会全表校验) | 插入一行即可 |
| 删除值 | 不支持 | 改约束即可 | 删除行即可 |
| 值附带元数据(展示名、排序、颜色) | 不支持 | 不支持 | 支持 |
| 业务顺序排序 | 原生支持 | 需 CASE WHEN | 需 JOIN + 排序列 |
| 存储开销 | 4 字节 | 取决于底层类型 | 2 或 4 字节 |
| 适合场景 | 值域稳定、不需要元数据 | 临时约束、跨库迁移 | 值域需要管理界面、需元数据 |
经验规则:
- 值域稳定且不需要元数据 → 枚举。
- 值域需要运营后台维护(如"标签分类"),或需要展示名/排序/颜色 → 外键表。
- 只是一次性的数据清洗校验 →
CHECK。
建表时的三种写法
-- 写法一:直接用枚举类型(推荐)
CREATE TABLE orders_a (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending'
);
-- 写法二:加 CHECK 做"子集约束"(枚举 + 约束组合)
CREATE TABLE orders_b (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending',
-- 只允许"进行中"的状态,不允许直接写入终态
CONSTRAINT chk_status_active CHECK (status IN ('pending', 'paid', 'shipped'))
);
-- 写法三:可空枚举(允许"未设置")
CREATE TABLE orders_c (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status order_status -- 无 NOT NULL,无 DEFAULT
);语法与用法
创建枚举
CREATE TYPE order_status AS ENUM (
'pending', -- 待付款
'paid', -- 已付款
'shipped', -- 已发货
'done', -- 已完成
'cancelled' -- 已取消
);
COMMENT ON TYPE order_status IS '订单状态:pending → paid → shipped → done;任意状态可转 cancelled';-- 验证创建结果
\dT+ order_status List of data types
Schema | Name | Internal name | Size | Elements | Owner | Access privileges | Description
--------+--------------+---------------+------+------------+----------+-------------------+-------------
public | order_status | order_status | 4 | pending +| postgres | | 订单状态:pending
| | | | paid +| | |
| | | | shipped +| | |
| | | | done +| | |
| | | | cancelled | | |
(1 row)-- 也可以用 \dT 的专用元命令查看所有枚举
\dT写入与校验
INSERT INTO orders_a (status) VALUES ('pending'), ('paid'), ('shipped');
-- 大小写敏感!'Paid' 不是合法值
INSERT INTO orders_a (status) VALUES ('Paid');ERROR: invalid input value for enum order_status: "Paid"
LINE 1: INSERT INTO orders_a (status) VALUES ('Paid');
^-- 错误!不在值域内
INSERT INTO orders_a (status) VALUES ('refunded');ERROR: invalid input value for enum order_status: "refunded"
LINE 1: INSERT INTO orders_a (status) VALUES ('refunded');
^枚举值大小写敏感
'paid' 与 'Paid' 是两个完全不同的值。建议统一用小写加下划线,与 SQL 标识符风格保持一致。
排序与比较
-- 按业务顺序比较:可以像数字一样用 < >
SELECT 'pending'::order_status < 'paid'::order_status AS a,
'shipped'::order_status > 'done'::order_status AS b,
'done'::order_status = 'cancelled'::order_status AS c; a | b | c
---+---+---
t | f | f
(4 rows)-- min / max 也按业务顺序
SELECT min(status) AS first_status,
max(status) AS last_status
FROM orders_a; first_status | last_status
--------------+-------------
pending | shipped
(1 row)枚举不能与整数比较
WHERE status = 2 会直接报类型错误。枚举只能与字符串字面量(会隐式转换)或同类型值比较。
-- 错误!operator does not exist: order_status = integer
SELECT * FROM orders_a WHERE status = 2;枚举支持的函数与操作符
| 操作 / 函数 | 语法 | 说明 |
|---|---|---|
| 相等 | a = b | 按排序号比较 |
| 大小比较 | a < b, a > b | 按声明顺序 |
| 排序 | ORDER BY a | 业务顺序 |
| 取最小 / 最大 | min(a) / max(a) | 按业务顺序 |
| 取全部值 | enum_range(a) | 需要传入一个枚举值 |
| 取全部值(带类型) | enum_range(NULL::order_status) | 常用写法 |
| 判断是否在集合内 | a IN ('paid','shipped') | 与 text 一致 |
| 转文本 | a::text | 得到标签字符串 |
| 序列位置 | array_position(enum_range(NULL::order_status), a) | 得到从 1 开始的序号 |
-- 列出全部枚举值(用 enum_range)
SELECT unnest(enum_range(NULL::order_status)) AS all_status; all_status
------------
pending
paid
shipped
done
cancelled
(5 rows)-- 取某个值的序号(从 1 开始)
SELECT array_position(enum_range(NULL::order_status), 'shipped'::order_status) AS pos; pos
-----
3
(1 row)ALTER TYPE:能改什么、不能改什么
| 操作 | 支持 | 语句 | 版本要求 | 代价 |
|---|---|---|---|---|
| 追加值到末尾 | 是 | ADD VALUE 'x' | 全版本 | 极低(只改目录) |
| 追加到指定位置 | 是 | ADD VALUE 'x' BEFORE 'y' | 全版本 | 重写表 |
| 追加(幂等) | 是 | ADD VALUE IF NOT EXISTS 'x' | 9.3+ | 极低 |
| 重命名某个值 | 是 | RENAME VALUE 'a' TO 'b' | 10+ | 只改目录 |
| 删除某个值 | 否 | — | — | 必须重建类型 |
| 修改值顺序 | 否 | — | — | 只能靠新增 + 迁移 |
| 重命名类型 | 是 | RENAME TO | 全版本 | 只改目录 |
-- 追加到末尾:最常用,开销最小
ALTER TYPE order_status ADD VALUE 'refunding' AFTER 'paid';
-- 查看结果:refunding 插在 paid 与 shipped 之间
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder; enumlabel | enumsortorder
-------------+---------------
pending | 1
paid | 2
refunding | 3
shipped | 4
done | 5
cancelled | 6
(6 rows)-- 重命名值(PostgreSQL 10+):'done' 改成 'completed'
ALTER TYPE order_status RENAME VALUE 'done' TO 'completed';ALTER TYPE-- 幂等追加(脚本可重复执行)
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'completed';NOTICE: enum label "completed" already exists, skipping
ALTER TYPE版本说明
PostgreSQL 12 起,ALTER TYPE ... ADD VALUE 可以在事务块中执行。但即使如此,不能在同一个事务里"新增值 + 写入该值"——新值在本事务的快照中不可见,会报 unsafe use of new value。
-- 错误!同一事务中新增值并立即使用
BEGIN;
ALTER TYPE order_status ADD VALUE 'archived';
UPDATE orders_a SET status = 'archived' WHERE id = 1;
COMMIT;ERROR: unsafe use of new value "archived" of enum type order_status
HINT: New enum values must be committed before they can be used.详细说明
枚举值顺序与排序号的本质
enumsortorder 是 real(单精度浮点)类型,PostgreSQL 通过"取相邻两值的中点"来插入新值,因此无需重排已有值。
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder; enumlabel | enumsortorder
-------------+---------------
pending | 1
paid | 2
refunding | 3
shipped | 4
completed | 5
cancelled | 6
(6 rows)-- 往中间插值时,排序号会变成小数(这里插到 pending 与 paid 之间)
ALTER TYPE order_status ADD VALUE 'drafting' BEFORE 'paid';
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder; enumlabel | enumsortorder
-------------+---------------
pending | 1
drafting | 1.5
paid | 2
refunding | 3
shipped | 4
completed | 5
cancelled | 6
(7 rows)反复往同一位置插值会耗尽精度
enumsortorder 是单精度浮点,约 24 位有效位。理论上在同一个位置反复插入几十次后,中点会精度不足触发"重新编号"。实践中很少遇到,但批量插入枚举值到中间位置是危险操作。
枚举值顺序的迁移难题
假设线上已经有一个值 cancelled,但业务要求把它改名为 canceled(美式拼写)。步骤:
-- 1. 追加新值(放末尾)
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'canceled';
-- 2. 迁移数据
UPDATE orders_a SET status = 'canceled' WHERE status = 'cancelled';
-- 3. 旧值无法删除,只能保留在类型里(或重建类型)-- 3'. 如果一定要删掉旧值:重建类型
-- 3'.1 建新类型
CREATE TYPE order_status_v2 AS ENUM (
'pending', 'drafting', 'paid', 'refunding', 'shipped', 'completed', 'canceled'
);
-- 3'.2 改列类型(会重写表,持有 ACCESS EXCLUSIVE 锁)
ALTER TABLE orders_a
ALTER COLUMN status TYPE order_status_v2
USING status::text::order_status_v2;
-- 3'.3 删旧类型、重命名
DROP TYPE order_status;
ALTER TYPE order_status_v2 RENAME TO order_status;ALTER TYPE重建类型期间业务不可用
第 3'.2 步会重写整张表。10 万行以下的表通常在秒级完成,千万行级别可能需要数分钟,期间该表的读写都会阻塞。生产环境必须走"建影子表 → 分批迁移 → 切换"或"先在低峰期演练"的流程。
枚举的存储与索引
-- 枚举列的索引与普通列一致
CREATE INDEX idx_orders_a_status ON orders_a (status);
-- 部分索引也很常用
CREATE INDEX idx_orders_a_active ON orders_a (status)
WHERE status IN ('pending', 'paid');-- 枚举列的统计信息
SELECT attname,
atttypid::regtype AS type,
CASE attstorage
WHEN 'p' THEN 'plain'
WHEN 'e' THEN 'external'
WHEN 'm' THEN 'main'
WHEN 'x' THEN 'extended'
END AS storage
FROM pg_attribute
WHERE attrelid = 'orders_a'::regclass
AND attnum > 0; attname | type | storage
---------+-----------------+---------
id | bigint | plain
status | order_status | plain
(2 rows)-- 枚举列的直方图统计(供规划器估算选择率)
ANALYZE orders_a;
SELECT attname,
n_distinct,
most_common_vals
FROM pg_stats
WHERE tablename = 'orders_a' AND attname = 'status'; attname | n_distinct | most_common_vals
---------+------------+----------------------------------
status | 4 | {pending,paid,shipped,cancelled}
(1 row)枚举与数组
枚举数组同样合法,用于"多选"场景。
-- 枚举数组
SELECT ARRAY['paid'::order_status, 'shipped'::order_status] AS arr; arr
-------------------------------
{paid,shipped}
(1 row)-- 错误!数组元素类型必须一致
SELECT ARRAY['paid'::order_status, 'unknown'];ERROR: invalid input value for enum order_status: "unknown"
LINE 1: SELECT ARRAY['paid'::order_status, 'unknown'];枚举与 JSONB 的转换
-- 枚举转 JSON 就是字符串
SELECT to_jsonb('paid'::order_status) AS j; j
--------
"paid"
(1 row)-- JSON 字符串转枚举
SELECT '{"status":"paid"}'::jsonb ->> 'status' AS raw_text,
('{"status":"paid"}'::jsonb ->> 'status')::order_status AS as_enum; raw_text | as_enum
----------+---------
paid | paid
(1 row)-- jsonb_populate_record 自动转换
CREATE TABLE tmp_order (id bigint, status order_status);
SELECT (jsonb_populate_record(NULL::tmp_order, '{"id":1,"status":"shipped"}'::jsonb)).*; id | status
----+---------
1 | shipped
(1 row)实战示例
案例一:订单状态机完整实现
DROP TABLE IF EXISTS orders CASCADE;
DROP TYPE IF EXISTS order_status CASCADE;
CREATE TYPE order_status AS ENUM (
'pending', -- 待付款
'paid', -- 已付款
'shipped', -- 已发货
'done', -- 已完成
'cancelled' -- 已取消
);
COMMENT ON TYPE order_status IS '订单状态机:pending → paid → shipped → done;pending/paid 可转 cancelled';
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no char(16) NOT NULL UNIQUE,
status order_status NOT NULL DEFAULT 'pending',
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz,
shipped_at timestamptz,
CONSTRAINT chk_paid_has_time
CHECK ((status = 'pending') OR (status <> 'pending' AND paid_at IS NOT NULL) OR status = 'cancelled')
);步骤 1:插入业务数据
-- 1. 模拟一天的订单
INSERT INTO orders (order_no, status, amount, paid_at, shipped_at) VALUES
('2026092100000001', 'done', 1280.00, now() - interval '2 days', now() - interval '1 day'),
('2026092100000002', 'paid', 390.50, now() - interval '3 hours', NULL),
('2026092100000003', 'pending', 899.00, NULL, NULL),
('2026092100000004', 'shipped', 1580.00, now() - interval '1 day', now() - interval '2 hours'),
('2026092100000005', 'cancelled', 260.00, NULL, NULL),
('2026092100000006', 'paid', 520.00, now() - interval '1 hour', NULL),
('2026092100000007', 'pending', 99.90, NULL, NULL),
('2026092100000008', 'done', 2400.00, now() - interval '5 days', now() - interval '4 days');
ANALYZE orders;步骤 2:按业务顺序聚合
-- 2. 统计各状态订单数与金额(顺序即枚举声明顺序)
SELECT status,
count(*) AS cnt,
round(sum(amount), 2) AS total,
round(avg(amount), 2) AS avg_amount
FROM orders
GROUP BY status
ORDER BY status; status | cnt | total | avg_amount
-----------+-----+---------+------------
pending | 2 | 998.90 | 499.45
paid | 2 | 910.50 | 455.25
shipped | 1 | 1580.00 | 1580.00
done | 2 | 3680.00 | 1840.00
cancelled | 1 | 260.00 | 260.00
(5 rows)步骤 3:状态流转函数
-- 3. 用枚举类型做参数,用状态机约束流转
CREATE OR REPLACE FUNCTION transit_order(p_id bigint, p_to order_status)
RETURNS order_status
LANGUAGE plpgsql AS $$
DECLARE
v_from order_status;
BEGIN
SELECT status INTO v_from FROM orders WHERE id = p_id FOR UPDATE;
IF v_from IS NULL THEN
RAISE EXCEPTION '订单 % 不存在', p_id;
END IF;
IF NOT (
(v_from = 'pending' AND p_to IN ('paid', 'cancelled'))
OR (v_from = 'paid' AND p_to IN ('shipped', 'cancelled'))
OR (v_from = 'shipped' AND p_to = 'done')
) THEN
RAISE EXCEPTION '非法状态流转:% -> %', v_from, p_to;
END IF;
UPDATE orders
SET status = p_to,
paid_at = CASE WHEN p_to = 'paid' THEN now() ELSE paid_at END,
shipped_at = CASE WHEN p_to = 'shipped' THEN now() ELSE shipped_at END
WHERE id = p_id;
RETURN p_to;
END;
$$;-- 4. 正常流转:pending → paid
SELECT transit_order(3, 'paid') AS after_transit; after_transit
---------------
paid
(1 row)-- 5. 错误!paid 不能直接到 done
SELECT transit_order(3, 'done');ERROR: 非法状态流转:paid -> done
CONTEXT: PL/pgSQL function transit_order(bigint,order_status) line 17 at RAISE步骤 4:无 CASE WHEN 的业务漏斗
-- 6. 用枚举的比较能力做漏斗:统计"至少到某状态"的订单数
SELECT s.label AS stage,
count(*) FILTER (WHERE o.status >= s.label) AS reached_cnt
FROM (SELECT unnest(enum_range(NULL::order_status)) AS label) s
LEFT JOIN orders o ON true
WHERE s.label <> 'cancelled'
GROUP BY s.label
ORDER BY s.label; stage | reached_cnt
-----------+-------------
pending | 8
paid | 7
shipped | 3
done | 2
(4 rows)这个查询不需要任何 CASE WHEN,因为枚举的 >= 直接表达了"业务进度"语义。
步骤 5:部分索引加速活跃订单查询
-- 7. 只索引"未完成"的订单,索引体积小、命中率高
CREATE INDEX idx_orders_active ON orders (status, created_at DESC)
WHERE status IN ('pending', 'paid', 'shipped');
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, status, amount
FROM orders
WHERE status IN ('pending', 'paid', 'shipped')
ORDER BY created_at DESC
LIMIT 5; QUERY PLAN
-------------------------------------------------------------------------------------------
Limit
-> Index Scan using idx_orders_active on orders
Index Cond: (status = ANY ('{pending,paid,shipped}'::order_status[]))
Planning Time: 0.203 ms
Execution Time: 0.021 ms
(5 rows)案例二:多级值域与枚举子集约束
-- 1. 审核状态枚举
DROP TABLE IF EXISTS articles CASCADE;
DROP TYPE IF EXISTS audit_status CASCADE;
CREATE TYPE audit_status AS ENUM (
'draft', -- 草稿
'submitted', -- 已提交待审
'reviewing', -- 审核中
'approved', -- 审核通过
'rejected', -- 审核驳回
'published' -- 已发布
);
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
status audit_status NOT NULL DEFAULT 'draft',
reject_reason text,
-- 子集约束:只有 rejected 才允许有驳回原因
CONSTRAINT chk_reject_reason
CHECK ((status = 'rejected' AND reject_reason IS NOT NULL)
OR (status <> 'rejected' AND reject_reason IS NULL))
);步骤 1:插入覆盖各状态的数据
-- 2. 插入
INSERT INTO articles (title, status, reject_reason) VALUES
('PostgreSQL 索引原理', 'published', NULL),
('PostgreSQL 事务隔离', 'approved', NULL),
('PostgreSQL 全文搜索', 'reviewing', NULL),
('PostgreSQL 复制原理', 'submitted', NULL),
('PostgreSQL 性能调优', 'draft', NULL),
('PostgreSQL 锁机制', 'rejected', '内容与已有文章重复'),
('PostgreSQL JSON 类型', 'published', NULL);
ANALYZE articles;步骤 2:统计各状态分布
-- 3. 按业务顺序统计
SELECT status, count(*) AS cnt
FROM articles
GROUP BY status
ORDER BY status; status | cnt
-----------+-----
draft | 1
submitted | 1
reviewing | 1
approved | 1
rejected | 1
published | 2
(6 rows)步骤 3:无绕过的状态推进
-- 4. 审核状态流转函数
CREATE OR REPLACE FUNCTION transit_article(p_id bigint, p_to audit_status,
p_reason text DEFAULT NULL)
RETURNS audit_status
LANGUAGE plpgsql AS $$
DECLARE
v_from audit_status;
BEGIN
SELECT status INTO v_from FROM articles WHERE id = p_id FOR UPDATE;
IF NOT (
(v_from = 'draft' AND p_to = 'submitted')
OR (v_from = 'submitted' AND p_to IN ('reviewing', 'rejected'))
OR (v_from = 'reviewing' AND p_to IN ('approved', 'rejected'))
OR (v_from = 'approved' AND p_to = 'published')
) THEN
RAISE EXCEPTION '非法审核流转:% -> %', v_from, p_to;
END IF;
UPDATE articles
SET status = p_to,
reject_reason = CASE WHEN p_to = 'rejected' THEN p_reason ELSE NULL END
WHERE id = p_id;
RETURN p_to;
END;
$$;-- 5. 正常流转
SELECT transit_article(4, 'reviewing') AS s1; s1
-----------
reviewing
(1 row)-- 6. 错误!draft 不能直接 approved
SELECT transit_article(5, 'approved');ERROR: 非法审核流转:draft -> approved
CONTEXT: PL/pgSQL function transit_article(bigint,audit_status) line 15 at RAISE步骤 4:校验子集约束真的生效
-- 7. 错误!published 不能带驳回原因(违反 chk_reject_reason)
INSERT INTO articles (title, status, reject_reason)
VALUES ('测试文章', 'published', '不该有原因');ERROR: new row for relation "articles" violates check constraint "chk_reject_reason"
DETAIL: Failing row contains (8, 测试文章, published, 不该有原因).案例三:枚举扩容与灰度迁移
业务新增"退款中"状态,需要在不停机的前提下扩容。
-- 1. 追加新值到 paid 之后(只改目录,瞬间完成)
ALTER TYPE audit_status ADD VALUE IF NOT EXISTS 'archiving' AFTER 'published';ALTER TYPE-- 2. 验证顺序正确
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'audit_status'::regtype
ORDER BY enumsortorder; enumlabel | enumsortorder
-------------+---------------
draft | 1
submitted | 2
reviewing | 3
approved | 4
rejected | 5
published | 6
archiving | 7
(7 rows)-- 3. 新值立即可用(如果上一步在独立事务中提交)
SELECT transit_article(1, 'archiving');ERROR: 非法审核流转:published -> archiving
CONTEXT: PL/pgSQL function transit_article(bigint,audit_status) line 15 at RAISE流转函数未更新,所以被拦下了——这正好证明状态机约束在 DB 层生效。更新函数后:
-- 4. 扩展流转规则,允许 published → archiving
CREATE OR REPLACE FUNCTION transit_article(p_id bigint, p_to audit_status,
p_reason text DEFAULT NULL)
RETURNS audit_status
LANGUAGE plpgsql AS $$
DECLARE
v_from audit_status;
BEGIN
SELECT status INTO v_from FROM articles WHERE id = p_id FOR UPDATE;
IF NOT (
(v_from = 'draft' AND p_to = 'submitted')
OR (v_from = 'submitted' AND p_to IN ('reviewing', 'rejected'))
OR (v_from = 'reviewing' AND p_to IN ('approved', 'rejected'))
OR (v_from = 'approved' AND p_to = 'published')
OR (v_from = 'published' AND p_to = 'archiving')
) THEN
RAISE EXCEPTION '非法审核流转:% -> %', v_from, p_to;
END IF;
UPDATE articles
SET status = p_to,
reject_reason = CASE WHEN p_to = 'rejected' THEN p_reason ELSE NULL END
WHERE id = p_id;
RETURN p_to;
END;
$$;
SELECT transit_article(1, 'archiving') AS s; s
-----------
archiving
(1 row)步骤 5:确认数据落库
-- 5. 查看 id=1 的文章状态
SELECT id, title, status FROM articles WHERE id = 1; id | title | status
----+----------------------+-----------
1 | PostgreSQL 索引原理 | archiving
(1 row)注意事项
- 枚举值大小写敏感。
'paid'与'Paid'是两个值,务必统一小写风格。 - 枚举值无法删除。 这是最硬的限制,设计时必须预留冗余值。
- 枚举排序是声明顺序。 一旦依赖它做业务排序,后续插入新值到中间会改变已有排序关系。
ORDER BY status::text会退化成字母序。 强制转换会丢掉业务顺序。- 枚举不能与整数比较。
WHERE status = 2直接报类型错误。 ALTER TYPE ... ADD VALUE与使用新值不能在同一事务。 即使 PostgreSQL 12+ 放开了事务限制,仍会报unsafe use of new value。- 往中间插值会重写表。
ADD VALUE ... BEFORE/AFTER在内部会导致表重建,大表上等同一次ALTER TABLE。 ADD VALUE IF NOT EXISTS不会报错但会发 NOTICE。 脚本里要预期到这条提示,不要当成失败。- 枚举不带元数据。 想给每个状态配"展示名、颜色、排序权重",得用外键表或额外维护一张映射表。
- 重建枚举类型会长时间锁表。
ALTER TABLE ... TYPE需要ACCESS EXCLUSIVE锁,期间该表完全不可读写。 - 枚举数组的元素类型必须同源。
ARRAY['paid'::order_status, 'unknown']会报错,因为'unknown'无法解析。 pg_enum.enumsortorder是单精度浮点。 反复在同一位置插值可能耗尽精度,触发内部重编号。- 跨库迁移时枚举定义必须先建。 否则
pg_dump恢复时的建表语句会失败;pg_dump默认按依赖顺序导出,但手写迁移脚本容易漏。 - 枚举类型不要在多个业务模块间共享。 "订单状态"和"售后状态"即使值相同也应各自定义,否则耦合会越来越重。
IN列表与枚举比较时,列表元素会被隐式转换。 拼错的值会在运行时才报错,用参数化查询时更隐蔽。
最佳实践
- 枚举值全小写加下划线。 与标识符风格统一,避免大小写混乱。
- 值名用"业务状态"而非"技术编码"。 用
'pending'而不是'0_1',可读性差距巨大。 - 创建时写
COMMENT ON TYPE,把状态机图写进去。 一句话就能让后来者明白合法流转路径。 - 永远用
ADD VALUE IF NOT EXISTS。 让迁移脚本可重复执行,幂等是运维的基本要求。 - 状态流转规则写进 PL/pgSQL 函数。 把"哪些状态能转哪些"在数据库层钉死,比散落在应用代码里可靠。
- 用
CHECK约束表达"状态相关的字段依赖"。 例如"只有 rejected 才能有驳回原因",把跨字段一致性交给数据库。 - 预留 1~2 个未使用值。 例如
'reserved1'、'reserved2',紧急需求时可以立刻上线。 - 枚举设计时把顺序一次定好。 顺序错了后期修正成本极高。
- 活跃数据用部分索引。
WHERE status IN (...)的部分索引比全列索引小得多、命中率更高。 - 枚举值数量控制在十几个以内。 超过 20 个值说明这个概念不够内聚,应考虑拆成外键表。
- 监控枚举值的使用分布。 定期跑
GROUP BY status检查是否有值长期为 0,及时清理业务语义。 - 跨环境(开发/测试/生产)保持枚举定义一致。 用同一份迁移脚本建类型,避免环境间值域不同导致的数据导入失败。
下一节
继续学习:域类型(Domain)
参考链接
- PostgreSQL 官方文档:Enumerated Types
- PostgreSQL 官方文档:CREATE TYPE
- PostgreSQL 官方文档:ALTER TYPE
- PostgreSQL 官方文档:Enum Support Functions