ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL PL/pgSQL存储过程:结构控制与循环实战

PostgreSQL PL/pgSQL存储过程:结构控制与循环实战

PostgreSQL存储过程(PL/pgSQL)写多了之后,你会发现真正决定代码质量的往往不是那些花哨的SQL技巧,而是结构控制和循环——IF怎么判断才不踩NULL的坑,FOR和WHILE到底选哪个,EXIT与CONTINUE怎么在嵌套循环里精准跳转,异常块为什么在循环里会拖垮性能。这篇是存储过程系列里的第六篇,专门把结构控制和循环这部分掰开揉碎讲一遍。内容适合已经写过几个存储过程、但控制流用得还不够得心应手的开发者,也适合准备系统梳理PL/pgSQL控制逻辑的DBA,看完可以直接拿这些写法去改造手头的存储过程。

1. 条件分支的坑:IF、ELSIF、CASE与NULL判断

1.1 IF语句的基本结构和常见拼写错误

PL/pgSQL里最常用的条件分支就是IF,官方的标准写法是:

IF 条件 THEN -- 条件为TRUE时执行 ELSIF 另一条件 THEN -- 上一个条件不成立且本条件成立时执行 ELSE -- 所有条件都不成立时执行 END IF;

第一个容易翻车的地方是拼写。ELSIF只有一个E,很多人习惯写ELSEIF,老版本的PostgreSQL对这两种写法都做了兼容,但官方文档和绝大多数老代码里使用的都是ELSIF。如果团队里有代码规范检查工具,建议统一用ELSIF,避免代码风格混乱。

第二个容易忽略的细节是:IF后面的条件必须是布尔表达式。它不像C语言或Python那样接受0或1这类数字,更不存在"非空即真"的隐式转换。如果你写:

IF p_count THEN -- ... END IF;

PostgreSQL会直接报错,因为p_count如果是个整数,它不会被自动转换成布尔值。正确的做法是显式写成IF p_count > 0 THEN。

第三个实用技巧:IF条件里可以嵌子查询,这在实际业务中非常好用。比如判断某张表里是否存在满足条件的记录,没必要先把结果取出来再判断,直接在IF里写:

IF EXISTS (SELECT 1 FROM orders WHERE user_id = p_user_id AND status = 'pending') THEN RAISE NOTICE '该用户有待处理订单'; END IF;

这一招既简洁又直观,比先SELECT count(*)到一个变量再判断不知道高到哪里去了。

1.2 NULL参与判断时最容易走错分支

这是我觉得PL/pgSQL里最防不胜防的一个坑,新手基本都踩过。SQL世界里的布尔逻辑是三值的:TRUE、FALSE,还有NULL。NULL和"假"完全是两回事,但很多判断语句会把NULL当false处理。

看下面这段代码:

IF p_score = NULL THEN RAISE NOTICE '分数为空'; ELSE RAISE NOTICE '分数不为空'; END IF;

你本来是想判断p_score是不是NULL,但p_score = NULL这个表达式的结果永远是NULL,永远不会变成TRUE。所以这段代码不管传入什么值,走到的都一定是ELSE分支,你以为的NULL分支压根不会被执行。

正确的写法是使用IS NULL或者IS NOT NULL:

IF p_score IS NULL THEN RAISE NOTICE '分数为空'; END IF;

更隐蔽的情况是反向判断。比如你想知道这个分数是不是大于60:

IF p_score > 60 THEN RAISE NOTICE '及格'; ELSE RAISE NOTICE '不及格'; END IF;

当p_score是NULL时,p_score > 60的结果是NULL,不是TRUE,于是执行的是ELSE分支,输出"不及格"。从业务语义上说,NULL既不是及格也不是不及格,你把它归到"不及格"可能恰好没问题,但如果你的ELSE分支里写的是"分数合法"之类的判断,那就不知不觉地把非法数据当成正常数据处理了。

