Skip to content

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 函数,在执行路径上到底差在哪里。

text
纯 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 提供
块BlockPL/pgSQL 的基本结构单元:[DECLARE] BEGIN ... END
函数Function用 CREATE FUNCTION 创建、可在 SQL 中调用的执行单元
存储过程Procedure用 CREATE PROCEDURE 创建(PG 11+)、用 CALL 调用,可提交事务
SPIServer Programming InterfacePL/pgSQL 执行每条 SQL 语句时走的内部接口
指令树Instruction Tree函数体首次调用时编译成的内部二进制结构,保存在会话内存中
计划缓存Plan Cache会话内缓存已规划的 SQL 语句,避免重复解析与规划
美元引用Dollar Quoting$tag$ ... $tag$,用来包裹函数体,内部单引号不必转义
可信语言Trusted Language非超级用户也能在其上创建函数的语言,PL/pgSQL 属于此类

编译与执行模型 ​

这是本节最需要建立的心智模型:PL/pgSQL 函数体不是一次性编译完的。

text
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 +(可能)执行计划

由此推出三条重要结论:

  1. 冷启动只有一次:同一会话(连接)里,第一个调用者承担编译开销,后续调用近乎免费。这也是为什么「连接池 + 长连接」对 PL/pgSQL 更友好。
  2. 没走到的分支不会报错:某个 IF 分支里的 SQL 写错了,只要这个分支在会话中从未被执行到,错误就不会暴露。因此测试覆盖率比语法检查更重要。
  3. 表名不能参数化:正因为语句与计划被缓存,SQL 命令每次执行必须引用同一张表、同一列。要动态换表名,只能用 EXECUTE(代价是每次重新解析 + 规划)。

函数 vs 存储过程 ​

PostgreSQL 11 引入了真正的存储过程(CREATE PROCEDURE)。它与函数的关键差异不是「能不能返回值」,而是事务控制能力。

维度函数 Function存储过程 Procedure
创建语句CREATE FUNCTIONCREATE 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 是可信语言 ​

查看语言清单:

sql
SELECT lanname, lanpltrusted, lanplcallfoid::regproc AS handler
FROM pg_language
ORDER BY lanname;
text
   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 版):

text
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 的骨架:

text
CREATE [ OR REPLACE ] PROCEDURE 名称 ( [ 参数模式 ] [ 参数名 ] 参数类型 [ DEFAULT 默认值 ] [, ...] )
  LANGUAGE plpgsql
  [ SECURITY INVOKER | SECURITY DEFINER ]
  [ SET 配置参数 { TO | = } 值 ]
  AS $$
  BEGIN
      语句;
      ...
  END;
  $$;

-- 调用
CALL 名称(实参...);

注意两处差异:过程没有 RETURNS 子句;过程用 CALL 而非出现在 SELECT 中。

美元引用(Dollar Quoting) ​

函数体是一段字符串。用单引号包裹时,体内每个单引号都得写成两个:

