假设你正坐在数据高速公路的收费站里,手里攥着两种通行卡:一种是"本地户籍卡"(PL/pgSQL变量),另一种是"临时通行卡"(SQL参数)。同样都是通行凭证,但当车辆(查询请求)通过时,收费员(查询优化器)的检查方式却大不相同。今天我们就用具体示例打开PostgreSQL执行计划的魔法黑箱,看看这两类变量是如何影响查询性能的。


1. 知识小课堂:基础认知准备

1.1 PL/pgSQL变量的特性

-- 典型的存储过程变量声明
CREATE OR REPLACE FUNCTION calculate_order_stats(order_year INT)
RETURNS TABLE (total_amount NUMERIC, avg_price NUMERIC) AS $$
DECLARE
    base_rate CONSTANT NUMERIC := 0.95; -- 声明为常量
    dynamic_rate NUMERIC;               -- 普通变量
BEGIN
    SELECT discount_rate 
    INTO dynamic_rate                  -- 从表中获取值
    FROM rate_settings 
    WHERE year = order_year;

    RETURN QUERY 
    SELECT SUM(amount * base_rate), 
           AVG(unit_price * dynamic_rate)
    FROM orders 
    WHERE EXTRACT(YEAR FROM order_date) = order_year;
END;
$$ LANGUAGE plpgsql;

这些变量在编译阶段就会被确定数据类型,且在函数内部始终保持可见性。就像给物品贴上的标签,在进入仓库(函数执行)时就确定了存放的位置(内存地址)。

1.2 SQL参数的本质

-- 参数化查询示例(使用JDBC风格的占位符)
PREPARE order_query (INT) AS
SELECT product_id, COUNT(*) 
FROM order_details 
WHERE order_id IN (
    SELECT order_id 
    FROM orders 
    WHERE EXTRACT(YEAR FROM order_date) = $1
)
GROUP BY product_id;

EXECUTE order_query(2023);

这种参数在传输时就像通过快递包裹传输物品,每次执行都会带着新的包装箱(参数值)到仓库,但包裹的形状(参数类型)在打包时(准备语句时)就已经确定。


2. 真实案例大揭秘:执行计划差异全景观察

2.1 值传递引发的规划器困惑

-- 案例1:PL/pgSQL中的变量导致执行计划固化
CREATE OR REPLACE FUNCTION get_user_orders(user_id INT) 
RETURNS SETOF orders AS $$
DECLARE
    target_id ALIAS FOR $1;  -- 别名变量
BEGIN
    RETURN QUERY 
    SELECT * 
    FROM orders 
    WHERE user_id = target_id  -- 使用PL/pgSQL变量
    ORDER BY order_date DESC;
END;
$$ LANGUAGE plpgsql;

-- 使用不同参数值调用时:
EXPLAIN ANALYZE SELECT * FROM get_user_orders(123);
EXPLAIN ANALYZE SELECT * FROM get_user_orders(456);

-- 案例2:直接使用参数化查询
PREPARE dynamic_user_orders (INT) AS
SELECT * 
FROM orders 
WHERE user_id = $1 
ORDER BY order_date DESC;

-- 对比执行计划
EXECUTE dynamic_user_orders(123);
EXECUTE dynamic_user_orders(456);

PL/pgSQL版本的两个调用会产生完全相同的执行计划,即使实际数据分布差异很大(比如用户123有3条订单,用户456有300万条订单)。而参数化查询的执行计划会根据首次执行时的参数生成,但当参数值变化较大时,后续可能重新生成计划。

2.2 数值范围的魔法效应

-- 创建测试环境
CREATE TABLE sensor_data (
    id SERIAL PRIMARY KEY,
    sensor_id INT NOT NULL,
    record_time TIMESTAMPTZ NOT NULL,
    temperature NUMERIC(5,2)
);
INSERT INTO sensor_data (sensor_id, record_time, temperature)
SELECT n%100, 
       now() - (n || ' hours')::INTERVAL,
       (RANDOM()*100)::NUMERIC(5,2)
FROM generate_series(1,1000000) n;

-- PL/pgSQL函数示例
CREATE OR REPLACE FUNCTION get_sensor_records(sensor INT, days INT)
RETURNS SETOF sensor_data AS $$
BEGIN
    RETURN QUERY 
    SELECT *
    FROM sensor_data
    WHERE sensor_id = sensor              -- PL变量
      AND record_time > now() - (days || ' days')::INTERVAL;
END;
$$ LANGUAGE plpgsql;

-- 对比参数化查询
PREPARE sensor_query (INT, INT) AS
SELECT *
FROM sensor_data
WHERE sensor_id = $1
  AND record_time > now() - ($2 || ' days')::INTERVAL;

当首次调用函数时使用days=1(近期数据量少),即使后续调用days=365(全年数据),执行计划仍可能选择索引扫描而不是全表扫描,因为规划器认为时间条件的筛选率仍然很高。