我的建议是:凡是参与分支判断的字段,第一件事就要想清楚"它有没有可能是NULL"。如果有可能,先把NULL情况单独拎出来处理,再谈大小比较。可以用COALESCE给个默认值,也可以像这样显式拆开:

IF p_score IS NULL THEN RAISE NOTICE '未录入成绩'; ELSIF p_score >= 60 THEN RAISE NOTICE '及格'; ELSE RAISE NOTICE '不及格'; END IF;

1.3 CASE语句和CASE表达式:一字之差行为不同

很多人知道SQL里有CASE WHEN ... THEN ... END这种表达式,但在PL/pgSQL里还有一个长得几乎一样的CASE ... END CASE语句,两者行为和用途并不完全一样。先看CASE语句:

DECLARE v_status text; BEGIN v_status := 'active'; CASE v_status WHEN 'active' THEN RAISE NOTICE '状态正常'; WHEN 'disabled' THEN RAISE NOTICE '状态被禁用'; ELSE RAISE NOTICE '未知状态'; END CASE; END;

注意结尾是END CASE而不是END。它和IF语句的地位是一样的,都是流程控制语句,执行完分支后继续往下走。它也有搜索形式,适合比较复杂的条件:

CASE WHEN p_score >= 90 THEN RAISE NOTICE '优秀'; WHEN p_score >= 80 THEN RAISE NOTICE '良好'; ELSE RAISE NOTICE '继续努力'; END CASE;

CASE语句有一个非常容易踩的坑:如果没有任何WHEN分支命中,而且你也没写ELSE,它会直接抛出异常CASE_NOT_FOUND。这一点和SQL里的CASE表达式完全不同,SQL的CASE表达式在没有匹配且没有ELSE时返回NULL,程序不会报错。这其实是好事还是坏事?要看场景。有些时候你希望"找不到匹配就是异常",CASE语句恰好满足了这种需求;但如果你只是想做简单的条件分发,忘了写ELSE,那就是一个隐藏炸弹。

所以在PL/pgSQL里写CASE语句时,我基本都会补一个ELSE,哪怕ELSE里只写一个NULL;占位也好。别小看这个习惯,关键时刻能帮你减少一次线上故障。

2. 循环家族全景:LOOP、WHILE、FOR、FOREACH

2.1 LOOP死循环:只有EXIT能救你

先说最原始的LOOP。它没有自己的终止条件,需要你在循环体里手动EXIT,否则就真的会永远跑下去:

DECLARE v_counter int := 0; BEGIN LOOP v_counter := v_counter + 1; EXIT WHEN v_counter >= 10; END LOOP; RAISE NOTICE 'counter = %', v_counter; END;

看起来很简单,但它其实是四种循环里最容易出事故的。因为循环体里一旦条件判断写错,或者某个变量的更新被跳过,整个循环就会变成无终止的"死循环",如果这个存储过程恰好又被并发任务调度,数据库连接可能直接被占满。

我见过一种比较危险的用法:在循环体里用IF来判定退出条件,但IF的条件和更新变量的逻辑前后顺序写反了,导致EXIT永远等不到想要的值。比如:

LOOP IF v_counter >= 10 THEN EXIT; END IF; v_counter := v_counter + 1; END LOOP;

这段代码其实能正常退出,但如果把递增语句写到IF之前,或者把比较符号写反成v_counter <= 10,那循环就会一直跑下去。我自己的习惯是:能用EXIT WHEN绝不用IF + EXIT,因为EXIT WHEN把条件和退出动作写在同一行,更容易检查,也少一对END IF。

LOOP适合什么场景呢?一般来说,当你无法在进入循环前确定迭代次数、需要完全由循环体内逻辑决定何时退出时,用它最合适。比如轮询等待某个外部条件满足,或者逐行处理一批数据但每步都可能跳过。

2.2 WHILE循环:先判断后执行

WHILE循环把终止条件放到了循环开头:

