本章目录

第 8 章:物化与函数

8.1 物化

物化是指保存一个中间行集,以便再次使用,通常会使用多次。保存结果的作用范围可以不同:在一次查询内部、在一个会话中,或者作为数据库对象保存。

大多数执行器节点以流水线方式工作。父节点向子节点请求下一行,请求沿计划树向下传递。不过,有些操作需要保存输入,以便反复读取。PostgreSQL 为此使用 Materialize 节点。

只要物化后的行集能够放入该操作可用的内存额度中,就会保存在后端进程的本地内存里。如果超过这一额度,PostgreSQL 就会使用临时存储。temp_file_limit 限制单个 PostgreSQL 进程使用的临时文件空间,也适用于同一客户端会话中参与执行的各个并行工作进程。

下图中,上层 Nested Loop 无法通过高效的参数化访问获取内集合,而这个内集合本身又是另一个操作的结果。

Materialize 保存内集合的中间结果,供上层 Nested Loop 重复扫描;work_mem 限制内存,temp_file_limit 限制临时文件空间。
Materialize 保存内集合的中间结果,供上层 Nested Loop 重复扫描;work_mem 限制内存,temp_file_limit 限制临时文件空间。

如果对外集合的每一行都重新计算一次内集合,就会造成浪费。因此,PostgreSQL 可以将其物化一次,再反复扫描保存的行。

例如,查找这样的机场对:一个机场使用莫斯科时间,另一个位于北极圈以北。

EXPLAIN (costs off)
SELECT a1.city, a2.city
FROM airports a1, airports a2
WHERE a1.timezone = 'Europe/Moscow'
  AND abs(a2.coordinates[1]) > 66.652;

                         QUERY PLAN
