函数与存储过程
这一章讲 PG 的服务端逻辑:CREATE FUNCTION / CREATE PROCEDURE / DO 匿名块,以及围绕它们的几个关键机制——波动性声明、SECURITY DEFINER、异常处理、过程内事务控制。手册基线是 PG 18,关键结论都对照了官方文档(Extending SQL 36.7、CREATE FUNCTION/PROCEDURE/CALL 参考页、PL/pgSQL 第 41 章),跨版本特性标注引入版本。
为什么要把逻辑放进数据库——以及不放什么
先诚实说清取舍。“业务逻辑写进存储过程"在应用开发圈是有争议的:版本管理比代码仓库麻烦、调试体验弱、和 ORM/框架生态割裂、数据库成了逻辑的第二个家。Go 服务里绝大多数业务规则应该留在应用层。但下面四类场景,把逻辑收进 PG 是明确划算的:
- 批量运维:回填、归档、清理,几十上百万行。放 Go 里做就是"取一批 → 网络往返 → 写回”,放库里是纯本地操作,还能用过程内分批提交控制事务膨胀(见
06-MVCC与Vacuum.md)。 - 数据不变量兜底:应用代码可以出错,数据库层的 CHECK/函数钩子不会。审计类需求尤其明显——任何路径的修改都留下记录(钩子本身见
14-触发器.md)。 - 跨表一致性的小事务:一次调用完成多表联动写,少一轮甚至十几轮往返,失败面也从分布式收敛回单事务。
- 减少 N 次往返:把"先查 ID 列表再逐个处理"的循环压成一次函数调用。
判断准则放在文末实战建议,先把机制讲透。
一、SQL 函数还是 PL/pgSQL
CREATE FUNCTION 的 LANGUAGE 决定函数体由谁解释:
-- LANGUAGE sql:单条(或一组)SQL 语句的封装
CREATE OR REPLACE FUNCTION fn_display_name(p_nickname text, p_email text)
RETURNS text LANGUAGE sql STABLE AS
$$ SELECT coalesce(nullif(trim(p_nickname), ''), split_part(p_email, '@', 1)) $$;
-- LANGUAGE plpgsql:带变量、控制流、异常捕获的过程语言块
CREATE OR REPLACE FUNCTION fn_count_active(p_since timestamptz)
RETURNS bigint LANGUAGE plpgsql STABLE AS
$$ BEGIN RETURN QUERY SELECT count(*) FROM orders WHERE created_at >= p_since; END $$;选择标准很朴素:能用一条 SQL 表达完的,用 LANGUAGE sql。规划器通常会把简单 SQL 函数展开(内联,inline)成调用查询的一部分,展开后整条语句一起优化——谓词可以下推、索引照常走;而 plpgsql 函数体对规划器是黑盒,它的查询要单独规划执行。需要变量、分支、循环、异常,才上 plpgsql。
SQL 函数还有一种标准风格写法(PG 14+)BEGIN ATOMIC ... END,函数体在定义时就解析,而且能登记函数体与所引用对象之间的依赖——DROP ... CASCADE 能正确级联;老的字符串形式这两点都做不到,实践中容易留下悬空引用:
CREATE OR REPLACE FUNCTION fn_order_total(p_order_no text)
RETURNS numeric LANGUAGE sql
BEGIN ATOMIC
SELECT total_amount FROM orders WHERE order_no = p_order_no;
END;二、参数与返回:模式、命名实参、RETURNS TABLE
参数模式
参数可以标 IN(默认)、OUT、INOUT、VARIADIC。几条硬规则(CREATE FUNCTION 参考页):
- 只有输入参数(含
INOUT)能带DEFAULT,且默认值之后的输入参数必须全有默认值; VARIADIC之后只能跟OUT参数;- 用了
RETURNS TABLE就不能再声明OUT/INOUT参数。
CREATE FUNCTION fn_user_stats(uid bigint,
OUT balance numeric,
OUT order_cnt bigint)
RETURNS record
LANGUAGE sql STABLE AS
$$ SELECT u.balance, count(o.id)
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = uid
GROUP BY u.balance $$;
CREATE FUNCTION fn_sum(VARIADIC nums numeric[])
RETURNS numeric LANGUAGE sql AS
$$ SELECT sum(v) FROM unnest(nums) AS t(v) $$;fn_user_stats(42) 的结果集里,两个 OUT 参数的名字 balance、order_cnt 直接就是结果列名——OUT 参数的名字是有实际意义的,它定义返回行的列名。
命名实参
调用时可以用 参数名 := 值 或 参数名 => 值,顺序随意、默认值可跳:
SELECT fn_sum(VARIADIC ARRAY[1, 2, 3]);
SELECT * FROM fn_user_stats(uid => 42);对多参数布尔开关尤其有价值:fn_report(p_since => now() - interval '7 days', p_include_cancelled := true) 比 fn_report(now(), true) 可读太多。
RETURNS TABLE:返回多列结果集
CREATE OR REPLACE FUNCTION fn_order_summary(p_since timestamptz DEFAULT '2000-01-01')
RETURNS TABLE (user_id bigint, order_cnt bigint, paid_amount numeric, last_order_at timestamptz)
LANGUAGE sql STABLE AS
$$ SELECT o.user_id,
count(*),
sum(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','completed')),
max(o.created_at)
FROM orders o
WHERE o.created_at >= p_since
GROUP BY o.user_id $$;
SELECT * FROM fn_order_summary(p_since => '2026-01-01') ORDER BY paid_amount DESC NULLS LAST;RETURNS TABLE 让函数可以像表一样出现在 FROM 里,配合 LATERAL 还能按行驱动(见 03-SQL查询实战.md)。返回单列多行用 RETURNS SETOF 类型;返回不定列结构用 RETURNS record + 调用侧 AS (col1 t1, ...) 声明列——上面 fn_user_stats 是 OUT 参数写法,调用侧不写 AS 时列名即 OUT 名。plpgsql 里产多行用 RETURN NEXT(逐行)或 RETURN QUERY(整条查询直传,更快,见第十三节)。
三、波动性:你是在向优化器作承诺,不是描述实现
IMMUTABLE / STABLE / VOLATILE 三个标记描述的是你向规划器承诺的函数行为,官方文档原话是 “the volatility category is a promise to the optimizer”。不写则默认 VOLATILE。这不是注释,规划器会真金白银地按你的承诺做变换,所以标错是事故源而不是风格问题。
三档的精确语义(官方 36.7 节):
- VOLATILE:什么都能干,同样的入参、连续多次调用的结果可以不同。含副作用的函数必须标 VOLATILE,否则调用可能整个被优化掉。
random()、currval()、timeofday()是典型;任何INSERT/UPDATE/DELETE逻辑都在此列。 - STABLE:不修改数据库;同一条语句内同参必同值,跨语句可能变。适合"读数据库当前状态"或"依赖会话参数"的函数:
current_setting('work_mem')、按表聚合取数的函数、now()所在的 current_timestamp 家族(官方明确:它们在一个事务内不变,属于 STABLE)。 - IMMUTABLE:不修改数据库;永远同参同值,不看表、不看会话参数、不看时钟。标了之后规划器可以在优化期直接把调用折叠成常量,还能放进表达式索引和生成列。纯数学/字符串计算(如
add、上文的fn_user_display_name这类不查表的)才够格。
错标 IMMUTABLE 的真实事故
最典型的写法事故:封装一个"取当天日期"的函数,顺手标了 IMMUTABLE——
-- 错误示范:函数体内用了 now(),却标 IMMUTABLE
CREATE OR REPLACE FUNCTION fn_today() RETURNS date
LANGUAGE sql IMMUTABLE AS $$ SELECT now()::date $$;PG 不会检查你的函数体是否与标记一致——它只信标记。后果按场景放大:
- 表达式索引要求被索引表达式里的函数必须是 IMMUTABLE(CREATE INDEX 文档原话)。
fn_today()顶着 IMMUTABLE 标记就能建出索引,索引值是建索引那一刻算好并冻结的,之后不会自动重算,全表查询静默给出错的结果; - 生成列(PG 12+,同样要求表达式 IMMUTABLE)同理,旧值会长期停留在存储里;
- 即使没进索引,官方文档也点名了这个坑:标了假的 IMMUTABLE,“might allow it to be prematurely folded to a constant during planning, resulting in a stale value being re-used”——预编译语句和 PL/pgSQL 的计划缓存会让这个陈旧值被后续执行反复复用,表现出"今天查对、明天查错"的诡异行为。
另一个常见误标:结果依赖配置参数的函数标成 IMMUTABLE——比如包了 current_setting('TimeZone')。这类函数按官方说法应标 STABLE。
选择准则
按这个顺序问自己:
- 函数体里有 DML、序列访问、
clock_timestamp()、random()?→VOLATILE(没得选)。 - 结果只由入参决定,不查表、不看会话参数、不看时间?→
IMMUTABLE,且这是你唯一能让它进表达式索引/生成列的机会。 - 其余(查表、读 GUC、读事务内不变的
now())→STABLE。
官方还补了两条容易漏的:STABLE/IMMUTABLE 函数体内不允许出现 SELECT 以外的 SQL 命令(文档原话);且这两类函数看到的快照固定在调用语句开始时刻,VOLATILE 函数每次执行内部查询都会取新快照——这也是 VOLATILE 函数能看见并发修改、STABLE 函数看不见的根本原因(快照机制见 06-MVCC与Vacuum.md)。
四、STRICT 与并行属性
CREATE OR REPLACE FUNCTION fn_safe_length(t text)
RETURNS int LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS
$$ SELECT length(t) $$;
SELECT fn_safe_length(null); -- 直接得 NULL,函数体根本没执行STRICT(等价于 RETURNS NULL ON NULL INPUT)的语义是短路:任一实参为 NULL 时函数直接返回 NULL,函数体不执行。它只在你确实"进 NULL 出 NULL"时才该标——如果函数需要"NULL 当 0 处理"这类逻辑,标 STRICT 会跳过逻辑、结果错误。默认 CALLED ON NULL INPUT 是体面地传 NULL 进来让你自己判。
PARALLEL { UNSAFE | RESTRICTED | SAFE } 决定函数能不能进并行执行计划,默认 UNSAFE——函数一出现就逼出串行计划。判据:修改数据、改变事务状态、访问序列的标 UNSAFE;只有 leader 进程能安全执行的(如会修改 GUC 的)标 RESTRICTED;只读的纯计算标 SAFE。注意"访问序列"要 UNSAFE 这条——nextval() 有副作用、必须 VOLATILE,也正因为如此它不能出现在并行 worker 里。这些标记同样是承诺,标错出问题时不会被 PG 拦下。
五、SECURITY DEFINER:提权重灾区
默认所有函数以调用者权限执行(SECURITY INVOKER)。标 SECURITY DEFINER 后,函数体以属主身份运行——低权账号借高权属主的权限做事。这是功能,也是 PG 历史上最著名的提权路径之一:CVE-2018-1058 的 search_path 投毒正是配合 DEFINER 函数起效的(背景与 REVOKE 动作见 12-安全与权限管理.md,public schema 在 PG 15 起新库才不再默认放开 CREATE,老库升级后必须手动收紧)。
反例(三重问题在一个函数里):
-- 危险:DEFINER 属主是超级用户,search_path 未固定,动态 SQL 直接拼接
CREATE OR REPLACE FUNCTION fn_admin_rename_role(p_old text, p_new text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
EXECUTE 'ALTER ROLE ' || p_new || ' RENAME TO ' || p_old; -- 拼接 + 参数反了 + 无权限自查
END $$;调用者没有 ALTER ROLE 权限,但这个函数替他执行;pg_temp 默认排在 search_path 最前,攻击者可以在临时 schema 里放一个同名对象劫持函数体内所有未限定引用(正是 1058 的玩法);拼进动态 SQL 的内容没有任何转义,注入直接把 DEFINER 变成超级用户的后门。
安全模板——三条缺一不可:固定 search_path、全限定对象名、显式授权:
CREATE SCHEMA IF NOT EXISTS audit;
CREATE OR REPLACE FUNCTION audit.fn_record_login(p_user_id bigint, p_ip inet)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = '' -- 关键:会话 search_path 被钉死,不受调用者污染
AS $$
BEGIN
-- 空 search_path 下 pg_catalog 仍隐式生效,内置函数可直接用;
-- 其余一切对象必须写全限定名
INSERT INTO audit.login_log(user_id, ip, at)
VALUES (p_user_id, p_ip, now());
END $$;
-- 精确授权:先收掉 PUBLIC,再给需要的角色
REVOKE ALL ON FUNCTION audit.fn_record_login(bigint, inet) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION audit.fn_record_login(bigint, inet) TO app_writer;官方文档给的写法是显式列出受信 schema 并把 pg_temp 放末尾(SET search_path = admin, pg_temp);SET search_path = '' 是更严格的变体,配合全限定名同样成立。实践中还有两招:函数体开头用 current_user 自查调用者白名单;能用视图/RLS 解决的不要上 DEFINER。
两个连带坑:过程若标 SECURITY DEFINER 或带 SET 子句,则不能执行事务控制语句(CREATE PROCEDURE 文档原话),和第十节配合时要记住;另外 DEFINER 函数属主换人(ALTER FUNCTION ... OWNER TO)等于换执行身份,属于权限审计范围(见 12-安全与权限管理.md)。
六、PL/pgSQL 语法核心
块结构与变量
CREATE OR REPLACE FUNCTION fn_ship_fee(p_order_id bigint)
RETURNS numeric
LANGUAGE plpgsql STABLE AS $$
DECLARE
v_qty orders.quantity%TYPE; -- %TYPE:跟随列类型,列改类型函数不炸
v_row orders%ROWTYPE; -- %ROWTYPE:整行复合变量
v_fee numeric := 0;
BEGIN
SELECT * INTO v_row FROM orders WHERE id = p_order_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'order % not found', p_order_id USING ERRCODE = 'no_data_found';
END IF;
v_qty := v_row.quantity;
CASE
WHEN v_row.status = 'cancelled' THEN v_fee := 0;
WHEN v_qty > 10 THEN v_fee := 0; -- 大件免邮
ELSE v_fee := 8.00;
END CASE;
RETURN v_fee;
END $$;要点:DECLARE 段声明变量和常量;BEGIN/END 在这里只是分组,不开始/结束事务(官方在 41.2 特别强调这一点,带 EXCEPTION 的块例外,见第七节)。SELECT ... INTO 没取到行时不报错,只把布尔变量 FOUND 置 false——判空靠它;想要"取不到就抛异常",写 SELECT ... INTO STRICT(零行抛 no_data_found,多行抛 too_many_rows)。
循环
-- 循环 0..n;游标式循环直接以查询为数据源
FOR i IN 1..10 LOOP ... END LOOP;
DECLARE r record;
BEGIN
FOR r IN SELECT id, email FROM users WHERE nickname IS NULL LIMIT 1000 LOOP
RAISE NOTICE 'backfilling %', r.id;
END LOOP;
END;循环家族:LOOP(配合 EXIT/EXIT WHEN/CONTINUE WHEN)、WHILE、数值 FOR i IN REVERSE 10..1、查询游标 FOR rec IN SELECT ...、数组/集合的 FOREACH x SLICE 1 IN ARRAY arr(SLICE n 表示每次取 n 个元素的子数组,配 ANY/ALL/REVERSE 使用)。FOR r IN SELECT ... LOOP ... RETURN NEXT r; END LOOP 是 plpgsql 返回集合的老写法,等价场景优先 RETURN QUERY。
RAISE
RAISE NOTICE 'batch % done, % rows', batch_no, n;
RAISE WARNING 'skipping bad row id=%', r.id;
RAISE EXCEPTION 'insufficient balance: have % need %', v_have, v_want
USING ERRCODE = 'P0001', HINT = '充值后重试';RAISE 的消息用 % 占位、逗号接实参(注意不是 format() 的 %s);USING 子句挂 ERRCODE(可写 SQLSTATE 原文,也可写条件名如 unique_violation)、HINT、DETAIL。文档明确:写 USING ERRCODE = 'unique_violation' 与 USING ERRCODE = '23505' 等价。裸 RAISE(不带任何参数)只能在异常处理器里用,含义是原样重抛当前错误。
动态 SQL 与注入防线
静态 plpgsql 里标识符不能当参数;要拼接表名/列名,只能 EXECUTE。正确姿势是 format() 的三种占位符:
DECLARE v_cnt bigint;
BEGIN
EXECUTE format('SELECT count(*) FROM %I.%I WHERE status = %L',
'public', 'orders', 'paid')
INTO v_cnt;
-- 值也可以走 USING 传参,避免拼进 SQL 文本:
EXECUTE 'DELETE FROM orders WHERE id = $1' USING p_id;
END;%I 处理标识符(自动加引号,等效 quote_ident),%L 处理字面量(自动加引号并转义内部单引号,等效 quote_literal),%s 原样插入——拼接表名用 %I、拼接值用 %L 或干脆用 USING 传参,这是服务端防注入的全部纪律,和 Go 侧参数化查询同理(见 12-安全与权限管理.md 的注入防线一节)。文档原示例正是用 %I 替换手工 quote_ident 拼接,称其为 “a cleaner approach”。
七、异常处理
语法与常用条件名
BEGIN
INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price, status)
VALUES (p_no, p_uid, p_pid, p_qty, p_price, 'pending');
EXCEPTION
WHEN unique_violation THEN -- 23505
RAISE WARNING 'duplicate order_no %', p_no;
WHEN check_violation THEN -- 23514
RAISE EXCEPTION 'bad order row: %', SQLERRM;
WHEN OTHERS THEN
RAISE EXCEPTION 'insert failed [%]: %', RETURNED_SQLSTATE, MESSAGE_TEXT;
END;常背的预定义条件名:no_data_found(P0002)、too_many_rows(P0003)、raise_exception(P0001)、unique_violation(23505)、check_violation(23514)、foreign_key_violation(23503)、not_null_violation(23502)、division_by_zero(22012)、lock_not_available(55P03)、deadlock_detected(40P01)。WHEN OTHERS 兜住一切;WHEN ... OR ... 可以并列多个条件。
处理块的隐式回滚是最要命的语义:官方 41.2 原话——“a block containing an EXCEPTION clause effectively forms a subtransaction that can be rolled back without affecting the outer transaction”。也就是说 BEGIN…EXCEPTION 块内已经执行成功的写会被整体撤销,块外的不受影响。这让它适合当"逐行尝试、坏行跳过"的隔离舱,但也意味着别在含 EXCEPTION 的块里指望"先写一半留底"。注意它救不了 DDL:PG 的 DDL 是事务性的,块里 ALTER 失败同样整块回滚。
取错误详情
处理器内有两个现成变量 SQLSTATE(错误码)和 SQLERRM(错误文本),块外未定义。更完整的取法是 GET STACKED DIAGNOSTICS(41.6.8.1):
DECLARE v_state text; v_msg text; v_ctx text;
BEGIN
...
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
v_state = RETURNED_SQLSTATE,
v_msg = MESSAGE_TEXT,
v_ctx = PG_EXCEPTION_CONTEXT;
RAISE LOG 'swallowed: % %', v_state, v_msg;
END;块外想知道上一条语句影响了几行,用普通形式 GET [CURRENT] DIAGNOSTICS v_n = ROW_COUNT;(41.5.5,类型 bigint)。
逐行处理 + 异常兜底继续
批量导入场景:单行失败不拖垮整批。先建本章自建的演示表(起新名,避免和 05-事务与锁.md 的 accounts 撞约束条件):
CREATE TABLE accounts_demo2 (
id bigint PRIMARY KEY,
owner text NOT NULL,
balance numeric(12,2) NOT NULL CHECK (balance >= 0)
);CREATE OR REPLACE FUNCTION fn_import_pairs(p_pairs bigint[][])
RETURNS bigint
LANGUAGE plpgsql AS $$
DECLARE
v_pair bigint[];
v_ok bigint := 0;
BEGIN
FOREACH v_pair SLICE 1 IN ARRAY p_pairs LOOP
BEGIN -- 内层块:每行一个隔离舱
INSERT INTO accounts_demo2 (id, owner, balance)
VALUES (v_pair[1], 'imported', v_pair[2]);
v_ok := v_ok + 1;
EXCEPTION
WHEN unique_violation THEN
RAISE WARNING 'skip existing id=%', v_pair[1];
WHEN check_violation THEN
RAISE WARNING 'skip invalid balance for id=%', v_pair[1];
END;
END LOOP;
RETURN v_ok;
END $$;内层 BEGIN…EXCEPTION 的隐式回滚只回滚当前行的尝试,循环继续。
注意 兜底要兜窄:
WHEN OTHERS NULL(吞掉一切)会把真故障静默掉,只兜你预期且能安全跳过的那几个条件名。
八、DO 匿名块:一次性运维脚本
不需要复用的循环、批量修补、发布脚本,用 DO(PG 9.0+)直接执行,不留对象;语言默认就是 plpgsql,连 LANGUAGE 都可以省:
DO $$
DECLARE
v_batch int := 50000;
v_rows bigint;
v_total bigint := 0;
BEGIN
LOOP
WITH src AS (
SELECT id FROM users WHERE nickname IS NULL LIMIT v_batch
), upd AS (
UPDATE users u
SET nickname = split_part(u.email, '@', 1)
FROM src s
WHERE u.id = s.id
RETURNING 1
)
SELECT count(*) INTO v_rows FROM upd;
EXIT WHEN v_rows = 0;
v_total := v_total + v_rows;
RAISE NOTICE 'nickname backfill: % rows done', v_total;
COMMIT; -- 顶级 DO 里允许(PG 11+,见第十节规则)
END LOOP;
RAISE NOTICE 'backfill complete, total % rows', v_total;
END $$;DO 块的价值:带进度(RAISE NOTICE)、可分批提交、随发布脚本走。05-事务与锁.md 里那段"SET lock_timeout + 重试 ALTER"的块就是同一模式的锁保护变体,两者对照读。运维脚本建议统一约定:跑前 \set ON_ERROR_STOP on(psql),或包在事务里试跑再放开。
九、返回游标:refcursor 与 pgx 消费
RETURNS SETOF 也能流式(pgx 的 Query 本来就是逐行收),但游标变量提供了另一种接口:函数打开一个查询游标,把句柄交给调用方,由调用方按自己的节奏 FETCH。PL/pgSQL 里所有游标变量的类型都是 refcursor,声明 DECLARE cur refcursor; 或直接在 DECLARE ... CURSOR FOR query 绑定查询,OPEN cur FOR SELECT ... 打开。
CREATE SCHEMA IF NOT EXISTS ops;
CREATE OR REPLACE FUNCTION ops.fn_open_order_report(p_since timestamptz)
RETURNS refcursor
LANGUAGE plpgsql AS $$
DECLARE cur refcursor := 'order_report';
BEGIN
OPEN cur FOR
SELECT o.id, o.order_no, u.email, o.total_amount
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.created_at >= p_since
ORDER BY o.created_at;
RETURN cur; -- 返回的是游标名
END $$;
-- psql 手工消费:游标活在打开它的事务里
BEGIN;
SELECT ops.fn_open_order_report('2026-01-01');
FETCH FORWARD 100 FROM order_report;
COMMIT;游标在事务提交时关闭(不写 WITH HOLD 的话),所以函数返回游标给客户端这一套必须在同一连接、同一事务内完成。Go 侧用 pgx v5 的标准消费方式——从池里拿一个事务(pgx 的 Tx 绑定单连接,天然满足):
package main
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type OrderRow struct {
ID int64 `json:"id"`
OrderNo string `json:"order_no"`
Email string `json:"email"`
TotalAmount float64 `json:"total_amount"`
}
func main() {
ctx := context.Background()
pool, _ := pgxpool.New(ctx, "postgres://app:secret@localhost:5432/shop?sslmode=disable")
defer pool.Close()
tx, err := pool.Begin(ctx) // Tx 绑定单连接,游标全程可见
if err != nil { panic(err) }
defer tx.Rollback(ctx)
since := time.Now().Add(-7 * 24 * time.Hour)
var cur string
if err := tx.QueryRow(ctx, `SELECT ops.fn_open_order_report($1)`, since).Scan(&cur); err != nil {
panic(err)
}
rows, err := tx.Query(ctx, `SELECT * FROM `+pgx.Identifier{cur}.Sanitize())
if err != nil { panic(err) }
out, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[OrderRow])
if err != nil { panic(err) }
fmt.Println(len(out))
tx.Commit(ctx) // 提交即关闭游标;小结果集没问题,大结果集边 FETCH 边处理
}注意
pgx.Identifier{cur}.Sanitize()对游标名做引号转义,别裸拼字符串。另外pool.Query每次可能拿不同连接,游标必须在同一个 Tx(或 Acquire 的专用连接)里打开并消费;结果集很大时把CollectRows换成流式rows.Next()/Scan(见08-Go客户端pgx.md的取舍)。实践中多数场景RETURNS SETOF+ pgx 流式更简单,refcursor 留给"需要游标语义"(分页式按需取、超大报表)的接口。
十、存储过程:CREATE PROCEDURE 与 CALL
过程(CREATE PROCEDURE/CALL,PG 11+)与函数的本质区别只有一条,但足够重要:过程可以执行事务控制语句。
官方两条规则合起来读:
- PL/pgSQL 文档(41.8):COMMIT/ROLLBACK 只能在
CALL调用的过程和DO块里写;且"Transaction control is only possible in CALL or DO invocations from the top level or nested CALL or DO invocations without any other intervening command"——调用栈里只要隔着一条SELECT func(),内层过程的 COMMIT 就非法; - CALL 文档:
CALL执行在事务块内时,被调过程不能做事务控制,即CALL 必须自己就是那个事务(autocommit 语义)。
所以用 pgx 调过程要走 pool.Exec(ctx, "CALL ...")(自动提交事务)而不是先 BeginTx 再在事务里 Exec——后者过程一执行 COMMIT 就报 invalid transaction termination。函数里更是完全不能 COMMIT(PG 11 起如此;RETURNS 也不可能在中间提交)。
实战:分批归档过期订单
大表清理教科书场景。一次 DELETE 几千万行 = 一个巨型事务:锁持有时间长、WAL 洪峰、undo 膨胀、vacuum 被 xmin horizon 卡住(这一串教训在 06-MVCC与Vacuum.md 反复出现)。过程把它切成每批一个独立事务:
CREATE SCHEMA IF NOT EXISTS ops;
-- 归档表单独建:orders 的 id 是 GENERATED ALWAYS,历史行要原样落库,
-- 用普通 bigint;total_amount 归档时固化为普通列(生成列不可插入)
CREATE TABLE public.orders_archive (
id bigint PRIMARY KEY,
order_no text NOT NULL,
user_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity int NOT NULL,
unit_price numeric(12,2) NOT NULL,
status order_status NOT NULL,
paid_at timestamptz,
total_amount numeric,
created_at timestamptz NOT NULL,
updated_at timestamptz,
archived_at timestamptz NOT NULL DEFAULT now()
);
CREATE OR REPLACE PROCEDURE ops.sp_archive_orders(
p_cutoff timestamptz,
p_batch int DEFAULT 50000)
LANGUAGE plpgsql AS $$
DECLARE
v_rows bigint;
v_total bigint := 0;
BEGIN
LOOP
-- 单条语句内 DELETE+INSERT 原子完成:崩溃也只丢当前批,不留半行
WITH src AS (
SELECT id FROM orders
WHERE status IN ('completed', 'cancelled')
AND created_at < p_cutoff
LIMIT p_batch
), moved AS (
DELETE FROM orders o USING src s
WHERE o.id = s.id
RETURNING o.id, o.order_no, o.user_id, o.product_id,
o.quantity, o.unit_price, o.status, o.paid_at,
o.total_amount, o.created_at, o.updated_at
), ins AS (
INSERT INTO orders_archive
(id, order_no, user_id, product_id, quantity, unit_price,
status, paid_at, total_amount, created_at, updated_at)
SELECT m.id, m.order_no, m.user_id, m.product_id, m.quantity, m.unit_price,
m.status, m.paid_at, m.total_amount, m.created_at, m.updated_at
FROM moved m
)
SELECT count(*) INTO v_rows FROM ins;
EXIT WHEN v_rows = 0;
v_total := v_total + v_rows;
COMMIT; -- 每批一提交,死行尽早对 vacuum 可见
RAISE NOTICE 'archived rows so far: %', v_total;
END LOOP;
END $$;
-- psql 顶级执行(不要包 BEGIN/COMMIT);命名实参或位置实参均可:
CALL ops.sp_archive_orders(p_cutoff => now() - interval '180 days', p_batch => 50000);顺带一提:过程与函数在同一 schema 下可以同名(按参数类型区分重载),但 pg_proc 里 prokind 不同(f/p),排查时别看错对象。
细节与坑:
- 过程不能带
SET子句还想内部 COMMIT(文档原话),也别做成 SECURITY DEFINER——两条都禁止事务控制; - 每批
COMMIT后新事务用默认隔离级别;想继承上一批的事务特性用COMMIT AND CHAIN; - 中途失败/杀进程是安全的:单条 DELETE+INSERT 语句原子;重跑幂等(已归档的行不在 orders 里了);归档期间 orders 的读取一致性靠逐批独立事务保证,业务可正常读写,只是"归档快照"边界按批推进;
- 定时执行交给
pg_cron这类调度扩展(15-扩展与生态.md一句带过),或干脆由 Go 侧 cron 发CALL。
十一、聚合与多态
自定义聚合
聚合 = 转移函数(逐行吸收)+ 状态类型 + 可选终结函数。完整小例子:库存加权平均价(Σ价×库存 / Σ库存):
CREATE FUNCTION agg_wacc_trans(state numeric[], val numeric, wt numeric)
RETURNS numeric[] LANGUAGE sql IMMUTABLE AS
$$ SELECT ARRAY[ coalesce(state[1], 0) + coalesce(val, 0) * coalesce(wt, 0),
coalesce(state[2], 0) + coalesce(wt, 0) ] $$;
CREATE FUNCTION agg_wacc_final(state numeric[])
RETURNS numeric LANGUAGE sql IMMUTABLE AS
$$ SELECT CASE WHEN coalesce(state[2], 0) > 0
THEN round(state[1] / state[2], 2) END $$;
CREATE AGGREGATE weighted_avg(numeric, numeric) (
SFUNC = agg_wacc_trans,
STYPE = numeric[],
INITCOND = '{0,0}',
FINALFUNC = agg_wacc_final
);
SELECT weighted_avg(price, stock::numeric) AS stock_weighted_price FROM products;STYPE 是累加状态(这里用两元素数组存"加权和"与"权重和"),INITCOND 决定初态。要支持并行聚合还要配 COMBINEFUNC/SERIALFUNC 并把转移函数标 PARALLEL SAFE;窗口聚合场景加 SORTED/MOVING 选项的语法存在但不常用,遇到再查官方 CREATE AGGREGATE 页。顺带一提,FINALFUNC ... RUN ON EMPTY 控制零输入时是否调用终结函数。
多态函数
anyelement/anyarray 让一个函数适配一族类型,同一调用的各个 anyelement 参数会被归并到同一个具体类型:
CREATE FUNCTION fn_elem_in_any(arr anyarray, elem anyelement)
RETURNS boolean LANGUAGE sql IMMUTABLE AS
$$ SELECT elem = ANY(arr) $$;
SELECT fn_elem_in_any(ARRAY['a','b','c'], 'b'); -- true
SELECT fn_elem_in_any(ARRAY[1,2,3], 2); -- trueanyelement 返回类型的函数必须至少有一个 anyelement 入参(PG 要从实参反推具体类型)。相关的还有 anynonarray、anyenum、anyrange、anycompatiblerange(PG 14+);PG 16 引入了 anyvalue——匹配任意类型且不要求与其他参数同型,写纯泛型工具函数(只透传/计数,不做类型间运算)时比 anyelement 宽松。
十二、管理函数:查看、替换、删除
查看完整定义(比 \df 更还原,含 volatility/strict/security 等属性):
SELECT pg_get_functiondef('fn_order_summary(timestamptz)'::regprocedure);
SELECT proname, prokind, -- f=函数 p=过程 a=聚合 w=窗口(prokind 为 PG 11+)
provolatile, -- i/s/v
proisstrict, prosecdef, parallel
FROM pg_proc
WHERE pronamespace = 'public'::regnamespace AND proname LIKE 'fn_%';四条必须记住的语义:
- DROP 要完整签名。
DROP FUNCTION fn_dup(int)只删这一个重载,同名多签名的逐个给出;IF EXISTS防报错,CASCADE会连依赖它的视图/触发器一起收,用前先看pg_depend。psql 里先SELECT oid::regprocedure FROM pg_proc WHERE proname='...'把现成签名复制出来最稳。 - CREATE OR REPLACE 的能力边界。同签名替换保留属主、权限和 OID,依赖它的视图照常工作(视图执行时实时调用新函数体);但返回类型不能改(文档原话,OUT 列的类型同论),改参数名/参数模式在替换时受限且不同大版本口径有差异——实践中把"签名与 OUT 列名"当 API 契约对待,要动就 drop 重建并同步修依赖。
- drop 再 create 是换了一个对象。文档明确:引用旧函数的 rules、视图、触发器必须重建,别拿
CREATE OR REPLACE当 drop/create 的省事写法,也别反过来指望 REPLACE 会传播一切——表达式索引、已存的生成列、物化视图里的旧值不会因函数体变化自动重算,该REINDEX/REFRESH MATERIALIZED VIEW的自己做。 - 函数与所引用对象的依赖记录在
pg_depend,BEGIN ATOMIC风格的 SQL 函数体才有完整依赖登记(第一节提过),字符串体则依赖缺失。
十三、性能:别把 N+1 搬进数据库
plpgsql 是解释执行的:SQL 语句在块里每遇到一次都要走一遍解析/计划路径(PG 会对重复语句缓存执行计划,循环里反复同一条 SQL 只规划一两次,参数换值是常规开销),但函数调用本身——SQL 引擎与 plpgsql 引擎之间每行一次的值转换和调用开销——是实打实的。最常见的写法事故:
-- 反面:游标循环里逐条 UPDATE,应用层 N+1 的数据库内翻版
FOR r IN SELECT id FROM users WHERE balance < 0 LOOP
UPDATE users SET balance = 0 WHERE id = r.id;
END LOOP;换成一条 UPDATE users SET balance = 0 WHERE balance < 0; 既让索引和 HOT 更新的机会留给规划器,也省掉每行一次往返解释器。plpgsql 里同样的准则成立:能一条 SQL 解决的绝不用循环;返回集合优先 RETURN QUERY/整条 SELECT 直传,而不是 FOR ... LOOP RETURN NEXT。第十节的分批循环是例外——那里的循环不是为了逐行处理,而是为了分批提交控制事务体积。
与计划缓存的交互要留意:预编译语句和 plpgsql 内部都会缓存查询计划,而波动性标错(第三节)会让缓存的计划带着被过早折叠成常量的旧值跑——“计划保存并稍后复用"正是官方点名 IMMUTABLE 假承诺的主要受害场景。函数放进并行查询还要看 PARALLEL 标记(第四节),一个标错 UNSAFE 的函数能把整条并行计划拖回串行。
实战建议
把这一章收束成几条可以贴墙上的准则:
- Go 与 PG 的分工线:易变的业务规则、编排、对外协议留在 Go;数据不变量的兜底(跨列/跨表一致性校验、审计落痕)、批量运维(回填/归档/清理)、需要一次调用消灭 N 次往返的热点,下沉 PG。写进 PG 的逻辑要有"它同时是 API"的自觉——版本、权限、回归测试都要按 API 管理。
- 波动性当承诺,不当描述。拿不准就
STABLE;IMMUTABLE只留给真正的纯函数,否则表达式索引/生成列/缓存计划会替你记住错误的过去。含副作用的一律VOLATILE,哪怕结果可预测。 - SECURITY DEFINER 三件套:
SET search_path = ''+ 全限定名 + 收 PUBLIC 给精确角色。做不到就改用视图/RLS。 - EXCEPTION 块是子事务:块内已成功写会回滚。逐行容错就每行一个小块,别用
WHEN OTHERS吞一切。 - 过程内 COMMIT 的三条红线:顶级
CALL/DO才有事务控制;pgx 下别用显式事务包CALL;DEFINER 或带 SET 子句的过程不能 COMMIT。分批默认 5 万行量级起步,观测 autovacuum 追得上再放大(06-MVCC与Vacuum.md)。 - 返回多行优先
RETURNS SETOF/TABLE,refcursor 留给需要游标按需消费的大报表,且保证同连接同事务消费完。 - 函数体变更走"契约"流程:返回类型和参数名视为不可变;动了依赖面先查
pg_depend,表达式索引/生成列/物化视图要手动重建刷新;触发器里的函数替换更要过一遍14-触发器.md的检查项。 - 日常排查:
pg_get_functiondef看现状、\df列清单、SELECT ... FROM pg_proc WHERE proname=...看属性;错误信息用GET STACKED DIAGNOSTICS补全 SQLSTATE;函数相关的坑汇总见16-常见陷阱与最佳实践.md。