Skip to content

异常处理(EXCEPTION) ​

PL/pgSQL 的 BEGIN ... EXCEPTION ... END 让函数可以在语句失败后继续运行、把错误翻译成业务语义、或者做补偿动作。它强大但有明确的代价:带 EXCEPTION 的块本质是一个子事务(subtransaction),进入与退出都有开销,而且块内已做的数据修改会在异常发生时被整体回滚。理解这两点,才能写出既健壮又不拖慢系统的错误处理代码。

前置知识

阅读本节前,建议先了解:隐式游标(FOR IN)

基础概念 ​

EXCEPTION 块的结构 ​

text
BEGIN
    statements
EXCEPTION
    WHEN condition_name [ OR condition_name ... ] THEN
        handler_statements
    WHEN other_condition THEN
        handler_statements
END;

BEGIN 到 EXCEPTION 之间的语句是被保护区。只有在这段区域内发生的错误才会被本块的 handler 捕获;EXCEPTION 段内部再出错,则向外层传播。

捕获的作用域:就近匹配 ​

text
函数最外层块
 ├─ BEGIN                    ← 块 A
 │    语句1                   ← 若出错,找 A 的 EXCEPTION
 │    BEGIN                  ← 块 B
 │      语句2                 ← 若出错,先找 B 的 EXCEPTION
 │    EXCEPTION WHEN ... THEN
 │    END;
 │    语句3
 └─ EXCEPTION WHEN ... THEN
   END;

规则很直接:错误先在当前块找 handler;找不到就往上一层块的 EXCEPTION 段找;一直找到函数最外层;仍找不到就报给调用者。

子事务的本质 ​

这是本节最重要的概念:

text
BEGIN                       -- 不是子事务
    ...
    BEGIN                   -- ★ 子事务开始(带 EXCEPTION 的块)
        INSERT ...;
        UPDATE ...;
    EXCEPTION               -- 出错时,回滚到 ★ 的位置
        WHEN others THEN ...
    END;                    -- ★ 子事务结束
END;

BEGIN ... EXCEPTION ... END 相当于 SQL 的 SAVEPOINT / ROLLBACK TO SAVEPOINT:

动作等价 SQL
进入带 EXCEPTION 的块SAVEPOINT s
块内某语句报错ROLLBACK TO SAVEPOINT s
块正常结束RELEASE SAVEPOINT s

性能警告:子事务不便宜

每次进入带 EXCEPTION 的块都要分配子事务资源,退出要释放。在循环体里放带 EXCEPTION 的块,会让子事务开销乘以行数——这是 PL/pgSQL 里最典型的性能陷阱之一。不写 EXCEPTION 就完全没有这项开销。

关键概念速查 ​

概念说明
条件名(condition name)unique_violation 这类可读名称,见错误码附录
SQLSTATE五字符错误码,如 23505
others通配所有未被前面 WHEN 匹配的错误
GET STACKED DIAGNOSTICS在 handler 里读取错误的详细信息
RAISE;在 handler 里原样重新抛出当前错误
回滚范围捕获异常后,块内已做的修改被回滚到块开始前

语法与用法 ​

完整语法 ​

text
[ <<label>> ]
[ DECLARE
    declarations ]
BEGIN
    statements
EXCEPTION
    WHEN condition [ OR condition ... ] THEN
        handler_statements
    [ WHEN condition [ OR condition ... ] THEN
        handler_statements
      ... ]
END [ label ];

条件名与 SQLSTATE 的写法 ​

sql
EXCEPTION
  WHEN unique_violation THEN ...          -- 用条件名
  WHEN SQLSTATE '23505' THEN ...          -- 用 SQLSTATE
  WHEN division_by_zero OR no_data_found THEN ...   -- 多个条件用 OR
  WHEN others THEN ...                    -- 通配
END;

GET STACKED DIAGNOSTICS ​

text
GET STACKED DIAGNOSTICS variable { = | := } item [ , ... ];

只能在 EXCEPTION 段里使用,用来读取「当前正在处理的错误」的详细信息。

sql
EXCEPTION
  WHEN unique_violation THEN
    GET STACKED DIAGNOSTICS
      v_msg      = MESSAGE_TEXT,
      v_state    = RETURNED_SQLSTATE,
      v_detail   = PG_EXCEPTION_DETAIL,
      v_hint     = PG_EXCEPTION_HINT,
      v_context  = PG_EXCEPTION_CONTEXT,
      v_constraint = CONSTRAINT_NAME,
      v_table    = TABLE_NAME,
      v_column   = COLUMN_NAME;
    RAISE WARNING '% | % | %', v_state, v_table, v_msg;
