☰
PostgreSQL 函数编写从入门到实战:封装 SQL 与业务逻辑
2026/10/1 11:34:07 网站建设 项目流程

很多刚开始接触 PostgreSQL 的人,第一次写函数是因为被同一段 SQL 反复逼疯了。要么是同一个计算逻辑要在七八个查询里各复制一遍,要么某个 INSERT 之后还得拖着十几个 UPDATE 和 DELETE,接口对接的时候还得小心翼翼地把整段 SQL 原文发给别人。我一开始也走的是“复制粘贴改表名”的路,直到某天半夜被需求方一个电话叫起来改一处入参,才下定决心把业务规则真正写进 PostgreSQL 函数里。

这篇内容就是一份从零到实战的函数编写记录。从为什么值得写函数,到最小可运行函数,再到变量、条件、循环、异常、动态 SQL,最后用 JSON 清洗和 CSV 批量处理的真实场景收尾。适合刚上手 PostgreSQL 的开发者、数据分析师,也适合想把自己那堆“面条 SQL”整理成可维护函数的老哥们参考。

1. 为什么要写函数:把 SQL 变成可以复用的接口

1.1 先从一段让我崩溃的 SQL 说起

我之前维护过一个报表系统,里面有一段“计算订单折扣后金额”的逻辑。这段逻辑散落在十几个查询里,每个查询的写法还能有细微差别:有的用COALESCE(discount, 0),有的直接discount,有的把折扣率除数和被除数写反了。每次业务规则一变,我就得把所有查询找出来,一个个改,改完还要祈祷没有漏网的。

后来需求方改了一次规则,少了三个地点的报表数据对不上,排查了整整一个下午。查到最后,原因就是某条查询里的旧逻辑忘了同步。从那天起我意识到,只要一段逻辑会被复用,它就应该是一个函数,而不是一段被拷贝的 SQL 文本。函数是接口,查询是调用方,接口不变,内部随便改。

1.2 函数能带来什么不能替代的好处

用 PostgreSQL 写函数,至少带来四个实打实的价值:

  • 封装业务规则,调用方只关心入参和出参,不关心内部表结构。
  • 强制统一校验逻辑,打折规则、格式校验、状态机变更只存在一处。
  • 可以被触发器调用,给数据变更自动挂上钩子,比如审计日志、自动填充字段。
  • 在数据库端完成数据加工,减少应用与数据库之间的往返次数,复杂计算跑在数据所在的地方。

还有一个容易被忽略的收益:函数天然自带“事务边界感”。一个函数内部的多个 SQL,默认在一个事务里执行,中途出错可以整体回滚。相比在外面逐条拼 SQL,函数让数据一致性有了容错兜底。

1.3 什么场景不该硬上函数

不是所有东西都应该写进数据库函数。如果业务规则特别复杂、需要调用外部 HTTP 接口、依赖文件系统或者批处理引擎,那该用外部服务还是用外部服务。数据库函数适合数据密集、规则稳定的逻辑,不太适合 I/O 密集和频繁变化的编排。这个边界想清楚,后面就不会写出“一个函数里套三个游标再调用两次网络请求”的怪物。

2. 最小可运行函数:从语法骨架到第一个例子

2.1 CREATE FUNCTION 的完整骨架

PostgreSQL 函数的创建语法看起来复杂,拆开其实就几块:

CREATE [OR REPLACE] FUNCTION 函数名(参数名 参数模式 参数类型, ...) RETURNS 返回值类型 LANGUAGE 语言名 [VOLATILE | STABLE | IMMUTABLE] [SECURITY INVOKER | SECURITY DEFINER] AS $$ 函数体 $$;

这里很容易被AS $$吓到,其实$$只是一个函数体定界符,用来告诉 PostgreSQL:“中间夹着的都是函数体内容,别当普通 SQL 解析。”你也可以用单引号包函数体,但$$的好处是函数体里还能放心写单引号,不会冲突。我习惯用$$,几乎不用单引号。

2.2 第一个函数:两个数相加

不管是什么语言,第一个程序都是加法。PostgreSQL 里最朴素的长这样:

CREATE OR REPLACE FUNCTION public.add_numbers( a INTEGER, b INTEGER ) RETURNS INTEGER LANGUAGE plpgsql AS $$ BEGIN RETURN a + b; END; $$;

调用方式也直接:

SELECT add_numbers(3, 5);

