Skip to content

退出与跳过(EXIT / CONTINUE / RETURN) ​

上一节讲的三种循环(LOOP / WHILE / FOR)解决了「怎么重复做」,但没有解决「什么时候别再做了」。本节补齐这块:EXIT 结束整个循环、CONTINUE 只跳过本次、RETURN 直接离开整个函数,再加上用于集合返回函数的 RETURN NEXT 与 RETURN QUERY。

这五条语句看着简单,但「跳出哪一层」「跳出后游标怎么处理」「在异常块里 RETURN 会发生什么」这些细节,恰恰是 PL/pgSQL 代码里最容易写错的地方。本节把它们一条条拆开讲清楚。

前置知识

阅读本节前,建议先了解:循环(LOOP / WHILE / FOR)、遍历数组(FOREACH)

基础概念 ​

五条「离开当前位置」的语句 ​

PL/pgSQL 里能让控制流「提前离开」的语句一共五条,它们的作用范围从小到大排列如下:

语句离开的范围是否返回值典型场景
CONTINUE本次迭代剩余部分否跳过不满足条件的行
EXIT整个循环 / 带标签的块否找到目标后立即收工
RETURN整个函数或过程是(函数)/ 否(过程)提前结束、返回标量结果
RETURN NEXT不离开函数,只追加一行追加一行集合返回函数逐行产出
RETURN QUERY不离开函数,只追加一批追加一批行集合返回函数直接吐一条查询

前两条只在循环(或块)内部有意义;后三条只在该函数/过程体内有意义。把它们的作用范围画出来:

text
函数边界 ────────────────────────────────────────────────
│                                                        │
│   RETURN / RETURN NEXT / RETURN QUERY  ← 影响函数级     │
│                                                        │
│   <<outer>> FOR ... LOOP                               │
│   │    EXIT outer       ← 跳出整个外层循环              │
│   │    CONTINUE outer   ← 直接进外层循环的下一轮         │
│   │                                                    │
│   │      FOR ... LOOP                                  │
│   │      │   EXIT        ← 只跳出内层循环(默认最内层)  │
│   │      │   CONTINUE    ← 只进内层循环的下一轮          │
│   │      END LOOP;                                     │
│   │  END LOOP outer;                                   │
│                                                        │
──────────────────────────────────────────────────────────

一条重要的区分:EXIT / CONTINUE 永远不返回数据,RETURN 家族才负责返回值。 很多初学者想在 EXIT 后顺手带个结果出去,这是做不到的——EXIT 只能让你离开循环,结果要通过变量或 RETURN 往外传。

EXIT 与 CONTINUE 的两种写法 ​

两者都支持「无条件」与「带 WHEN」两种形式:

sql
-- 写法一:IF ... THEN 包裹
IF v_done THEN
  EXIT;
END IF;

-- 写法二:EXIT WHEN(等价,更简洁)
EXIT WHEN v_done;

EXIT WHEN cond; 与 IF cond THEN EXIT; END IF; 在语义上完全等价。差别只在可读性和一处细节:当 cond 为 NULL 时,两者都按 FALSE 处理,即不退出——因为 WHEN 后面接的是布尔表达式,遵循三值逻辑。

RETURN 家族的三副面孔 ​

RETURN 家族按「函数返回类型」分工,这是最容易混淆的地方:

函数返回类型用哪条语句说明
标量(如 int、text)RETURN 表达式结束函数并返回该值
voidRETURN;提前结束,不带表达式
带 OUT 参数的函数/过程RETURN;返回 OUT 参数的当前值
SETOF 类型 / TABLE(...)RETURN NEXT / RETURN QUERY + 末尾 RETURN;逐行或逐批追加,最后整体返回

一句话记忆:返回「一个值」用 RETURN 表达式;返回「一堆值」用 RETURN NEXT / RETURN QUERY,并在最后补一个光秃秃的 RETURN;。

一个必须记住的硬约束 ​

如果函数的返回类型不是 void、也没有 OUT 参数,那么控制流走到函数最外层块的末尾而没有碰到任何 RETURN 时,运行时一定会报错:

text
ERROR:  control reached end of function without RETURN

这不是警告,是错误。它和「返回 NULL」是两回事。带 OUT 参数的函数与 void 函数不受此约束,因为它们会自动补一个隐式 RETURN。

语法与用法 ​

五条语句的完整语法:

text
EXIT    [ label ] [ WHEN 布尔表达式 ];
CONTINUE[ label ] [ WHEN 布尔表达式 ];

RETURN;
RETURN 表达式;

RETURN NEXT [ 表达式 ];
RETURN QUERY 查询;
RETURN QUERY EXECUTE 命令字符串 [ USING 表达式 [, ...] ];

语法要点拆解:

text
EXIT / CONTINUE
 ├── 不带 label:作用于最内层的循环
 ├── 带 label :作用于该 label 对应的循环,或带 label 的 BEGIN 块
 │              (label 必须是当前层或更外层的,不能指向内层)
 ├── 不带 WHEN :无条件执行
 ├── 带 WHEN   :仅在布尔表达式为 TRUE 时执行
 └── WHEN 为 NULL 时按 FALSE 处理,即不跳出/不跳过

RETURN
 ├── 带表达式:结束函数并返回该值(可自动做赋值转换)
 ├── 不带表达式:用于 void 函数、带 OUT 参数的函数/过程
 └── 返回值不能「留空」——非 void 且无 OUT 参数的函数必须有出口