END;

RAISE; 重新抛出 ​

sql
EXCEPTION
  WHEN others THEN
    INSERT INTO error_log(...) VALUES (...);
    RAISE;                    -- 原样重新抛出,保留原始 SQLSTATE 与消息
END;

详细说明 ​

捕获范围:只有被保护区的语句受保护 ​

sql
CREATE OR REPLACE FUNCTION guarded()
RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
  RAISE NOTICE 'A: 不在此块的保护区之外';

  BEGIN
    -- 保护区开始
    INSERT INTO t (id) VALUES (1);
    INSERT INTO t (id) VALUES (1);      -- 违反主键
    RAISE NOTICE 'B: 不会执行到这里';
  EXCEPTION
    WHEN unique_violation THEN
      RAISE NOTICE 'C: 捕获到唯一约束冲突';
  END;

  RAISE NOTICE 'D: 回到外层,继续执行';
END;
$$;
text
SELECT guarded();
NOTICE:  A: 不在此块的保护区之外
NOTICE:  C: 捕获到唯一约束冲突
NOTICE:  D: 回到外层,继续执行
 guarded
---------
(1 row)

关键点:第二条 INSERT 报错时,第一条 INSERT 的效果也被回滚了(见下节)。

异常被捕获后,块内修改会被回滚 ​

这是很多人第一次写异常处理时踩的坑:

sql
CREATE OR REPLACE FUNCTION partial_rollback_demo()
RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
  BEGIN
    INSERT INTO t (id, note) VALUES (9001, '第一行');
    INSERT INTO t (id, note) VALUES (9002, '第二行');
    INSERT INTO t (id, note) VALUES (9002, '重复主键');   -- 报错
  EXCEPTION
    WHEN unique_violation THEN
      -- 9001 也不会留下!
      RAISE NOTICE '捕获异常,块内所有修改已被回滚';
  END;
END;
$$;
text
SELECT partial_rollback_demo();
NOTICE:  捕获异常,块内所有修改已被回滚
 partial_rollback_demo
-----------------------
(1 row)

SELECT count(*) FROM t WHERE id IN (9001, 9002);
 count
-------
     0

想要「部分保留」必须自己设计

捕获异常后块内修改全被回滚。若想让「第一条插入成功、第二条失败」这种语义成立,需要把每条语句放进各自独立的 BEGIN ... EXCEPTION 块,或改用 ON CONFLICT DO NOTHING 之类的非异常路径。

sql
-- 独立子事务:第一条保留,第二条失败不影响它
BEGIN
  INSERT INTO t (id, note) VALUES (9001, '第一行');
EXCEPTION WHEN unique_violation THEN NULL;
END;

BEGIN
  INSERT INTO t (id, note) VALUES (9002, '第二行');
EXCEPTION WHEN unique_violation THEN NULL;
END;

子事务开销:不要在循环体里滥用 ​

sql
-- 不推荐:每次循环都建一个子事务
FOR r IN SELECT * FROM rows_to_import LOOP
  BEGIN
    INSERT INTO target VALUES (r.*);
  EXCEPTION
    WHEN unique_violation THEN
      INSERT INTO target_conflict VALUES (r.*);
  END;
END LOOP;

10 万行就是 10 万次子事务建立/释放。更省的替代:

sql
-- 推荐:用 ON CONFLICT 走非异常路径
INSERT INTO target
SELECT * FROM rows_to_import
ON CONFLICT DO NOTHING;              -- 或 DO UPDATE SET ...

INSERT INTO target_conflict
SELECT r.* FROM rows_to_import r
WHERE EXISTS (SELECT 1 FROM target t WHERE t.id = r.id);
sql
-- 推荐:确实需要异常处理时,把块放在循环外
BEGIN
  INSERT INTO target SELECT * FROM rows_to_import ON CONFLICT DO NOTHING;
EXCEPTION
  WHEN others THEN
    RAISE WARNING '批量导入失败,已整体回滚';
    RAISE;
END;

判据

只有「错误真的会发生且你需要分支」时才用 EXCEPTION。如果错误能用 ON CONFLICT、DISTINCT ON、WHERE NOT EXISTS、先查后插等方式避免,就不要制造异常。

错误条件名与 SQLSTATE 对照 ​

常用条件名(condition name)与 SQLSTATE:

条件名SQLSTATE触发场景
unique_violation23505唯一索引/主键冲突
foreign_key_violation23503外键引用违规
check_violation23514CHECK 约束不满足
not_null_violation23502NOT NULL 列被写 NULL
exclusion_violation23P01EXCLUDE 约束冲突
division_by_zero22012除以零
no_data_foundP0002SELECT INTO 无行
too_many_rowsP0003SELECT INTO 返回多行
raise_exceptionP0001RAISE EXCEPTION 未指定 SQLSTATE 时的默认码
assert_failureP0004ASSERT 失败
others(无)通配所有其他错误
sql
EXCEPTION
  WHEN not_null_violation OR check_violation THEN
    RAISE EXCEPTION '数据校验失败' USING ERRCODE = '23514';
  WHEN foreign_key_violation THEN
    RAISE EXCEPTION '引用了不存在的父记录' USING ERRCODE = '23503';
  WHEN others THEN
    RAISE;
END;

no_data_found 与 too_many_rows 的来源

它们由 SELECT INTO(无 STRICT 时只报 too_many_rows;加 STRICT 后两者都可能)以及 FETCH 之外的赋值语句抛出。普通 SELECT INTO 无行时不报错,只是把目标置 NULL、FOUND 置 false。

GET STACKED DIAGNOSTICS 的可用项 ​

在 EXCEPTION 段里能读取的诊断项:

诊断项含义
MESSAGE_TEXT主错误消息文本
RETURNED_SQLSTATE错误的 SQLSTATE
COLUMN_NAME相关列名
CONSTRAINT_NAME相关约束名
PG_DATATYPE_NAME相关数据类型名
TABLE_NAME相关表名
SCHEMA_NAME相关 schema 名
PG_EXCEPTION_DETAIL错误的 DETAIL 文本
PG_EXCEPTION_HINT错误的 HINT 文本
PG_EXCEPTION_CONTEXT错误上下文(调用栈/执行位置)
sql
CREATE OR REPLACE FUNCTION diagnose_conflict(p_id int)
RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  v_state      text;
  v_msg        text;
  v_detail     text;
  v_hint       text;
  v_constraint text;
  v_table      text;
  v_column     text;
  v_context    text;
BEGIN
  INSERT INTO demo (id, email) VALUES (p_id, 'a@example.com');
  RETURN 'ok';
EXCEPTION
  WHEN others THEN
    GET STACKED DIAGNOSTICS
      v_state      = RETURNED_SQLSTATE,
      v_msg        = MESSAGE_TEXT,
      v_detail     = PG_EXCEPTION_DETAIL,
      v_hint       = PG_EXCEPTION_HINT,
      v_constraint = CONSTRAINT_NAME,
      v_table      = TABLE_NAME,
      v_column     = COLUMN_NAME,
      v_context    = PG_EXCEPTION_CONTEXT;

    RETURN format('sqlstate=%s table=%s column=%s constraint=%s msg=%s hint=%s detail=%s context=%s',
                  v_state, v_table, v_column, v_constraint, v_msg, v_hint, v_detail, v_context);
END;
$$;
text
SELECT diagnose_conflict(1);
-- 输出(单行,实际内容较长,这里折行展示)
sqlstate=23505 table=demo column= constraint=demo_pkey msg=duplicate key value violates
unique constraint "demo_pkey" hint= detail=Key (id)=(1) already exists.
context=SQL statement "INSERT INTO demo (id, email) VALUES (p_id, 'a@example.com')" ...

变量名会与诊断项重名

GET STACKED DIAGNOSTICS 的第一个参数是变量,不能写成 SQLSTATE = ...(因为 SQLSTATE 是诊断项名但不是可赋值变量)。所以要用 RETURNED_SQLSTATE 这个项名去读 SQLSTATE。

「吞掉」还是「重抛」:handler 的取舍 ​

三种 handler 策略:

策略写法适用
吞掉(swallow)handler 里不 RAISE,继续走错误可安全忽略(如幂等插入)
转换(translate)RAISE EXCEPTION ... USING ERRCODE = ...把底层错误翻译成业务错误码
重抛(re-raise)RAISE;记日志后仍要让调用方感知失败
sql
-- 吞掉:明确知道可以忽略
EXCEPTION
  WHEN unique_violation THEN
    NULL;               -- 幂等语义:已存在就当作成功
END;
sql
-- 转换:保留业务语义
EXCEPTION
  WHEN foreign_key_violation THEN
    RAISE EXCEPTION '客户 % 不存在', p_customer_id
      USING ERRCODE = '23503',
            HINT = '请先创建客户记录';