结果就是 8。就这么简单,一个函数已经能跑了。这里有个新手容易忽略的点:函数名和字段名一样,属于数据库对象命名空间里的一部分。如果表和函数同名,调用时可能会引起歧义,所以像add_numbers这种带语义的名字,比add这种过于简短的名字更好。

2.3 参数模式:IN、OUT、INOUT 的真实作用

初学者最容易懵的是IN、OUT、INOUT这三个关键字。其实它们决定参数的方向:

  • IN:输入参数,函数只读它,不能把它当回传通道。
  • OUT:输出参数,函数内部可以给它赋值,调用方在结果集里看到它。注意,只要有一个OUT参数,RETURNS就可以省略,因为你已经在参数列表里声明了输出结构。
  • INOUT:输入输出双向,既能传进来,也能通过它返回新值。

一个带OUT参数的例子:

CREATE OR REPLACE FUNCTION public.calc_rect( width NUMERIC, height NUMERIC, OUT area NUMERIC, OUT perimeter NUMERIC ) LANGUAGE plpgsql AS $$ BEGIN area := width * height; perimeter := 2 * (width + height); END; $$;

调用:

SELECT * FROM calc_rect(3, 4);

返回两列,area是 12,perimeter是 14。这种写法对“一个函数返回多个标量”的场景非常友好,比返回一个拼好的字符串强得多。

2.4 语言选择:plpgsql、sql 和 C,各干什么用

函数声明里的LANGUAGE决定了函数体用什么方言写。最常见的两个是plpgsql和sql:

  • LANGUAGE sql:函数体就是一条或一组纯 SQL 语句,适合简单查询的封装,性能好,没有过程控制能力。
  • LANGUAGE plpgsql:PostgreSQL 自带的存储过程语言,支持变量、条件、循环、异常,写复杂业务逻辑几乎都靠它。
  • LANGUAGE c:C 语言扩展,一般人用不上,需要编译成共享库,通常是扩展模块作者才会碰。

如果只是“把一条 SELECT 封装成函数”,用LANGUAGE sql就够了:

CREATE OR REPLACE FUNCTION public.count_users_by_status(status TEXT) RETURNS BIGINT LANGUAGE sql AS $$ SELECT count(*) FROM users WHERE status = count_users_by_status.status; $$;

注意我写了count_users_by_status.status,因为函数体内如果直接写status,可能与外部同名列产生歧义。PostgreSQL 的函数参数本身可以像字段一样被引用,但不加限定时会触发歧义错误。这个细节值得记下来。

3. 函数体里的编程细节:变量、条件、循环和游标

3.1 变量声明与赋值::= 和 SELECT INTO

函数里声明变量放在DECLARE段,给变量赋值用:=:

CREATE OR REPLACE FUNCTION public.demo_variables() RETURNS TEXT LANGUAGE plpgsql AS $$ DECLARE total_count INTEGER := 0; product_name TEXT; avg_price NUMERIC; BEGIN SELECT count(*), avg(price) INTO total_count, avg_price FROM products; SELECT name INTO product_name FROM products ORDER BY price DESC LIMIT 1; RETURN format('总数=%s, 均价=%s, 最贵商品=%s', total_count, avg_price, product_name); END; $$;

这里的关键是SELECT INTO。它的逻辑是“把查询结果的第一行数据灌进变量”,不是把整张表塞进变量。如果查询返回多行,只有第一行会被取走,后面的行会被静默丢弃;如果返回零行,变量维持原有的值,不会变成 NULL。想严格区分“没查到”和“查到了 NULL”,可以加FOUND判断,这个后面在异常部分一起讲。

3.2 条件分支:IF 与 CASE

plpgsql里条件分支有两种写法。第一种是过程式的IF:

IF score >= 90 THEN RETURN 'A'; ELSIF score >= 60 THEN RETURN 'B'; ELSE RETURN 'C'; END IF;

第二种是 SQL 风味的CASE,适合做简单值映射:

CASE status WHEN 'ACTIVE' THEN '启用' WHEN 'DISABLED' THEN '停用' ELSE '未知' END

IF和CASE都支持嵌套,但嵌套多了可读性很差。我的建议是:分支超过三个,就把判断逻辑抽成单独的私有函数,或者用CASE表达式替换连续IF。

3.3 循环的几种姿势:FOR、WHILE、FOREACH

plpgsql的循环大体有四类:

-- 数字范围 FOR i IN 1..10 LOOP RAISE NOTICE '当前 i=%', i; END LOOP; -- 遍历查询结果 FOR r IN SELECT id, name FROM users WHERE status = 'ACTIVE' LOOP RAISE NOTICE '处理 % %', r.id, r.name; END LOOP; -- WHILE 条件循环 WHILE counter < 100 LOOP counter := counter + 1; END LOOP; -- 遍历数组 FOREACH elem IN ARRAY arr LOOP RAISE NOTICE '数组元素=%', elem; END LOOP;

遍历查询结果用的FOR ... IN SELECT是最高频的写法,它会自动逐行处理查询结果,不需要手写游标的打开、抓取、关闭。绝大多数业务需求到这里就够用了,不必上真正的游标对象。

3.4 游标与分批处理

大表上必须分批处理时,显式游标才真正派上用场。它的逻辑像拿起一根吸管,一点一点吸:

CREATE OR REPLACE FUNCTION public.process_orders_in_batches() RETURNS VOID LANGUAGE plpgsql AS $$ DECLARE cur CURSOR FOR SELECT id FROM orders WHERE processed = FALSE; rec RECORD; batch_count INTEGER := 0; BEGIN OPEN cur; LOOP FETCH NEXT FROM cur INTO rec; EXIT WHEN NOT FOUND; UPDATE orders SET processed = TRUE WHERE id = rec.id; batch_count := batch_count + 1; IF batch_count % 1000 = 0 THEN RAISE NOTICE '已处理 % 条', batch_count; END IF; END LOOP; CLOSE cur; END; $$;

跑一百万行的表,用这种游标方式循环,速度不一定最优,但至少内存稳定可控。如果数据量更大、机器资源也更紧张,我会建议拆成“分段范围扫描”,用主键范围切分,而不是逐行游标。逐行游标适合规则复杂、每行都需要额外计算的场景;纯批量更新,一条UPDATE ... WHERE id IN (...)往往更高效。

4. 异常处理与动态 SQL:让函数在出错时可控

4.1 RAISE:不只是打印日志

RAISE有多个级别,最常用的是NOTICE、WARNING、EXCEPTION:

RAISE NOTICE '正在处理订单 %', order_id; RAISE EXCEPTION '订单 % 不存在', order_id;

RAISE NOTICE和RAISE WARNING只是输出信息,不会中断执行;RAISE EXCEPTION会直接抛出一个错误,中止当前事务,如果外面有EXCEPTION块就能捕获它。用%做占位符,后面跟多个变量,这个习惯比字符串拼接安全得多。

调试时,我经常在函数里临时塞几个RAISE NOTICE,把中间变量打出来。确认逻辑没问题之后再删掉。这个过程比想象中好用,因为它直接走数据库日志,不需要额外工具。

4.2 BEGIN...EXCEPTION 块

在函数里想捕获错误,用的是嵌套的BEGIN ... EXCEPTION ... END:

CREATE OR REPLACE FUNCTION public.safe_divide( a NUMERIC, b NUMERIC ) RETURNS TEXT LANGUAGE plpgsql AS $$ BEGIN RETURN (a / b)::TEXT; EXCEPTION WHEN division_by_zero THEN RETURN '除数不能为0'; END; $$;

捕获的核心是WHEN 错误名 THEN。错误名可以是系统定义的unique_violation、foreign_key_violation、division_by_zero、data_exception等。如果想捕获所有错误,可以写成WHEN OTHERS THEN,但我不建议无脑用OTHERS,因为它会把程序 bug 和数据规则冲突一起吞掉,排查问题的时候什么线索都留不下来。

在EXCEPTION块里还有一个SQLSTATE、SQLERRM这两个内置变量可用,分别给出错误码和错误描述,记录日志时很有用:

EXCEPTION WHEN OTHERS THEN RAISE NOTICE '出错,SQLSTATE=%,信息=%', SQLSTATE, SQLERRM; RETURN NULL;

4.3 动态 SQL:EXECUTE 与 format

函数里如果表名、字段名、条件都是动态拼出来的,就必须用EXECUTE:

CREATE OR REPLACE FUNCTION public.dynamic_count( tbl_name TEXT ) RETURNS BIGINT LANGUAGE plpgsql AS $$ DECLARE result BIGINT; BEGIN EXECUTE format('SELECT count(*) FROM %I', tbl_name) INTO result; RETURN result; END; $$;