RETURN NEXT
 ├── 把「当前一行」追加到结果集,函数不结束
 ├── 可带表达式;若函数声明了 OUT 参数,写 RETURN NEXT 不带表达式
 └── 通常配合循环逐行产出

RETURN QUERY
 ├── 执行一条查询,把它的全部结果行追加到结果集,函数不结束
 ├── RETURN QUERY EXECUTE 用于动态 SQL,参数经 USING 传入
 └── 可与 RETURN NEXT 混用,结果按执行顺序拼接

一段最短的可用示例,把五条语句都覆盖到:

sql
-- 标量函数:用 RETURN 提前返回
CREATE OR REPLACE FUNCTION demo_exit_early(p_n int)
RETURNS text LANGUAGE plpgsql AS $$
DECLARE
  v_i int;
BEGIN
  IF p_n <= 0 THEN
    RETURN '参数必须为正';        -- 提前结束整个函数
  END IF;

  FOR v_i IN 1..100 LOOP
    CONTINUE WHEN v_i % 2 = 0;    -- 跳过偶数
    IF v_i >= p_n THEN
      RETURN format('第一个不小于 %s 的奇数:%s', p_n, v_i);
    END IF;
  END LOOP;

  RETURN '循环外返回';            -- 保证有出口
END;
$$;

SELECT demo_exit_early(7);        -- 第一个不小于 7 的奇数:7
sql
-- 集合返回函数:用 RETURN NEXT 逐行产出
CREATE OR REPLACE FUNCTION demo_even_next(p_n int)
RETURNS SETOF int LANGUAGE plpgsql AS $$
DECLARE
  v_i int;
BEGIN
  FOR v_i IN 1..p_n LOOP
    CONTINUE WHEN v_i % 2 <> 0;   -- 只保留偶数
    RETURN NEXT v_i;              -- 追加一行,函数不结束
  END LOOP;
  RETURN;                          -- 收尾,表示没有更多行了
END;
$$;

SELECT * FROM demo_even_next(10);  -- 2,4,6,8,10
sql
-- 集合返回函数:用 RETURN QUERY 一次吐一批
CREATE OR REPLACE FUNCTION demo_even_query(p_n int)
RETURNS SETOF int LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
    SELECT g FROM generate_series(1, p_n) AS g WHERE g % 2 = 0;
  RETURN;
END;
$$;

详细说明 ​

一、EXIT:结束整个循环 ​

EXIT 用于 LOOP、WHILE、FOR、FOREACH 以及 FOR ... IN SELECT 这五种循环结构,语义是「立即终止循环,跳到 END LOOP 之后的第一条语句」。

sql
CREATE OR REPLACE FUNCTION demo_find_first(p_threshold int)
RETURNS int LANGUAGE plpgsql AS $$
DECLARE
  v_i int;
BEGIN
  FOR v_i IN 1..1000 LOOP
    IF v_i * v_i > p_threshold THEN
      EXIT;                       -- 找到就停,不必跑完 1000 次
    END IF;
  END LOOP;
  RETURN v_i;                     -- 循环结束后 v_i 保留最后的值
END;
$$;

与 RETURN 的关键区别:EXIT 只离开循环,函数还会继续往下执行;RETURN 是直接离开函数,后面的语句一句都不会跑。

sql
-- EXIT 之后函数继续跑
FOR v_i IN 1..10 LOOP
  EXIT WHEN v_i = 3;
END LOOP;
RAISE NOTICE 'EXIT 后我还会执行';   -- 会输出

-- RETURN 之后函数立即结束
FOR v_i IN 1..10 LOOP
  IF v_i = 3 THEN RETURN v_i; END IF;
END LOOP;
RAISE NOTICE 'RETURN 后我不会执行'; -- 不会输出

二、CONTINUE:跳到下一轮 ​

CONTINUE 跳过本轮循环剩余的语句,直接开始下一轮迭代。对于 FOR / FOREACH,它等价于「本轮到此为止,按步长推进到下一轮」;对于 WHILE,它会回到循环顶部重新求值条件。

sql
CREATE OR REPLACE FUNCTION demo_sum_skip_null(p_arr int[])
RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE
  v_x   int;
  v_sum bigint := 0;
BEGIN
  FOREACH v_x IN ARRAY p_arr LOOP
    CONTINUE WHEN v_x IS NULL;    -- NULL 不参与累加,也不报错
    CONTINUE WHEN v_x < 0;        -- 负数直接跳过
    v_sum := v_sum + v_x;
  END LOOP;
  RETURN v_sum;
END;
$$;

SELECT demo_sum_skip_null(ARRAY[1, NULL, 5, -3, 9]::int[]);  -- 15

把「不满足条件」的情况用 CONTINUE WHEN 顶到循环体最前面,能显著减少 IF ... ELSE 的嵌套层级,可读性提升很明显。

三、EXIT WHEN 与 IF ... THEN EXIT 的取舍 ​

两者等价,选哪个主要看表达意图:

sql
-- 意图:这是一个「出口条件」,用 EXIT WHEN 更贴切
LOOP
  v_i := v_i + 1;
  EXIT WHEN v_i > 10;
END LOOP;

-- 意图:这是一段「条件逻辑」,里面顺手退出,用 IF 更自然
LOOP
  v_i := v_i + 1;
  IF v_i > 5 AND v_flag THEN
    RAISE NOTICE '触发复杂条件,退出';
    EXIT;
  END IF;
END LOOP;

一条经验规则:条件只与「该不该停下」有关,用 EXIT WHEN;条件本身是业务逻辑的一部分(后面还要在分支里做事),用 IF。

