PostgreSQL: PostgreSQL存储过程与PL/pgSQL
最后更新:2026-08-26
1. 你将学到
- CREATE FUNCTION / CREATE PROCEDURE(PG 区分函数与存储过程)
- PL/pgSQL 语法:变量、赋值、IF/CASE/LOOP/WHILE/FOR
- 参数模式:IN / OUT / INOUT / VARIADIC
- RETURN 与 RETURN QUERY
- 游标:CURSOR / REFCURSOR
- 异常处理:EXCEPTION / RAISE
- 触发器函数
- 动态 SQL:EXECUTE
2. 故事
Bob 是电商平台的后端工程师。每天凌晨需要自动执行一系列数据任务:
- 计算前一天的销售额汇总
- 刷新物化视图
mv_daily_sales - 如果计算出错,发送通知给运维团队
Bob 决定用 PL/pgSQL 写一个存储过程 sp_daily_sales_refresh(),将所有逻辑封装在数据库端,实现"一键执行"。
3. Concept:FUNCTION vs PROCEDURE
(1) PG 中函数与存储过程的区别
| 特性 | FUNCTION | PROCEDURE |
|---|---|---|
| 返回值 | 必须有 RETURNS | 无 RETURNS(可无返回) |
| 调用方式 | SELECT func() |
CALL proc() |
| 事务控制 | 不能内部 COMMIT/ROLLBACK | 可以内部 COMMIT/ROLLBACK |
| SQL 中使用 | 可在 SELECT/WHERE 中 | 不能嵌入 SQL |
| INOUT 参数 | 可用,自动成为返回列 | 可用,通过 CALL 传回 |
▶ 示例:CREATE FUNCTION 基础
SQL
CREATE OR REPLACE FUNCTION fn_get_order_count(p_customer_id INT)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count
FROM orders
WHERE customer_id = p_customer_id;
RETURN v_count;
END;
$$;
-- Call as a scalar in SELECT
SELECT fn_get_order_count(1001);
输出:
TEXT
📖 仅展示
count
-------
5
(1 row)
▶ 示例:CREATE PROCEDURE 基础
SQL
CREATE OR REPLACE PROCEDURE sp_reset_daily_stats()
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE TABLE daily_stats;
INSERT INTO daily_stats (stat_date, total_orders, total_revenue)
VALUES (CURRENT_DATE, 0, 0.00);
COMMIT;
END;
$$;
-- Call with CALL
CALL sp_reset_daily_stats();
输出:
TEXT
📖 仅展示
INSERT 0 1
(2) 何时用 FUNCTION vs PROCEDURE
| 场景 | 推荐 | 原因 |
|---|---|---|
| 计算并返回值 | FUNCTION | 可嵌入 SQL |
| 批量 ETL 操作 | PROCEDURE | 支持内部事务控制 |
| 触发器回调 | FUNCTION | 触发器只接受函数 |
| 定时调度任务 | PROCEDURE | 可分步提交 |
▶ 示例:带默认值的函数
SQL
CREATE OR REPLACE FUNCTION fn_calc_tax(
p_amount NUMERIC,
p_rate NUMERIC DEFAULT 0.08
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
RETURN ROUND(p_amount * p_rate, 2);
END;
$$;
SELECT fn_calc_tax(1000); -- use default 8%
SELECT fn_calc_tax(1000, 0.10); -- custom 10%
输出:
TEXT
📖 仅展示
CREATE TABLE
4. Concept:PL/pgSQL 基础语法
(1) 变量与赋值
SQL
DECLARE
v_name TEXT;
v_price NUMERIC(10,2) := 0.00;
v_count INT;
v_row orders%ROWTYPE; -- row type from table
v_status TEXT NOT NULL := 'pending';
BEGIN
v_name := 'Bob';
SELECT unit_price INTO v_price FROM products WHERE product_id = 1;
END;
| 变量声明方式 | 语法 | 说明 |
|---|---|---|
| 基本类型 | v_name TEXT; |
直接声明 |
| 默认值 | v_price NUMERIC := 0; |
:= 或 DEFAULT |
| 行类型 | v_row orders%ROWTYPE; |
匹配表结构 |
| NOT NULL | v_status TEXT NOT NULL := 'x'; |
必须赋初始值 |
▶ 示例:变量与 SELECT INTO
SQL
CREATE OR REPLACE FUNCTION fn_customer_summary(p_id INT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_name TEXT;
v_total NUMERIC(12,2);
v_msg TEXT;
BEGIN
SELECT first_name || ' ' || last_name, SUM(total_amount)
INTO v_name, v_total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.customer_id = p_id
GROUP BY c.customer_id, c.first_name, c.last_name;
v_msg := v_name || ': $' || COALESCE(v_total::TEXT, '0');
RETURN v_msg;
END;
$$;
输出:
TEXT
📖 仅展示
result
----------
42.50
(1 row)
(2) 条件语句
▶ 示例:IF/ELSIF/ELSE
SQL
CREATE OR REPLACE FUNCTION fn_discount_tier(p_total NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
IF p_total >= 10000 THEN
RETURN 'PLATINUM';
ELSIF p_total >= 5000 THEN
RETURN 'GOLD';
ELSIF p_total >= 1000 THEN
RETURN 'SILVER';
ELSE
RETURN 'BRONZE';
END IF;
END;
$$;
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:CASE 语句
SQL
CREATE OR REPLACE FUNCTION fn_order_priority(p_amount NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
CASE
WHEN p_amount >= 5000 THEN RETURN 'URGENT';
WHEN p_amount >= 1000 THEN RETURN 'HIGH';
WHEN p_amount >= 100 THEN RETURN 'NORMAL';
ELSE RETURN 'LOW';
END CASE;
END;
$$;
输出:
TEXT
📖 仅展示
CREATE TABLE
(3) 循环语句
| 循环类型 | 语法 | 适用场景 |
|---|---|---|
| LOOP | LOOP ... END LOOP; |
需手动 EXIT |
| WHILE | WHILE cond LOOP ... END LOOP; |
条件前置 |
| FOR (integer) | FOR i IN 1..10 LOOP ... END LOOP; |
固定次数 |
| FOR (query) | FOR rec IN SELECT ... LOOP ... END LOOP; |
遍历查询结果 |
▶ 示例:LOOP 与 EXIT
SQL
CREATE OR REPLACE FUNCTION fn_find_price_threshold(
p_target NUMERIC
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_limit INT := 10;
v_sum NUMERIC := 0;
v_id INT;
BEGIN
LOOP
SELECT unit_price INTO v_sum FROM products
WHERE product_id = v_limit;
v_sum := v_sum + p_target;
EXIT WHEN v_limit > 100 OR v_sum > p_target * 10;
v_limit := v_limit + 10;
END LOOP;
RETURN v_limit;
END;
$$;
输出:
TEXT
📖 仅展示
result
----------
42.50
(1 row)
▶ 示例:FOR 查询循环
SQL
CREATE OR REPLACE FUNCTION fn_sum_category_revenue()
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
v_rec RECORD;
v_total NUMERIC(14,2) := 0;
BEGIN
FOR v_rec IN
SELECT category, SUM(unit_price * stock_qty) AS cat_rev
FROM products
GROUP BY category
LOOP
v_total := v_total + v_rec.cat_rev;
RAISE NOTICE 'Category %: $%', v_rec.category, v_rec.cat_rev;
END LOOP;
RETURN v_total;
END;
$$;
输出:
TEXT
📖 仅展示
result
----------
42.50
(1 row)
5. Concept:参数模式
(1) IN / OUT / INOUT / VARIADIC
| 模式 | 传入 | 传出 | 说明 |
|---|---|---|---|
| IN(默认) | Yes | No | 只读参数 |
| OUT | No | Yes | 输出参数,自动成为返回列 |
| INOUT | Yes | Yes | 双向参数 |
| VARIADIC | Yes | No | 可变参数列表(数组) |
▶ 示例:OUT 参数函数
SQL
CREATE OR REPLACE FUNCTION fn_order_stats(
p_customer_id INT,
OUT o_count INT,
OUT o_total NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT COUNT(*), COALESCE(SUM(total_amount), 0)
INTO o_count, o_total
FROM orders
WHERE customer_id = p_customer_id;
END;
$$;
SELECT * FROM fn_order_stats(1001);
输出:
TEXT
📖 仅展示
count
-------
5
(1 row)
▶ 示例:INOUT 参数
SQL
CREATE OR REPLACE FUNCTION fn_apply_discount(
p_price INOUT NUMERIC,
p_rate NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
p_price := ROUND(p_price * (1 - p_rate), 2);
END;
$$;
-- Returns modified p_price as result
SELECT fn_apply_discount(99.99, 0.15);
输出:
TEXT
📖 仅展示
count
-------
5
(1 row)
(2) RETURN vs RETURN QUERY
| 语句 | 用途 | 返回类型 |
|---|---|---|
RETURN value; |
返回单个值 | 匹配 RETURNS 类型 |
RETURN NEXT row; |
逐行追加到结果集 | RETURNS SETOF |
RETURN QUERY SELECT ...; |
返回整个查询结果 | RETURNS SETOF/TABLE |
▶ 示例:RETURN QUERY 返回集合
SQL
CREATE OR REPLACE FUNCTION fn_orders_by_date(p_date DATE)
RETURNS SETOF orders
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT * FROM orders
WHERE order_date = p_date
ORDER BY order_id;
END;
$$;
SELECT * FROM fn_orders_by_date('2025-06-01');
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:RETURN TABLE 自定义返回结构
SQL
CREATE OR REPLACE FUNCTION fn_top_customers(p_limit INT)
RETURNS TABLE(
customer_name TEXT,
total_spent NUMERIC,
order_count INT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
c.first_name || ' ' || c.last_name,
SUM(o.total_amount)::NUMERIC,
COUNT(*)::INT
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.first_name, c.last_name
ORDER BY total_spent DESC
LIMIT p_limit;
END;
$$;
输出:
TEXT
📖 仅展示
count
-------
5
(1 row)
6. Concept:游标
(1) 显式游标与 REFCURSOR
| 游标类型 | 声明方式 | 适用场景 |
|---|---|---|
| 绑定游标 | CURSOR (query) FOR |
固定查询 |
| REFCURSOR | REFCURSOR |
动态查询,可传回调用方 |
| 隐式游标 | FOR rec IN SELECT | 简单遍历 |
▶ 示例:显式游标遍历
SQL
CREATE OR REPLACE FUNCTION fn_process_overdue_orders()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_cur CURSOR(p_date DATE) FOR
SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_date < p_date
AND order_status = 'pending';
v_rec RECORD;
v_count INT := 0;
BEGIN
OPEN v_cur(CURRENT_DATE - INTERVAL '7 days');
LOOP
FETCH v_cur INTO v_rec;
EXIT WHEN NOT FOUND;
UPDATE orders SET order_status = 'cancelled'
WHERE order_id = v_rec.order_id;
v_count := v_count + 1;
END LOOP;
CLOSE v_cur;
RETURN v_count;
END;
$$;
输出:
TEXT
📖 仅展示
count
-------
5
(1 row)
▶ 示例:REFCURSOR 返回游标
SQL
CREATE OR REPLACE FUNCTION fn_open_category_cursor(
p_category TEXT
)
RETURNS REFCURSOR
LANGUAGE plpgsql
AS $$
DECLARE
v_ref REFCURSOR;
BEGIN
OPEN v_ref FOR
SELECT product_id, product_name, unit_price
FROM products
WHERE category = p_category
ORDER BY unit_price DESC;
RETURN v_ref;
END;
$$;
BEGIN;
SELECT fn_open_category_cursor('Electronics');
-- Returns cursor name like "<unnamed portal 1>"
FETCH ALL FROM "<unnamed portal 1>";
COMMIT;
输出:
TEXT
📖 仅展示
CREATE TABLE
7. Concept:异常处理
(1) EXCEPTION 块
SQL
BEGIN
-- normal logic
EXCEPTION
WHEN OTHERS THEN
-- handle error
END;
| 异常条件 | 说明 |
|---|---|
NO_DATA_FOUND |
SELECT INTO 无行 |
TOO_MANY_ROWS |
SELECT INTO 多于一行 |
UNIQUE_VIOLATION |
唯一约束冲突 |
FOREIGN_KEY_VIOLATION |
外键冲突 |
DIVISION_BY_ZERO |
除零 |
OTHERS |
捕获所有异常 |
▶ 示例:EXCEPTION 捕获唯一约束冲突
SQL
CREATE OR REPLACE FUNCTION fn_safe_insert_product(
p_name TEXT, p_price NUMERIC, p_cat TEXT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO products (product_name, unit_price, category)
VALUES (p_name, p_price, p_cat);
RETURN 'OK';
EXCEPTION
WHEN unique_violation THEN
RETURN 'DUPLICATE: ' || p_name;
END;
$$;
输出:
TEXT
📖 仅展示
INSERT 0 1
(2) RAISE 通知与错误
| RAISE 级别 | 行为 |
|---|---|
DEBUG |
仅开发日志 |
LOG |
写入服务器日志 |
NOTICE |
客户端显示 |
WARNING |
客户端显示+日志 |
EXCEPTION |
抛出错误,回滚事务 |
▶ 示例:RAISE NOTICE 与 RAISE EXCEPTION
SQL
CREATE OR REPLACE FUNCTION fn_validate_order(p_amount NUMERIC)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'Invalid order amount: $%', p_amount;
ELSIF p_amount > 100000 THEN
RAISE WARNING 'Large order: $% - requires approval', p_amount;
ELSE
RAISE NOTICE 'Order validated: $%', p_amount;
END IF;
RETURN true;
END;
$$;
输出:
TEXT
📖 仅展示
CREATE TABLE
8. Concept:动态 SQL
(1) EXECUTE 用法
| 场景 | 语法 | 说明 |
|---|---|---|
| 执行动态 DDL | EXECUTE 'CREATE TABLE ...'; |
运行时构建 SQL |
| 带参数执行 | EXECUTE fmt USING v1, v2; |
参数化防注入 |
| 获取结果 | EXECUTE sql INTO v_var; |
存入变量 |
| 获取多行 | FOR rec IN EXECUTE sql LOOP |
遍历动态查询 |
▶ 示例:动态 SQL 按月分表
SQL
CREATE OR REPLACE FUNCTION fn_create_monthly_partition(
p_year INT, p_month INT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_sql TEXT;
v_table TEXT;
v_start DATE;
v_end DATE;
BEGIN
v_table := 'orders_y' || p_year || 'm' || LPAD(p_month::TEXT, 2, '0');
v_start := make_date(p_year, p_month, 1);
v_end := v_start + INTERVAL '1 month';
v_sql := format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders
FOR VALUES FROM (%L) TO (%L)',
v_table, v_start, v_end
);
EXECUTE v_sql;
RETURN v_table || ' created';
END;
$$;
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:动态查询带 USING
SQL
CREATE OR REPLACE FUNCTION fn_search_products(
p_col TEXT, p_val TEXT
)
RETURNS SETOF products
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE format(
'SELECT * FROM products WHERE %I = $1',
p_col
) USING p_val;
END;
$$;
SELECT * FROM fn_search_products('category', 'Electronics');
输出:
TEXT
📖 仅展示
CREATE TABLE
9. 流程图:PL/pgSQL 结构决策
flowchart TD
A[需要封装逻辑?] --> B{需要返回值?}
B -->|是| C[CREATE FUNCTION]
B -->|否| D{需要事务控制?}
D -->|是| E[CREATE PROCEDURE]
D -->|否| C
C --> F{返回单行/多行?}
F -->|单值| G[RETURN value]
F -->|多行| H{查询固定?}
H -->|是| I[RETURN QUERY SELECT]
H -->|否| J[FOR rec IN EXECUTE ... LOOP]
E --> K{需要动态SQL?}
K -->|是| L[EXECUTE format(...) USING]
K -->|否| M[静态 SQL 语句]
G --> N{可能出错?}
N -->|是| O[EXCEPTION block]
N -->|否| P[直接逻辑]
I --> N
style A fill:#e1f5fe
style O fill:#ffcdd2
style L fill:#c8e6c9
10. 综合示例
Bob 的每日销售汇总存储过程——计算销售额、刷新物化视图、异常通知:
SQL
CREATE OR REPLACE PROCEDURE sp_daily_sales_refresh()
LANGUAGE plpgsql
AS $$
DECLARE
v_yesterday DATE := CURRENT_DATE - INTERVAL '1 day';
v_order_count INT;
v_total_revenue NUMERIC(14,2);
v_avg_order NUMERIC(10,2);
BEGIN
RAISE NOTICE 'Start daily refresh for %', v_yesterday;
SELECT COUNT(*), COALESCE(SUM(total_amount), 0),
COALESCE(AVG(total_amount), 0)
INTO v_order_count, v_total_revenue, v_avg_order
FROM orders
WHERE order_date = v_yesterday
AND order_status = 'completed';
INSERT INTO daily_sales_summary
(stat_date, order_count, total_revenue, avg_order_value, created_at)
VALUES
(v_yesterday, v_order_count, v_total_revenue, v_avg_order, NOW())
ON CONFLICT (stat_date) DO UPDATE
SET order_count = EXCLUDED.order_count,
total_revenue = EXCLUDED.total_revenue,
avg_order_value = EXCLUDED.avg_order_value;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;
RAISE NOTICE 'Done: % orders, $% revenue, $% avg',
v_order_count, v_total_revenue, v_avg_order;
EXCEPTION
WHEN OTHERS THEN
INSERT INTO error_log (error_time, procedure_name, error_msg)
VALUES (NOW(), 'sp_daily_sales_refresh', SQLERRM);
RAISE NOTICE 'ERROR in daily refresh: %', SQLERRM;
-- Notify ops team via pg_notify
PERFORM pg_notify('ops_alerts',
'Daily sales refresh failed: ' || SQLERRM);
END;
$$;
❓ 常见问题
Q FUNCTION 和 PROCEDURE 最核心的区别是什么?
A PROCEDURE 可以在内部执行 COMMIT/ROLLBACK(自主事务),而 FUNCTION 不能。FUNCTION 必须有 RETURNS 且可在 SQL 中调用,PROCEDURE 用 CALL 调用。
Q SELECT INTO 没有匹配行时会怎样?
A 变量保持原值(不会设为 NULL)。如果需要检测"无数据",用
IF NOT FOUND THEN 或 EXCEPTION WHEN NO_DATA_FOUND。Q RETURN NEXT 和 RETURN QUERY 有什么区别?
A RETURN NEXT 每次追加一行到结果集,需配合循环使用;RETURN QUERY 一次性返回整个 SELECT 结果,更简洁。两者都要求 RETURNS SETOF。
Q 动态 SQL 为什么推荐 format + USING 而不是字符串拼接?
A format 的 %I 自动加引号处理标识符,%L 处理字面量,USING 参数化执行防 SQL 注入。直接拼接字符串有注入风险且需手动处理引号转义。
Q EXCEPTION 块会影响性能吗?
A 会。EXCEPTION 块会创建一个子事务保存点(savepoint),有额外开销。高频调用的热路径函数应避免不必要的 EXCEPTION 块。
Q REFCURSOR 游标在什么场景下有用?
A 当需要将查询结果延迟到调用方提取时,如应用层分批获取大数据集、存储过程返回游标让调用方决定如何遍历。注意游标必须在事务内使用。
Q PL/pgSQL 的 FOR rec IN SELECT 循环性能如何?
A 等同于服务端游标遍历,比应用层逐行 SELECT 高效得多。但大量数据操作优先考虑集合式 SQL(INSERT/UPDATE ... SELECT),循环只做集合 SQL 无法完成的逻辑。
Q RAISE NOTICE 的消息客户端一定能看到吗?
A 取决于客户端配置。psql 默认显示 NOTICE,但很多 ORM 和驱动默认忽略。可用 RAISE WARNING 或 log_min_messages 控制。
📖 小节
- PG 区分 FUNCTION(必须有返回值,可嵌入 SQL)和 PROCEDURE(无返回值,支持事务控制)
- PL/pgSQL 变量用
:=赋值,SELECT INTO取查询值,%ROWTYPE匹配行类型 - 条件:IF/ELSIF/ELSE 和 CASE 两种写法,CASE 更适合多分支
- 循环:LOOP(需 EXIT)、WHILE(前置条件)、FOR(固定次数或遍历查询)
- 参数模式:IN 只读,OUT 输出列,INOUT 双向,VARIADIC 可变参数
- RETURN 返回单值,RETURN NEXT/QUERY 返回集合
- 游标:绑定游标适合固定查询,REFCURSOR 适合动态查询和延迟提取
- EXCEPTION 块捕获错误但有子事务开销,RAISE 控制消息级别
- EXECUTE + format + USING 执行动态 SQL,防注入
📝 作业
-
⭐ 编写函数
fn_get_product_price(p_id INT),使用 SELECT INTO 查询 products 表的 unit_price,如果不存在则返回 0。 -
⭐⭐ 编写函数
fn_customer_tier(p_customer_id INT),查询该客户的总消费金额,用 IF/ELSIF 返回 PLATINUM/GOLD/SILVER/BRONZE 等级(阈值分别为 10000/5000/1000 USD)。 -
⭐⭐⭐ 编写存储过程
sp_monthly_revenue_report(p_year INT, p_month INT):动态查询该月订单汇总,INSERT 到 monthly_reports 表,用 EXCEPTION 捕获错误写入 error_log,用 RAISE NOTICE 输出进度,并用 format 动态构建表名(如orders_y2025m06)。