------------------------------------------------------------
 Nested Loop
   -> Seq Scan on airports_data ml_1
       Filter: (abs(coordinates[1]) > '66.652'::double p...
   -> Materialize
       -> Seq Scan on airports_data ml
           Filter: (timezone = 'Europe/Moscow'::text)
(6 rows)

本例中,没有可用于内集合谓词的有效索引。复用这个很小的内集合结果,比针对外集合的每一行都重新扫描和过滤成本更低。

8.1.1 CTE 物化

公用表表达式 (CTE),也称为 WITH 子查询,是组织查询结构、改善可读性的一种好方法。与普通子查询不同,使用 CTE 不会导致很深的嵌套。1

只要条件允许,规划器就会将 CTE 折叠进父查询,而不是物化它。这样,规划器就可以选择最优的连接顺序。

默认情况下,以下情形中的 CTE 会被物化:

  • 主查询多次引用这个 CTE,因此其结果无须重复计算。这种情况下,可以通过 AS NOT MATERIALIZED 禁用物化。
  • CTE 有副作用,包括修改数据或调用 VOLATILE 函数,这种情况下不能禁用物化。其中,修改数据的 CTE 会完整执行一次;SELECT CTE 则通常按父查询的需要求值。

总可以通过 AS MATERIALIZED 强制物化。

CTE 物化与非物化方案:左侧单独计算并通过 CTE Scan 读取结果,右侧将 CTE 折叠进主查询,规划器可以重新安排连接顺序。
CTE 物化与非物化方案:左侧单独计算并通过 CTE Scan 读取结果,右侧将 CTE 折叠进主查询,规划器可以重新安排连接顺序。

来看一个只使用一次的 CTE:

EXPLAIN (costs off)
WITH q AS (
  SELECT r.route_no, a.airplane_code
  FROM routes r
    JOIN airplanes a ON a.airplane_code = r.airplane_code
)
SELECT *
FROM q
  JOIN seats s ON s.airplane_code = q.airplane_code
WHERE s.seat_no = '1A';

                      QUERY PLAN
-------------------------------------------------------
 Hash Join
   Hash Cond: (ml.airplane_code = s.airplane_code)
   -> Hash Join
       Hash Cond: (r.airplane_code = ml.airplane_code)
       -> Seq Scan on routes r
       -> Hash
           -> Seq Scan on airplanes_data ml
   -> Hash
       -> Seq Scan on seats s
           Filter: (seat_no = '1A'::text)
(10 rows)

计划中没有 CTE Scan:CTE 已经直接折叠进主查询。

强制物化:

EXPLAIN (costs off)
WITH q AS MATERIALIZED (
  SELECT r.route_no, a.airplane_code
  FROM routes r
    JOIN airplanes a ON a.airplane_code = r.airplane_code
)
SELECT *
FROM q
  JOIN seats s ON s.airplane_code = q.airplane_code
WHERE s.seat_no = '1A';

                       QUERY PLAN
---------------------------------------------------------
 Hash Join
   Hash Cond: (q.airplane_code = s.airplane_code)
   CTE q
     -> Hash Join
         Hash Cond: (r.airplane_code = ml.airplane_code)
         -> Seq Scan on routes r
         -> Hash
             -> Seq Scan on airplanes_data ml
   -> CTE Scan on q
   -> Hash
       -> Seq Scan on seats s
           Filter: (seat_no = '1A'::text)
(12 rows)

现在,CTE 作为独立单元生成计划,父查询通过 CTE Scan 读取其结果。如果一个计算成本很高的结果会被重复使用,物化通常是不错的选择。但如果父查询只需要一个大型 CTE 结果中的极小部分,物化的代价就可能非常高。

例如:

EXPLAIN (analyze, costs off, buffers)
WITH b AS (
  SELECT * FROM bookings
)
SELECT *
FROM b AS b1
  JOIN b AS b2 ON b1.book_ref = b2.book_ref
WHERE b2.book_ref = '000112';

                         QUERY PLAN
------------------------------------------------------------
 Nested Loop (actual time=228.999..229.000 rows=0.00 loo...
   Buffers: shared read=15856, temp written=9746
   CTE b
     -> Seq Scan on bookings (actual time=0.064..71.089 ...
         Buffers: shared read=15856
   -> CTE Scan on b b1 (actual time=228.999..228.999 row...
       Filter: (book_ref = '000112'::bpchar)
       Rows Removed by Filter: 2486710
       Storage: Disk  Maximum Storage: 77968kB
       Buffers: shared read=15856, temp written=9746
   -> CTE Scan on b b2 (never executed)
       Filter: (book_ref = '000112'::bpchar)
       Storage: Disk  Maximum Storage: 77968kB
 Planning:
   Buffers: shared hit=23 read=1
 Planning Time: 0.106 ms
 Execution Time: 232.733 ms
(17 rows)

父查询只查找一个键,却物化了整张 bookings 表。现在允许对这个 CTE 的两次引用分别进行优化:

EXPLAIN (analyze, costs off, buffers)
WITH b AS NOT MATERIALIZED (
  SELECT * FROM bookings
)
SELECT *
FROM b AS b1
  JOIN b AS b2 ON b1.book_ref = b2.book_ref
WHERE b2.book_ref = '000112';

                         QUERY PLAN
------------------------------------------------------------
 Nested Loop (actual time=0.020..0.020 rows=0.00 loops=1)
   Buffers: shared read=3
   -> Index Scan using bookings_pkey on bookings (actual...
       Index Cond: (book_ref = '000112'::bpchar)
       Index Searches: 1
       Buffers: shared read=3
   -> Index Scan using bookings_pkey on bookings booking...
       Index Cond: (book_ref = '000112'::bpchar)
       Index Searches: 0
 Planning:
   Buffers: shared hit=5
 Planning Time: 0.075 ms
 Execution Time: 0.028 ms
(13 rows)

规划器现在可以将键上的限制条件下推到两次扫描中,并使用主键索引。可以对比这一方案与前一个方案的 Buffers 值。

不过,如果 CTE 修改数据,无论如何都会物化:修改必须恰好执行一次。

8.1.2 临时表

如果数据需要由多条查询复用,就不能通过一个 CTE 为这些查询统一计算,但可以将数据放入临时表,再由这些查询使用。临时表具有几个有用的性质:2

  • 属于一个会话。
  • ON COMMIT 可以在事务结束时保留行、删除行,或者删除整张表。
  • 表中的数据不按普通表的方式记录 WAL。
  • 使用后端进程的本地缓冲区,而不是共享缓冲区。

这些性质并不意味着临时表没有成本。PostgreSQL 仍然需要创建系统目录条目和关系文件。

如果工作负载以很高的频率创建和删除临时表,就可能给系统目录和文件系统带来压力。历史上,有些应用生态采用过 PostgreSQL 补丁或专门的设计来减少这类开销。这是针对具体应用的工程问题,不能据此笼统地认为应当避免使用临时表。

temp_buffers 限制一个会话可用于临时关系的本地缓冲区内存。只有在会话首次访问临时表之前,才能更改它的值。

创建一张包含北半球机场的临时表:

CREATE TEMP TABLE airports_nh -- Northern Hemisphere
ON COMMIT PRESERVE ROWS -- default
AS SELECT *
FROM airports
WHERE coordinates[1] > 0;
SELECT 4328

这张表创建在临时模式中,可以通过 pg_temp 访问。

对刚填充完的临时表,在用于查询之前执行分析通常是有意义的。后端进程的本地内存不能由 autovacuum 进程访问,因此临时表的清理和分析必须手动执行。

对比分析前后的基数估算:

EXPLAIN SELECT *
FROM airports_nh;

                QUERY PLAN
------------------------------------------
 Seq Scan on airports_nh
   (cost=0.00..91.52 rows=2752 width=160)
(2 rows)

ANALYZE airports_nh;
EXPLAIN SELECT *
FROM airports_nh;

                QUERY PLAN
------------------------------------------
 Seq Scan on airports_nh
   (cost=0.00..107.28 rows=4328 width=63)
(2 rows)

8.1.3 物化视图

物化视图是以数据库关系形式存储、具有名称的查询结果。可以像查询普通的只读表一样查询它。3

物化视图上可以创建索引,但不能添加完整性约束;约束必须在基表上检查。PostgreSQL 也会为物化视图收集规划器统计信息。

与普通视图不同,基表变化时,物化视图中的行不会随之变化,必须显式刷新。

全量刷新的成本可能很高。PostgreSQL 核心目前不会在基表变化时,逐行增量维护物化视图。Yugo Nagata 开发的 pg_ivm 扩展,在它所支持的场景中实现了增量视图维护。4

基于 airports 创建物化视图:

CREATE MATERIALIZED VIEW airports_mv AS
SELECT * FROM airports;
SELECT 5501

创建索引:

CREATE UNIQUE INDEX ON airports_mv(airport_code);
EXPLAIN (costs off) SELECT *
FROM airports_mv
ORDER BY airport_code
LIMIT 3;

                         QUERY PLAN
------------------------------------------------------------
 Limit
   -> Index Scan using airports_mv_airport_code_idx on a...
(2 rows)

现在修改一个基表中的值。2025 年 8 月,玛纳斯国际机场的 IATA 代码从 FRU 改为 BSZ;我们可以利用示例数据库中的这项信息来演示。

UPDATE airports_data
SET airport_code = 'BSZ'
WHERE airport_code = 'FRU';
UPDATE 1

SELECT count(*)
FROM airports_mv
WHERE airport_code = 'BSZ';

 count
-------
     0
(1 row)

物化视图中保存的行没有变化。同步必须显式进行,刷新这个物化视图:

REFRESH MATERIALIZED VIEW CONCURRENTLY airports_mv;
SELECT count(*) FROM airports_mv
WHERE airport_code = 'BSZ';

 count
-------
     1
(1 row)

8.2 函数

8.2.1 稳定性类别

每个函数都有一个稳定性类别。这一类别是向优化器作出的、关于函数行为的承诺。5

VOLATILE 是默认类别。对于相同的参数,VOLATILE 函数在连续调用时可能返回不同结果,也可能产生副作用。PostgreSQL 不能假定两次调用可以相互替换。

STABLE 承诺:在同一条语句执行期间,对于相同的参数,函数始终返回相同的结果,并且不修改数据库。这样的函数可以在查询执行期间计算一次,然后复用计算出的值。

IMMUTABLE 的承诺更强:对于相同的参数,结果永远相同。因此,参数为常量的调用可以在规划阶段折叠。

这些类别只是允许优化器进行某些处理,并不规定精确的执行时机和次数。STABLE 并不意味着每个 STABLE 函数在所有情形下都恰好执行一次;它意味着在计划允许的情况下,优化器可以将参数相同的重复求值视为等价。

错误的标签可能产生错误结果。如果函数依赖时间、配置、表内容或其他会变化的状态,却被标记为 IMMUTABLE,就可能将一个过时的值固定在可复用的计划中。

为 random() 创建一个包装函数:

CREATE FUNCTION rnd() RETURNS float
LANGUAGE sql VOLATILE
RETURN random();

使用正确的 VOLATILE 标签时,谓词会对每一行求值:

EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

一次执行可能返回这十行中的任意子集:

SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

 generate_series
-----------------
               1
               3
               4
               9
              10
(5 rows)

现在向 PostgreSQL 提供一个不真实的声明:

ALTER FUNCTION rnd() STABLE;
EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

                      QUERY PLAN
------------------------------------------------------
 Result
   One-Time Filter: (rnd() > '0.5'::double precision)
   -> Function Scan on generate_series
(3 rows)

在这个计划中,STABLE 表达式成为 One-Time Filter,因此结果要么包含全部十行,要么一行也没有:

SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

 generate_series
-----------------
(0 rows)

将包装函数标记为 IMMUTABLE,会给规划器更大的处理空间:

ALTER FUNCTION rnd() IMMUTABLE;
EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

        QUERY PLAN
--------------------------
 Result
   One-Time Filter: false
(2 rows)

EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

           QUERY PLAN
---------------------------------
Function Scan on generate_series
(2 rows)

两次独立的规划可能将不同的随机结果固定下来。这些出人意料的计划不是 PostgreSQL 的错误,而是因为我们向优化器作出了不真实的承诺。

8.2.2 函数内联

对于满足条件的 SQL 语言函数,PostgreSQL 可以将函数体内联到外层查询中,标量函数和返回集合的函数都可以如此。6 两种情况都有许多限制:函数必须用 SQL 编写,只使用一条 SELECT 语句,并满足其他条件。标量函数不能访问数据库表,也不能调用稳定性类别更宽松的函数;返回集合的函数则必须是 STABLE 或 IMMUTABLE。

内联很重要,因为从规划器的角度看,它消除了函数边界。外层查询中的谓词有时就可以下推到函数体内部,连接顺序也可以跨越原有的函数边界进行调整。

简单标量 SQL 函数的函数体,可以在规划前直接替换进主 SQL 语句。此时,就没有函数调用开销。

对于标量 SQL 函数,限制条件包括稳定性类别必须一致。声明为 STABLE 的函数,如果函数体调用了 random() 这样的 VOLATILE 操作,就不能安全地内联。将 rnd() 标记为 STABLE:

ALTER FUNCTION rnd() STABLE;
EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

                      QUERY PLAN
------------------------------------------------------
 Result
   One-Time Filter: (rnd() > '0.5'::double precision)
   -> Function Scan on generate_series
(3 rows)

包装函数仍然出现在计划中。

恢复与实际行为相符的稳定性类别:

ALTER FUNCTION rnd() VOLATILE;
EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;

                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

现在,过滤条件引用的是 random(),直接调用它,绕过了 rnd() 包装函数。

8.2.3 调用返回集合的函数

当返回集合的函数作为表表达式出现在 FROM 中时,PostgreSQL 用 Function Scan 表示它。此时,会先物化函数返回的全部行,再传给父计划节点。这是当前实现的一个限制。

当返回集合的表达式出现在目标列表中时,PostgreSQL 使用 ProjectSet,可以按需生成行。SQL 函数、大多数用 C 编写的内置函数,以及返回迭代器的 PL/Python 函数,都可以这样工作。其他编程语言的函数,如果其语言实现支持同一接口,也可以采用这种方式。

使用 LIMIT 时,两者的差别可能非常大。在 FROM 中调用 generate_series:

EXPLAIN (analyze, costs off, buffers off)
SELECT * FROM generate_series(1,10_000_000)
LIMIT 10;

                         QUERY PLAN
------------------------------------------------------------
 Limit (actual time=290.495..290.497 rows=10.00 loops=1)
   -> Function Scan on generate_series (actual time=290....
 Planning Time: 0.025 ms
 Execution Time: 296.988 ms
(4 rows)

现在,将返回集合的函数放入目标列表:

EXPLAIN (analyze, costs off, buffers off)
SELECT generate_series(1,10_000_000)
LIMIT 10;

                         QUERY PLAN
------------------------------------------------------------
 Limit (actual time=0.002..0.003 rows=10.00 loops=1)
   -> ProjectSet (actual time=0.001..0.002 rows=10.00 lo...
       -> Result (actual time=0.000..0.000 rows=1.00 loo...
 Planning Time: 0.020 ms
 Execution Time: 0.010 ms
(5 rows)

第一个计划中,Function Scan 先获取函数的全部结果行,再应用 Limit。第二个计划中,ProjectSet 只生成十行,恰好满足 Limit 的请求,查询执行时间降低了几个数量级。Result 节点对应省略了 FROM 子句的情形,向父节点传递恰好一行。

Function Scan 与 ProjectSet:前者先保存函数结果,后者可通过支持按需返回的函数接口逐行生成;是否能按需返回还取决于函数语言的实现。
Function Scan 与 ProjectSet:前者先保存函数结果,后者可通过支持按需返回的函数接口逐行生成;是否能按需返回还取决于函数语言的实现。

这并不意味着所有函数语言都能以流式方式返回行。例如,PL/pgSQL 返回集合的函数目前会先物化全部结果,再将它们交给调用方的执行器接口。

CREATE FUNCTION plpgsql_rows() RETURNS SETOF integer
AS $$
BEGIN
  RETURN QUERY
    SELECT * FROM generate_series(1,10_000_000);
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

EXPLAIN (analyze, costs off, buffers off)
SELECT plpgsql_rows() LIMIT 10;

                         QUERY PLAN
------------------------------------------------------------
 Limit (actual time=652.500..652.502 rows=10.00 loops=1)
   -> ProjectSet (actual time=652.499..652.500 rows=10.0...
       -> Result (actual time=0.000..0.000 rows=1.00 loo...
 Planning Time: 0.020 ms
 Execution Time: 660.376 ms
(5 rows)

计划中仍然有 ProjectSet,但函数实现必须在返回第一行之前,先完成那些开销很大的工作。执行时间清楚地说明了这一点。

8.2.4 COST 与 ROWS

当函数不能内联时,规划器往往只能在很大程度上将它视为黑盒。CREATE FUNCTION 允许我们提供两项重要的估算信息。

COST 是预估执行成本,以 cpu_operator_cost 为单位。对于返回集合的函数,它表示每个返回行的成本。7

ROWS 是返回集合的函数预期返回的行数。

COST 与 ROWS 的估算信息示意:未指定时使用默认值,指定后采用相应的成本与行数;图中不是完整的 EXPLAIN 输出。
COST 与 ROWS 的估算信息示意:未指定时使用默认值,指定后采用相应的成本与行数;图中不是完整的 EXPLAIN 输出。

创建一个返回一周各天名称的 PL/pgSQL 函数:

CREATE FUNCTION days_of_week() RETURNS SETOF text
AS $$
BEGIN
  FOR i IN 7 .. 13 LOOP
    RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
  END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION
SELECT * FROM days_of_week();

 days_of_week
--------------
 Mon
 Tue
 Wed
 Thu
 Fri
 Sat
 Sun
(7 rows)

查询计划如下:

EXPLAIN SELECT * FROM days_of_week();

               QUERY PLAN
-----------------------------------------
 Function Scan on days_of_week
   (cost=0.25..10.25 rows=1000 width=32)
(2 rows)

服务器将这个函数结果的基数估算为 1000 行,而实际只有七行。

启动成本部分反映了函数成本估算:

SELECT 100 * current_setting('cpu_operator_cost')::float;

 ?column?
----------
     0.25
(1 row)

提高函数成本:

ALTER FUNCTION days_of_week COST 10000;
EXPLAIN SELECT * FROM days_of_week();

                QUERY PLAN
------------------------------------------
 Function Scan on days_of_week
   (cost=25.00..35.00 rows=1000 width=32)
(2 rows)

设置函数预期返回的行数:

ALTER FUNCTION days_of_week ROWS 10;
EXPLAIN SELECT * FROM days_of_week();

               QUERY PLAN
----------------------------------------
 Function Scan on days_of_week
   (cost=25.00..25.10 rows=10 width=32)
(2 rows)

得到的预估成本为:

SELECT 10000 * current_setting('cpu_operator_cost')::float
  + 10 * current_setting('cpu_tuple_cost')::float;

 ?column?
----------
     25.1
(1 row)

可以通过 pg_proc 或元命令查看这些值:

SELECT procost, prorows
 FROM pg_proc WHERE proname='days_of_week';

 procost | prorows
---------+---------
   10000 |      10
(1 row)

\sf days_of_week
CREATE OR REPLACE FUNCTION bookings.days_of_week()
 RETURNS SETOF text
 LANGUAGE plpgsql
 COST 10000 ROWS 10
AS $function$
BEGIN
  FOR i IN 7 .. 13 LOOP
    RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
  END LOOP;
END;
$function$

8.2.5 规划器支持函数

COST 和 ROWS 子句让我们以常量形式指定函数成本和行数。但固定值并不总是足够准确。8

在 PostgreSQL 中,一个目标函数可以关联一个规划器支持函数,根据目标函数的实参值,向规划器提供相应的信息。

规划器支持函数:固定 ROWS 对不同参数使用同一估算,fun_support 则可以为不同实参提供不同的行数估算。
规划器支持函数:固定 ROWS 对不同参数使用同一估算,fun_support 则可以为不同实参提供不同的行数估算。

支持函数可以提供的信息包括:

  • 函数成本的估算。
  • 返回行数的估算。
  • 与函数调用等价的表达式。

对于布尔函数,还可以提供:

  • 选择率估算。
  • 使用可索引操作符的等价谓词。

规划器支持函数使用 PostgreSQL 内部的规划器 API,目前必须用 C 编写。

8.2.6 并行安全标签

并非所有查询都能并行运行。规划器无法安全地推断用户定义函数可能产生的所有影响,因此并行执行依赖显式的函数标签。9

函数可以标记为:

  • PARALLEL UNSAFE:只要查询中出现这个函数,就不能生成并行计划。
  • PARALLEL RESTRICTED:可以参与使用并行执行的查询,但函数本身只能在并行组的领导者进程中执行。
  • PARALLEL SAFE:可以在并行工作进程中执行。

用户定义函数的默认标签是 PARALLEL UNSAFE。用户定义的聚合函数也需要指定并行安全标签。

所有基本的标准函数都是并行安全的。

SELECT proparallel, count(*)
FROM pg_proc
GROUP BY proparallel;

 proparallel | count
-------------+-------
 r           |   202
 s           |  3374
 u           |   109
(3 rows)

具体数量取决于已经安装的扩展和 PostgreSQL 的小版本。

psql 也会显示这个标签:

\x \df+ random numeric \x
Expanded display is on.
List of functions
-[ RECORD 1 ]-------+-------------------------
Schema              | pg_catalog
Name                | random
Result data type    | numeric
Argument data types | min numeric, max numeric
Type                | func
Volatility          | volatile
Parallel            | restricted
Owner               | postgres
Security            | invoker
Leakproof?          | no
Access privileges   |
Language            | internal
Internal name       | numeric_random
Description         | random numeric in range
Expanded display is off.

现在创建一个函数,计算一张机票所有航段的价格之和:

CREATE FUNCTION ticket_price(ticket_no char(13))
RETURNS numeric
LANGUAGE plpgsql STABLE PARALLEL SAFE
AS $$
BEGIN
  RETURN (SELECT sum(price)
          FROM segments s
          WHERE s.ticket_no = ticket_price.ticket_no
  );
END;
$$;
CREATE FUNCTION

在一个子查询中使用它,再将总额与另一个子查询得到的预订总金额比较:

EXPLAIN (costs off) SELECT
  (SELECT sum(ticket_price(ticket_no)) FROM tickets) =
  (SELECT sum(total_amount) FROM bookings);

                    QUERY PLAN
--------------------------------------------------
 Result
   InitPlan 1
     -> Finalize Aggregate
         -> Gather
             Workers Planned: 2
             -> Partial Aggregate
                 -> Parallel Seq Scan on tickets
   InitPlan 2
     -> Finalize Aggregate
         -> Gather
             Workers Planned: 2
             -> Partial Aggregate
                 -> Parallel Seq Scan on bookings
(13 rows)

函数标记为 SAFE 时,两个子查询都可以使用并行计划。

将它标记为 UNSAFE:

ALTER FUNCTION ticket_price PARALLEL UNSAFE;
EXPLAIN (costs off) SELECT
  (SELECT sum(ticket_price(ticket_no)) FROM tickets) =
  (SELECT sum(total_amount) FROM bookings);

           QUERY PLAN
---------------------------------
 Result
   InitPlan 1
     -> Aggregate
         -> Seq Scan on tickets
   InitPlan 2
     -> Aggregate
         -> Seq Scan on bookings
(7 rows)

一个 UNSAFE 函数,就会禁用整条语句的并行查询。

现在将它标记为 RESTRICTED:

ALTER FUNCTION ticket_price PARALLEL RESTRICTED;
EXPLAIN (costs off) SELECT
  (SELECT sum(ticket_price(ticket_no)) FROM tickets) =
  (SELECT sum(total_amount) FROM bookings);

                    QUERY PLAN
--------------------------------------------------
 Result
   InitPlan 1
     -> Aggregate
         -> Seq Scan on tickets
   InitPlan 2
     -> Finalize Aggregate
         -> Gather
             Workers Planned: 2
             -> Partial Aggregate
                 -> Parallel Seq Scan on bookings
(10 rows)

调用 RESTRICTED 函数的子查询必须在领导者进程中运行,而独立的 bookings 子查询仍然可以使用并行工作进程。

SAFE 标签不只是一个性能提示。错误的标签可能导致报错或错误结果。无法确定时,应使用限制更严格的标签。