END;
sql
-- 重抛:先留痕再抛出
EXCEPTION
  WHEN others THEN
    INSERT INTO error_log (func, message, state, happened_at)
    VALUES (TG_NAME, SQLERRM, SQLSTATE, now());
    RAISE;
END;

不要无声吞掉

sql
EXCEPTION WHEN others THEN NULL;   -- 危险:所有错误都消失,问题被隐藏

除非确知「所有可能错误都可忽略」,否则至少 RAISE WARNING 记一笔。

SQLERRM 与 SQLSTATE ​

在 handler 里可以直接使用两个特殊变量:

sql
EXCEPTION
  WHEN others THEN
    RAISE WARNING '错误码=%, 消息=%', SQLSTATE, SQLERRM;
    -- SQLSTATE 是五字符码,SQLERRM 是错误消息文本
END;

它们与 GET STACKED DIAGNOSTICS ... MESSAGE_TEXT / RETURNED_SQLSTATE 等价,但更简短。若要拿 DETAIL / HINT / CONTEXT,则必须用 GET STACKED DIAGNOSTICS。

嵌套块与 handler 的匹配顺序 ​

sql
CREATE OR REPLACE FUNCTION nested_demo()
RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  v_result text := 'unknown';
BEGIN
  BEGIN
    BEGIN
      RAISE EXCEPTION 'inner error' USING ERRCODE = 'P0001';
    EXCEPTION
      WHEN division_by_zero THEN       -- 不匹配 P0001
        v_result := 'inner-division';
    END;                                -- 内层没匹配 → 向外抛
  EXCEPTION
    WHEN raise_exception THEN           -- 匹配 P0001
      v_result := 'outer-raise';
  END;
  RETURN v_result;                     -- 返回 'outer-raise'
END;
$$;

要点:内层 WHEN 列表不匹配时,异常原样向外传播,不会被内层块「挡住」。others 会挡住一切,因此 others 必须放在最后。

handler 中不能做的两件事与可以做的事 ​

sql
EXCEPTION
  WHEN unique_violation THEN
    -- ✅ 可以:查表、插日志、改其他表(都在同一个已回滚的子事务之外执行)
    INSERT INTO conflict_log (happened_at) VALUES (now());
    -- ✅ 可以:读取诊断信息
    -- ✅ 可以:重新抛出
    -- ❌ 不能:让被回滚的修改"复活"
    -- ❌ 不能:在 handler 里再用 GET STACKED DIAGNOSTICS 读上一次的另一个 handler 的错误
END;

注意:handler 里做的修改是提交的(它们发生在回滚点之后),因此「把冲突记录写进日志表」这类补偿动作是有效的。

用异常实现「先试后改」的 upsert ​

在 ON CONFLICT 出现之前,PL/pgSQL 里常见的 upsert 模式依赖异常捕获。现在更推荐 ON CONFLICT,但了解老模式有助于读懂遗留代码:

sql
-- 老模式:先 UPDATE,没有行再 INSERT(靠 FOUND 而非异常)
CREATE OR REPLACE FUNCTION upsert_counter(p_key text, p_delta int)
RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE counters SET value = value + p_delta WHERE key = p_key;
  IF NOT FOUND THEN
    INSERT INTO counters (key, value) VALUES (p_key, p_delta);
  END IF;
END;
$$;
sql
-- 更稳的写法:一条语句搞定,无异常、无子事务
INSERT INTO counters (key, value)
VALUES (p_key, p_delta)
ON CONFLICT (key) DO UPDATE SET value = counters.value + EXCLUDED.value;

实战示例 ​

场景:批量导入,逐行容错并汇总失败原因 ​

需求:把暂存表里的数据导入目标表,允许部分行失败,最终返回成功/失败行数与失败明细。

先把容错集中在一条异常处理的批量语句上,避免逐行子事务:

sql
CREATE TABLE import_staging (
  line_no int,
  sku     text,
  price   numeric(12,2),
  qty     int
);

CREATE TABLE products (
  sku    text PRIMARY KEY,
  price  numeric(12,2) NOT NULL CHECK (price >= 0),
  qty    int NOT NULL CHECK (qty >= 0)
);