sql
-- 不推荐:单引号需要层层转义,可读性差
CREATE FUNCTION demo_escape() RETURNS text
LANGUAGE plpgsql
AS '
BEGIN
  RETURN ''it''''s ok'';
END;
';

美元引用让转义彻底消失:

sql
-- 推荐:$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 表达不了的流程。记住这条工程判断法则:

text
能用一条 SQL 表达   →  写 SQL(哪怕是个 CTE / 窗口函数)
能用 SQL 函数封装   →  写 LANGUAGE sql
需要分支、循环、异常 →  才写 LANGUAGE plpgsql

执行计划缓存带来的「惊喜」 ​

官方文档举了一个经典例子:两个函数逻辑看起来几乎一样,行为却不同。

sql
-- 版本 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;
sql
-- 版本 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 的函数,也可以写成过程。区别在于:

sql
-- 函数形式:RETURNS void,可被 SELECT 调用,但内部不能 COMMIT
CREATE FUNCTION noop_fn() RETURNS void AS $$
BEGIN
  PERFORM pg_sleep(0);
END;
$$ LANGUAGE plpgsql;

SELECT noop_fn();
sql
-- 过程形式:无 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 访问行数据:

sql
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 观察:

sql
\timing on

SELECT demo_dollar();   -- 第一次:解析函数体 + 生成指令树
SELECT demo_dollar();   -- 第二次:直接复用
text
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 官方组件)能在不执行的情况下做静态检查。

sql
-- 需要先安装扩展(通常由 DBA 在操作系统层编译安装后执行本句)
CREATE EXTENSION IF NOT EXISTS plpgsql_check;
sql
-- 检查一个函数,返回发现的问题
SELECT * FROM plpgsql_check_function('demo_dollar()');

它常见的入口函数包括 plpgsql_check_function()(返回文本诊断)与 plpgsql_check_function_tb()(返回表格形式)。本教程不依赖该扩展,但强烈建议在生产项目里作为 CI 的一环。没有它时,替代做法是写单元测试把每条分支都跑一遍。

pg_proc:函数的元数据都在这里 ​

想查一个函数被登记成了什么,直接看系统目录:

sql
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;
text
   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

实战示例 ​

场景:一个简化的下单接口。要求「检查库存 → 扣减库存 → 写订单 → 写流水」,全部在一个函数里完成,客户端只发一次调用。

sql
-- 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);
sql
-- 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;
$$;

调用与输出:

sql
SELECT sp_demo_place_order(1001, 3) AS order_id;
text
      order_id
--------------------
 1
(1 row)

Time: 2.734 ms
sql
-- 库存不足时
SELECT sp_demo_place_order(1001, 999);
text
ERROR:  库存不足,sku=1001, 请求=999
CONTEXT:  PL/pgSQL function sp_demo_place_order(integer,integer) line 24 at RAISE
sql
-- 最终状态
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;
text
 remaining | orders | ledger_rows
-----------+--------+-------------
        17 |      1 |           1
(1 row)

这个例子体现了 PL/pgSQL 的几个典型收益:1 次网络往返完成 3 次写入 + 2 次读取;UPDATE ... WHERE ... RETURNING 的写法即使被并发调用也不会超卖;错误路径用 RAISE EXCEPTION 直接回滚整个事务。注意:这些写入都在同一个事务中,函数本身没有 COMMIT——这是函数不能提交事务的体现,也是它天然具备原子性的原因。

第二个小例子——一个返回集合的函数:

sql
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;
$$;
sql
SELECT * FROM sp_demo_orders_of(1001);
text
 order_id | buy_qty |          created_at
----------+---------+-------------------------------
        1 |       3 | 2026-09-28 10:12:31.442+08
(1 row)

注意事项 ​

  • BEGIN 后面不能有分号。这是最常见的低级错误,且报错信息比较抽象。

    sql
    CREATE FUNCTION bad_begin() RETURNS int AS $$
    BEGIN;                 -- 错误!PL/pgSQL 的 BEGIN 是块开始,不是事务开始
      RETURN 1;
    END;
    $$ LANGUAGE plpgsql;
  • 函数内不能提交事务。COMMIT 在函数里是硬错误,需要事务控制请改用过程。

    sql
    CREATE 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 的表」:

    sql
    CREATE 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;。

  • 没走到的分支不会在运行时校验。函数能创建成功不代表正确。

    sql
    CREATE 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)。

    sql
    CREATE 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 会直接语法错误。

    sql
    CREATE 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 区分嵌套层次。

    sql
    AS $func$
    ...
    $func$;
  • 变量命名加前缀,从源头规避歧义。约定俗成用 v_ 表示局部变量、p_ 表示参数。

    sql
    DECLARE
      v_total integer := 0;
    BEGIN
      SELECT count(*) INTO v_total FROM sp_demo_orders;
  • 给函数显式标注易变性。纯计算标 IMMUTABLE,只读查询标 STABLE,会写数据才用默认的 VOLATILE。这直接影响优化器能否缓存结果。

    sql
    CREATE 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 端区分处理。

    sql
    IF p_qty <= 0 THEN
      RAISE EXCEPTION '数量必须为正' USING ERRCODE = 'invalid_parameter_value';
    END IF;
  • 优先用 UPDATE ... WHERE 余量 >= 需求 RETURNING 这种原子写法,而不是「先 SELECT 再判断再 UPDATE」。

    sql
    UPDATE sp_demo_stock SET qty = qty - p_qty
     WHERE sku_id = p_sku_id AND qty >= p_qty
    RETURNING qty INTO v_left;
  • 需要分批提交,就用过程。

    sql
    CREATE 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。

    sql
    CREATE 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 的默认权限。

    sql
    REVOKE 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,元数据是可维护性的一部分。

    sql
    COMMENT ON FUNCTION sp_demo_place_order(integer, integer)
      IS '下单:校验库存并原子扣减,返回新订单号';

下一节

继续学习:PL/pgSQL 块结构与函数骨架

参考链接 ​