这里format('%I', tbl_name)是关键。%I会把传入内容按数据库标识符处理,自动加双引号并转义,能有效防 SQL 注入。如果只是嵌入普通值,用%L表示字面量,会自动加单引号。很多写动态 SQL 的人踩坑就栽在忘了%I和%L的区别上,我一开始也是,以为format只是把字符串拼起来,直到传了一个带引号的表名直接语法错误。

5. 实战案例一:写一个 JSON 数据清洗函数

5.1 场景:日志表里的 JSON 字段越来越乱

我维护过一张订单日志表,里面有一个extra_info JSONB字段,最初设计得很美好,实际数据却越来越乱:有的记录没有channel字段,有的amount是字符串"100.50",有的status大小写不统一。下游取数时,每次都要写一长串COALESCE和CASE WHEN。于是干脆写了一个 JSON 清洗函数,把所有脏数据统一成规范结构。

5.2 函数代码与逐步解释

CREATE OR REPLACE FUNCTION public.clean_extra_info( raw_info JSONB ) RETURNS JSONB LANGUAGE plpgsql AS $$ DECLARE channel_text TEXT; amount_num NUMERIC; status_text TEXT; BEGIN -- 空值兜底 IF raw_info IS NULL OR raw_info = '{}'::JSONB THEN RETURN '{}'::JSONB; END IF; -- 渠道字段:取字符串并转为小写,缺省给默认值 channel_text := lower(COALESCE(raw_info ->> 'channel', 'unknown')); -- 金额字段:兼容数值和字符串两种形态 BEGIN amount_num := COALESCE( (raw_info ->> 'amount')::NUMERIC, 0 ); EXCEPTION WHEN others THEN amount_num := 0; END; -- 状态字段:标准化 status_text := upper(COALESCE(raw_info ->> 'status', 'PENDING')); RETURN jsonb_build_object( 'channel', channel_text, 'amount', amount_num, 'status', status_text, 'raw_keys', (SELECT jsonb_agg(key) FROM jsonb_object_keys(raw_info) AS k(key)) ); END; $$;

这个函数里有几个关键点值得展开。raw_info ->> 'channel'返回text类型,即使 JSON 里是数字,它也会先转成字符串;lower负责把ACTIVE、Active等统一成active。金额字段的清洗用了内层BEGIN...EXCEPTION,只在amount转换出错时兜底为 0,不会影响外层主要逻辑。最妙的是最后用jsonb_build_object直接构造规范 JSON,比手工拼字符串安全得多。

5.3 调用与效果验证

SELECT clean_extra_info('{"channel": "APP", "amount": "99.90", "status": "paid"}'::JSONB); SELECT clean_extra_info('{"amount": "abc"}'::JSONB);

第一条返回:

{"channel": "app", "amount": 99.9, "status": "PAID", ...}

第二条金额会被兜底成 0,status 变成PENDING,脏数据不再污染下游报表。jsonb_object_keys那个技巧是我后来加的,用于保留原始字段名列表,方便保留现场,方便排查哪些字段被清洗走了。

6. 实战案例二:CSV 导入后的批量处理函数

6.1 场景:从 COPY 开始,到自动加工结束

另一个高频场景是 CSV 导入。很多人用COPY命令把 CSV 一股脑倒进临时表,然后就开始写各种 SQL 清洗。清洗逻辑如果写在应用层,倒一次就要重复一次;如果写成函数,truncate + 导入 + 调用函数就能一气呵成。

表结构大概是:

CREATE TABLE temp_import ( id INT, name TEXT, tags TEXT, price NUMERIC );

tags字段在 CSV 里是逗号分隔的字符串,比如"手机,数码,二手",导入后需要拆成数组,还要做去重和排序。

6.2 批量处理函数实现

CREATE OR REPLACE FUNCTION public.process_imported_rows() RETURNS INTEGER LANGUAGE plpgsql AS $$ DECLARE processed_count INTEGER := 0; r RECORD; tag_array TEXT[]; BEGIN FOR r IN SELECT * FROM temp_import LOOP -- 拆分标签,去掉空白,去重并排序 SELECT array_agg(DISTINCT trim(elem)) INTO tag_array FROM unnest(string_to_array(r.tags, ',')) AS t(elem) WHERE trim(elem) <> ''; -- 写入正式表,冲突则更新 INSERT INTO products(id, name, tags, price, updated_at) VALUES (r.id, r.name, tag_array, r.price, now()) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, tags = EXCLUDED.tags, price = EXCLUDED.price, updated_at = now(); processed_count := processed_count + 1; END LOOP; RETURN processed_count; END; $$;