需要注意的一个细节是 EXIT WHEN 只接受一个布尔表达式。如果退出条件需要复合逻辑,可以写 EXIT WHEN a AND b;,但一旦逻辑再复杂(涉及临时计算、RAISE 等),拆成 IF 反而更清晰。

四、标签(label):跨层跳出与块级退出 ​

默认的 EXIT / CONTINUE 只作用于最内层循环。多层嵌套时想操作外层,必须给目标结构加标签 <<label>>,并在语句中指名:

sql
CREATE OR REPLACE FUNCTION demo_label_find()
RETURNS text LANGUAGE plpgsql AS $$
DECLARE
  v_i int;
  v_j int;
BEGIN
  <<outer_i>>
  FOR v_i IN 1..5 LOOP
    FOR v_j IN 1..5 LOOP
      IF v_i * v_j = 12 THEN
        RAISE NOTICE '命中 i=% j=%', v_i, v_j;
        EXIT outer_i;              -- 一次跳出两层
      END IF;
    END LOOP;
    RAISE NOTICE '第 % 行没找到,继续', v_i;
  END LOOP outer_i;

  RETURN format('结束于 i=%s j=%s', v_i, v_j);
END;
$$;

CONTINUE 同样支持标签,用来「跳过外层循环的本次迭代」:

sql
<<row_loop>>
FOR v_row IN SELECT * FROM demo_order LOOP
  FOR v_item IN SELECT * FROM demo_item WHERE order_id = v_row.id LOOP
    IF v_item.qty IS NULL THEN
      RAISE NOTICE '订单 % 数据异常,整单跳过', v_row.id;
      CONTINUE row_loop;           -- 直接进入下一张订单
    END IF;
    PERFORM app_handle_item(v_item);
  END LOOP;
END LOOP row_loop;

EXIT label 也可以作用于 BEGIN 块。 这时它的效果是「跳出这个块」,相当于一次受控的 GOTO:

sql
CREATE OR REPLACE FUNCTION app_validate(p_a int, p_b int)
RETURNS text LANGUAGE plpgsql AS $$
DECLARE
  v_msg text := '通过';
BEGIN
  <<chk>>
  BEGIN
    IF p_a IS NULL THEN
      v_msg := 'a 为空';
      EXIT chk;
    END IF;
    IF p_a <= 0 THEN
      v_msg := 'a 必须为正';
      EXIT chk;
    END IF;
    IF p_b IS NULL OR p_b <= p_a THEN
      v_msg := 'b 必须大于 a';
      EXIT chk;
    END IF;
    v_msg := format('通过:a=%s b=%s', p_a, p_b);
  END chk;

  RETURN v_msg;
END;
$$;

有一个容易踩的坑:不带标签的 EXIT 永远不会匹配一个 BEGIN 块,只会匹配循环。想跳出块,标签是必需的。

另外,标签名在同一层作用域内必须唯一;END LOOP label; 与 END loop_label; 里的标签是可选的,但写上能让结构一目了然,也便于编译器更早发现配错标签的问题。

五、RETURN:从函数或过程返回 ​

对函数,RETURN 表达式 结束函数并把表达式的值返回给调用者。返回值会按赋值转换规则自动转换到函数的返回类型;返回复合类型(行)时需要表达式正好产出目标列集,必要时显式转型。

sql
-- 标量返回
CREATE OR REPLACE FUNCTION demo_tax(p_amount numeric)
RETURNS numeric LANGUAGE plpgsql AS $$
BEGIN
  IF p_amount IS NULL OR p_amount < 0 THEN
    RETURN 0;                       -- 提前返回一个合法值
  END IF;
  RETURN round(p_amount * 0.06, 2);
END;
$$;

对过程(CREATE PROCEDURE),它没有返回值,因此可以不写 RETURN 直接结束;如果要用 RETURN 提前退出,必须写不带表达式的 RETURN;:

sql
CREATE OR REPLACE PROCEDURE app_touch_order(p_id bigint)
LANGUAGE plpgsql AS $$
BEGIN
  IF p_id IS NULL THEN
    RETURN;                          -- 过程只能用裸 RETURN
  END IF;
  UPDATE demo_order SET updated_at = now() WHERE id = p_id;
END;
$$;

对带 OUT 参数的函数或过程,裸 RETURN; 会返回 OUT 参数变量的当前值:

sql
CREATE OR REPLACE FUNCTION app_calc(p_a int, p_b int, OUT o_sum int, OUT o_diff int)
LANGUAGE plpgsql AS $$
BEGIN
  o_sum  := p_a + p_b;
  o_diff := p_a - p_b;
  RETURN;                            -- OUT 参数即返回值,无需表达式
END;
$$;

SELECT * FROM app_calc(10, 3);       -- o_sum=13, o_diff=7

六、RETURN NEXT 与 RETURN QUERY:集合返回函数 ​

当一个函数声明为 RETURNS SETOF 类型(或用 RETURNS TABLE(...))时,它要产出的是一「行集」。这时用 RETURN NEXT / RETURN QUERY 逐条追加,最后以裸 RETURN; 收尾。

关键语义:RETURN NEXT 与 RETURN QUERY 并不会从函数返回。 它们只是往结果集里追加零行或多行,然后执行继续往下走。函数真正的结束是那个末尾的裸 RETURN;(或者控制流自然走到函数末尾)。

sql
CREATE OR REPLACE FUNCTION demo_stock_move(p_n int)
RETURNS TABLE(sku text, qty int)
LANGUAGE plpgsql AS $$
DECLARE
  v_i int;
