Skip to content

枚举类型(Enum) ​

枚举类型(Enum Type)是一组有限、固定、有序的字符串值集合。它把业务值域直接固化进 schema,写入非法值时数据库立刻报错,而不是等到应用层才发现脏数据。订单状态、审核状态、会员等级这类"状态机字段"是最典型的应用场景。

前置知识

阅读本节前,建议先了解:复合类型(Composite Type)

基础概念 ​

为什么用枚举而不是 text + CHECK ​

三种常见做法的对比:

方案写入校验值域集中管理排序语义存储开销改值域成本
text + CHECK有,但分散在每张表差(约束散落)按字母序变长,含重复字符串改 CHECK 需扫表
varchar 无约束无无按字母序变长无
枚举类型强,类型系统级好(一个类型一处定义)按声明顺序4 字节加值容易,删值很难

枚举的核心优势有三条:

  1. 类型级校验。 不只是这一张表,任何接受该类型的函数、变量、视图都会校验。
  2. 业务顺序排序。 ORDER BY status 直接按 pending → paid → shipped 的声明顺序,不用写 CASE WHEN。
  3. 固定 4 字节存储。 值本身存在 pg_enum 里,表里只存 4 字节的引用(oid),比重复存字符串省空间。

枚举的存储模型 ​

sql
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'done', 'cancelled');

这条语句在系统目录里产生了两个层次:

层次位置内容
类型定义pg_type 一行类型名、长度 4、typtype = 'e'
枚举值pg_enum 多行每个值的标签(enumlabel)与排序号(enumsortorder)
sql
-- 表里存的到底是什么?看存储层面
SELECT 'paid'::order_status AS value,
       pg_column_size('paid'::order_status) AS bytes,
       pg_column_size('pending'::order_status) AS bytes2;
text
 value | bytes | bytes2
-------+-------+--------
 paid  |     4 |      4
(1 row)
text
-- 对比 text 的占用(长度不同大小不同)
SELECT pg_column_size('paid'::text)    AS as_text,     -- 5(含 1 字节头)
       pg_column_size('pending'::text) AS as_text2;    -- 8
text
 as_text | as_text2
---------+----------
       5 |        8
(2 rows)
sql
-- 查看枚举值与排序号
SELECT enumlabel AS label, enumsortorder AS ord
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder;
text
  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)。

枚举的排序规则 ​

这是枚举最容易被误解的地方:排序按声明顺序,不是字母序。

sql
SELECT status
FROM (VALUES ('done'::order_status),
             ('pending'::order_status),
             ('shipped'::order_status),
             ('paid'::order_status)) AS t(status)
ORDER BY status;
text
  status
-----------
 pending
 paid
 shipped
 done
(4 rows)
sql
-- 对比:同样的值以 text 存储时,走的是字母序
SELECT status
FROM (VALUES ('done'), ('pending'), ('shipped'), ('paid')) AS t(status)
ORDER BY status;
text
  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 VALUEALTER TABLE ... DROP/ADD CONSTRAINT(会全表校验)插入一行即可
删除值不支持改约束即可删除行即可
值附带元数据(展示名、排序、颜色)不支持不支持支持
业务顺序排序原生支持需 CASE WHEN需 JOIN + 排序列
存储开销4 字节取决于底层类型2 或 4 字节
适合场景值域稳定、不需要元数据临时约束、跨库迁移值域需要管理界面、需元数据

经验规则:

  • 值域稳定且不需要元数据 → 枚举。
  • 值域需要运营后台维护(如"标签分类"),或需要展示名/排序/颜色 → 外键表。
  • 只是一次性的数据清洗校验 → CHECK。

建表时的三种写法 ​

sql
-- 写法一:直接用枚举类型(推荐)
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
);

语法与用法 ​

创建枚举 ​

sql
CREATE TYPE order_status AS ENUM (
  'pending',    -- 待付款
  'paid',       -- 已付款
  'shipped',    -- 已发货
  'done',       -- 已完成
  'cancelled'   -- 已取消
);

COMMENT ON TYPE order_status IS '订单状态:pending → paid → shipped → done;任意状态可转 cancelled';
sql
-- 验证创建结果
\dT+ order_status
text
                                      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)
sql
-- 也可以用 \dT 的专用元命令查看所有枚举
\dT

写入与校验 ​

sql
INSERT INTO orders_a (status) VALUES ('pending'), ('paid'), ('shipped');

-- 大小写敏感!'Paid' 不是合法值
INSERT INTO orders_a (status) VALUES ('Paid');
text
ERROR:  invalid input value for enum order_status: "Paid"
LINE 1: INSERT INTO orders_a (status) VALUES ('Paid');
                                             ^