DECLARE v_counter int := 1; BEGIN WHILE v_counter <= 10 LOOP RAISE NOTICE '当前值 %', v_counter; v_counter := v_counter + 1; END LOOP; END;

它的关键特点是先判断,后执行。如果初始条件就不成立,循环体一次都不会执行。这跟LOOP有本质区别:LOOP无论如何都会先执行一遍循环体,WHILE可能整个循环体直接被跳过。

写WHILE时最容易犯的错是忘记在循环体里更新条件变量,或者更新的位置不对。比如条件要求v_counter从1增到10,但你把递增写在一个永远不会执行到的IF分支里,循环就卡死在原地。另一个常见问题是边界条件写错:<= 10和< 10差一次迭代,虽然不致命,但会带来边界数据漏处理的问题。

应用场景上,WHILE非常适合"处理完一批还剩一批"的批处理模式。比如一个订单表里有大量待处理记录,你想每轮处理100条,直到全部处理完。用WHILE就很顺:

WHILE EXISTS (SELECT 1 FROM orders WHERE status = 'pending') LOOP UPDATE orders SET status = 'processing' WHERE id IN ( SELECT id FROM orders WHERE status = 'pending' LIMIT 100 ); END LOOP;

这种写法每轮都重新检查表里还有没有待处理数据,天然就不容易漏。

2.3 FOR整数循环:边界、步长和循环变量

FOR循环是我日常写得最多的一种,语法非常简洁:

FOR i IN 1..10 LOOP RAISE NOTICE '第 % 次循环', i; END LOOP;

几点需要特别注意。首先是边界,1..10是包含两端的,也就是从1到10一共执行10次,和很多语言里"左闭右开"的习惯不一样。反着写用REVERSE:

FOR i IN REVERSE 10..1 LOOP RAISE NOTICE '%', i; END LOOP;

其次是步长。PostgreSQL 14开始支持BY参数,比如:

FOR i IN 1..10 BY 2 LOOP RAISE NOTICE '%', i; -- 输出 1, 3, 5, 7, 9 END LOOP;

如果你还在用14之前的版本,这个参数用不了,只能自己用变量模拟步进,写法会麻烦一些。

第三是循环变量的"身世"。FOR循环里这个i不需要在DECLARE区声明,它是自动生成的局部变量,作用域就是这个循环体。关键限制是:循环体内不能给这个变量赋值,你即使写了i := 5,下一次迭代i还是会被FOR机制重新赋值为下一个整数,你的赋值根本不起作用。这个设计其实是有意为之的,防止开发者干扰循环的正常推进。

还有一个小细节:循环结束时,循环变量的值是最后一次迭代时的值。如果循环一次都没执行,那这个变量在循环体内部其实等于不存在,你在循环外引用它可能会报错或者拿到预期之外的值。所以不要指望靠循环变量来"传递"结果,结果应该存在独立的DECLARE变量里。

2.4 FOREACH:专为数组设计的遍历器

FOREACH是PostgreSQL特有的循环语法,遍历数组时比FOR舒服得多:

DECLARE v_arr int[] := ARRAY[1, 2, 3, 4]; v_item int; BEGIN FOREACH v_item IN ARRAY v_arr LOOP RAISE NOTICE '%', v_item; END LOOP; END;

如果是多维数组,默认不带SLICE时,FOREACH会把所有最内层的元素挨个遍历出来,不会保留数组维度的结构。如果你想按"切片"来遍历,比如把一个二维数组按第一层切成一维数组逐个处理,就需要指定SLICE 1:

DECLARE v_arr int[] := ARRAY[[1, 2], [3, 4]]; v_slice int[]; BEGIN FOREACH v_slice SLICE 1 IN ARRAY v_arr LOOP RAISE NOTICE '切片 %', v_slice; -- 依次输出 {1,2}, {3,4} END LOOP; END;