CREATE TABLE import_error_log (
  line_no   int,
  sqlstate  text,
  message   text,
  detail    text,
  logged_at timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION import_products()
RETURNS TABLE(ok_rows bigint, bad_rows bigint)
LANGUAGE plpgsql AS $$
DECLARE
  v_ok  bigint := 0;
  v_bad bigint := 0;
BEGIN
  -- 先做数据校验,把不合格行记下来(不产生异常,无子事务开销)
  INSERT INTO import_error_log (line_no, sqlstate, message)
  SELECT s.line_no, 'PRE001', 'price/qty 为负或为空'
  FROM import_staging s
  WHERE s.price IS NULL OR s.qty IS NULL OR s.price < 0 OR s.qty < 0;

  GET DIAGNOSTICS v_bad = ROW_COUNT;

  -- 合格行批量写入,用 ON CONFLICT 处理重复,不制造异常
  WITH valid AS (
    SELECT s.*
    FROM import_staging s
    WHERE s.price IS NOT NULL AND s.qty IS NOT NULL
      AND s.price >= 0 AND s.qty >= 0
  ), ins AS (
    INSERT INTO products (sku, price, qty)
    SELECT v.sku, v.price, v.qty FROM valid v
    ON CONFLICT (sku) DO UPDATE
      SET price = EXCLUDED.price,
          qty   = EXCLUDED.qty
    RETURNING 1
  )
  SELECT count(*) INTO v_ok FROM ins;

  RETURN QUERY SELECT v_ok, v_bad;
END;
$$;

调用与输出:

text
INSERT INTO import_staging VALUES
  (1, 'SKU-001', 19.90, 100),
  (2, 'SKU-002', 5.50, 200),
  (3, 'SKU-003', -1.00, 50),
  (4, 'SKU-004', 8.80, NULL);

SELECT * FROM import_products();
 ok_rows | bad_rows
---------+----------
       2 |        2
(1 row)

SELECT line_no, sqlstate, message FROM import_error_log ORDER BY line_no;
 line_no | sqlstate |            message
---------+----------+--------------------------------
       3 | PRE001   | price/qty 为负或为空
       4 | PRE001   | price/qty 为负或为空
(2 rows)

场景:把数据库错误翻译成业务错误码 ​

需求:一个下单函数,要把「库存不足」「客户不存在」「重复下单」分别翻译成稳定的业务错误码返回给 PHP 层。

sql
CREATE TABLE app_error (
  code    text PRIMARY KEY,
  message text NOT NULL
);

CREATE OR REPLACE FUNCTION place_order(p_customer_id int, p_sku text, p_qty int)
RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  v_stock int;
  v_result text;
BEGIN
  -- 客户存在性(显式检查优于依赖异常)
  IF NOT EXISTS (SELECT 1 FROM customers WHERE id = p_customer_id) THEN
    RETURN 'E_CUSTOMER_NOT_FOUND';
  END IF;

  -- 库存预检
  SELECT qty INTO v_stock FROM products WHERE sku = p_sku FOR UPDATE;
  IF NOT FOUND THEN
    RETURN 'E_SKU_NOT_FOUND';
  END IF;
  IF v_stock < p_qty THEN
    RETURN 'E_OUT_OF_STOCK';
  END IF;

  BEGIN
    -- 保护区:只包住可能因约束冲突失败的核心操作
    INSERT INTO orders (customer_id, sku, qty, status)
    VALUES (p_customer_id, p_sku, p_qty, 'created');

    UPDATE products SET qty = qty - p_qty WHERE sku = p_sku;

    v_result := 'OK';
  EXCEPTION
    WHEN unique_violation THEN
      -- 把约束名/表名带进日志,便于定位是哪个唯一约束
      GET STACKED DIAGNOSTICS
        v_result = CONSTRAINT_NAME;
      RAISE WARNING '重复下单,约束=%', v_result;
      v_result := 'E_DUPLICATE_ORDER';

    WHEN check_violation THEN
      v_result := 'E_INVALID_QTY';

    WHEN others THEN
      -- 未预期的错误:留痕后重抛,让调用方看到真实错误
      INSERT INTO app_error (code, message)
      VALUES ('E_UNEXPECTED', SQLERRM)
      ON CONFLICT (code) DO UPDATE SET message = EXCLUDED.message;
      RAISE;
  END;

  RETURN v_result;
END;
$$;
text
-- 正常下单
SELECT place_order(101, 'SKU-001', 2);
 place_order
-------------
 OK
(1 row)

-- 库存不足
SELECT place_order(101, 'SKU-001', 999999);
  place_order
-----------------
 E_OUT_OF_STOCK
(1 row)

-- 重复下单(假设 (customer_id, sku, status) 有唯一约束)
SELECT place_order(101, 'SKU-001', 2);
WARNING:  重复下单,约束=orders_customer_sku_status_key
       place_order
------------------------
 E_DUPLICATE_ORDER
(1 row)

注意事项 ​

  • 在循环体里放 EXCEPTION 块:子事务开销乘以行数,是最常见的性能陷阱。-- 不推荐
    sql
    FOR r IN SELECT * FROM big_table LOOP
      BEGIN
        INSERT INTO t VALUES (r.*);
      EXCEPTION WHEN others THEN NULL;
      END;
    END LOOP;
  • 误以为块内之前的成功语句会保留:捕获异常后它们同样被回滚。
    sql
    BEGIN
      INSERT INTO a VALUES (1);      -- 出错后这条也白做
      INSERT INTO b VALUES (1/0);
    EXCEPTION WHEN others THEN NULL;
    END;
  • WHEN others 放在前面:后面的 WHEN 永远不生效。-- 错误!
    sql
    EXCEPTION
      WHEN others THEN ...
      WHEN unique_violation THEN ...   -- 永远不会执行
  • 无条件吞掉所有错误:掩盖真实故障。-- 危险
    sql
    EXCEPTION WHEN others THEN NULL;
  • handler 里把 SQLSTATE 当变量赋值:混淆了「变量」与「诊断项」。
    sql
    -- 错误!SQLSTATE 是诊断项名,不是赋值目标
    GET STACKED DIAGNOSTICS SQLSTATE = RETURNED_SQLSTATE;
    -- 正确
    GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
  • 在 handler 里再抛新异常而丢掉原始信息:调试时找不到根因。-- 不推荐
    sql
    EXCEPTION WHEN others THEN
      RAISE EXCEPTION '操作失败';    -- 丢失 SQLSTATE / DETAIL / CONTEXT
    END;
    -- 推荐:保留底层信息
    EXCEPTION WHEN others THEN
      RAISE EXCEPTION '操作失败: % (%)', SQLERRM, SQLSTATE;
    END;
  • handler 改数据后忘记返回/继续:handler 执行完会继续到 END 之后,不会自动返回错误给调用方——若失败语义需要向上传,必须 RAISE。
  • 依赖异常做流程控制:把「看起来可能失败」当成正常分支会拖慢性能并掩盖 bug。
  • 在 EXCEPTION 段里使用 GET DIAGNOSTICS(非 STACKED):拿不到异常信息,必须用 GET STACKED DIAGNOSTICS。

最佳实践 ​

  • 优先用非异常路径表达可预期的冲突:ON CONFLICT、WHERE NOT EXISTS、先查后改。
    sql
    INSERT INTO t (id, v) VALUES (1, 2) ON CONFLICT (id) DO NOTHING;   -- 首选
  • EXCEPTION 只包住真正需要保护的最小代码段:包得越窄,回滚代价与影响面越小。
  • 把 EXCEPTION 移到循环之外:批量操作一次性保护,而不是逐行保护。
    sql
    FOR r IN ... LOOP ... END LOOP;              -- 无异常块
    -- 需要保护时,把整段放进一个 BEGIN ... EXCEPTION ... END
  • handler 里一定留痕:至少 RAISE WARNING,重要场景写错误日志表。
    sql
    EXCEPTION WHEN others THEN
      INSERT INTO error_log(func, message, state) VALUES ('f', SQLERRM, SQLSTATE);
      RAISE;
    END;
  • 能重抛就重抛:调用方需要知道失败;若业务要求「降级成功」,则显式在 handler 里做补偿并返回明确的业务标记。
  • 用具体条件名而不是 others:精确的表意让代码自解释,也避免误吞其他错误。
    sql
    EXCEPTION
      WHEN unique_violation THEN ...
      WHEN foreign_key_violation THEN ...
      WHEN others THEN RAISE;      -- 兜底重抛
    END;
  • 自定义错误码时避开内置类:用 USING ERRCODE = '...' 时选不易与 PostgreSQL 冲突的码段,详见 RAISE 错误报告。
  • 把错误详情用 GET STACKED DIAGNOSTICS 结构化落库:只写 SQLERRM 会丢掉约束名、列名、上下文,排障效率差异巨大。
  • 为业务错误划分稳定错误码:PHP 层按错误码分支,而不是去匹配消息文本(消息文本可能随版本变化)。
  • 在测试里覆盖异常分支:用 DO $$ ... $$ 或临时表模拟约束冲突,确认 handler 行为与返回码符合预期。

下一节

继续学习:RAISE 错误报告

参考链接 ​