sql
-- 错误!不在值域内
INSERT INTO orders_a (status) VALUES ('refunded');
text
ERROR:  invalid input value for enum order_status: "refunded"
LINE 1: INSERT INTO orders_a (status) VALUES ('refunded');
                                             ^

枚举值大小写敏感

'paid' 与 'Paid' 是两个完全不同的值。建议统一用小写加下划线,与 SQL 标识符风格保持一致。

排序与比较 ​

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;
text
 a | b | c
---+---+---
 t | f | f
(4 rows)
sql
-- min / max 也按业务顺序
SELECT min(status) AS first_status,
       max(status) AS last_status
FROM orders_a;
text
 first_status | last_status
--------------+-------------
 pending      | shipped
(1 row)

枚举不能与整数比较

WHERE status = 2 会直接报类型错误。枚举只能与字符串字面量(会隐式转换)或同类型值比较。

sql
-- 错误!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 开始的序号
sql
-- 列出全部枚举值(用 enum_range)
SELECT unnest(enum_range(NULL::order_status)) AS all_status;
text
 all_status
------------
 pending
 paid
 shipped
 done
 cancelled
(5 rows)
sql
-- 取某个值的序号(从 1 开始)
SELECT array_position(enum_range(NULL::order_status), 'shipped'::order_status) AS pos;
text
 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全版本只改目录
sql
-- 追加到末尾:最常用,开销最小
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;
text
  enumlabel  | enumsortorder
-------------+---------------
 pending     |             1
 paid        |             2
 refunding   |             3
 shipped     |             4
 done        |             5
 cancelled   |             6
(6 rows)
sql
-- 重命名值(PostgreSQL 10+):'done' 改成 'completed'
ALTER TYPE order_status RENAME VALUE 'done' TO 'completed';
text
ALTER TYPE
sql
-- 幂等追加(脚本可重复执行)
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'completed';
text
NOTICE:  enum label "completed" already exists, skipping
ALTER TYPE

版本说明

PostgreSQL 12 起,ALTER TYPE ... ADD VALUE 可以在事务块中执行。但即使如此,不能在同一个事务里"新增值 + 写入该值"——新值在本事务的快照中不可见,会报 unsafe use of new value。

sql
-- 错误!同一事务中新增值并立即使用
BEGIN;
ALTER TYPE order_status ADD VALUE 'archived';
UPDATE orders_a SET status = 'archived' WHERE id = 1;
COMMIT;
text
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 通过"取相邻两值的中点"来插入新值,因此无需重排已有值。

sql
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'order_status'::regtype
ORDER BY enumsortorder;
text
  enumlabel  | enumsortorder
-------------+---------------
 pending     |             1
 paid        |             2
 refunding   |             3
 shipped     |             4
 completed   |             5
 cancelled   |             6