BEGIN
  FOR v_i IN 1..p_n LOOP
    sku := 'SKU-' || lpad(v_i::text, 4, '0');
    qty := v_i * 10;
    RETURN NEXT;                     -- 用 OUT 参数当前值追加一行
  END LOOP;

  -- 再追加一批「汇总行」
  RETURN QUERY
    SELECT 'TOTAL'::text, sum(qty) FROM demo_stock_move(p_n);

  RETURN;                            -- 收尾
END;
$$;

同一条函数里 RETURN NEXT 与 RETURN QUERY 可以自由混用,追加的行会按执行顺序拼接起来。

带 EXECUTE 的动态版本是 RETURN QUERY EXECUTE,参数通过 USING 传入,和 EXECUTE 的规则一致:

sql
CREATE OR REPLACE FUNCTION demo_dyn_query(p_table text, p_status text)
RETURNS SETOF record LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY EXECUTE
    format('SELECT id, name FROM %I WHERE status = $1', p_table)
    USING p_status;
  RETURN;
END;
$$;

七、SETOF 的构建过程与 work_mem 的关系 ​

理解 RETURN NEXT / RETURN QUERY 的执行模型,才能理解它们的性能特征。官方文档明确说明:当前实现会把整个结果集缓存起来,等函数返回时一次性交给调用者。 也就是说,函数产出的行会先被攒着,攒不下时写到磁盘。

text
函数开始
   │
   ├── RETURN NEXT r1  →  结果集: [r1]
   ├── RETURN NEXT r2  →  结果集: [r1, r2]
   ├── RETURN QUERY ...→  结果集: [r1, r2, q1, q2, ...]
   │
   ├── 数据量超过 work_mem 时自动溢写到磁盘
   │
   └── RETURN;         →  整个结果集一次性返回给调用者

因此有两条实践结论:

  • 函数在生成完整个结果集之前不会向调用者返回任何一行,所以它不能在「边生成边消费」的流式场景下降低首行延迟。
  • 结果集的落盘阈值由 work_mem 控制,如果一个集合返回函数经常产出很大的结果集,可以临时提高该函数的 work_mem(例如用函数级 SET)来避免频繁写盘。
sql
CREATE OR REPLACE FUNCTION demo_big_set(p_n int)
RETURNS SETOF int
LANGUAGE plpgsql
SET work_mem = '64MB'                -- 仅本函数生效
AS $$
BEGIN
  RETURN QUERY SELECT g FROM generate_series(1, p_n) AS g;
  RETURN;
END;
$$;

如果你确实需要「边算边取、提前拿第一行」的流式行为,那应该考虑游标而不是集合返回函数——这也是下一节要展开的主题。

八、RETURN 与异常块(EXCEPTION)的交互 ​

RETURN 在 BEGIN ... EXCEPTION ... END 里有一处需要特别留意的语义。官方文档规定:当错误被 EXCEPTION 子句捕获时,PL/pgSQL 函数的局部变量保持错误发生时的值,但块内对持久化数据库状态所做的修改全部回滚。

sql
DO $$
DECLARE
  v_cnt int := 0;
BEGIN
  BEGIN
    INSERT INTO demo_log(msg) VALUES ('第一条');
    v_cnt := 1;
    PERFORM 1 / 0;                   -- 制造除零错误
    v_cnt := 2;
  EXCEPTION
    WHEN division_by_zero THEN
      -- 到这里:v_cnt 保持为 1(局部变量不回滚)
      -- 但那条 INSERT 已经被回滚(持久状态回滚)
      RAISE NOTICE 'v_cnt = %(本地变量未回滚)', v_cnt;
  END;

  RAISE NOTICE 'demo_log 里不会有任何行';
END;
$$;

还有一条性能提醒:带 EXCEPTION 的块进出开销显著高于普通块,因为每个这样的块都要建立一个子事务(subtransaction)。所以不要为了「图省事」包一层 EXCEPTION,更不要把 EXCEPTION 放进循环体里当成逐行的错误分支——那会让每一轮迭代都付出子事务的代价。

sql
-- 不推荐:异常块放在循环里,每行都建一次子事务
FOR v_r IN SELECT * FROM demo_import LOOP
  BEGIN
    INSERT INTO demo_target VALUES (v_r.*);
  EXCEPTION WHEN unique_violation THEN
    NULL;                             -- 忽略重复
  END;
END LOOP;

-- 推荐:能用 ON CONFLICT 就别用异常块
INSERT INTO demo_target
SELECT * FROM demo_import
ON CONFLICT DO NOTHING;

九、循环与 FOUND、GET DIAGNOSTICS 的配合 ​

EXIT / CONTINUE 常与两个状态信息来源一起用:FOUND 与 GET DIAGNOSTICS。

FOUND 是一个布尔特殊变量,在 SELECT ... INTO、PERFORM、UPDATE/DELETE/INSERT、以及 FOR/FOREACH 循环执行后被设置:

sql
CREATE OR REPLACE FUNCTION demo_retry(p_id bigint)
RETURNS int LANGUAGE plpgsql AS $$
DECLARE
  v_try int := 0;
  v_row demo_order%rowtype;
BEGIN
  LOOP
    v_try := v_try + 1;

    SELECT * INTO v_row FROM demo_order WHERE id = p_id;
    EXIT WHEN FOUND;                 -- 查到了就退出
    EXIT WHEN v_try >= 3;            -- 重试三次仍没有就放弃

    PERFORM pg_sleep(0.1);
  END LOOP;

  RETURN v_try;