这个函数的重点不在循环,而在unnest(string_to_array(...))这套组合拳:string_to_array把逗号分隔的字符串变成 PostgreSQL 数组,unnest把数组展开成行,array_agg(DISTINCT trim(elem))把清洗后的行再聚合回数组。三步操作完成 CSV 里“a,b,a, c”到{a,b,c}的转换。

6.3 用触发器让导入后处理自动化

如果希望每次插入正式表前自动处理,很多人在导入流程后面手动调用函数。手动调用没问题,但有个更省事的方案:建一个 BEFORE INSERT 触发器,在插入前自动调用处理函数:

CREATE OR REPLACE FUNCTION public.before_insert_product() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.tags := ( SELECT array_agg(DISTINCT trim(elem)) FROM unnest(string_to_array(NEW.tags::TEXT, ',')) AS t(elem) WHERE trim(elem) <> '' ); RETURN NEW; END; $$; CREATE TRIGGER trg_product_before_insert BEFORE INSERT ON products FOR EACH ROW EXECUTE FUNCTION public.before_insert_product();

触发器和普通函数最核心的差异是返回类型:普通函数返回业务值,触发器函数返回TRIGGER,且必须操作NEW或OLD记录,最后返回NEW才表示“用修改后的行继续插入”。这个很容易忘,我第一次写的时候直接RETURN NULL,结果行没插进去,排查了半天才发现是RETURN写错了。

7. 性能、安全与权限:函数里容易踩的隐藏坑

7.1 VOLATILE、STABLE、IMMUTABLE 不是摆设

函数声明里的稳定级别,直接影响查询规划器能不能对它做优化。三个级别的含义:

级别含义例子优化能力
VOLATILE每次执行结果都可能不同now(), random()每次都要重新计算
STABLE在同一事务内结果稳定读取当前快照的查询可以多传一次参数,不能用于索引
IMMUTABLE输入相同则输出永远相同字符串拼接、数学运算可以用在索引表达式里

如果函数只做纯计算、不查表,比如add_numbers,声明成IMMUTABLE是对的。这样在创建表达式索引时,可以直接调用它:

CREATE INDEX idx_users_upper_name ON users (upper(name));

但有些人图省事,所有函数一律VOLATILE,导致查询优化器不敢缓存、不敢简化,性能白白损失。反过来,如果把依赖表的函数声明成IMMUTABLE,又可能导致优化器在错误位置缓存结果,出现数据已经更新旧值却还被使用的诡异问题。我的原则很简单:查表的函数用STABLE,纯计算不查表的用IMMUTABLE,涉及nextval、now()这些的才用VOLATILE。

7.2 SECURITY INVOKER 与 SECURITY DEFINER

SECURITY INVOKER是默认行为:函数以“调用者”的权限执行。SECURITY DEFINER则以“函数所有者”的权限执行。看起来只是权限归属,实际上差别巨大。

举个例子,普通用户app_user可能没有orders表的权限,但函数创建者admin有。如果把函数声明成SECURITY DEFINER,app_user就能通过函数查询订单。这很灵活,但也是高风险功能。函数里包含动态 SQL 时,一旦调用者能控制表名或条件,就容易变成提权入口。

我自己的经验是:默认用SECURITY INVOKER,只有明确要做“受限用户通过函数访问高权限表”这种场景,才用SECURITY DEFINER,并且在函数体里强制SET search_path = pg_catalog, public,防止调用者用搜索路径劫持对象名。

7.3 search_path 的坑

search_path决定了解析函数名、表名时按照什么顺序去找。函数里一旦没有显式指定,外部会话的search_path就会影响里面所有 SQL。攻击者如果能在public前面插入一个自己的 schema,并在里面放一个同名的表或函数,你的函数内部 SQL 就可能被劫持。

解法很直接:

CREATE OR REPLACE FUNCTION public.safe_function() RETURNS VOID LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ BEGIN -- 放心写业务逻辑 END; $$;

这句话写在AS之前,是函数级配置,调用时自动生效。不值得为省这几个字符去冒风险。

7.4 并发与锁的注意事项

函数里多表更新时,要注意锁的顺序。两个函数如果按相反的顺序更新同一组表,高并发下就可能死锁。比如functionA先更新orders再更新products,functionB先更新products再更新orders,两边同时跑就可能互相等锁。