注意这里v_slice要声明成int[],如果声明成int,SLICE模式下类型就对不上。

FOREACH最常用的场景是以数组作为输入参数,在存储过程里展开处理。比如某个业务接口传入一批订单ID,你希望逐个处理并跳过无效ID,用FOREACH循环会非常自然。

3. 循环跳转控制:EXIT、CONTINUE与标签的正确用法

3.1 用WHEN子句把条件并入EXIT/CONTINUE

EXIT和CONTINUE是循环里两个最重要的"操盘手"。EXIT表示彻底退出循环,CONTINUE表示跳过本次迭代剩余代码、直接进入下一轮。它们都可以搭配WHEN子句,让条件判断更紧凑:

LOOP v_counter := v_counter + 1; EXIT WHEN v_counter > 100; CONTINUE WHEN v_counter % 2 = 0; RAISE NOTICE '只打印奇数 %', v_counter; END LOOP;

注意执行顺序:先判断EXIT WHEN,再看CONTINUE WHEN。上面这段代码里,当v_counter是偶数时,CONTINUE成立,直接进入下一轮循环,下面的RAISE不会执行。

WHEN子句里的条件同样要小心NULL的问题。如果条件表达式结果是NULL,WHEN不会生效,循环不会退出、也不会跳过,会继续往下走。大多数情况下这符合直觉,但如果你写的条件里涉及可能为NULL的字段,最好先显式处理NULL。

3.2 命名标签:嵌套循环中跳出指定层级

只有一层循环的时候,EXIT和CONTINUE没什么悬念,跳的就是当前这一层。一旦嵌套循环多起来,默认行为就不够了——你只想跳出内层循环,外层继续跑,或者干脆直接跳出整个双层循环。

这时候就要给循环命名标签。语法是用一对尖括号把标签名包起来,放在循环关键字之前:

<<outer_loop>> FOR i IN 1..3 LOOP <<inner_loop>> FOR j IN 1..3 LOOP -- 条件满足时直接跳出外层循环 EXIT outer_loop WHEN i * j = 4; RAISE NOTICE 'i=%, j=%', i, j; END LOOP; END LOOP;

同样,CONTINUE outer_loop会跳过外层循环的当前迭代,重新开始外层循环的下一轮。这个功能在两层以上嵌套里特别好用,没有它的话,你得用好多个IF标志位才能实现相同的控制逻辑。

标签的另一个好处是让代码自解释。看到EXIT outer_loop,读代码的人马上就知道这是要退出哪一层;而没有标签的EXIT在多层嵌套里经常让人一头雾水。

3.3 EXIT还能直接退出整个BEGIN块

这条算是冷门知识,但某些场景下非常有用。EXIT并不只能用在循环里,它还能配合带标签的BEGIN块使用,实现"直接跳到块末尾"的效果:

<<process_block>> BEGIN -- 一些前置校验 IF p_skip THEN EXIT process_block; END IF; -- 这里是一大段复杂的业务处理 PERFORM do_something(p_param); END process_block; RAISE NOTICE '继续后续流程';

这个写法的本质是:EXIT带着标签退出指定的块,控制流直接跳到块结束后的下一条语句。有点类似其他语言里的goto,但PostgreSQL把它限制得很安全,只能在包围当前语句的标签块之间跳。

它有什么用?我有一个比较深刻的体会:当你的业务逻辑存在"如果满足某个条件,就跳过后面这一大段处理"的需求时,与其把一大段代码都包在IF NOT ... THEN里,导致嵌套层级越来越深,不如直接用这种带标签的EXIT把块的中间切断。代码不仅横向缩进少了,可读性也明显更好。

不过要注意,CONTINUE不能用在非循环的BEGIN块里,它只能出现在循环或者FOR/FOREACH内部。

4. 在循环里处理查询结果:游标、FOR查询与DML陷阱

4.1 FOR-IN-QUERY隐式游标的基本玩法

