退出与跳过(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 | 不离开函数,只追加一批 | 追加一批行 | 集合返回函数直接吐一条查询 |
前两条只在循环(或块)内部有意义;后三条只在该函数/过程体内有意义。把它们的作用范围画出来:
函数边界 ────────────────────────────────────────────────
│ │
│ 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」两种形式:
-- 写法一: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 表达式 | 结束函数并返回该值 |
void | RETURN; | 提前结束,不带表达式 |
| 带 OUT 参数的函数/过程 | RETURN; | 返回 OUT 参数的当前值 |
SETOF 类型 / TABLE(...) | RETURN NEXT / RETURN QUERY + 末尾 RETURN; | 逐行或逐批追加,最后整体返回 |
一句话记忆:返回「一个值」用 RETURN 表达式;返回「一堆值」用 RETURN NEXT / RETURN QUERY,并在最后补一个光秃秃的 RETURN;。
一个必须记住的硬约束
如果函数的返回类型不是 void、也没有 OUT 参数,那么控制流走到函数最外层块的末尾而没有碰到任何 RETURN 时,运行时一定会报错:
ERROR: control reached end of function without RETURN这不是警告,是错误。它和「返回 NULL」是两回事。带 OUT 参数的函数与 void 函数不受此约束,因为它们会自动补一个隐式 RETURN。
语法与用法
五条语句的完整语法:
EXIT [ label ] [ WHEN 布尔表达式 ];
CONTINUE[ label ] [ WHEN 布尔表达式 ];
RETURN;
RETURN 表达式;
RETURN NEXT [ 表达式 ];
RETURN QUERY 查询;
RETURN QUERY EXECUTE 命令字符串 [ USING 表达式 [, ...] ];语法要点拆解:
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 混用,结果按执行顺序拼接一段最短的可用示例,把五条语句都覆盖到:
-- 标量函数:用 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-- 集合返回函数:用 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-- 集合返回函数:用 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 之后的第一条语句」。
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 是直接离开函数,后面的语句一句都不会跑。
-- 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,它会回到循环顶部重新求值条件。
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 的取舍
两者等价,选哪个主要看表达意图:
-- 意图:这是一个「出口条件」,用 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>>,并在语句中指名:
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 同样支持标签,用来「跳过外层循环的本次迭代」:
<<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:
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 表达式 结束函数并把表达式的值返回给调用者。返回值会按赋值转换规则自动转换到函数的返回类型;返回复合类型(行)时需要表达式正好产出目标列集,必要时显式转型。
-- 标量返回
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;:
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 参数变量的当前值:
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;(或者控制流自然走到函数末尾)。
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 的规则一致:
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 的执行模型,才能理解它们的性能特征。官方文档明确说明:当前实现会把整个结果集缓存起来,等函数返回时一次性交给调用者。 也就是说,函数产出的行会先被攒着,攒不下时写到磁盘。
函数开始
│
├── RETURN NEXT r1 → 结果集: [r1]
├── RETURN NEXT r2 → 结果集: [r1, r2]
├── RETURN QUERY ...→ 结果集: [r1, r2, q1, q2, ...]
│
├── 数据量超过 work_mem 时自动溢写到磁盘
│
└── RETURN; → 整个结果集一次性返回给调用者因此有两条实践结论:
- 函数在生成完整个结果集之前不会向调用者返回任何一行,所以它不能在「边生成边消费」的流式场景下降低首行延迟。
- 结果集的落盘阈值由
work_mem控制,如果一个集合返回函数经常产出很大的结果集,可以临时提高该函数的work_mem(例如用函数级SET)来避免频繁写盘。
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 函数的局部变量保持错误发生时的值,但块内对持久化数据库状态所做的修改全部回滚。
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 放进循环体里当成逐行的错误分支——那会让每一轮迭代都付出子事务的代价。
-- 不推荐:异常块放在循环里,每行都建一次子事务
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 循环执行后被设置:
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 搭配做「批处理跑到没有数据为止」:
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 层「过一遍手」,多出逐行开销。
-- 推荐:一条 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 次。
建表与造数:
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建函数:
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;
$$;调用与输出:
SELECT * FROM app_run_jobs(100, 3); 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 表里文章很多,需要一个函数按游标式分页返回某作者的文章标题与发布时间,供前端「加载更多」使用。
建表与造数:
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;建函数:
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;
$$;调用(模拟翻页):
-- 第一页
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 处理「订单-明细」双层校验
需求:校验订单及其明细;一旦发现某张订单存在明细数量为负,就整张订单放弃并记录原因,但继续校验下一张订单。
建表与造数:
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);建函数:
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(); 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; 并用一个布尔标记控制后续:
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 之后函数会继续执行。
-- 错误!以为 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。
-- 错误!分支覆盖不全,可能走到函数末尾
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; 虽不报错(控制流到末尾即结束),但容易漏掉后续逻辑。
-- 不推荐:以为 RETURN NEXT 会退出
LOOP
RETURN NEXT v_r; -- 追加一行,继续循环
END LOOP;
-- 正确:循环结束后补 RETURN;,语义明确
RETURN;4. 过程里不要写 RETURN 表达式
CREATE PROCEDURE 没有返回值,带表达式的 RETURN 会报语法错误。
-- 错误!过程不能带返回值
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 块
想从块里跳出去,必须给块加标签。
-- 不推荐:期望跳出这个块,实际匹配不到任何循环
BEGIN
BEGIN
EXIT; -- 若外层没有循环则报错
END;
END;
-- 正确:给块加标签
BEGIN
<<blk>>
BEGIN
EXIT blk; -- 跳出 blk 块
END blk;
END;6. 标签不能指向内层
EXIT / CONTINUE 的标签必须是当前层或更外层的结构名,指到内层会报错。
-- 错误!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 块都建子事务,逐行进入开销极大。
-- 不推荐:逐行建子事务
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 溢写磁盘。
-- 不推荐:超大结果集 + 默认 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 无条件执行时,后面同一层级的语句永远跑不到,通常意味着逻辑写错了。
-- 错误!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 会回到条件求值处,如果推进语句在被跳过的区间里,就会死循环。
-- 错误!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
条件语义决定写法,代码意图更清楚。
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 做前置过滤
把「不处理」的情况顶到最前面,减少嵌套层级。
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. 嵌套循环一律给外层加标签
即便当下不需要跨层跳出,先加标签,后续改动不用重构结构。
<<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 逐行搬运。
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 的正当理由。
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 覆盖所有分支
避免「某个分支忘了返回」导致的运行时报错。
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(*) 高效。
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 之类的保险,避免异常数据导致无限循环。
WHILE v_round < p_max_round LOOP
v_round := v_round + 1;
...
EXIT WHEN v_affected < p_batch_size;
END LOOP;9. 提前返回时用 RETURN 而不是层层嵌套
参数校验、前置条件不满足,直接 RETURN,主逻辑保持在最外层。
IF p_arr IS NULL OR cardinality(p_arr) = 0 THEN
RETURN 0; -- 提前返回
END IF;
-- 主逻辑平铺在最外层10. 集合返回函数的参数非法时优先返回空集而不是报错
分页、筛选类接口对「边界参数」应宽容,用裸 RETURN; 返回零行。
IF p_limit IS NULL OR p_limit <= 0 THEN
RETURN; -- 返回空集
END IF;11. 用 FOUND 做「查到了没」的判断,而不是反复 count(*)
SELECT ... INTO / PERFORM 之后 FOUND 已就绪,配合 EXIT WHEN 最自然。
SELECT * INTO v_row FROM demo_order WHERE id = p_id;
EXIT WHEN FOUND;12. 记录循环的出入口,便于线上排查
EXIT 之前打一条 RAISE NOTICE(或写日志表),让「为什么提前停了」有迹可循。
IF v_job.prio > p_max_prio THEN
RAISE NOTICE '优先级 % 超过上限 %,停止处理', v_job.prio, p_max_prio;
EXIT;
END IF;下一节
继续学习:游标声明与打开
参考链接
- PL/pgSQL 控制结构(Control Structures:RETURN / RETURN NEXT / EXIT / CONTINUE)
- PL/pgSQL 中的基本语句(Basic Statements:PERFORM / 动态命令 / GET DIAGNOSTICS)
- PL/pgSQL 的整体结构(PL/pgSQL Structure)
- PL/pgSQL 声明(Declarations:FOUND 等特殊变量)
- PL/pgSQL 游标(Cursors)
- CREATE FUNCTION 官方文档
- CREATE PROCEDURE 官方文档
- 错误代码附录(PostgreSQL Error Codes)