另外一个隐藏很深的坑是SELECT FOR UPDATE。在函数里配合游标做“逐行处理并加锁”时,如果条件不是主键索引,PostgreSQL 会先锁整张表相关页,再逐步过滤,很容易把并发全打崩。我遇到过的情况是:批量任务一跑,业务侧所有更新全部排队,最后发现罪魁祸首就是游标里那条FOR UPDATE。

8. 调试技巧与常见坑:我在生产环境踩过的雷

8.1 用 RAISE NOTICE 做断点调试

没有 IDE 的时候,RAISE NOTICE就是最好的断点。写复杂函数时,我会在关键节点打日志:

RAISE NOTICE '进入函数,参数 a=%,b=%', a, b; RAISE NOTICE '查询结果行数=%', found_count;

用psql跑函数,前台能直接看到这些信息。如果挂到定时任务里,也能在 PostgreSQL 日志里翻到。调试完再决定保留还是删除,比拍脑袋改逻辑可靠得多。

8.2 CREATE OR REPLACE 的限制

CREATE OR REPLACE FUNCTION很好用,但它有一个硬限制:不能修改函数的参数个数、参数类型和返回值类型。想改函数签名,只能先DROP FUNCTION再CREATE FUNCTION。这个限制救过人,也坑过人。

我遇到过这样的情况:线上函数返回INTEGER,业务需求要改成NUMERIC。直接CREATE OR REPLACE报错,然后我手快删了函数,忘记还有两个视图依赖它。第二天上线才发现视图调用报函数不存在,回滚了一整天。现在我的做法是:凡是签名的变更,先确认依赖关系,用DROP FUNCTION ... CASCADE只在明确知道连带对象情况下才用,否则老老实实新建新签名的函数对象。

8.3 返回多行数据的三种姿势

函数返回多行,在 PostgreSQL 里有三种常见写法,初学者总在这晕:

  • RETURNS SETOF 表名:直接返回一张表的全部行,适合“过滤后的整行”场景。
  • RETURNS TABLE (字段定义...):返回自定义结构的多行,适合“不绑定具体表”的通用查询。
  • RETURNS SETOF 自定义复合类型:先 CREATE TYPE 再返回,适合反复复用的复杂结构。

一个RETURNS TABLE的例子:

CREATE OR REPLACE FUNCTION public.list_top_users(limit_n INT) RETURNS TABLE(user_id INT, user_name TEXT, order_cnt BIGINT) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT u.id, u.name, count(o.id)::BIGINT FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name ORDER BY count(o.id) DESC LIMIT limit_n; END; $$;

RETURN QUERY是plpgsql里快速返回查询结果的语法,不需要手动构造记录循环。这个函数调用时会输出三列,下游直接当表嵌套使用:

SELECT * FROM list_top_users(10);

8.4 几个常见错误信息解读

新手在写函数时,最常见的报错有三个,我都踩过:

  • function ... does not exist:函数签名不匹配。看清楚调用时的参数类型,加了引号的和没加引号的数字类型可能完全不同。
  • control reached end of function without RETURN:函数声明的返回类型非空,但函数体里没有RETURN语句,或者RETURN条件覆盖不全。
  • query has no destination for result data:在函数里执行了一条SELECT,但没把结果INTO到变量,plpgsql不允许这种裸查询。解决方法是加INTO,或者改成PERFORM语句。

最后一个特别容易出现在从 SQL 函数改成 plpgsql 函数的时候。原来LANGUAGE sql里写SELECT ...就是返回结果,改成plpgsql之后裸SELECT变成“结果无去处”,得改成RETURN QUERY SELECT ...,这个转换我每次都要提醒自己一遍。

写在最后

PostgreSQL 函数写起来不复杂,难得是把业务逻辑、性能、权限、并发这些要素一起装进一个函数里。我自己写函数的习惯是先写清楚输入和输出,再补错误处理,最后才上循环和动态 SQL。真要塞复杂逻辑,一次只加一个特性,加完立刻跑一次调用验证。靠着这个节奏,生产环境里那些最麻烦的批量处理和 JSON 清洗逻辑,反而成了最稳定、最不用回去改的代码。

如果这篇文章解决了你的问题,或者你踩过更隐蔽的函数坑,不用客气,直接在评论区把场景丢出来,咱们照着具体问题再聊一圈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询