存储过程里处理查询结果集,最常见的写法就是FOR循环直接跟一个SELECT语句:

FOR rec IN SELECT id, order_no, amount FROM orders WHERE created_at >= '2024-01-01' LOOP RAISE NOTICE '订单 %, 金额 %', rec.order_no, rec.amount; END LOOP;

这里rec是一条记录变量,不需要在DECLARE区声明,字段名就是SELECT列表里的别名。它内部走的是隐式游标机制:查询结果被打开成一个游标,循环每轮取一行,直到取完自动结束。从使用体验上来讲非常友好,写起来也快。

但这里有一个需要了解的性能背景:如果查询返回的结果集特别大,FOR-IN-QUERY循环依然是把查询作为一个整体在事务里推进的,长事务、内存、锁这些资源压力都会变大。官方文档的态度是:返回超大结果集时,应该考虑显式游标,并且在循环里用FETCH逐批取数据。所以FOR-IN-QUERY适合中小规模结果集,比如几千几万行的批量处理,如果是百万人级别的大报表遍历,建议看下一节的显式游标方案。

4.2 显式游标:大规模结果集的兜底方案

当你在PL/pgSQL里需要精细控制读取行为时,用显式游标是更好的选择。一个完整的套路是这样:

DECLARE cur CURSOR FOR SELECT id, name FROM users WHERE status = 'active'; rec record; BEGIN OPEN cur; LOOP FETCH NEXT FROM cur INTO rec; EXIT WHEN NOT FOUND; -- 处理 rec RAISE NOTICE '处理用户 %', rec.name; END LOOP; CLOSE cur; END;

这里面的核心是FETCH NEXT FROM cur INTO rec,它每轮取一行;EXIT WHEN NOT FOUND是取不到行之后退出循环的固定搭配。为什么用EXIT WHEN NOT FOUND而不是判断rec IS NULL?因为rec是记录类型,取不到行时它可能还保留着上一次的值,只有NOT FOUND状态是正确的判据。

显式游标的优势在于:你可以控制FETCH的方向、一次取多少行(比如用FETCH FORWARD 100),可以在中途随意关闭游标,不必等循环自然结束。在处理几百万行的大表扫描时,这种控制感非常宝贵。

需要注意,PL/pgSQL里NOT FOUND是一个全局的变量状态,不只由FETCH置位,普通的SELECT INTO如果没取到行也会让它变成TRUE。所以在循环里如果有其他查询动作,要小心它对NOT FOUND状态的覆盖,建议在开销允许的情况下,用独立的布尔变量记录"是否需退出"。

4.3 循环内做DML的锁冲突与快照问题

循环里对同一张表或者相关表做INSERT/UPDATE/DELETE,是存储过程业务里非常常见的操作,但这里坑很深。

最典型的是"先SELECT一行,再UPDATE这一行"的模式。在READ COMMITTED隔离级别下,如果你SELECT时没有加FOR UPDATE,这行可能在你处理期间被其他事务更新掉。等你执行UPDATE时会收到一个tuple concurrently updated的错误,整个事务可能就此回滚。

比较稳健的写法是,在SELECT阶段就锁定目标行:

FOR rec IN SELECT id, balance FROM accounts WHERE status = 'processed' AND balance < 0 FOR UPDATE LOOP UPDATE accounts SET status = 'checked' WHERE id = rec.id; END LOOP;

但即便加了FOR UPDATE,你也必须意识到:如果你在事务里锁了很多行,锁的持有时间会一直到事务结束,并发事务一旦碰到这些行就会阻塞。所以循环体里有DML时,尽量让每个事务的处理量保持在可控范围,不要一个超大事务跑几个小时,那样在业务高峰期等于事故本身。

