SQL 函数 vs PL/pgSQL 函数
PostgreSQL 允许你用同一种 CREATE FUNCTION 语法写两种截然不同的函数:LANGUAGE sql 的函数体是一段交给优化器的查询,LANGUAGE plpgsql 的函数体是一段带变量、分支、异常处理的程序。本节讲清两者的执行模型差异、sql 函数内联(Inlining)的条件清单、plpgsql 为什么永远慢一档,以及「先用 sql,直到必须上 plpgsql」这条实践原则背后的依据。
与「函数参数与返回类型」的分工
函数参数与返回类型 讲参数怎么声明、返回值怎么定型;本节只聚焦函数体语言的选择:同样的逻辑写成 sql 还是 plpgsql,性能与能力上有什么区别。
前置知识
阅读本节前,建议先了解:函数参数与返回类型、CREATE FUNCTION 基础
基础概念
两种语言最本质的差别不在语法,而在优化器能不能看见函数体内部。
LANGUAGE sql LANGUAGE plpgsql
┌────────────────────────┐ ┌────────────────────────┐
│ CREATE FUNCTION │ │ CREATE FUNCTION │
│ 函数体 = SQL 语句 │ │ 函数体 = 块结构文本 │
│ 创建时解析为查询树 │ │ 创建时仅校验块结构 │
└───────────┬────────────┘ └───────────┬────────────┘
│ │
│ 调用时: │ 首次调用:
│ ① 可内联 → 拼进调用查询 │ ① 编译为执行树
│ 由调用者的规划器统一优化 │ ② 每条 SQL 走 SPI
│ ② 不可内联 → 单独规划后执行 │ 解析 → 规划 → 执行
▼ ▼
优化器「看得见」 优化器「看不见」对照表:
| 维度 | LANGUAGE sql | LANGUAGE plpgsql |
|---|---|---|
| 函数体形态 | 一条或多条 SQL 语句 | 块:DECLARE / BEGIN / END |
| 局部变量 | 无 | 有 |
控制流 IF / LOOP | 无 | 有 |
| 异常捕获 | 无 | EXCEPTION 子句 |
| 动态 SQL | 无 | EXECUTE |
| 输出日志 | 无 | RAISE |
| 可被内联 | 可以 | 永远不可以 |
| 计划缓存 | 无(每次规划或内联) | 会话内按语句缓存 |
| 调用开销 | 低 | 有 SPI + 执行树解释开销 |
| 典型定位 | 表达式封装、单查询映射 | 业务逻辑、多步写入、错误处理 |
关键术语:
- 内联(Inlining):优化器把
sql函数的函数体展开到调用点,随后与调用查询一起规划。 - SPI(Server Programming Interface):
plpgsql执行每条 SQL 语句时走的内部接口,负责解析、规划、执行。 - 计划缓存(Plan Cache):
plpgsql在同一会话中缓存已规划语句,避免每行都重新规划。 - 常量折叠(Constant Folding):参数全为常量且函数为
IMMUTABLE时,调用点直接被替换成结果值。 - 条件下推(Qual Pushdown):内联后,调用方的
WHERE条件可以进入函数体内的查询,从而使用索引。
先摆最反直觉的一条:plpgsql 不是「高级语法所以更强」,而是「优化器看不透所以更慢」。把一个单行表达式包成 plpgsql 函数,性能可能比 sql 版差一个数量级;反过来,让 sql 函数去做上千次逐行判断,又可能慢于 plpgsql——因为 sql 无法保存中间状态。
最小示例——同一逻辑两种写法:
-- sql 版
CREATE FUNCTION is_even_sql(i bigint) RETURNS boolean
LANGUAGE sql IMMUTABLE
AS $$ SELECT i % 2 = 0 $$;
-- plpgsql 版:逻辑一模一样,但优化器看不见内部
CREATE FUNCTION is_even_plpgsql(i bigint) RETURNS boolean
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN i % 2 = 0;
END;
$$;SELECT is_even_sql(42) AS sql_result, is_even_plpgsql(42) AS plpgsql_result; sql_result | plpgsql_result
------------+----------------
t | t
(1 row)结果相同,但 EXPLAIN 下的执行方式完全不同。
语法与用法
LANGUAGE sql 的骨架:
CREATE FUNCTION 名称 (参数...)
RETURNS 返回类型
LANGUAGE sql
[ IMMUTABLE | STABLE | VOLATILE ]
[ SET search_path = ... ]
AS $$
SQL 语句; -- 可多条,用分号分隔
SQL 语句 -- 最后一条必须是 SELECT 或带 RETURNING 的写语句
$$;LANGUAGE plpgsql 的骨架:
CREATE FUNCTION 名称 (参数...)
RETURNS 返回类型
LANGUAGE plpgsql
[ IMMUTABLE | STABLE | VOLATILE ]
AS $$
[ <<label>> ]
[ DECLARE
变量名 类型 [ := 初值 ];
]
BEGIN
语句;
...
[ EXCEPTION
WHEN 条件 THEN
语句;
]
END;
$$;最小可运行示例:
CREATE FUNCTION sql_version(p_n integer) RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT p_n * 2 $$;
CREATE FUNCTION plpgsql_version(p_n integer) RETURNS integer
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN p_n * 2;
END;
$$;SELECT sql_version(21) AS a, plpgsql_version(21) AS b; a | b
----+----
42 | 42
(1 row)详细说明
LANGUAGE sql 的执行模型
sql 函数体在创建时就被解析成查询树,存进 pg_proc.prosrc(文本)与内部表示。调用时有两种走向:
- 可内联:函数体被替换到调用点,跟着调用查询一起规划。此时函数调用「消失」了。
- 不可内联:以调用时给定的参数单独规划一次,然后执行。
无论哪种,优化器都有机会看到函数体内部的表、条件与表达式。这正是 sql 函数的全部价值来源。
内联:sql 函数性能的关键
内联最直接的收益是常量折叠与索引下推。
CREATE TABLE t_inline (id integer PRIMARY KEY, payload text);
INSERT INTO t_inline SELECT g, 'v' || g FROM generate_series(1, 100000) AS g;
ANALYZE t_inline;
CREATE FUNCTION f_identity(i integer) RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT i $$;
EXPLAIN (COSTS OFF) SELECT * FROM t_inline WHERE id = f_identity(500); QUERY PLAN
-------------------------------------------
Index Only Scan using t_inline_pkey on t_inline
Index Cond: (id = 500)
(2 rows)函数调用在计划里完全消失,Index Cond 里直接是常量 500。
把同一个函数写成 plpgsql:
CREATE FUNCTION f_identity_pg(i integer) RETURNS integer
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN i;
END;
$$;
EXPLAIN (COSTS OFF) SELECT * FROM t_inline WHERE id = f_identity_pg(500); QUERY PLAN
-------------------------------------------------
Seq Scan on t_inline
Filter: (id = f_identity_pg(500))
(2 rows)Filter 里残留着函数调用,索引用不上——十行的函数体,写错了语言就从索引扫描退化成全表扫描。
内联的条件清单
内联不是无条件发生的,条件相当苛刻,而且官方文档并未完整列出。对标量函数,需要同时满足(核心几条):
| 条件 | 说明 |
|---|---|
语言是 sql | plpgsql、c、internal 一律不内联 |
不是 SECURITY DEFINER | 安全上下文不能改变,因此不内联 |
函数体是单条 SELECT 表达式 | 不能是多条语句拼起来的 |
函数体不含 FROM / 子查询 / CTE | 一旦引用表就不满足标量内联条件 |
| 不含聚合、窗口函数 | count()、row_number() 等出局 |
不含 GROUP BY / HAVING / ORDER BY / DISTINCT / LIMIT / UNION | 同上 |
| 返回恰好一列,且类型与声明一致 | 需要隐式转换就不行 |
不声明 RETURNS SETOF / TABLE / RECORD | 集合返回走另一套(表函数)内联规则 |
定义里没有 SET 子句 | 有 SET search_path 就不内联 |
| 不是递归调用自身 | 只展开最外层一次 |
声明 IMMUTABLE 时,体内不得调用非 IMMUTABLE 函数 | 否则假设被破坏 |
声明 STABLE 时,体内不得调用 VOLATILE 函数 | 同上 |
声明 STRICT 时,优化器必须能证明「任一参数为 NULL 则结果为 NULL」 | 一般难以满足 |
| 实参若含易变或昂贵表达式,在体内只能被引用一次 | 防止重复求值 |
注意最后一条:参数在内体被引用多次是常见的「内联杀手」。
-- 参数 p 在体内用了两次,调用时传的是易变表达式 → 不内联
CREATE FUNCTION f_twice(p integer) RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT p + p $$;
EXPLAIN (COSTS OFF) SELECT f_twice(random()::integer * 0) FROM t_inline LIMIT 1;表函数(RETURNS SETOF / RETURNS TABLE)另有独立的一套条件,宽严不同:要求函数声明为 STABLE 或 IMMUTABLE、不能是 STRICT、函数体是单条 SELECT(可以是复杂查询,甚至含 CTE)、实参里不能有易变表达式或子查询。这套条件让可内联的表函数比视图更强大——视图不能带参数,它却能带。
LANGUAGE plpgsql 的执行模型
plpgsql 函数体对 CREATE FUNCTION 而言只是一段字符串。创建时(check_function_bodies = on,默认值)会做块结构的语法校验;真正的编译发生在首次调用时,编译成执行树后在本会话内复用。
执行时,函数体里每一条 SQL 语句都要经过 SPI:
plpgsql 一次调用
├─ 进入函数:建立执行环境
├─ 语句 1:SPI 解析 → 规划(或命中本会话计划缓存)→ 执行
├─ 语句 2:同上
└─ 返回:销毁执行环境代价主要体现在三处:
- 无法内联,优化器对函数体内部一无所知;
- 逐语句的 SPI 开销,即使命中计划缓存,仍有执行树解释与上下文切换;
- 类型转换与变量赋值,
plpgsql的变量有独立的内存管理。
sql 函数做不到的事
这些能力 sql 完全没有,遇到就得换语言:
| 需求 | sql | plpgsql |
|---|---|---|
| 保存中间结果 | ✗ | DECLARE v integer; |
IF / CASE 分支逻辑 | ✗ | ✓ |
FOR / LOOP 循环 | ✗ | ✓ |
| 捕获并处理错误 | ✗ | EXCEPTION WHEN ... |
| 动态拼接 SQL | ✗ | EXECUTE |
输出 NOTICE / 日志 | ✗ | RAISE |
| 逐行游标处理 | ✗ | FOR r IN SELECT ... |
| 事务控制 | ✗ | 仅过程 CALL 支持 |
另外,sql 函数体内不能使用 COMMIT、SAVEPOINT、VACUUM 这类语句。
plpgsql 的块结构:DECLARE / BEGIN / EXCEPTION
CREATE FUNCTION safe_divide(p_a numeric, p_b numeric)
RETURNS numeric
LANGUAGE plpgsql IMMUTABLE
AS $$
DECLARE
v_result numeric;
BEGIN
IF p_b = 0 THEN
RAISE EXCEPTION '除数不能为零'
USING ERRCODE = '22012';
END IF;
v_result := round(p_a / p_b, 4);
RETURN v_result;
EXCEPTION
WHEN division_by_zero THEN
RETURN NULL;
END;
$$;SELECT safe_divide(10, 4) AS ok; ok
--------
2.5000
(1 row)SELECT safe_divide(10, 0) AS zero;ERROR: 除数不能为零注意 EXCEPTION 子句天然形成一个子事务:块内回滚不影响外层事务。这意味着异常处理有真实代价——每次进入带 EXCEPTION 的块都要建立子事务。不要把 EXCEPTION 当 try/catch 随手包住整个函数,只在确实需要容错处使用。
只读的 SELECT ... INTO 也要注意:
CREATE FUNCTION lookup_amount(p_customer text)
RETURNS numeric
LANGUAGE plpgsql STABLE
AS $$
DECLARE
v_amount numeric;
BEGIN
SELECT sum(amount) INTO v_amount FROM demo.orders WHERE customer = p_customer;
RETURN COALESCE(v_amount, 0);
END;
$$;SELECT ... INTO 在 plpgsql 里很常用,但它是把结果取到一个变量,和 CREATE TABLE ... AS 的 SELECT INTO 完全是两回事。
动态 SQL:EXECUTE 与 format()
需要拼接表名、列名、排序方向时,只有 plpgsql 能做:
CREATE FUNCTION count_rows(p_table text)
RETURNS bigint
LANGUAGE plpgsql STABLE
AS $$
DECLARE
v_count bigint;
BEGIN
EXECUTE format('SELECT count(*) FROM %I', p_table) INTO v_count;
RETURN v_count;
END;
$$;format() 的占位符必须选对:
| 占位符 | 用途 | 等价函数 |
|---|---|---|
%I | 标识符(表名、列名、schema) | quote_ident() |
%L | 字面量(字符串、值) | quote_literal() |
%s | 直接转成字符串,不做转义 | 拼字符串,仅用于已确认安全的片段 |
-- 安全:表名走 %I,用户输入走参数
EXECUTE format('SELECT count(*) FROM %I WHERE status = $1', p_table)
INTO v_count USING p_status;-- 危险:%s 直接拼接用户输入 —— SQL 注入
EXECUTE 'SELECT count(*) FROM orders WHERE customer = ' || quote_literal(p_customer)
INTO v_count; -- 若手写拼接易错
EXECUTE format('SELECT count(*) FROM orders WHERE customer = %s', p_customer)
INTO v_count; -- 明确错误用法只要值可以当参数传,就一律用 USING,不拼字符串。动态 SQL 是 plpgsql 的安全薄弱点,凡是接受外部输入的动态语句都必须走 %I / %L / USING。
RETURN QUERY vs 直接 SELECT
plpgsql 里返回行集必须显式 RETURN QUERY:
CREATE FUNCTION paid_orders(p_customer text)
RETURNS TABLE(id bigint, amount numeric)
LANGUAGE plpgsql STABLE
AS $$
BEGIN
RETURN QUERY
SELECT o.id, o.amount
FROM demo.orders AS o
WHERE o.customer = p_customer
AND o.status = 'paid'
ORDER BY o.amount DESC;
END;
$$;RETURN QUERY 后不需要再写 RETURN;如果需要在同一函数里追加结果,可以多次 RETURN QUERY,或改用 RETURN NEXT。
对比 sql 版的写法——没有任何 RETURN,最后一条 SELECT 就是返回值:
CREATE FUNCTION paid_orders_sql(p_customer text)
RETURNS TABLE(id bigint, amount numeric)
LANGUAGE sql STABLE
AS $$
SELECT o.id, o.amount
FROM demo.orders AS o
WHERE o.customer = p_customer
AND o.status = 'paid'
ORDER BY o.amount DESC;
$$;两者功能等价,但 sql 版可被内联,调用方的额外条件有机会下推进函数体;plpgsql 版永远是一堵墙。
性能对比
同一逻辑,两种语言:
CREATE FUNCTION is_even_sql(i bigint) RETURNS boolean
LANGUAGE sql IMMUTABLE
AS $$ SELECT i % 2 = 0 $$;
CREATE FUNCTION is_even_pg(i bigint) RETURNS boolean
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN i % 2 = 0;
END;
$$;\timing on
SELECT count(*) FROM generate_series(1, 1000000) AS g WHERE is_even_sql(g);
SELECT count(*) FROM generate_series(1, 1000000) AS g WHERE is_even_pg(g); count
--------
500000
(1 row)
Time: 210.418 ms
count
--------
500000
(1 row)
Time: 1084.762 ms数字仅供感受量级
上表的具体毫秒数强依赖硬件与 PostgreSQL 版本,不同机器上可能相差数倍;要关注的是两者的比例关系——简单标量函数上,plpgsql 通常是 sql 版的数倍到十余倍。
但结论不是「永远用 sql」。同一批数据做逐行累加:
CREATE FUNCTION sum_evens_sql(p_n integer) RETURNS bigint
LANGUAGE sql IMMUTABLE
AS $$
SELECT COALESCE(sum(g), 0) FROM generate_series(2, p_n, 2) AS g;
$$;
CREATE FUNCTION sum_evens_pg(p_n integer) RETURNS bigint
LANGUAGE plpgsql IMMUTABLE
AS $$
DECLARE
v_total bigint := 0;
g integer;
BEGIN
FOR g IN 2..p_n BY 2 LOOP
v_total := v_total + g;
END LOOP;
RETURN v_total;
END;
$$;这里 sql 版把整个计算交给一条聚合查询,plpgsql 版是 n 次循环加赋值——循环次数一上来,plpgsql 的逐次开销就会反超。选择语言要看「能不能表达成一条查询」,而不是盲目追求内联。
选择判据
能不能写成一条 SELECT?
├─ 能 ──> 用 LANGUAGE sql
│ (还会自动获得内联机会)
└─ 不能
├─ 需要 IF / LOOP / 变量 ──> LANGUAGE plpgsql
├─ 需要 EXCEPTION 容错 ──> LANGUAGE plpgsql
├─ 需要动态 SQL ──────────> LANGUAGE plpgsql
└─ 需要 RAISE 日志 ───────> LANGUAGE plpgsql再补一条经验判据:中间结果需要「记住」时才用 plpgsql。如果整个逻辑能表达成一个查询(哪怕带 CTE、窗口函数、LATERAL),就用 sql。
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单位换算、金额含税 | sql | 纯表达式,可常量折叠 |
| 字符串整形、拼接 | sql | 单表达式 |
| 「查一个聚合值」 | sql | 可内联,条件可下推 |
| 参数化视图(带参数的查询) | sql | 表函数内联优于视图 |
| 「写入并返回 id」 | sql | RETURNING 一步到位 |
| 多步写入 + 校验 + 回滚语义 | plpgsql | 需要变量与分支 |
| 逐行处理 + 累加状态 | plpgsql | 需要循环与变量 |
| 按参数动态选表/列 | plpgsql | 需要 EXECUTE |
| 容错:失败时降级返回默认值 | plpgsql | 需要 EXCEPTION |
| 输出调试信息 | plpgsql | 需要 RAISE |
SQL 标准函数体(BEGIN ATOMIC)
PostgreSQL 14 起支持 SQL 标准的函数体写法,不用美元引用包裹字符串:
CREATE FUNCTION add_two(a integer, b integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
RETURN a + b;CREATE FUNCTION classify(p_amount numeric)
RETURNS text
LANGUAGE sql
IMMUTABLE
BEGIN ATOMIC
SELECT CASE
WHEN p_amount >= 1000 THEN 'large'
WHEN p_amount >= 100 THEN 'medium'
ELSE 'small'
END;
END;CREATE FUNCTION它的特点:
- 函数体在创建时就被完整解析,语法错误立即暴露,不像
AS $$ ... $$那样可能延迟到运行时; LANGUAGE可以省略,此时默认为sql;- 语法上是标准 SQL,可移植性更好,但限制了可写的语句种类。
两种写法的取舍
AS $$ ... $$ 兼容所有版本、写法灵活、便于脚本生成;BEGIN ATOMIC 错误暴露更早、更标准。存量项目大多沿用 AS $$ ... $$,新项目可以按团队习惯二选一,不要在同一套函数里混用两种风格。
在 pg_proc 里区分
SELECT p.proname,
l.lanname,
p.provolatile,
p.prosecdef
FROM pg_proc AS p
JOIN pg_language AS l ON l.oid = p.prolang
WHERE p.proname IN ('is_even_sql', 'is_even_pg', 'f_identity', 'f_identity_pg')
ORDER BY p.proname; proname | lanname | provolatile | prosecdef
--------------+---------+-------------+-----------
f_identity | sql | i | f
f_identity_pg| plpgsql | i | f
is_even_pg | plpgsql | i | f
is_even_sql | sql | i | f
(4 rows)provolatile 是单字符编码:
| 值 | 含义 |
|---|---|
i | IMMUTABLE |
s | STABLE |
v | VOLATILE(默认) |
想统计库里有多少 plpgsql 函数、其中多少还是默认的 VOLATILE:
SELECT l.lanname,
p.provolatile,
count(*) AS funcs
FROM pg_proc AS p
JOIN pg_language AS l ON l.oid = p.prolang
WHERE p.pronamespace = 'public'::regnamespace
GROUP BY 1, 2
ORDER BY 1, 2; lanname | provolatile | funcs
---------+-------------+-------
plpgsql | v | 37
sql | i | 12
sql | s | 5
(3 rows)睁着一堆 plpgsql + v(未声明易变性)的函数,往往就是性能与优化机会的流失点。
实战示例
下面用一个完整流程演示两种语言的实际差异。
建表与造数据
CREATE SCHEMA IF NOT EXISTS demo;
SET search_path = demo, public;
CREATE TABLE demo.orders (
id bigserial PRIMARY KEY,
customer text NOT NULL,
amount numeric(12, 2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO demo.orders (customer, amount, status)
SELECT
'cust_' || (g % 50),
round((random() * 500)::numeric, 2),
(ARRAY['pending', 'paid', 'cancelled'])[1 + (g % 3)]
FROM generate_series(1, 2000) AS g;
ANALYZE demo.orders;CREATE TABLE
INSERT 0 2000
ANALYZE同一需求,两种写法:税后金额
CREATE FUNCTION demo.with_tax_sql(p_amount numeric, p_rate numeric DEFAULT 0.06)
RETURNS numeric
LANGUAGE sql IMMUTABLE
AS $$ SELECT round(p_amount * (1 + p_rate), 2) $$;
CREATE FUNCTION demo.with_tax_pg(p_amount numeric, p_rate numeric DEFAULT 0.06)
RETURNS numeric
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN round(p_amount * (1 + p_rate), 2);
END;
$$;SELECT demo.with_tax_sql(100) AS sql_v, demo.with_tax_pg(100) AS pg_v; sql_v | pg_v
--------+--------
106.00 | 106.00
(1 row)用 EXPLAIN 观察内联差异
EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.with_tax_sql(500.00); QUERY PLAN
------------------------------------------------------
Seq Scan on orders
Filter: (amount > 530.00)
(2 rows)常量折叠发生:with_tax_sql(500.00) → 530.00。
EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.with_tax_pg(500.00); QUERY PLAN
---------------------------------------------------------------------------
Seq Scan on orders
Filter: (amount > demo.with_tax_pg(500.00::numeric, 0.06))
(2 rows)plpgsql 版调用被原样保留。注意这里两者都是 Seq Scan,因为 amount 上没有索引;若存在 amount 上的表达式索引或范围索引,内联与否就会带来计划差异。
给 amount 加索引再看一次
CREATE INDEX idx_orders_amount ON demo.orders (amount);
ANALYZE demo.orders;
EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.with_tax_sql(500.00); QUERY PLAN
-----------------------------------------------------
Index Scan using idx_orders_amount on orders
Index Cond: (amount > 530.00)
(2 rows)EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.with_tax_pg(490.00); QUERY PLAN
---------------------------------------------------------------------------
Seq Scan on orders
Filter: (amount > demo.with_tax_pg(490.00::numeric, 0.06))
(2 rows)同样的查询形状,一个走索引、一个全表扫。这就是「句式相同的两段代码,性能差一个量级」的完整成因。
破坏内联的几种写法
-- 1) 带 SET 子句 → 不内联
CREATE FUNCTION demo.wt_set(p_amount numeric) RETURNS numeric
LANGUAGE sql IMMUTABLE
SET search_path = demo, public
AS $$ SELECT round(p_amount * 1.06, 2) $$;
-- 2) 声明 SECURITY DEFINER → 不内联
CREATE FUNCTION demo.wt_definer(p_amount numeric) RETURNS numeric
LANGUAGE sql IMMUTABLE SECURITY DEFINER
AS $$ SELECT round(p_amount * 1.06, 2) $$;EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.wt_set(500.00); QUERY PLAN
--------------------------------------------------------------------
Seq Scan on orders
Filter: (amount > demo.wt_set(500.00::numeric))
(2 rows)EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount > demo.wt_definer(500.00); QUERY PLAN
------------------------------------------------------------------------
Seq Scan on orders
Filter: (amount > demo.wt_definer(500.00::numeric))
(2 rows)两个都没能常量折叠。如果你既想要 SECURITY DEFINER 又想要内联,只能把逻辑拆开:内层用普通 sql 函数(可内联),外层用 SECURITY DEFINER 薄薄地包一层。
逐行处理:plpgsql 的领地
按状态汇总并同时输出每档占比——需要中间变量,只能 plpgsql:
CREATE FUNCTION demo.status_share()
RETURNS TABLE(status text, cnt bigint, pct numeric)
LANGUAGE plpgsql STABLE
AS $$
DECLARE
v_total bigint;
BEGIN
SELECT count(*) INTO v_total FROM demo.orders;
IF v_total = 0 THEN
RETURN;
END IF;
RETURN QUERY
SELECT o.status,
count(*),
round(100.0 * count(*) / v_total, 2)
FROM demo.orders AS o
GROUP BY o.status
ORDER BY count(*) DESC;
END;
$$;SELECT * FROM demo.status_share(); status | cnt | pct
-----------+------+-------
paid | 667 | 33.35
cancelled | 667 | 33.35
pending | 666 | 33.30
(3 rows)这里 IF v_total = 0 THEN RETURN; 是纯粹的防御分支——sql 没有 IF,也就写不出这种「提前退出」。反过来说,如果去掉这个分支,整段逻辑可以改写成一条带窗口函数的 SELECT,那就应该回到 sql:
CREATE FUNCTION demo.status_share_sql()
RETURNS TABLE(status text, cnt bigint, pct numeric)
LANGUAGE sql STABLE
AS $$
SELECT o.status,
count(*),
round(100.0 * count(*) / sum(count(*)) OVER (), 2)
FROM demo.orders AS o
GROUP BY o.status
ORDER BY count(*) DESC;
$$;SELECT * FROM demo.status_share_sql(); status | cnt | pct
-----------+------+-------
paid | 667 | 33.35
cancelled | 667 | 33.35
pending | 666 | 33.30
(3 rows)能力允许时优先 sql:这次改写用一个窗口函数消灭了变量与分支。
动态 SQL 与标识符安全
CREATE FUNCTION demo.count_by_status(p_schema text, p_table text, p_status text)
RETURNS bigint
LANGUAGE plpgsql STABLE SECURITY INVOKER
AS $$
DECLARE
v_count bigint;
BEGIN
EXECUTE format('SELECT count(*) FROM %I.%I WHERE status = $1', p_schema, p_table)
INTO v_count
USING p_status;
RETURN v_count;
END;
$$;SELECT demo.count_by_status('demo', 'orders', 'paid') AS paid_count; paid_count
------------
667
(1 row)用户传进来的表名走 %I(标识符转义),状态值走 USING(参数绑定),两层都堵死。若写成 %s 拼接,一个名为 orders; DROP TABLE x; -- 的「表名」就能造成破坏。
异常容错
CREATE FUNCTION demo.try_cast_int(p_input text)
RETURNS integer
LANGUAGE plpgsql IMMUTABLE
AS $$
BEGIN
RETURN p_input::integer;
EXCEPTION
WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$;SELECT demo.try_cast_int('42') AS ok, demo.try_cast_int('abc') AS bad; ok | bad
----+-----
42 |
(1 row)同样的功能在 PostgreSQL 内置函数里已有等价物(pg_input_is_valid() 配 ::integer),能用内置就别自建——内置函数是 C 实现且更了解优化器。
清理
DROP FUNCTION demo.status_share();
DROP FUNCTION demo.status_share_sql();
DROP FUNCTION demo.count_by_status(text, text, text);
DROP FUNCTION demo.try_cast_int(text);
DROP FUNCTION demo.with_tax_sql(numeric, numeric);
DROP FUNCTION demo.with_tax_pg(numeric, numeric);
DROP FUNCTION demo.wt_set(numeric);
DROP FUNCTION demo.wt_definer(numeric);
DROP SCHEMA demo CASCADE;注意事项
plpgsql永远不能被内联。这不是版本限制或配置开关,而是设计决定:优化器无法看见它的内部,因此无法做条件折叠与下推。凡是「被误写进plpgsql的简单表达式」,都会成为长期的性能税。sql-- 一个常见的反模式:查询封装成 plpgsql CREATE FUNCTION bad_lookup(p_id bigint) RETURNS text LANGUAGE plpgsql STABLE AS $$ DECLARE v text; BEGIN SELECT payload INTO v FROM t_inline WHERE id = p_id; RETURN v; END; $$;改成
LANGUAGE sql+ 直接SELECT即可,简单且更快。sql函数的返回值是「最后一条语句」。若中间写了多条语句,只有最后的SELECT/RETURNING生效;最后一条不是SELECT就报错(除非返回void)。sql函数体内不能有变量,别指望用AS $$ SELECT $1 AS x, x + 1 $$这种方式复用中间结果。EXCEPTION子句有真实代价。它隐式开启子事务,每次进入都要保存与恢复状态。把整个函数体包在EXCEPTION里是常见的性能陷阱。RAISE EXCEPTION与RAISE NOTICE语义不同:前者中止执行(默认ERRCODE为P0001),后者只输出信息。生产代码里给业务异常显式指定ERRCODE:sqlRAISE EXCEPTION '余额不足:当前 %,需要 %', v_balance, p_amount USING ERRCODE = '23514';plpgsql的语法错误可能延迟到运行时。创建时不报错不等于没问题,新建后必须实际调用一次。想提前暴露可在DO块或测试用例中覆盖各分支。动态 SQL 拼接字符串是头号注入面。表名、列名用
%I,值用USING或%L,绝不用%s拼用户输入。RETURN QUERY与RETURN NEXT不能混用于同一结果集之外的语义。RETURN QUERY追加一次查询的全部结果,RETURN NEXT追加一行;两者可以在同一函数中先后使用,但顺序就是输出顺序。plpgsql的SELECT ... INTO没找到行时把变量置为NULL,不会报错。必须自己用IF NOT FOUND THEN或COALESCE处理。sql函数里的函数调用不会「自动」内联。内联条件很苛刻,判断方法只有一个:看EXPLAIN里函数调用是否消失。别用函数替代视图。若只是给一段固定查询起个名字、不需要参数,用
CREATE VIEW而不是函数——视图没有任何调用开销。
最佳实践
默认选
LANGUAGE sql。只有当逻辑确实写不成一条查询时,才升级到plpgsql。这是本节最重要的一条。用
EXPLAIN验证内联,而不是凭感觉。写完sql函数后跑一次EXPLAIN (COSTS OFF),确认函数调用是否已在计划里消失:sqlEXPLAIN (COSTS OFF) SELECT * FROM demo.orders WHERE amount > demo.with_tax_sql(500.00);改写成一条查询的通用手段:
CASE替IF,generate_series替循环,窗口函数替累加变量,COALESCE/NULLIF替空值分支,LATERAL替嵌套过程。需要「提前退出」时,先想想能不能用
WHERE表达。IF v_total = 0 THEN RETURN;常可等价为查询里的WHERE条件。需要
SECURITY DEFINER时拆成两层:内层普通sql函数负责可内联的计算,外层SECURITY DEFINER只做权限边界与最小逻辑。避免在函数定义里写
SET子句来「顺手固定环境」,它会直接关闭内联。若确实需要固定search_path(SECURITY DEFINER必须),接受放弃内联的代价。plpgsql里把能合并的语句合并。一次SELECT ... INTO取回多个值,胜过多次单值查询:sqlSELECT count(*), COALESCE(sum(amount), 0) INTO v_cnt, v_sum FROM demo.orders WHERE status = p_status;避免
plpgsql里逐行UPDATE。用集合式UPDATE ... FROM一条搞定,减少 SPI 往返。给所有函数显式声明易变性。内联判断、索引使用、常量折叠都依赖它,详见 IMMUTABLE / STABLE / VOLATILE。
EXCEPTION只用在真正需要容错的边界,不要用「包住整个函数防崩」的写法。动态 SQL 统一走
format()+USING,并把它当作代码审查的必查项写入规范。给
plpgsql写测试用例,覆盖每个IF分支与EXCEPTION分支;扩展plpgsql_check可以在测试阶段做静态检查。函数体保持短小。一个函数只做一件事;长过程应拆成若干个能独立测试的
sql函数。
下一节
继续学习:函数重载与可变参数