(6 rows)
sql
-- 往中间插值时,排序号会变成小数(这里插到 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;
text
  enumlabel  | enumsortorder
-------------+---------------
 pending     |             1
 drafting    |           1.5
 paid        |             2
 refunding   |             3
 shipped     |             4
 completed   |             5
 cancelled   |             6
(7 rows)

反复往同一位置插值会耗尽精度

enumsortorder 是单精度浮点,约 24 位有效位。理论上在同一个位置反复插入几十次后,中点会精度不足触发"重新编号"。实践中很少遇到,但批量插入枚举值到中间位置是危险操作。

枚举值顺序的迁移难题 ​

假设线上已经有一个值 cancelled,但业务要求把它改名为 canceled(美式拼写)。步骤:

sql
-- 1. 追加新值(放末尾)
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'canceled';

-- 2. 迁移数据
UPDATE orders_a SET status = 'canceled' WHERE status = 'cancelled';

-- 3. 旧值无法删除,只能保留在类型里(或重建类型)
sql
-- 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;
text
ALTER TYPE

重建类型期间业务不可用

第 3'.2 步会重写整张表。10 万行以下的表通常在秒级完成,千万行级别可能需要数分钟,期间该表的读写都会阻塞。生产环境必须走"建影子表 → 分批迁移 → 切换"或"先在低峰期演练"的流程。

枚举的存储与索引 ​

sql
-- 枚举列的索引与普通列一致
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');
sql
-- 枚举列的统计信息
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;
text
 attname |      type       | storage
---------+-----------------+---------
 id      | bigint          | plain
 status  | order_status    | plain
(2 rows)
sql
-- 枚举列的直方图统计(供规划器估算选择率)
ANALYZE orders_a;

SELECT attname,
       n_distinct,
       most_common_vals
FROM pg_stats
WHERE tablename = 'orders_a' AND attname = 'status';
text
 attname | n_distinct |     most_common_vals
---------+------------+----------------------------------
 status  |          4 | {pending,paid,shipped,cancelled}
(1 row)

枚举与数组 ​

枚举数组同样合法,用于"多选"场景。

sql
-- 枚举数组
SELECT ARRAY['paid'::order_status, 'shipped'::order_status] AS arr;
text
              arr
-------------------------------
 {paid,shipped}
(1 row)
sql
-- 错误!数组元素类型必须一致
SELECT ARRAY['paid'::order_status, 'unknown'];
text
ERROR:  invalid input value for enum order_status: "unknown"
LINE 1: SELECT ARRAY['paid'::order_status, 'unknown'];

枚举与 JSONB 的转换 ​

sql
-- 枚举转 JSON 就是字符串
SELECT to_jsonb('paid'::order_status) AS j;
text
   j
--------
 "paid"
(1 row)
sql
-- JSON 字符串转枚举
SELECT '{"status":"paid"}'::jsonb ->> 'status' AS raw_text,
       ('{"status":"paid"}'::jsonb ->> 'status')::order_status AS as_enum;
text
 raw_text | as_enum
----------+---------
 paid     | paid
(1 row)
sql
-- jsonb_populate_record 自动转换
CREATE TABLE tmp_order (id bigint, status order_status);

SELECT (jsonb_populate_record(NULL::tmp_order, '{"id":1,"status":"shipped"}'::jsonb)).*;
text
 id | status
----+---------
  1 | shipped
(1 row)

实战示例 ​

案例一:订单状态机完整实现 ​

sql
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:插入业务数据

sql
-- 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:按业务顺序聚合

sql
-- 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;
text
  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:状态流转函数

sql
-- 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;
$$;
sql
-- 4. 正常流转:pending → paid
SELECT transit_order(3, 'paid') AS after_transit;
text
 after_transit
---------------
 paid
(1 row)
sql
-- 5. 错误!paid 不能直接到 done
SELECT transit_order(3, 'done');
text
ERROR:  非法状态流转:paid -> done
CONTEXT:  PL/pgSQL function transit_order(bigint,order_status) line 17 at RAISE

步骤 4:无 CASE WHEN 的业务漏斗

sql
-- 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;
text
   stage   | reached_cnt
-----------+-------------
 pending   |           8
 paid      |           7
 shipped   |           3
 done      |           2
(4 rows)

这个查询不需要任何 CASE WHEN,因为枚举的 >= 直接表达了"业务进度"语义。

步骤 5:部分索引加速活跃订单查询

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

案例二:多级值域与枚举子集约束 ​

sql
-- 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:插入覆盖各状态的数据

sql
-- 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:统计各状态分布

sql
-- 3. 按业务顺序统计
SELECT status, count(*) AS cnt
FROM articles
GROUP BY status
ORDER BY status;
text
  status   | cnt
-----------+-----
 draft     |   1
 submitted |   1
 reviewing |   1
 approved  |   1
 rejected  |   1
 published |   2
(6 rows)

步骤 3:无绕过的状态推进

sql
-- 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;
$$;
sql
-- 5. 正常流转
SELECT transit_article(4, 'reviewing') AS s1;
text
   s1
-----------
 reviewing
(1 row)
sql
-- 6. 错误!draft 不能直接 approved
SELECT transit_article(5, 'approved');
text
ERROR:  非法审核流转:draft -> approved
CONTEXT:  PL/pgSQL function transit_article(bigint,audit_status) line 15 at RAISE

步骤 4:校验子集约束真的生效

sql
-- 7. 错误!published 不能带驳回原因(违反 chk_reject_reason)
INSERT INTO articles (title, status, reject_reason)
VALUES ('测试文章', 'published', '不该有原因');
text
ERROR:  new row for relation "articles" violates check constraint "chk_reject_reason"
DETAIL:  Failing row contains (8, 测试文章, published, 不该有原因).

案例三:枚举扩容与灰度迁移 ​

业务新增"退款中"状态,需要在不停机的前提下扩容。

sql
-- 1. 追加新值到 paid 之后(只改目录,瞬间完成)
ALTER TYPE audit_status ADD VALUE IF NOT EXISTS 'archiving' AFTER 'published';
text
ALTER TYPE
sql
-- 2. 验证顺序正确
SELECT enumlabel, enumsortorder
FROM pg_enum
WHERE enumtypid = 'audit_status'::regtype
ORDER BY enumsortorder;
text
  enumlabel  | enumsortorder
-------------+---------------
 draft       |             1
 submitted   |             2
 reviewing   |             3
 approved    |             4
 rejected    |             5
 published   |             6
 archiving   |             7
(7 rows)
sql
-- 3. 新值立即可用(如果上一步在独立事务中提交)
SELECT transit_article(1, 'archiving');
text
ERROR:  非法审核流转:published -> archiving
CONTEXT:  PL/pgSQL function transit_article(bigint,audit_status) line 15 at RAISE

流转函数未更新,所以被拦下了——这正好证明状态机约束在 DB 层生效。更新函数后:

sql
-- 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;
text
     s
-----------
 archiving
(1 row)

步骤 5:确认数据落库

sql
-- 5. 查看 id=1 的文章状态
SELECT id, title, status FROM articles WHERE id = 1;
text
 id |        title         |  status
----+----------------------+-----------
  1 | PostgreSQL 索引原理  | archiving
(1 row)

注意事项 ​

  1. 枚举值大小写敏感。 'paid' 与 'Paid' 是两个值,务必统一小写风格。
  2. 枚举值无法删除。 这是最硬的限制,设计时必须预留冗余值。
  3. 枚举排序是声明顺序。 一旦依赖它做业务排序,后续插入新值到中间会改变已有排序关系。
  4. ORDER BY status::text 会退化成字母序。 强制转换会丢掉业务顺序。
  5. 枚举不能与整数比较。 WHERE status = 2 直接报类型错误。
  6. ALTER TYPE ... ADD VALUE 与使用新值不能在同一事务。 即使 PostgreSQL 12+ 放开了事务限制,仍会报 unsafe use of new value。
  7. 往中间插值会重写表。 ADD VALUE ... BEFORE/AFTER 在内部会导致表重建,大表上等同一次 ALTER TABLE。
  8. ADD VALUE IF NOT EXISTS 不会报错但会发 NOTICE。 脚本里要预期到这条提示,不要当成失败。
  9. 枚举不带元数据。 想给每个状态配"展示名、颜色、排序权重",得用外键表或额外维护一张映射表。
  10. 重建枚举类型会长时间锁表。 ALTER TABLE ... TYPE 需要 ACCESS EXCLUSIVE 锁,期间该表完全不可读写。
  11. 枚举数组的元素类型必须同源。 ARRAY['paid'::order_status, 'unknown'] 会报错,因为 'unknown' 无法解析。
  12. pg_enum.enumsortorder 是单精度浮点。 反复在同一位置插值可能耗尽精度,触发内部重编号。
  13. 跨库迁移时枚举定义必须先建。 否则 pg_dump 恢复时的建表语句会失败;pg_dump 默认按依赖顺序导出,但手写迁移脚本容易漏。
  14. 枚举类型不要在多个业务模块间共享。 "订单状态"和"售后状态"即使值相同也应各自定义,否则耦合会越来越重。
  15. IN 列表与枚举比较时,列表元素会被隐式转换。 拼错的值会在运行时才报错,用参数化查询时更隐蔽。

最佳实践 ​

  1. 枚举值全小写加下划线。 与标识符风格统一,避免大小写混乱。
  2. 值名用"业务状态"而非"技术编码"。 用 'pending' 而不是 '0_1',可读性差距巨大。
  3. 创建时写 COMMENT ON TYPE,把状态机图写进去。 一句话就能让后来者明白合法流转路径。
  4. 永远用 ADD VALUE IF NOT EXISTS。 让迁移脚本可重复执行,幂等是运维的基本要求。
  5. 状态流转规则写进 PL/pgSQL 函数。 把"哪些状态能转哪些"在数据库层钉死,比散落在应用代码里可靠。
  6. 用 CHECK 约束表达"状态相关的字段依赖"。 例如"只有 rejected 才能有驳回原因",把跨字段一致性交给数据库。
  7. 预留 1~2 个未使用值。 例如 'reserved1'、'reserved2',紧急需求时可以立刻上线。
  8. 枚举设计时把顺序一次定好。 顺序错了后期修正成本极高。
  9. 活跃数据用部分索引。 WHERE status IN (...) 的部分索引比全列索引小得多、命中率更高。
  10. 枚举值数量控制在十几个以内。 超过 20 个值说明这个概念不够内聚,应考虑拆成外键表。
  11. 监控枚举值的使用分布。 定期跑 GROUP BY status 检查是否有值长期为 0,及时清理业务语义。
  12. 跨环境(开发/测试/生产)保持枚举定义一致。 用同一份迁移脚本建类型,避免环境间值域不同导致的数据导入失败。

下一节

继续学习:域类型(Domain)

参考链接 ​