PL/pgSQL 概述
PL/pgSQL 是 PostgreSQL 内置的、默认安装的过程语言(procedural language),它给 SQL 加上了变量、条件分支、循环、异常处理和动态 SQL 这些过程化能力。对 PHP/后端开发者来说,它的核心价值不在「语法更花哨」,而在于把一段多步逻辑放进数据库服务器内部执行,从而消掉客户端与服务端之间一轮又一轮的网络往返。本节建立整章的基础心智模型:PL/pgSQL 是什么、编译与执行模型如何工作、函数与存储过程怎么选,以及什么时候不该用它。
前置知识
阅读本节前,建议先了解:SQL 函数 vs PL/pgSQL 函数、CREATE FUNCTION 基础
基础概念
PostgreSQL 的过程语言不是写死在数据库二进制里的,而是以可加载模块(loadable module)的形式提供。PL/pgSQL 从 PostgreSQL 9.0 起随发行版默认安装,但本质上它仍是一个可选模块——安全意识强的管理员完全可以把它删掉。
先看一张全局图:一条 SELECT 语句和一段 PL/pgSQL 函数,在执行路径上到底差在哪里。
纯 SQL(客户端驱动) PL/pgSQL(服务端过程化)
────────────────────────── ──────────────────────────
PHP PHP
│ ① 发送 SELECT ┌───► │ ① 发送 SELECT my_func(...)
│ ② 等待、收结果 │ │ ② 等待
│ ③ 在 PHP 里算一下 │ │
│ ④ 再发 UPDATE │ ▼
│ ⑤ 等待、收结果 │ ┌───────────────────────────────┐
│ ⑥ 再发 INSERT │ │ PostgreSQL 服务端 │
▼ │ │ ┌─────────────────────────┐ │
PostgreSQL ◄──────────────┘ │ │ PL/pgSQL 解释器 │ │
│ │ ├─ SELECT(走 SPI) │ │
N 次往返 = N 次网络延迟 │ │ ├─ IF / 循环 │ │
N 次解析 + 规划 │ │ └─ UPDATE / INSERT │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘
1 次往返,中间结果不出服务端核心术语表:
| 术语 | 英文 | 说明 |
|---|---|---|
| 过程语言 | Procedural Language | 能承载过程化代码的语言,登记在 pg_language 中 |
| 语言处理器 | Language Handler | 解释/执行函数体的模块,PL/pgSQL 的处理器由 plpgsql.so 提供 |
| 块 | Block | PL/pgSQL 的基本结构单元:[DECLARE] BEGIN ... END |
| 函数 | Function | 用 CREATE FUNCTION 创建、可在 SQL 中调用的执行单元 |
| 存储过程 | Procedure | 用 CREATE PROCEDURE 创建(PG 11+)、用 CALL 调用,可提交事务 |
| SPI | Server Programming Interface | PL/pgSQL 执行每条 SQL 语句时走的内部接口 |
| 指令树 | Instruction Tree | 函数体首次调用时编译成的内部二进制结构,保存在会话内存中 |
| 计划缓存 | Plan Cache | 会话内缓存已规划的 SQL 语句,避免重复解析与规划 |
| 美元引用 | Dollar Quoting | $tag$ ... $tag$,用来包裹函数体,内部单引号不必转义 |
| 可信语言 | Trusted Language | 非超级用户也能在其上创建函数的语言,PL/pgSQL 属于此类 |
编译与执行模型
这是本节最需要建立的心智模型:PL/pgSQL 函数体不是一次性编译完的。
CREATE FUNCTION 执行时
└─ 只做两件事:
① 解析函数头(名称、参数、返回类型、属性)→ 写入 pg_proc
② 把 AS $$ ... $$ 里的文本原样存进 pg_proc.prosrc
└─ 注意:函数体内部的语法「通常」不检查
会话内首次调用时
└─ PL/pgSQL 解释器解析整个函数体源码 → 生成指令树
① 块结构、IF、LOOP 等控制结构全部翻译完毕
② 但内嵌的 SQL 表达式/命令「不」立即翻译
语句首次被执行到那一行时
└─ 调用 SPI_prepare 解析该 SQL 语句 → 生成 prepared statement
① 之后的执行复用这个 prepared statement
② SPI 可能进一步缓存「通用计划(generic plan)」
同一会话内后续调用
└─ 直接复用指令树 + prepared statement +(可能)执行计划由此推出三条重要结论:
- 冷启动只有一次:同一会话(连接)里,第一个调用者承担编译开销,后续调用近乎免费。这也是为什么「连接池 + 长连接」对 PL/pgSQL 更友好。
- 没走到的分支不会报错:某个
IF分支里的 SQL 写错了,只要这个分支在会话中从未被执行到,错误就不会暴露。因此测试覆盖率比语法检查更重要。 - 表名不能参数化:正因为语句与计划被缓存,SQL 命令每次执行必须引用同一张表、同一列。要动态换表名,只能用
EXECUTE(代价是每次重新解析 + 规划)。
函数 vs 存储过程
PostgreSQL 11 引入了真正的存储过程(CREATE PROCEDURE)。它与函数的关键差异不是「能不能返回值」,而是事务控制能力。
| 维度 | 函数 Function | 存储过程 Procedure |
|---|---|---|
| 创建语句 | CREATE FUNCTION | CREATE PROCEDURE(PG 11+) |
必须有 RETURNS | 是(或 OUT 参数 / RETURNS TABLE) | 否,不允许写 RETURNS |
| 如何返回值 | RETURN expr / RETURN NEXT / RETURN QUERY | 只能通过 OUT / INOUT 参数 |
| 调用方式 | 出现在 SQL 表达式中,如 SELECT f(1) | CALL proc(1) |
| 能否内部提交事务 | 不能(严格禁止 COMMIT / ROLLBACK) | 能,除非处于原子上下文中 |
| 能否用于索引表达式 | 能 | 不能 |
| 能否被触发器调用 | 能 | 不能 |
| 典型用途 | 计算、映射、返回结果集 | 批处理、运维脚本、多事务作业 |
一句话记忆:函数是「查询里的一个值」,过程是「一段可以被调用的作业」。
为什么用 PL/pgSQL
官方文档给出的三条收益,值得逐条落实到工程语境:
| 收益 | 说明 | 典型场景 |
|---|---|---|
| 消除额外往返 | 客户端不必为每个中间步骤单独发查询 | 一次下单要写 5 张表 |
| 中间结果不过网 | 客户端不需要的中间数据不必序列化传输 | 大表聚合后再取摘要 |
| 避免多轮解析 | 同一条语句只解析一次,后续复用执行计划 | 循环中反复执行的 UPDATE |
加上工程上常见的另外两条:
- 封装与复用:把业务规则收进一个数据库对象,多个应用(PHP、报表、定时任务)共用。
- 权限控制:配合
SECURITY DEFINER,可以让应用账号只能通过函数间接改表,实现「最小权限」。
PL/pgSQL 是可信语言
查看语言清单:
SELECT lanname, lanpltrusted, lanplcallfoid::regproc AS handler
FROM pg_language
ORDER BY lanname; lanname | lanpltrusted | handler
-------------+--------------+---------------------
c | f | -
internal | f | -
plpgsql | t | plpgsql_call_handler
sql | t | -
(4 rows)lanpltrusted = t(trusted language)意味着:只要用户对某个数据库有 CREATE 权限,并且对 plpgsql 语言有 USAGE(默认 PUBLIC 就有),他就能创建 PL/pgSQL 函数,而不需要超级用户身份。相对的,c 语言是不可信的——用 C 写函数能执行任意代码,必须超级用户才能创建。
语法与用法
CREATE FUNCTION 的完整骨架(PL/pgSQL 版):
CREATE [ OR REPLACE ] FUNCTION 名称 ( [ 参数模式 ] [ 参数名 ] 参数类型 [ DEFAULT 默认值 ] [, ...] )
RETURNS 返回类型
LANGUAGE plpgsql
[ IMMUTABLE | STABLE | VOLATILE ] -- 易变性,默认 VOLATILE
[ STRICT | CALLED ON NULL INPUT ] -- NULL 入参策略
[ SECURITY INVOKER | SECURITY DEFINER ] -- 安全上下文,默认 INVOKER
[ PARALLEL { UNSAFE | RESTRICTED | SAFE } ] -- 并行安全性,默认 UNSAFE
AS $$
[ <<label>> ]
[ DECLARE
变量名 类型 [ := 初值 | DEFAULT 初值 ];
]
BEGIN
语句;
...
[ EXCEPTION
WHEN 条件 THEN 语句;
]
END;
$$;CREATE PROCEDURE 的骨架:
CREATE [ OR REPLACE ] PROCEDURE 名称 ( [ 参数模式 ] [ 参数名 ] 参数类型 [ DEFAULT 默认值 ] [, ...] )
LANGUAGE plpgsql
[ SECURITY INVOKER | SECURITY DEFINER ]
[ SET 配置参数 { TO | = } 值 ]
AS $$
BEGIN
语句;
...
END;
$$;
-- 调用
CALL 名称(实参...);注意两处差异:过程没有 RETURNS 子句;过程用 CALL 而非出现在 SELECT 中。
美元引用(Dollar Quoting)
函数体是一段字符串。用单引号包裹时,体内每个单引号都得写成两个:
-- 不推荐:单引号需要层层转义,可读性差
CREATE FUNCTION demo_escape() RETURNS text
LANGUAGE plpgsql
AS '
BEGIN
RETURN ''it''''s ok'';
END;
';美元引用让转义彻底消失:
-- 推荐:$func$ ... $func$ 内部无需转义单引号
CREATE FUNCTION demo_dollar() RETURNS text
LANGUAGE plpgsql
AS $func$
BEGIN
RETURN 'it''s ok'; -- 只有这一个 SQL 字面量需要转义
END;
$func$;规则要点:
$tag$的 tag 必须是一个合法标识符(字母或下划线开头),不能是关键字;$$等价于空 tag。- 开始与结束的 tag 必须完全一致。
- 嵌套使用时换个 tag 即可:外层
$func$,内层$body$。 - 官方示例几乎全部使用美元引用,本教程同样如此。
详细说明
PL/pgSQL 的定位:不是「更强的 SQL」,而是「SQL 的家务处理器」
SQL 是声明式的,无法保存中间状态。有些任务本质上需要「一步步来」:先查、再判断、再写、再记录。PL/pgSQL 的定位就是承接这些 SQL 表达不了的流程。记住这条工程判断法则:
能用一条 SQL 表达 → 写 SQL(哪怕是个 CTE / 窗口函数)
能用 SQL 函数封装 → 写 LANGUAGE sql
需要分支、循环、异常 → 才写 LANGUAGE plpgsql执行计划缓存带来的「惊喜」
官方文档举了一个经典例子:两个函数逻辑看起来几乎一样,行为却不同。
-- 版本 A:'now' 在 INSERT 解析时就被转成 timestamp 常量
CREATE FUNCTION log_a(txt text) RETURNS void AS $$
BEGIN
INSERT INTO log_tbl(msg, ts) VALUES (txt, 'now');
END;
$$ LANGUAGE plpgsql;-- 版本 B:'now' 先以 text 形式存在,赋值给 timestamp 变量时才转换
CREATE FUNCTION log_b(txt text) RETURNS void AS $$
DECLARE
curtime timestamp;
BEGIN
curtime := 'now';
INSERT INTO log_tbl(msg, ts) VALUES (txt, curtime);
END;
$$ LANGUAGE plpgsql;版本 A 里,主解析器在解析 INSERT 时就知道目标列是 timestamp,于是把 'now' 折叠成常量并缓存;此后同一会话的所有调用都写入同一个时间戳。版本 B 里,'now' 先作为 text 传入,每次赋值时才由 timestamp_in 转换成真正的时间。后者「碰巧」符合直觉,但效率差。正确做法是两处都用 now() 函数,因为函数调用不会被折叠成常量。
RETURNS void 与过程的区别
一个「只做事、不返回有意义结果」的逻辑,既可以写成 RETURNS void 的函数,也可以写成过程。区别在于:
-- 函数形式:RETURNS void,可被 SELECT 调用,但内部不能 COMMIT
CREATE FUNCTION noop_fn() RETURNS void AS $$
BEGIN
PERFORM pg_sleep(0);
END;
$$ LANGUAGE plpgsql;
SELECT noop_fn();-- 过程形式:无 RETURNS,用 CALL 调用,可在合适时提交事务
CREATE PROCEDURE noop_proc() LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_sleep(0);
END;
$$;
CALL noop_proc();如果逻辑需要分多次提交(例如「分批清理、每批提交一次避免长事务」),就必须用过程。
触发器中同样使用 PL/pgSQL
PL/pgSQL 的三大出口是函数、过程、触发器。触发器函数必须 RETURNS trigger,并通过 NEW / OLD 访问行数据:
CREATE FUNCTION trg_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();触发器章节会详细展开,本节只需知道:PL/pgSQL 是触发器默认的语言。
首次调用的编译开销长什么样
同一个函数,在新会话里第一次调用与第二次调用,耗时差别明显。可以用 \timing 观察:
\timing on
SELECT demo_dollar(); -- 第一次:解析函数体 + 生成指令树
SELECT demo_dollar(); -- 第二次:直接复用Timing is on.
demo_dollar
-------------
it's ok
(1 row)
Time: 1.842 ms
demo_dollar
-------------
it's ok
(1 row)
Time: 0.061 ms差距主要来自首次的编译。注意:这不是「首次慢是缺陷」,而是把一次性成本前置了——在长连接、连接池的场景下这笔成本几乎被摊薄到零。
plpgsql_check 扩展(第三方)
原生 PL/pgSQL 的函数体在创建时不检查内部语法,这带来一个痛点:拼写错误、错误的列名、错误的参数直到运行时才暴露。第三方扩展 plpgsql_check(由 Pavel Stehule 维护,非 PostgreSQL 官方组件)能在不执行的情况下做静态检查。
-- 需要先安装扩展(通常由 DBA 在操作系统层编译安装后执行本句)
CREATE EXTENSION IF NOT EXISTS plpgsql_check;-- 检查一个函数,返回发现的问题
SELECT * FROM plpgsql_check_function('demo_dollar()');它常见的入口函数包括 plpgsql_check_function()(返回文本诊断)与 plpgsql_check_function_tb()(返回表格形式)。本教程不依赖该扩展,但强烈建议在生产项目里作为 CI 的一环。没有它时,替代做法是写单元测试把每条分支都跑一遍。
pg_proc:函数的元数据都在这里
想查一个函数被登记成了什么,直接看系统目录:
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS args,
p.provolatile AS volatility, -- i=immutable, s=stable, v=volatile
p.prosecdef AS secdef, -- t = SECURITY DEFINER
p.proisstrict AS strict,
l.lanname
FROM pg_proc p
JOIN pg_language l ON l.oid = p.prolang
WHERE p.proname IN ('demo_dollar', 'demo_escape')
ORDER BY p.proname; proname | args | volatility | secdef | strict | lanname
--------------+------------+------------+--------+--------+---------
demo_dollar | | v | f | f | plpgsql
demo_escape | | v | f | f | plpgsql
(2 rows)记住两点:provolatile 用单字符表示易变性;prosecdef 为 t 表示 SECURITY DEFINER。
何时不该用 PL/pgSQL
这是本节最重要的「反面清单」:
| 反模式 | 问题 | 更好的做法 |
|---|---|---|
| 单行表达式包成 PL/pgSQL 函数 | 优化器看不见内部,无法内联,慢一档 | 用 LANGUAGE sql,或用内联表达式 |
| 逐行循环累加 | 每行一次 SPI 调用,行数一多就雪崩 | 用 SUM / 窗口函数等集合操作 |
在函数里随意 RAISE NOTICE | 每个调用都产生一次消息往返 | 只在异常路径上 RAISE WARNING / EXCEPTION |
| 用函数做长时间批处理 | 函数内无法 COMMIT,会撑出长事务 | 用 CREATE PROCEDURE 分片提交 |
| 动态拼 SQL 传参 | SQL 注入 | EXECUTE ... USING |
实战示例
场景:一个简化的下单接口。要求「检查库存 → 扣减库存 → 写订单 → 写流水」,全部在一个函数里完成,客户端只发一次调用。
-- 1) 建表
CREATE TABLE sp_demo_stock (
sku_id integer PRIMARY KEY,
qty integer NOT NULL CHECK (qty >= 0)
);
CREATE TABLE sp_demo_orders (
order_id bigserial PRIMARY KEY,
sku_id integer NOT NULL,
buy_qty integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sp_demo_ledger (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL,
delta integer NOT NULL,
note text
);
-- 2) 初始化库存
INSERT INTO sp_demo_stock(sku_id, qty) VALUES (1001, 20);-- 3) 下单函数
CREATE OR REPLACE FUNCTION sp_demo_place_order(
p_sku_id integer,
p_qty integer
) RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
v_left integer;
v_order_id bigint;
BEGIN
-- 参数校验
IF p_qty IS NULL OR p_qty <= 0 THEN
RAISE EXCEPTION '购买数量必须为正数,收到 %', p_qty
USING ERRCODE = 'invalid_parameter_value';
END IF;
-- 扣减库存(原子性:WHERE 里同时判定余量,避免超卖)
UPDATE sp_demo_stock
SET qty = qty - p_qty
WHERE sku_id = p_sku_id
AND qty >= p_qty
RETURNING qty INTO v_left;
IF NOT FOUND THEN
RAISE EXCEPTION '库存不足,sku=%, 请求=%', p_sku_id, p_qty
USING ERRCODE = 'check_violation';
END IF;
-- 写订单
INSERT INTO sp_demo_orders(sku_id, buy_qty)
VALUES (p_sku_id, p_qty)
RETURNING order_id INTO v_order_id;
-- 写流水
INSERT INTO sp_demo_ledger(order_id, delta, note)
VALUES (v_order_id, -p_qty, format('剩余库存 %s', v_left));
RETURN v_order_id;
END;
$$;调用与输出:
SELECT sp_demo_place_order(1001, 3) AS order_id; order_id
--------------------
1
(1 row)
Time: 2.734 ms-- 库存不足时
SELECT sp_demo_place_order(1001, 999);ERROR: 库存不足,sku=1001, 请求=999
CONTEXT: PL/pgSQL function sp_demo_place_order(integer,integer) line 24 at RAISE-- 最终状态
SELECT (SELECT qty FROM sp_demo_stock WHERE sku_id = 1001) AS remaining,
(SELECT count(*) FROM sp_demo_orders) AS orders,
(SELECT count(*) FROM sp_demo_ledger) AS ledger_rows; remaining | orders | ledger_rows
-----------+--------+-------------
17 | 1 | 1
(1 row)这个例子体现了 PL/pgSQL 的几个典型收益:1 次网络往返完成 3 次写入 + 2 次读取;UPDATE ... WHERE ... RETURNING 的写法即使被并发调用也不会超卖;错误路径用 RAISE EXCEPTION 直接回滚整个事务。注意:这些写入都在同一个事务中,函数本身没有 COMMIT——这是函数不能提交事务的体现,也是它天然具备原子性的原因。
第二个小例子——一个返回集合的函数:
CREATE OR REPLACE FUNCTION sp_demo_orders_of(p_sku integer)
RETURNS TABLE(order_id bigint, buy_qty integer, created_at timestamptz)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT o.order_id, o.buy_qty, o.created_at
FROM sp_demo_orders o
WHERE o.sku_id = p_sku
ORDER BY o.order_id;
END;
$$;SELECT * FROM sp_demo_orders_of(1001); order_id | buy_qty | created_at
----------+---------+-------------------------------
1 | 3 | 2026-09-28 10:12:31.442+08
(1 row)注意事项
BEGIN后面不能有分号。这是最常见的低级错误,且报错信息比较抽象。sqlCREATE FUNCTION bad_begin() RETURNS int AS $$ BEGIN; -- 错误!PL/pgSQL 的 BEGIN 是块开始,不是事务开始 RETURN 1; END; $$ LANGUAGE plpgsql;函数内不能提交事务。
COMMIT在函数里是硬错误,需要事务控制请改用过程。sqlCREATE FUNCTION bad_commit() RETURNS void AS $$ BEGIN INSERT INTO sp_demo_ledger(order_id, delta) VALUES (1, 1); COMMIT; -- 错误!invalid transaction termination END; $$ LANGUAGE plpgsql;表名/列名不能被参数替代。下面的写法会当成「名字就叫
tblname的表」:sqlCREATE FUNCTION bad_dyn(tblname text) RETURNS bigint AS $$ DECLARE c bigint; BEGIN SELECT count(*) INTO c FROM tblname; -- 错误!不是动态表名 RETURN c; END; $$ LANGUAGE plpgsql;正确做法是
EXECUTE format('SELECT count(*) FROM %I', tblname) INTO c;。没走到的分支不会在运行时校验。函数能创建成功不代表正确。
sqlCREATE FUNCTION bad_branch(x int) RETURNS text AS $$ BEGIN IF x > 0 THEN RETURN 'pos'; ELSE RETURN (SELECT no_such_column FROM sp_demo_stock LIMIT 1); -- 只有走这里才报错 END IF; END; $$ LANGUAGE plpgsql; SELECT bad_branch(1); -- 返回 pos,不报错 SELECT bad_branch(-1); -- 这时才报列不存在变量名与列名冲突会直接报错(默认
plpgsql.variable_conflict = error)。sqlCREATE FUNCTION bad_conflict(sku_id integer) RETURNS integer AS $$ DECLARE v integer; BEGIN SELECT sku_id INTO v FROM sp_demo_stock; -- 错误!ambiguous column reference RETURN v; END; $$ LANGUAGE plpgsql;解决办法见 变量与赋值。
RETURNS与过程互斥。给过程写RETURNS会直接语法错误。sqlCREATE PROCEDURE bad_proc() RETURNS int -- 错误!过程不能有 RETURNS LANGUAGE plpgsql AS $$ BEGIN END; $$;CREATE OR REPLACE不能改返回类型、参数名。想改只能先DROP再建;加参数会导致重载(旧签名仍存在)。sql-- 不推荐:以为改了参数,其实是新建了一个重载 CREATE OR REPLACE FUNCTION sp_demo_orders_of(p_sku integer, p_limit integer) RETURNS TABLE(order_id bigint, buy_qty integer, created_at timestamptz) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT 1::bigint,1,null::timestamptz WHERE false; END; $$;RAISE NOTICE有成本。在百万行的循环里RAISE NOTICE会显著拖慢,甚至刷爆客户端。sql-- 不推荐:每行一条消息 FOR r IN SELECT * FROM sp_demo_orders LOOP RAISE NOTICE '处理订单 %', r.order_id; END LOOP;EXCEPTION子句会开启子事务(subtransaction)。进入带EXCEPTION的块有额外开销(约几十微秒量级),不要在超高频的内层循环里滥用。SECURITY DEFINER函数必须固定search_path,否则存在被劫持的风险。sql-- 不推荐:search_path 未固定 CREATE FUNCTION unsafe_secdef() RETURNS int LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN RETURN (SELECT count(*) FROM some_table); END; $$;
最佳实践
先用
sql,不够用再上plpgsql。判断标准很简单:需要变量/分支/循环/异常,才选plpgsql。sql-- 单表达式 → sql 函数,可内联 CREATE FUNCTION price_with_tax(amount numeric) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT amount * 1.06 $$;函数体一律用美元引用,并用有意义的 tag 区分嵌套层次。
sqlAS $func$ ... $func$;变量命名加前缀,从源头规避歧义。约定俗成用
v_表示局部变量、p_表示参数。sqlDECLARE v_total integer := 0; BEGIN SELECT count(*) INTO v_total FROM sp_demo_orders;给函数显式标注易变性。纯计算标
IMMUTABLE,只读查询标STABLE,会写数据才用默认的VOLATILE。这直接影响优化器能否缓存结果。sqlCREATE FUNCTION fmt_order(o sp_demo_orders) RETURNS text LANGUAGE sql STABLE AS $$ SELECT o.sku_id || '/' || o.buy_qty $$;参数校验放在函数入口,用
RAISE EXCEPTION ... USING ERRCODE给出稳定的错误码,方便 PHP 端区分处理。sqlIF p_qty <= 0 THEN RAISE EXCEPTION '数量必须为正' USING ERRCODE = 'invalid_parameter_value'; END IF;优先用
UPDATE ... WHERE 余量 >= 需求 RETURNING这种原子写法,而不是「先 SELECT 再判断再 UPDATE」。sqlUPDATE sp_demo_stock SET qty = qty - p_qty WHERE sku_id = p_sku_id AND qty >= p_qty RETURNING qty INTO v_left;需要分批提交,就用过程。
sqlCREATE PROCEDURE purge_old_orders(p_days integer) LANGUAGE plpgsql AS $$ DECLARE v_n integer; BEGIN LOOP DELETE FROM sp_demo_orders WHERE order_id IN ( SELECT order_id FROM sp_demo_orders WHERE created_at < now() - make_interval(days => p_days) LIMIT 1000); GET DIAGNOSTICS v_n = ROW_COUNT; COMMIT; -- 过程内可以提交 EXIT WHEN v_n = 0; END LOOP; END; $$;SECURITY DEFINER必须锁死search_path。sqlCREATE FUNCTION safe_secdef() RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ BEGIN RETURN (SELECT count(*) FROM sp_demo_orders); END; $$;只授予必要的
EXECUTE,并考虑撤销PUBLIC的默认权限。sqlREVOKE EXECUTE ON FUNCTION sp_demo_place_order(integer, integer) FROM PUBLIC; GRANT EXECUTE ON FUNCTION sp_demo_place_order(integer, integer) TO app_rw;在 CI 里跑一遍所有函数。至少让每条分支被执行一次,配合
plpgsql_check效果更好。bash# 简易冒烟:把每个函数的 0 参调用跑一遍 for f in $(psql -Atc "SELECT proname FROM pg_proc WHERE prolang='plpgsql'::regtype"); do echo "=== $f" done长连接/连接池场景优先把热点逻辑放进 PL/pgSQL,让编译与计划缓存真正生效;短连接场景要评估每次冷启动的成本。
给函数写
COMMENT,元数据是可维护性的一部分。sqlCOMMENT ON FUNCTION sp_demo_place_order(integer, integer) IS '下单:校验库存并原子扣减,返回新订单号';
下一节
继续学习:PL/pgSQL 块结构与函数骨架