多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PostgreSQL存储过程入门:从语法到实战案例与常见坑

PostgreSQL存储过程入门:从语法到实战案例与常见坑 今天聊一个很实际的话题PostgreSQL里的存储过程到底怎么入门怎么写、怎么调、有哪些坑。网上教程零零散散讲环境的一堆、讲语法的又一堆但很少有一篇能直接照着抄、抄完能跑的。这篇的目的很简单就是让你从“听说过存储过程”到“能在项目里写出第一个能用的存储过程”。我会把环境准备、语法核心、三个亲手跑过的案例、常见的排查思路都捋一遍中间夹杂一些我自己踩过的坑尽量让每个知识点都有对应的代码和可以理解的解释。适合刚接触PostgreSQL、之前写过MySQL或Oracle存储过程想迁移过来、以及想在项目里把复杂SQL封装进数据库层的同学。前100字里我已经埋了关键词PostgreSQL存储过程下面直接上干货。1. 环境准备不能忽略版本、安装方式和工具选择1.1 版本选型的3个判断标准先回应一个很常见的问题PostgreSQL下载哪个版本才好这个问题没有标准答案但有几个很实际的判断标准尤其是今天聊存储过程这个主题版本直接决定你能不能用PROCEDURE。第一版本别太老至少11以上。PostgreSQL在11版本之前只有FUNCTION函数没有真正意义上的存储过程PROCEDURE。11开始引入了CREATE PROCEDURE和CALL语法让PostgreSQL终于能像Oracle、MySQL那样做事务控制更灵活的存储过程。如果你非要在一个PG 10的老库上写PROCEDURE那就只能退而求其次用函数但很多特性你体验不到。所以做新项目直接选当前稳定版比如PG 14、PG 15、PG 16都行别纠结“最新版是不是稳”这种问题PostgreSQL的稳定程度在开源数据库里是出了名的可靠。第二看你的部署方式。大多数开发者直接用apt、yum装个包就行或者用Docker拉官方镜像这是最省心的路径。为什么因为PostgreSQL的生态非常成熟官方仓库的包基本开箱即用日常开发根本不需要从源码编译。那什么时候你才需要接触Ubuntu源码编译PostgreSQL这套流程一般是你要做定制化扩展比如编译一个自己写的C扩展、或者要在特殊嵌入式环境跑、再或者你就是想彻底搞清楚编译参数时才值得折腾。源码编译的坑在于依赖库多、configure选项多还要自己设置安装目录的权限稍不注意就把系统里的PostgreSQL搞乱了。我的建议开发期用包管理器装研究源码编译放到专门的容器里去做。第三看周边工具的兼容性。比如你接入了某个ORM、某套BI工具它的驱动或版本对PG版本有硬性要求那你就要跟随这个约束。这个判断标准在工作里比“哪个版本新”更优先因为你可以等下游工具适配了再升级。1.2 环境搭建与调试工具如果你在Ubuntu上一条命令就能启动sudo apt update sudo apt install postgresql postgresql-contrib装完以后PostgreSQL默认会创建postgres超级用户通常要切到这个用户下才能操作数据库sudo -u postgres psql这里有个很关键的操作点别用root直接跑psql。PG的默认安全策略不允许你用系统root身份通过psql连接这是很多人刚开始会被卡住的地方。正确的是先切到postgres用户然后再执行psql。日常调试存储过程我强烈建议用官方自带的psql命令行工具而不是一上来就开图形化管理工具。为什么因为psql里能看到函数报错的完整堆栈、能直接执行\df查看函数列表、能方便地设置服务器变量这些在图形工具里往往要绕很多弯。等代码写稳了再连DBeaver、pgAdmin看表结构、做数据巡检那才是它们的强项。2. 存储过程语法核心先弄懂这几个基础结构2.1 FUNCTION与PROCEDURE看起来像用起来不一样很多从Oracle、MySQL转过来的同学一上来就会把PostgreSQL的FUNCTION和PROCEDURE混淆。这是入门阶段最大的认知障碍必须拆开说清楚。PostgreSQL里函数FUNCTION和存储过程PROCEDURE的核心区别有三点函数必须有返回值。你可以返回一个标量、一行、一个表但不能什么都不返回。存储过程则不需要返回值它本身可以被看成“一段有名字的事务代码块”可以只有副作用比如修改数据而没有返回结果。函数的调用方式是SELECT而存储过程的调用方式是CALL。刚开始总有人拿SELECT去调用一个PROCEDURE结果报错“不存在这样的函数”原因就是把两者混着用了。函数内部不能执行事务控制COMMIT、ROLLBACK而存储过程可以。这个区别特别重要。函数在设计上就被要求是“原子操作”的一部分事务边界由外面的调用方控制而存储过程自己就能管理事务边界更适合做“一系列增删改然后提交”这种动作。可以这么记函数更像一个“计算器”输入参数、返回结果不自己管提交存储过程更像一个“调度员”负责把多个步骤串起来自己判断什么时候提交、什么时候回滚。这里就牵扯到一个常见的迁移问题Oracle和MySQL的存储过程怎么搬过来Oracle的PL/SQL与PostgreSQL的PL/pgSQL语法本来就同源很多内置函数名称不一样但块结构、变量声明、异常处理非常接近只要熟悉两者差异迁移并不难。MySQL的存储过程用BEGIN...END包裹变量用DECLARE、赋值用SETPostgreSQL这块的写法略有不同赋值是:这些细节在下面实战案例里都会看到。2.2 PL/pgSQL代码块变量、赋值、控制流一次说清写PostgreSQL存储过程默认的语言是PL/pgSQL。它不是一个独立的编程语言而是建立在SQL之上的过程式扩展。它的代码块结构大概长这样CREATE OR REPLACE FUNCTION 函数名(参数列表) RETURNS 返回类型 LANGUAGE plpgsql AS $$ DECLARE -- 这里声明局部变量 变量名 类型; BEGIN -- 这里是函数主体逻辑 -- 可以写SQL、条件判断、循环、异常处理 END; $$;注意几个细节。DECLARE区块用来声明局部变量变量名推荐用v_开头参数名用p_开头这样可以避免变量名和表里的列名产生歧义。这个习惯听起来很小但在实际写代码时能省大量排查时间因为PL/pgSQL有个容易踩坑的特性如果变量名和列名重名在很多上下文里会优先解析成变量而不是列逻辑就会神秘出错。赋值用:而不是等号。等号在PL/pgSQL里更多用于比较运算和默认参数值。比如v_count INT : 0; v_count : v_count 1;条件判断最常用的是IF...THEN...ELSIF...ELSE...END IF。注意这里的关键字是ELSIF不是ELSEIFPython或MySQL写多了的人经常在这里写错。循环有LOOP、WHILE、FOR。用得最多的其实是FOR循环遍历查询结果FOR rec IN SELECT * FROM products WHERE stock 10 LOOP RAISE NOTICE 库存不足: %, rec.name; END LOOP;这里rec不需要提前声明直接当成行记录引用即可。这个模式后面案例里会用到。如果你是第一次接触PL/pgSQL我建议先不要试图记住所有语法而是把它当“带SQL能力的面包机”主干的逻辑安排是普通编程语言的思路碰到需要查数据的地方直接混写SQL碰到需要异常处理的地方扔进EXCEPTION块。这样一个心法就能应对大部分场景。2.3 异常处理RAISE EXCEPTION与EXCEPTION块一套能用的存储过程没异常处理几乎等于裸奔。PostgreSQL的异常处理有两层主动抛错用RAISE EXCEPTION它后面跟一个错误信息可以用百分号占位符把变量带进去。抛错的作用是中断当前执行并在事务里标记为需要回滚。捕获异常用EXCEPTION块如果BEGIN到EXCEPTION之间的语句出现错误就会被EXCEPTION块里的WHEN条件捕获。之后可以根据不同错误类型做不同处理比如记录日志、回滚某个中间步骤、或者转换成自定义错误信息再抛出去。典型例子BEGIN INSERT INTO orders(...) VALUES(...); EXCEPTION WHEN unique_violation THEN RAISE NOTICE 订单号重复走补单逻辑; -- 这里可以执行别的SQL END;这里补充一点经验不是所有地方都应该捕获异常。如果你本来就想让错误直接暴露给调用方就不要自己吞掉。捕获异常的目的是让系统能优雅降级、或者给用户更友好的提示而不是把一个需要人工介入的bug藏进日志里。这也是新人和老手的一个重要区别。3. 从0到1写3个能直接上手的案例光讲语法没用下面三个案例我都跑过可以直接抄走改改就能用。设计上由浅入深第一个是最简单的统计函数第二个是带参数校验和错误抛出的状态流转过程第三个是带事务控制的库存扣减与下单过程。3.1 案例一统计函数熟悉RETURNS与SELECT调用假设有个订单表orders字段有id、status、created_at。我现在想统计某个状态下的订单数量。用函数写就是CREATE OR REPLACE FUNCTION count_orders_by_status(p_status VARCHAR(20)) RETURNS INT LANGUAGE plpgsql AS $$ DECLARE v_count INT; BEGIN SELECT COUNT(*) INTO v_count FROM orders WHERE status p_status; RETURN v_count; END; $$;这段代码要拆开理解三件事。第一RETURNS INT规定了函数返回一个整数。这里也可以是RETURNS TABLE、RETURNS SETOF等后面案例我会用到返回表结构的情况。第二SELECT COUNT(*) INTO v_count把查询结果塞进局部变量这是PL/pgSQL里最常用的“取单值”手法。第三调用方式是SELECT不写CALLSELECT count_orders_by_status(PAID);在这个案例里我想强调一个初学者特别容易忽略的点函数返回类型和RETURN语句必须严格匹配。你RETURNS INTRETURN就必须是整数你想返回一行多列就得定义返回类型是RECORD、TABLE或一个复合类型。这个匹配关系搞错了后面会出现“返回结构不符合预期”的诡异报错排查起来很烦。3.2 案例二订单状态流转学会参数校验和错误抛出接下来写一个修改类的存储过程。需求很简单把某一个订单的状态改成新状态但前提是订单存在、且当前状态不能是终态比如已完成、已取消。这个例子会教会你两种最重要的错误处理手段条件校验和RAISE EXCEPTION。CREATE OR REPLACE PROCEDURE update_order_status( p_order_id BIGINT, p_new_status VARCHAR(20) ) LANGUAGE plpgsql AS $$ DECLARE v_current_status VARCHAR(20); BEGIN -- 取当前状态FOR UPDATE带行锁防止并发下状态被改 SELECT status INTO v_current_status FROM orders WHERE id p_order_id FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 订单 % 不存在, p_order_id; END IF; IF v_current_status IN (COMPLETED, CANCELLED) THEN RAISE EXCEPTION 订单 % 当前状态为 %不允许变更为 %, p_order_id, v_current_status, p_new_status; END IF; UPDATE orders SET status p_new_status, updated_at NOW() WHERE id p_order_id; COMMIT; RAISE NOTICE 订单 % 状态已更新为 %, p_order_id, p_new_status; END; $$;这里我用了SELECT INTO配合FOR UPDATE。为什么要锁行因为如果两个会话同时读到同一个订单的同一状态都去执行更新就可能产生“状态覆盖”的问题比如客服把已支付订单改成已发货另一边系统同时把它改成已退款最后状态很难说。FOR UPDATE就是在读的时候先把这一行锁住让后到的会话等待前一个事务结束。对订单这种高价值数据来说这个操作是必须的。另一块重点是IF NOT FOUND。PL/pgSQL里SELECT INTO如果没有查到数据会自动设置一个特殊的隐式状态IF NOT FOUND就能判断并主动抛错。这样调用方拿到的就不是“0行被更新”而是明确的错误信息订单不存在。调用方式如下CALL update_order_status(1001, SHIPPING);这里我特意放了COMMIT。前面讲过PROCEDURE可以控制事务而FUNCTION不能这就是一个活生生的例子。要注意的是在存储过程里写COMMIT必须非常克制如果你这个存储过程是被外面一个更大的事务调用的里面的COMMIT会提前把外部事务的一部分提交掉那外部事务就没办法整体回滚了。所以什么情况下该在PROCEDURE里写COMMIT我个人的标准是这个PROCEDURE本身就是业务的一个原子单位你确定调用方不会在事务中嵌套调用它才考虑把COMMIT放进去。如果被嵌在外部事务里的可能性比较大我建议写PROCEDURE但不写COMMIT事务边界完全交给调用方。3.3 案例三库存扣减与下单一个完整的事务过程第三个案例是个综合业务场景用户在商城下单时需要同时做库存扣减和订单创建两者必须要么都成功、要么都失败。这个逻辑在应用层也能做但在数据库层做成一个存储过程可以减少一次网络往返还能在数据库层面保证一致性。代码如下CREATE OR REPLACE PROCEDURE deduct_stock_and_create_order( p_product_id BIGINT, p_quantity INT, p_user_id BIGINT ) LANGUAGE plpgsql AS $$ DECLARE v_stock INT; v_price NUMERIC(10,2); v_order_id BIGINT; BEGIN -- 锁定商品行同时拿到价格与库存 SELECT stock, price INTO v_stock, v_price FROM products WHERE id p_product_id FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 商品不存在: %, p_product_id; END IF; IF p_quantity 0 THEN RAISE EXCEPTION 商品数量必须大于0当前值: %, p_quantity; END IF; IF v_stock p_quantity THEN RAISE EXCEPTION 库存不足当前库存: %需求: %, v_stock, p_quantity; END IF; -- 扣减库存 UPDATE products SET stock v_stock - p_quantity, updated_at NOW() WHERE id p_product_id; -- 创建订单 INSERT INTO orders(user_id, product_id, quantity, amount, status, created_at) VALUES (p_user_id, p_product_id, p_quantity, v_price * p_quantity, PAID, NOW()) RETURNING id INTO v_order_id; COMMIT; RAISE NOTICE 下单成功订单号: %, v_order_id; EXCEPTION WHEN OTHERS THEN ROLLBACK; RAISE; END; $$;这个案例的信息量比较大我挨个讲。先看“读库存再更新”的逻辑。SELECT...FOR UPDATE拿到的是当前事务版本的数据v_stock成为这个事务可见的库存快照。之后的UPDATE可以简写成UPDATE products SET stock stock - p_quantity但我特意写成v_stock - p_quantity原因是为了让“扣减后是否变负数”的可读性更强也方便你在调试时看到具体计算过程。这种写法在低并发场景完全没问题如果吞吐量极高还有更极端的优化方案比如直接UPDATE然后检查ROW_COUNT但那属于进阶内容入门阶段不建议一开始就追求那种手段。再看异常处理。BEGIN和EXCEPTION之间是正常流程一旦任何步骤出错比如库存不足、插入订单时外键违反、唯一约束冲突EXCEPTION WHEN OTHERS会捕获所有错误先ROLLBACK再重新RAISE把原始错误抛给调用方。这里有两个细节要提醒一是WHEN OTHERS别乱用因为它会把所有意料内外的错误都吞进来如果你只是在异常里记个日志然后RAISE那没问题但如果你想着“捕获所有错误然后忽略”这样绝对会埋雷。二是ROLLBACK之后一定要重新RAISE否则调用方看到的是“过程执行成功”数据却没变这是最可怕的假成功。这个过程的调用很简单CALL deduct_stock_and_create_order(101, 2, 10086);到这里三个案例覆盖了PostgreSQL存储过程的常见场景查询统计、状态更新、事务性写入。跑完这三个基本语法和调用方式你已经掌握了。4. 调用方式与调试技巧别只会SELECT4.1 CALL、SELECT、OUT参数怎么用调用方式这块容易混淆我认真理一遍。函数FUNCTION用SELECT调用可以嵌在SQL里当表达式用。比如SELECT product_id, count_orders_by_status(PAID) FROM products;这个写法在简单查询里很顺手但要注意函数嵌入查询时PostgreSQL的优化器不一定能把它下推到每个过滤条件里如果函数体比较重性能会比较差。这属于性能优化的话题后面有一节专门讲。存储过程PROCEDURE用CALL调用CALL传递参数有两种方式。第一种是位置传参第二种是指定参数名传参CALL update_order_status(1001, SHIPPING); CALL update_order_status(p_order_id 1001, p_new_status SHIPPING);位置传参的优点是简洁缺点是你必须记住参数的顺序命名传参的优点是代码可读性强、参数顺序无所谓。我更推荐在存储过程参数超过3个时用命名传参避免顺序写错导致灾难。另外还有一个OUT参数的概念它是用来让函数或过程把结果输出给调用方的。比如CREATE OR REPLACE FUNCTION get_product_name(p_id BIGINT, OUT p_name VARCHAR) LANGUAGE plpgsql AS $$ BEGIN SELECT name INTO p_name FROM products WHERE id p_id; END; $$;这里OUT参数p_name就是输出参数调用时写法如下SELECT * FROM get_product_name(101);注意当函数有OUT参数时RETURNS子句可以省略因为输出参数本身已经定义了函数返回结构。这一点在阅读别人写的代码时经常能看到。4.2 调试三板斧RAISE NOTICE、\df与EXPLAIN调试存储过程我离不开三个技巧。第一RAISE NOTICE。这是你自己在代码里埋的日志点可以把变量值打印到客户端。比如前面案例里写的RAISE NOTICE 下单成功订单号: %, v_order_id。运行时客户端会直接看到这行输出非常适合确认流程走到了哪一步。我建议新手在写过程时多插入几个RAISE NOTICE尤其是不确定分支会不会执行的时候。第二\df命令。在psql里执行\df可以列出所有函数的名称、参数、返回类型。想查看某个函数的源码可以输入\df 函数名或者直接用 \ef 函数名 打开编辑视图。比在很多客户端工具里到处找“查看函数定义”按钮高效得多。第三EXPLAIN ANALYZE。当存储过程变慢我要确认瓶颈在哪会先抽出来最核心的SQL单独执行EXPLAIN ANALYZE观察执行计划。比如一个存储过程里有一句UPDATE很慢那瓶颈很可能是表缺索引。先在过程外把SQL跑一遍确认它是不是慢点再决定要不要加索引这个思路能让你少走弯路。有一个常被忽略的小细节在客户端工具里RAISE NOTICE不一定每次都显示有些工具默认忽略服务器通知。如果在DBeaver里看不到RAISE NOTICE先别急着怀疑代码检查一下客户端的日志配置或者干脆切到psql里跑一次。这种“配置导致看不到日志”的问题排查起来比逻辑bug还磨人。5. 常见问题与排查思路5.1 新手最爱踩的5个坑我把这些年见到的、自己踩过的最典型的问题整理成了一张速查表尤其适合“报错后不知道怎么回事”的时候查。现象根本原因解决办法SELECT调用PROCEDURE报“函数不存在”把过程和函数混淆了PROCDURE用CALL调用FUNCTION用SELECT调用函数里写COMMIT报错函数不允许事务控制换成PROCEDURE或者把COMMIT挪到外围事务UPDATE 0行但代码没报错条件不匹配或变量名和列名冲突检查WHERE条件避免变量名跟列名同名用GET DIAGNOSTICS ROW_COUNTRAISE NOTICE看不到输出客户端没有显示服务器通知打开客户端通知选项或直接切到psql里跑存储过程里循环一条条INSERT慢到怀疑人生循环逐行处理开销太大能用一个SQL完成就用一个SQL循环只用来做必要的过程逻辑第3个坑我想重点展开一下。有次我在SQL脚本里写了一个UPDATE条件看起来没问题但实际影响0行调试了很久发现是我用了status作为变量名表里也有status列PL/pgSQL优先把status解析成变量导致WHERE status PAID变成了“拿变量值跟PAID比较”自然查不出东西。从那以后我强制自己规范命名参数、变量、列名绝不重名。这个教训在入门期尽早越早越好。还有一个很多人忽视的点是大小写与引号。PostgreSQL会把不带引号的标识符统一转成小写所以你在SQL里写OrderID实际存储的是orderid。如果你建表时用了双引号把名字写成OrderID那以后每次查询都必须带双引号否则就报“关系不存在”。这个规则在存储过程的参数和变量上同样适用。建议统一用小写下划线命名别给自己挖坑。5.2 函数性能问题当存储过程开始拖慢业务存储过程最大的性能风险是“把一个表的数据放在循环里挨个处理”。这种写法在应用代码里看着不明显在数据库里会被放大很多倍因为每一条SQL都有解析、计划、执行的完整开销。如果一个循环要执行上万次那就是上万次SQL执行即使在本地也会很慢。解决思路很简单能用一条SQL完成的事绝不用循环完成。比如我观察到一个场景有人要把表A的字段同步到表B用存储过程从A里逐条查出再UPDATE B跑了几十分钟。后来直接改成一段SQLINSERT ... ON CONFLICT DO UPDATE十几秒就结束了。性能优化的第二个点是别让存储过程成为查询里的高频调用点。比如你在一个几万行的结果集里每行都调用某个自定义函数数据库要为每一行运行一次函数这个开销在数据量大起来后非常吓人。适用场景是把函数用在聚合或批量计算里而不是逐行热路径上。性能优化的第三点是确认索引生效。存储过程里的WHERE条件、JOIN条件所在的列必须要有合适的索引。这个我建议用EXPLAIN ANALYZE直接看别靠猜。你可以在存储过程里临时加一行EXPLAIN ANALYZE吗不行但你可以把那句SQL抽出来单独跑分析完再放回去。6. 安全与性能建议我项目里的几个坚持6.1 权限控制别把敏感表直接暴露给调用层数据库层写存储过程有个天然优势你可以让应用程序只能调用存储过程而无法直接SELECT或修改底层表。PostgreSQL的权限模型支持这种设计。比如REVOKE ALL ON TABLE orders FROM app_user; GRANT EXECUTE ON PROCEDURE update_order_status(app_user);这样app_user这个角色没了orders表的直接访问权限但可以正常使用update_order_status这个存储过程。存储过程在执行时默认以调用者身份SECURITY INVOKER运行意味着它能不能操作orders表取决于调用者有没有权限。如果你想让过程有“绕过表权限”的能力可以把它改成SECURITY DEFINER但这时候必须格外小心等于让普通用户借着一个高权限的过程去操作数据库很容易变成权限提升漏洞。我的做法是能用SECURITY INVOKER就绝不用DEFINER。如果确实需要DEFINER那过程内部必须做严格的参数校验比如限定用户ID、限定角色而且必须拒绝动态SQL的拼接。第一件动态SQL是权限泄露的重灾区。如果在存储过程里用EXECUTE拼接用户输入那和SQL注入没有本质区别。千万别图方便把表名、字段名直接拼进动态SQL除非你能确保这些内容是白名单。6.2 粒度控制什么业务才值得写存储过程很多团队从ORM时代过来看见存储过程就下意识排斥这也可以理解。但我个人的经验是关键不是排斥不排斥而是要知道边界在哪里。我总结下来适合写存储过程的场景有这么几类多个数据表必须同时变更、且包含多步校验。比如库存扣减订单创建这种过程内部可以锁定行、校验、插入、抛出可读错误比应用层拼凑逻辑要清晰得多。大批量报表统计。在数据库层聚合远比把数据拉回应用层算要快。跨表的数据迁移或回流任务。比如每天定时从一个表清洗数据到另一个表写成存储过程配合定时任务非常稳定。不适合写存储过程的场景也很明确简单的CRUD、只有一张表的增删改查这种用ORM或SQL模板直接写维护成本更低也更容易做各种查询优化。存储过程不是万能的用在不合适的场景里会让SQL变得难调试、难迁移、甚至成为团队成员互相抱怨的源头。6.3 关于“要不要用存储过程”的个人体会我自己在几个项目里踩过一轮之后对存储过程的态度变得很务实它不是银弹但在关键业务路径上确实是很好的“强制约束器”。当你把库存扣减、订单创建、优惠券核销这些逻辑收进一个过程中时业务规则的入口就变成唯一一个调用方很难绕过规则去改数据。这个“来源单一”的价值容易被人低估可一旦出问题你就能体会到它有多省心。另外开源数据库社区里PostgreSQL的存储过程兼容性正在快速增长很多开源国产数据库比如openGauss本身也兼容PostgreSQL的PL/pgSQL语法。所以现在花时间学会这一套并不是学了一个“只有PG能用”的知识它未来在很多基于PG内核的产品里都能复用。这个底层思路能帮你走得更远。最后送一个小技巧写存储过程时先把需求拆成“输入参数、前置条件、核心操作、事务边界”四部分再动手写代码。这个拆法能让代码结构清晰很多也方便别人来review。把这个习惯保持住比记住任何一条语法都值钱。
返回列表