END;
$$;

GET DIAGNOSTICS ... = ROW_COUNT 用来取上一条 SQL 影响的行数,常与 EXIT 搭配做「批处理跑到没有数据为止」:

sql
LOOP
  WITH picked AS (
    SELECT id FROM demo_task WHERE status = 'new' ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED
  )
  UPDATE demo_task t SET status = 'doing' FROM picked p WHERE t.id = p.id;

  GET DIAGNOSTICS v_affected = ROW_COUNT;
  EXIT WHEN v_affected = 0;          -- 没有可处理的行,收工
  v_total := v_total + v_affected;
END LOOP;

十、性能:为什么 RETURN QUERY 通常优于 RETURN NEXT ​

当你要产出的结果集本身就能由一条查询表达时,RETURN QUERY 几乎总是更快。原因不在语法,而在执行方式:

  • RETURN QUERY SELECT ... 把整条查询交给优化器一次规划、一次执行,是集合式操作。
  • 用 RETURN NEXT 逐行产出的版本,通常要先把数据读进一个循环(FOR ... IN SELECT),再一行行追加。数据在 PL/pgSQL 层「过一遍手」,多出逐行开销。
sql
-- 推荐:一条 RETURN QUERY 交给优化器
RETURN QUERY
  SELECT o.id, o.amount
    FROM demo_order o
   WHERE o.created_at >= p_from AND o.status = 'paid';

-- 不推荐:先循环再逐行追加(数据被 PL/pgSQL 逐行搬运)
FOR v_r IN
  SELECT o.id, o.amount FROM demo_order o
   WHERE o.created_at >= p_from AND o.status = 'paid'
LOOP
  id := v_r.id; amount := v_r.amount;
  RETURN NEXT;
END LOOP;

RETURN NEXT 的价值在于「每行都需要 PL/pgSQL 才能做的加工」——比如调用其它函数、逐行改写字段、按复杂条件过滤。凡是能写进单条 SQL 的逻辑,都应该优先写进 SQL。

实战示例 ​

场景一:用 EXIT / CONTINUE 实现带跳过的重试与限流 ​

需求:从 demo_job 里按优先级取任务处理,遇到已取消的任务直接跳过(CONTINUE),遇到不可重试的错误立即停止(EXIT),最多重试 3 次。

建表与造数:

sql
CREATE TABLE IF NOT EXISTS demo_job(
  id       bigserial PRIMARY KEY,
  name     text    NOT NULL,
  status   text    NOT NULL DEFAULT 'new',   -- new / canceled / done
  prio     int     NOT NULL DEFAULT 100,
  tries    int     NOT NULL DEFAULT 0
);

INSERT INTO demo_job(name, status, prio) VALUES
  ('job-a', 'new',      10),
  ('job-b', 'canceled', 20),
  ('job-c', 'new',      30),
  ('job-d', 'new',     200);   -- prio 超限,触发 EXIT

建函数:

sql
CREATE OR REPLACE FUNCTION app_run_jobs(p_max_prio int, p_max_try int DEFAULT 3)
RETURNS TABLE(job_id bigint, result text)
LANGUAGE plpgsql AS $$
DECLARE
  v_job  record;
  v_back int := 0;
BEGIN
  FOR v_job IN
    SELECT id, name, status, prio, tries
      FROM demo_job
     WHERE status <> 'done'
     ORDER BY prio, id
  LOOP
    -- 已取消:整行跳过
    CONTINUE WHEN v_job.status = 'canceled';

    -- 优先级过高:整段收工
    IF v_job.prio > p_max_prio THEN
      RAISE NOTICE '优先级 % 超过上限 %,停止处理', v_job.prio, p_max_prio;
      EXIT;
    END IF;

    -- 重试到上限:跳过
    CONTINUE WHEN v_job.tries >= p_max_try;

    UPDATE demo_job SET tries = tries + 1, status = 'done' WHERE id = v_job.id;
    v_back := v_back + 1;

    job_id := v_job.id;
    result := format('处理成功(第 %s 次尝试)', v_job.tries + 1);
    RETURN NEXT;
  END LOOP;

  RETURN;
END;
$$;

调用与输出:

sql
SELECT * FROM app_run_jobs(100, 3);
text
 job_id |        result
--------+----------------------
      1 | 处理成功(第 1 次尝试)
      3 | 处理成功(第 1 次尝试)
-- job-b(canceled)被 CONTINUE 跳过
-- job-d(prio 200 > 100)触发 EXIT,循环终止

这段代码里 CONTINUE WHEN 承担「静默跳过」,EXIT 承担「提前终止」,两者作用域不同但不冲突。注意 EXIT 之前的 RAISE NOTICE 是排查线上行为的关键——不要静默退出。

场景二:用 RETURN QUERY 实现分页集合返回函数 ​

需求:demo_article 表里文章很多,需要一个函数按游标式分页返回某作者的文章标题与发布时间,供前端「加载更多」使用。

建表与造数:

sql
CREATE TABLE IF NOT EXISTS demo_article(
  id         bigserial PRIMARY KEY,
  author     text        NOT NULL,
  title      text        NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO demo_article(author, title, created_at)
SELECT (ARRAY['alice','bob','carol'])[(i % 3) + 1],
       'article-' || i,
       now() - (i || ' minutes')::interval
FROM generate_series(1, 300) AS i;

建函数:

sql
CREATE OR REPLACE FUNCTION app_page_articles(
  p_author    text,
  p_after_id  bigint DEFAULT 0,
  p_limit     int    DEFAULT 20
)
RETURNS TABLE(id bigint, title text, created_at timestamptz)
LANGUAGE plpgsql AS $$
BEGIN
  IF p_limit IS NULL OR p_limit <= 0 OR p_limit > 100 THEN
    -- 用 RETURN 提前结束:这里直接返回 0 行
    RETURN;
  END IF;

  RETURN QUERY
    SELECT a.id, a.title, a.created_at
      FROM demo_article a
     WHERE a.author = p_author
       AND a.id > p_after_id          -- 基于「上一页最后一个 id」翻页
     ORDER BY a.id
     LIMIT p_limit;

  RETURN;
END;
$$;

调用(模拟翻页):

sql
-- 第一页
SELECT * FROM app_page_articles('alice', 0, 20);
-- 第二页:用上一页最大 id 作为游标
SELECT * FROM app_page_articles('alice', 20, 20);
-- 非法 limit:直接返回空集,不报错
SELECT * FROM app_page_articles('alice', 0, 0);

这里有两个细节值得注意:一是用 RETURN;(裸返回)在参数非法时返回空集,比 RAISE EXCEPTION 更适合「前端传参兜底」的场景;二是 RETURN QUERY 里的 LIMIT 让排序+截断都在 SQL 层完成,PL/pgSQL 不搬运数据。

场景三:用带标签的 EXIT 处理「订单-明细」双层校验 ​

需求:校验订单及其明细;一旦发现某张订单存在明细数量为负,就整张订单放弃并记录原因,但继续校验下一张订单。

建表与造数:

sql
CREATE TABLE IF NOT EXISTS demo_order2(
  id     bigserial PRIMARY KEY,
  code   text NOT NULL
);
CREATE TABLE IF NOT EXISTS demo_item2(
  order_id bigint NOT NULL,
  sku      text   NOT NULL,
  qty      int    NOT NULL
);

INSERT INTO demo_order2(code) VALUES ('O-1'), ('O-2'), ('O-3');
INSERT INTO demo_item2(order_id, sku, qty) VALUES
  (1, 'A', 2), (1, 'B', 3),
  (2, 'C', -1),                     -- 负数量:整单放弃
  (3, 'D', 5), (3, 'E', 6);

建函数:

sql
CREATE OR REPLACE FUNCTION app_check_orders()
RETURNS TABLE(order_id bigint, code text, verdict text)
LANGUAGE plpgsql AS $$
DECLARE
  v_o record;
  v_i record;
BEGIN
  FOR v_o IN SELECT id, code FROM demo_order2 ORDER BY id LOOP
    <<item_loop>>
    FOR v_i IN SELECT sku, qty FROM demo_item2 WHERE order_id = v_o.id LOOP
      IF v_i.qty < 0 THEN
        order_id := v_o.id;
        code     := v_o.code;
        verdict  := format('明细 %s 数量非法(%s),整单放弃', v_i.sku, v_i.qty);
        RETURN NEXT;

        CONTINUE item_loop;             -- 注意:见下方说明
      END IF;
    END LOOP item_loop;

    -- 若未发现非法明细,记为通过
    IF NOT EXISTS (
      SELECT 1 FROM demo_item2 WHERE order_id = v_o.id AND qty < 0
    ) THEN
      order_id := v_o.id;
      code     := v_o.code;
      verdict  := '通过';
      RETURN NEXT;
    END IF;
  END LOOP;

  RETURN;
END;
$$;

SELECT * FROM app_check_orders();
text
 order_id | code |              verdict
----------+------+----------------------------------
        1 | O-1  | 通过
        2 | O-2  | 明细 C 数量非法(-1),整单放弃
        3 | O-3  | 通过

上面的 CONTINUE item_loop; 是一个刻意保留的陷阱示例:在内层 FOR ... IN SELECT 中,CONTINUE 只跳过内层循环的当前行,外层订单循环仍会继续,因此「整单放弃」的语义并没有真的实现——它只是跳过了那条非法明细,后面还存在继续校验并写入第二条 verdict 的可能。真正要「整单放弃」应该用 EXIT item_loop; 跳出内层并配合标志位,或者干脆调整外层结构。这个例子说明:CONTINUE / EXIT 的标签选错,逻辑会「看起来对、实际错」,非常隐蔽。

修正版:把内层改为 EXIT item_loop; 并用一个布尔标记控制后续:

sql
CREATE OR REPLACE FUNCTION app_check_orders_fixed()
RETURNS TABLE(order_id bigint, code text, verdict text)
LANGUAGE plpgsql AS $$
DECLARE
  v_o     record;
  v_i     record;
  v_bad   text;
BEGIN
  FOR v_o IN SELECT id, code FROM demo_order2 ORDER BY id LOOP
    v_bad := NULL;

    <<item_loop>>
    FOR v_i IN SELECT sku, qty FROM demo_item2 WHERE order_id = v_o.id LOOP
      IF v_i.qty < 0 THEN
        v_bad := format('明细 %s 数量非法(%s),整单放弃', v_i.sku, v_i.qty);
        EXIT item_loop;               -- 跳出内层,整单不再逐行校验
      END IF;
    END LOOP item_loop;

    order_id := v_o.id;
    code     := v_o.code;
    verdict  := coalesce(v_bad, '通过');
    RETURN NEXT;
  END LOOP;

  RETURN;
END;
$$;

注意事项 ​

1. EXIT 只跳出循环,不要指望它结束函数

这是最常见的误解。EXIT 之后函数会继续执行。

sql
-- 错误!以为 EXIT 后函数就结束了
DO $$
BEGIN
  FOR v_i IN 1..10 LOOP
    EXIT WHEN v_i = 3;
  END LOOP;
  RAISE NOTICE '这行仍会执行';       -- 实际会输出
END;
$$;

-- 如果要结束函数
FOR v_i IN 1..10 LOOP
  IF v_i = 3 THEN RETURN; END IF;    -- 正确:函数立即结束
END LOOP;

2. 非 void 且无 OUT 参数的函数必须有出口

少了 RETURN,运行时必报 control reached end of function without RETURN。

sql
-- 错误!分支覆盖不全,可能走到函数末尾
CREATE OR REPLACE FUNCTION bad_f(p_n int) RETURNS text LANGUAGE plpgsql AS $$
BEGIN
  IF p_n > 0 THEN
    RETURN '正数';
  END IF;
  -- p_n <= 0 时没有 RETURN,运行时报错
END;
$$;

-- 正确
CREATE OR REPLACE FUNCTION good_f(p_n int) RETURNS text LANGUAGE plpgsql AS $$
BEGIN
  IF p_n > 0 THEN
    RETURN '正数';
  END IF;
  RETURN '非正数';                    -- 兜底出口
END;
$$;

3. RETURN NEXT 不会结束函数

它只追加一行,执行会继续往下走;忘了末尾的裸 RETURN; 虽不报错(控制流到末尾即结束),但容易漏掉后续逻辑。

sql
-- 不推荐:以为 RETURN NEXT 会退出
LOOP
  RETURN NEXT v_r;                    -- 追加一行,继续循环
END LOOP;
-- 正确:循环结束后补 RETURN;,语义明确
RETURN;

4. 过程里不要写 RETURN 表达式

CREATE PROCEDURE 没有返回值,带表达式的 RETURN 会报语法错误。

sql
-- 错误!过程不能带返回值
CREATE OR REPLACE PROCEDURE bad_p() LANGUAGE plpgsql AS $$
BEGIN
  RETURN 1;                           -- 报错
END;
$$;

-- 正确
CREATE OR REPLACE PROCEDURE good_p() LANGUAGE plpgsql AS $$
BEGIN
  RETURN;                             -- 仅用于提前退出
END;
$$;

5. 不带标签的 EXIT 不会匹配 BEGIN 块

想从块里跳出去,必须给块加标签。

sql
-- 不推荐:期望跳出这个块,实际匹配不到任何循环
BEGIN
  BEGIN
    EXIT;                             -- 若外层没有循环则报错
  END;
END;

-- 正确:给块加标签
BEGIN
  <<blk>>
  BEGIN
    EXIT blk;                         -- 跳出 blk 块
  END blk;
END;

6. 标签不能指向内层

EXIT / CONTINUE 的标签必须是当前层或更外层的结构名,指到内层会报错。

sql
-- 错误!outer 循环里引用内层标签 inner
<<outer>>
FOR i IN 1..3 LOOP
  <<inner>>
  FOR j IN 1..3 LOOP
    EXIT inner;                       -- 合法
  END LOOP inner;
  EXIT inner;                         -- 错误:跳出到已结束/更内层
END LOOP outer;

7. 不要把 EXCEPTION 放进循环当逐行错误分支

每个 EXCEPTION 块都建子事务,逐行进入开销极大。

sql
-- 不推荐:逐行建子事务
FOR v_r IN SELECT * FROM demo_import LOOP
  BEGIN
    INSERT INTO demo_target VALUES (v_r.*);
  EXCEPTION WHEN unique_violation THEN NULL;
  END;
END LOOP;

-- 推荐:集合式处理
INSERT INTO demo_target SELECT * FROM demo_import ON CONFLICT DO NOTHING;

8. RETURN QUERY 的结果集当前会被完整缓存

函数在生成完整个结果集前不会返回;大数据量时结果集会按 work_mem 溢写磁盘。

sql
-- 不推荐:超大结果集 + 默认 work_mem,频繁写盘
CREATE OR REPLACE FUNCTION bad_big() RETURNS SETOF int LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY SELECT g FROM generate_series(1, 50000000) AS g;
  RETURN;
END;
$$;

-- 推荐:函数级提高 work_mem,或改用游标做流式处理
CREATE OR REPLACE FUNCTION ok_big() RETURNS SETOF int LANGUAGE plpgsql
SET work_mem = '128MB' AS $$
BEGIN
  RETURN QUERY SELECT g FROM generate_series(1, 50000000) AS g;
  RETURN;
END;
$$;

9. CONTINUE 之后再写语句是死代码

CONTINUE 无条件执行时,后面同一层级的语句永远跑不到,通常意味着逻辑写错了。

sql
-- 错误!v_sum 永远不会被累加
FOR v_i IN 1..10 LOOP
  CONTINUE;
  v_sum := v_sum + v_i;               -- 死代码
END LOOP;

-- 正确:把 CONTINUE 放在条件里
FOR v_i IN 1..10 LOOP
  CONTINUE WHEN v_i % 2 = 0;
  v_sum := v_sum + v_i;
END LOOP;

10. WHILE 里的 CONTINUE 要确认条件变量会被推进

CONTINUE 会回到条件求值处,如果推进语句在被跳过的区间里,就会死循环。

sql
-- 错误!v_i 的自增被跳过,死循环
WHILE v_i < 10 LOOP
  CONTINUE WHEN v_i % 2 = 0;
  v_i := v_i + 1;
END LOOP;

-- 正确:先推进,再判断跳过
WHILE v_i < 10 LOOP
  v_i := v_i + 1;
  CONTINUE WHEN v_i % 2 = 0;
  v_sum := v_sum + v_i;
END LOOP;

最佳实践 ​

1. 「该不该停」用 EXIT WHEN,「属于业务分支」用 IF

条件语义决定写法,代码意图更清楚。

sql
LOOP
  v_i := v_i + 1;
  EXIT WHEN v_i > 10;                 -- 纯粹的出口条件

  IF v_i % 3 = 0 THEN                 -- 业务分支
    RAISE NOTICE '% 是 3 的倍数', v_i;
  END IF;
END LOOP;

2. 循环体开头用 CONTINUE WHEN 做前置过滤

把「不处理」的情况顶到最前面,减少嵌套层级。

sql
FOREACH v_m IN ARRAY p_moves LOOP
  CONTINUE WHEN v_m.qty = 0;
  CONTINUE WHEN v_m.sku IS NULL;
  PERFORM app_apply_one_move(v_m.sku, v_m.qty);
END LOOP;

3. 嵌套循环一律给外层加标签

即便当下不需要跨层跳出,先加标签,后续改动不用重构结构。

sql
<<order_loop>>
FOR v_o IN SELECT * FROM demo_order LOOP
  FOR v_i IN SELECT * FROM demo_item WHERE order_id = v_o.id LOOP
    IF v_i.qty < 0 THEN
      RAISE NOTICE '订单 % 数据异常,整单跳过', v_o.id;
      CONTINUE order_loop;            -- 结构已就绪,直接可用
    END IF;
  END LOOP;
END LOOP order_loop;

4. 集合能由一条查询表达时,优先 RETURN QUERY

把排序、过滤、连接都交给优化器,不要让 PL/pgSQL 逐行搬运。

sql
RETURN QUERY
  SELECT o.id, o.amount
    FROM demo_order o
   WHERE o.status = 'paid' AND o.created_at >= p_from;
RETURN;

5. RETURN NEXT 只用于「每行需要 PL/pgSQL 加工」的场景

调用其它函数、逐行改写、复杂分支过滤,这些是 RETURN NEXT 的正当理由。

sql
FOR v_r IN SELECT sku, qty FROM demo_item WHERE order_id = p_id LOOP
  sku := upper(v_r.sku);              -- 逐行加工
  qty := app_round_qty(v_r.qty);
  RETURN NEXT;
END LOOP;
RETURN;

6. 函数出口要么收敛到一处,要么用兜底 RETURN 覆盖所有分支

避免「某个分支忘了返回」导致的运行时报错。

sql
CREATE OR REPLACE FUNCTION app_grade(p_score int) RETURNS text LANGUAGE plpgsql AS $$
BEGIN
  IF p_score IS NULL THEN RETURN '未知'; END IF;
  IF p_score >= 90 THEN RETURN 'A'; END IF;
  IF p_score >= 80 THEN RETURN 'B'; END IF;
  IF p_score >= 60 THEN RETURN 'C'; END IF;
  RETURN 'D';                         -- 兜底,覆盖剩余全部情况
END;
$$;

7. 批处理循环用 GET DIAGNOSTICS ... ROW_COUNT + EXIT WHEN 收敛

这是「跑到没有数据为止」的标准写法,比来回 SELECT count(*) 高效。

sql
LOOP
  WITH picked AS (
    SELECT id FROM demo_task WHERE status = 'new' ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED
  )
  UPDATE demo_task t SET status = 'doing' FROM picked p WHERE t.id = p.id;

  GET DIAGNOSTICS v_affected = ROW_COUNT;
  EXIT WHEN v_affected = 0;
  v_total := v_total + v_affected;
END LOOP;

8. 循环一定要有兜底次数上限

无论是 WHILE 还是 LOOP,加一个 v_round < p_max_round 之类的保险,避免异常数据导致无限循环。

sql
WHILE v_round < p_max_round LOOP
  v_round := v_round + 1;
  ...
  EXIT WHEN v_affected < p_batch_size;
END LOOP;

9. 提前返回时用 RETURN 而不是层层嵌套

参数校验、前置条件不满足,直接 RETURN,主逻辑保持在最外层。

sql
IF p_arr IS NULL OR cardinality(p_arr) = 0 THEN
  RETURN 0;                           -- 提前返回
END IF;
-- 主逻辑平铺在最外层

10. 集合返回函数的参数非法时优先返回空集而不是报错

分页、筛选类接口对「边界参数」应宽容,用裸 RETURN; 返回零行。

sql
IF p_limit IS NULL OR p_limit <= 0 THEN
  RETURN;                             -- 返回空集
END IF;

11. 用 FOUND 做「查到了没」的判断,而不是反复 count(*)

SELECT ... INTO / PERFORM 之后 FOUND 已就绪,配合 EXIT WHEN 最自然。

sql
SELECT * INTO v_row FROM demo_order WHERE id = p_id;
EXIT WHEN FOUND;

12. 记录循环的出入口,便于线上排查

EXIT 之前打一条 RAISE NOTICE(或写日志表),让「为什么提前停了」有迹可循。

sql
IF v_job.prio > p_max_prio THEN
  RAISE NOTICE '优先级 % 超过上限 %,停止处理', v_job.prio, p_max_prio;
  EXIT;
END IF;

下一节

继续学习:游标声明与打开

参考链接 ​