另外一个常见怪现象是:循环里UPDATE了一张表,而FOR循环的查询源恰好也是这张表。由于查询结果是在循环开始时就打开了快照,你在循环里更新过的行,游标可能仍然返回旧版本的数据,这会让你觉得"明明改了怎么又读出来还是旧的"。这不算Bug,是PostgreSQL的快照隔离机制在起作用。理解了这一点,你就能猜到:能用一个UPDATE语句完成的批量修改,就不要用循环逐行来搞。先问自己一句"能不能写成集合操作?"这句灵魂拷问,能帮你省掉很多不必要的麻烦。

5. 异常处理如何与结构控制协同

5.1 EXCEPTION块本质上也是一种控制流

PL/pgSQL里的异常处理和普通语言的try-catch很像,语法长这样:

BEGIN INSERT INTO orders(id, order_no) VALUES (nextval('orders_id_seq'), p_order_no); EXCEPTION WHEN unique_violation THEN RAISE NOTICE '订单号重复,已跳过'; END;

一个带EXCEPTION子句的BEGIN块,本质上是开启了子事务。当块内部发生异常时,PostgreSQL会把整个块里已经做的修改全部回滚,然后进入异常分支继续执行。从控制流角度看,它有点像"正常路径失败就切换分支"的特殊结构控制。

但它和IF有本质区别:IF是主动判断,异常是被动响应。什么时候用异常,什么时候用条件判断,这是一条值得死记硬背的分界线:能提前判断的异常,优先用IF;无法预判或判断成本极高的情况,才用EXCEPTION。

比如你要处理一个可能不存在的用户。正常流程是先查用户存不存在,不存在就跳过:

IF EXISTS (SELECT 1 FROM users WHERE id = p_user_id) THEN PERFORM process_user(p_user_id); END IF;

这种场景就不该用EXCEPTION WHEN no_data_found去捕获,因为"用户可能不存在"这一点你完全可以提前查出来,没必要让程序在异常路径里走一遭。

5.2 能用IF判断就不要依赖异常捕获

但有另一类场景,条件判断根本帮不上忙。比如同时有大量并发事务在往同一张表里插入数据,而这张表上有唯一约束。你即使先SELECT再INSERT,在SELECT结束到INSERT执行之间,别的会话也可能已经插入了相同记录。这种"判断之后依然可能失败"的竞态条件,唯一可靠的兜底方式就是捕获unique_violation异常:

BEGIN INSERT INTO users(id, email) VALUES (v_id, v_email); EXCEPTION WHEN unique_violation THEN RAISE NOTICE '邮箱已存在:%', v_email; END;

这里就体现了异常处理的真正价值:它处理的是那些"不该正常发生、但一旦发生就要兜住"的场景。判断一个值是否已存在,属于可以预判的逻辑,主要靠IF;而并发冲突导致的唯一键违规,属于不可完全预判的边界情况,交给异常处理更合适。核心原则是,不要用异常处理来掩盖你本该在正常逻辑里做掉的判断,否则异常块会大量增加性能开销和调试难度。

5.3 异常块的子事务开销:一个量级级的性能差

我早期写过一段存储过程,功能是对一批历史订单逐条尝试匹配新价格,匹配失败就跳过。我当时图省事,直接在循环体内包了一个带EXCEPTION的BEGIN块。逻辑完全没问题,结果线上跑了很久没跑完,一查会话,发现它在处理大约几十万行的时候慢到无法接受。

后来我单独做了个对比测试。同样一段循环,不包异常块的情况下,处理一万行几乎瞬间完成;包了异常块、每一行都开启子事务之后,时间变成了原来的几十倍不止,而且数据量越大差距越明显。

原因是PostgreSQL的EXCEPTION块不是廉价的"加一个标记",它真正开启了一个子事务。子事务需要额外记录事务状态、保存点信息,正常不抛异常时开销已经不小,一旦真发生异常,还要回滚整个子事务并刷新状态。在一个反复执行几十万次的循环体里,这个小开销会被放大到触目惊心的程度。