3. 性能显微镜:技术细节深度解析

3.1 计划缓存的运作机制

当使用PL/pgSQL变量时,执行计划的缓存钥匙包含:

  • 函数对象ID
  • 参数类型列表
  • 不包含参数具体值

而参数化查询的计划缓存钥匙则包含:

  • 查询语句哈希值
  • 参数类型列表
  • 以及部分统计信息(当开启plan_cache_mode=auto时)

3.2 数据类型转换暗礁

-- 隐藏的类型转换陷阱示例
CREATE FUNCTION find_products(search_term VARCHAR) 
RETURNS SETOF products AS $$
DECLARE
    pattern TEXT := '%' || search_term || '%'; -- 隐式转换
BEGIN
    RETURN QUERY 
    SELECT *
    FROM products 
    WHERE description ILIKE pattern;
END;
$$ LANGUAGE plpgsql;

-- 改进方案:显式转换
CREATE FUNCTION find_products_v2(search_term TEXT)
RETURNS SETOF products AS $$
DECLARE
    pattern TEXT := '%' || search_term || '%';
BEGIN
    RETURN QUERY 
    SELECT *
    FROM products 
    WHERE description ILIKE pattern::TEXT; -- 确保类型一致
END;
$$ LANGUAGE plpgsql;

当传入参数类型与变量定义类型不匹配时(比如传入CHAR类型参数给VARCHAR变量),可能导致执行时产生隐式转换,不仅消耗额外资源,还会破坏索引使用条件。


4. 战场指南:生产环境的生存法则

4.1 必须使用PL变量的场景

  • 需要多次复用的计算逻辑
CREATE FUNCTION calculate_tax(order_id INT) 
RETURNS NUMERIC AS $$
DECLARE
    base_amount NUMERIC;
    tax_rate NUMERIC;
BEGIN
    SELECT total_amount 
    INTO base_amount 
    FROM orders 
    WHERE id = order_id;

    SELECT rate 
    INTO tax_rate 
    FROM tax_table 
    WHERE amount_range @> base_amount;

    RETURN base_amount * tax_rate; -- 两次计算使用同一变量
END;
$$ LANGUAGE plpgsql;
  • 需要保持事务一致性的操作
CREATE PROCEDURE transfer_funds(
    from_account INT, 
    to_account INT, 
    amount NUMERIC
) AS $$
DECLARE
    from_balance NUMERIC;
BEGIN
    SELECT balance 
    INTO from_balance 
    FROM accounts 
    WHERE id = from_account 
    FOR UPDATE;

    IF from_balance < amount THEN
        RAISE EXCEPTION '余额不足';
    END IF;

    UPDATE accounts 
    SET balance = balance - amount 
    WHERE id = from_account;

    UPDATE accounts 
    SET balance = balance + amount 
    WHERE id = to_account;
END;
$$ LANGUAGE plpgsql;

4.2 应该采用SQL参数的场景

  • WHERE条件中的筛选值
-- 动态SQL的示例
CREATE FUNCTION search_products(
    category_id INT DEFAULT NULL, 
    min_price NUMERIC DEFAULT 0,
    max_price NUMERIC DEFAULT 1000000
) RETURNS SETOF products AS $$
BEGIN
    RETURN QUERY EXECUTE '
        SELECT * FROM products 
        WHERE ($1 IS NULL OR category_id = $1)
          AND price BETWEEN $2 AND $3'
    USING category_id, min_price, max_price;
END;
$$ LANGUAGE plpgsql;
  • 分页查询中的偏移量
CREATE FUNCTION get_paged_orders(
    page_num INT, 
    page_size INT
) RETURNS SETOF orders AS $$
BEGIN
    RETURN QUERY EXECUTE '
        SELECT * FROM orders 
        ORDER BY order_date DESC 
        LIMIT $1 OFFSET $2'
    USING page_size, (page_num - 1) * page_size;
END;
$$ LANGUAGE plpgsql;

5. 终局启示录:最佳实践总结

5.1 决策流程图

  • 需要复用中间计算结果 → PL变量
  • 需要维护中间状态 → PL变量
  • WHERE条件中接收外部输入 → SQL参数
  • 需要利用参数嗅探优化 → SQL参数
  • 查询条件存在多种模式 → 动态SQL+参数

5.2 性能优化锦囊

  • 在事务块中重用函数调用
BEGIN;
SELECT * FROM get_user_orders(123) WHERE total_amount > 1000;
SELECT * FROM get_user_orders(123) WHERE status = 'pending';
COMMIT;
  • 强制重新生成执行计划
-- 对于参数化查询
DEALLOCATE sensor_query;
EXECUTE sensor_query(456);

-- 在PL/pgSQL函数中
EXECUTE '...' USING params; -- 动态SQL每次都会重新计划