PostgreSQL: PostgreSQL存储过程与PL/pgSQL

最后更新:2026-08-26

1. 你将学到


2. 故事

Bob 是电商平台的后端工程师。每天凌晨需要自动执行一系列数据任务:

  1. 计算前一天的销售额汇总
  2. 刷新物化视图 mv_daily_sales
  3. 如果计算出错,发送通知给运维团队

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 结构决策

100%
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 控制。

📖 小节


📝 作业

  1. ⭐ 编写函数 fn_get_product_price(p_id INT),使用 SELECT INTO 查询 products 表的 unit_price,如果不存在则返回 0。

  2. ⭐⭐ 编写函数 fn_customer_tier(p_customer_id INT),查询该客户的总消费金额,用 IF/ELSIF 返回 PLATINUM/GOLD/SILVER/BRONZE 等级(阈值分别为 10000/5000/1000 USD)。

  3. ⭐⭐⭐ 编写存储过程 sp_monthly_revenue_report(p_year INT, p_month INT):动态查询该月订单汇总,INSERT 到 monthly_reports 表,用 EXCEPTION 捕获错误写入 error_log,用 RAISE NOTICE 输出进度,并用 format 动态构建表名(如 orders_y2025m06)。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