所以我的习惯是:需要逐行容错时,尽量先把那些"注定会失败"的数据提前过滤掉,让异常块的触发概率无限趋近于零。比如批量插入前,先在存储过程里用一条SELECT ... GROUP BY把重复数据剔除,再进循环做逐行插入,这样循环里的异常块基本不会触发,性能自然就回来了。

6. 实操过程中的几个进阶技巧与备忘

6.1 用RAISE把循环状态可视化

存储过程调试起来远比普通应用代码麻烦,尤其是循环几千次之后你根本不知道它跑到了哪里。我的习惯是在关键循环里加RAISE NOTICE输出进度信息,尤其是变量比较多、逻辑比较绕的时候:

FOR rec IN SELECT id, name FROM users LOOP RAISE NOTICE '正在处理用户 id=%, name=%', rec.id, rec.name; -- ... END LOOP;

如果数据量很大,每一行都RAISE NOTICE会让日志爆炸,实际使用中我通常只打印每处理一段的进度,比如每次循环100行就输出一条:

v_count := 0; FOR rec IN SELECT ... LOOP v_count := v_count + 1; IF v_count % 100 = 0 THEN RAISE NOTICE '已处理 % 行', v_count; END IF; END LOOP;

RAISE的级别可以选DEBUG、LOG、NOTICE、WARNING,线上生产环境我会用RAISE DEBUG,避免影响正常的应用日志。

6.2 动态SQL与循环组合时的格式化和注入防御

存储过程里要循环处理一批动态的表名或列名时,需要把动态SQL和循环组合起来。比如你有一组归档表,想统一做某种操作:

FOR t IN SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'archive\_%' LOOP EXECUTE format('UPDATE %I SET archived_at = now()', t.table_name); END LOOP;

这里的关键原则是:表名、列名这类结构对象不能直接用拼接字符串,必须用format函数的%I占位符;值呢,则应该用USING参数传进去,而不是拼到SQL字符串里。我见过不少人图省事写了EXECUTE 'SELECT * FROM ' || t.table_name,一旦表名来自外部输入,就是妥妥的注入风险。format加%I会自动把标识符正确转义并加双引号,这个习惯必须养成。

另外,动态SQL在循环里的成本不低,因为它每次都要走一遍解析和规划的过程。如果循环次数很多且SQL结构不变,尽量能把动态SQL提到循环外面就别放里面;实在没办法的话,至少保持循环里的SQL模板一致,让PostgreSQL能复用计划。

6.3 嵌套结构过深时怎么办

不管是IF嵌套还是循环嵌套,当代码缩进层级超过三层,我就觉得这段代码离"维护地狱"不远了。PL/pgSQL本身不是一种擅长表达复杂嵌套逻辑的语言,硬往里堆会把读代码的人折磨疯。

我自己的两个处理手段很朴素。第一,拆函数。把内层那坨复杂的循环体抽成一个独立的函数或存储过程,在外层循环里直接调用,边界清晰,调试也方便。第二,改用集合操作替代嵌套循环。比如你要对A表的每一行,去B表里查找匹配数据,这本质上是一个JOIN,绝大多数情况下,一条带窗口函数的UPDATE或INSERT...SELECT就能解决,完全用不着双循环。

这个建议听起来不新鲜,但我发现真正去实践的人不多。很多PL/pgSQL的代码膨胀,就是因为当初没忍住写了一个三重嵌套循环,后面所有人都在这个循环里打补丁。与其如此,不如在一开始多花十分钟想想:能不能换一种不靠循环的写法?

最后再分享一点个人感触。写存储过程的结构控制,难的不是语法本身,而是每写一个分支、一个循环,都要强迫自己把NULL、边界、并发、性能这四个问题在脑子里过一遍。这个习惯形成之后,大部分控制流的坑基本就踩不到你了。如果你也遇到过类似的问题,或者有更好的PL/pgSQL控制流写法,欢迎在评论区一起交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进