Skip to content

IMMUTABLE / STABLE / VOLATILE ​

同一个函数,标成 IMMUTABLE 还是 VOLATILE,执行计划可能相差几百倍——这不是玄学,而是优化器能否常量折叠、能否跨行复用结果、能否用于表达式索引的分水岭。本节讲清三类易变性(Volatility)的准确定义、优化器对每一类的具体假设、三种最常见的误标及其后果,并给出可落地的决策清单与 EXPLAIN ANALYZE 实测对比。

与「SQL 函数 vs PL/pgSQL 函数」的分工

SQL 函数 vs PL/pgSQL 函数 讲过语言决定函数能否被内联;本节讲易变性(Volatility)——同一门语言下,标注不同会让优化器的行为完全不同。标错易变性比选错语言更隐蔽,因为它不会报错:它只会悄悄地慢,或者悄悄地返回错误结果。

前置知识

阅读本节前,建议先了解:CREATE FUNCTION 基础、函数重载与可变参数

基础概念 ​

易变性(Volatility)是函数声明里对优化器的一个承诺(Promise):承诺这个函数的结果在多大范围内保持稳定。承诺越强,优化器能做的优化越多;一旦承诺与实际不符,得到的是错误结果,而不是报错。

概念术语说明
易变性Volatility函数结果随时间与行变化程度的分类
易变函数VOLATILE可以改库、每次调用都可能不同;优化器不做任何假设
稳定函数STABLE不改库,单条语句内同参数同结果,可优化成一次调用
不变函数IMMUTABLE不改库,同参数永远同结果,可预先求值
常量折叠Constant Folding计划期把常量参数的 IMMUTABLE 调用算成字面量
表达式索引Expression Index对表达式建索引,要求表达式必须 IMMUTABLE
快照可见性Snapshot VisibilitySTABLE / IMMUTABLE 使用调用查询开始时的快照
计划缓存Plan Caching预备语句与 plpgsql 复用计划,会固化计划期折叠出的旧值

三类对照(本节的核心表):

维度VOLATILE(默认)STABLEIMMUTABLE
能否修改数据库可以不可以(只允许 SELECT)不可以(只允许 SELECT)
同参数结果的稳定范围每次调用都可能不同单条语句内不变永远不变
优化器常量折叠不可以不可以可以
跨行复用(同一查询内只算一次)不可以,每行重算可以可以
用于索引扫描条件可以写,但每行重算可以(只算一次)可以
用于表达式索引不可以不可以可以
用于生成列不可以不可以可以
用于 CHECK 约束不可以不可以可以
MVCC 快照每次执行取新快照调用查询的快照调用查询的快照
典型内置例子random()、nextval()、clock_timestamp()now()、current_user、to_char(timestamptz, text)abs()、upper()、md5()

先记住这条:VOLATILE 是默认值,也是最保守的标注。默认值不代表「安全」,它代表放弃所有优化——不写易变性,等于把所有优化机会一并关掉。

sql
SELECT proname,
       provolatile,                                  -- i / s / v
       CASE provolatile
         WHEN 'i' THEN 'IMMUTABLE'
         WHEN 's' THEN 'STABLE'
         WHEN 'v' THEN 'VOLATILE'
       END AS volatility
FROM pg_proc
WHERE proname IN ('upper', 'now', 'random', 'md5', 'to_char')
ORDER BY proname;
text
 proname | provolatile | volatility
---------+-------------+------------
 md5     | i           | IMMUTABLE
 now     | s           | STABLE
 random  | v           | VOLATILE
 to_char | s           | STABLE
 upper   | i           | IMMUTABLE
(5 rows)

to_char 是 STABLE 而非 IMMUTABLE——因为它对带时区的时间戳做格式化时依赖 TimeZone 设置,而 TimeZone 在一个会话内可以变。这是「看起来纯计算、实际不是」的经典例子。

语法与用法 ​

易变性写在函数选项列表里,位置不影响语义,习惯放在 LANGUAGE 之后:

text
CREATE [ OR REPLACE ] FUNCTION 名称 ( 参数列表 )
RETURNS 返回类型
LANGUAGE 语言名
[ IMMUTABLE | STABLE | VOLATILE ]          -- 不写 = VOLATILE
[ CALLED ON NULL INPUT | RETURNS NULL ON NULL INPUT | STRICT ]
[ SECURITY INVOKER | SECURITY DEFINER ]
AS 函数体;

-- 事后修改(不用重建函数体)
ALTER FUNCTION 名称 ( 参数类型列表 ) IMMUTABLE;

最小可运行示例——同一个加法,三种标注:

sql
-- 纯计算:标 IMMUTABLE 才能被常量折叠
CREATE FUNCTION demo.m_immutable(a integer, b integer) RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT a + b $$;

-- 读表:一个语句内稳定,标 STABLE
CREATE FUNCTION demo.m_stable(p_key text) RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT value FROM demo.settings WHERE key = p_key $$;

-- 每次都不同:只能标 VOLATILE(也是默认值)
CREATE FUNCTION demo.m_volatile() RETURNS double precision
LANGUAGE sql VOLATILE
AS $$ SELECT random() $$;

三者在同一查询里的表现完全不同:

sql
EXPLAIN (COSTS OFF)
SELECT * FROM demo.big WHERE bucket = demo.m_immutable(40, 2);
text
              QUERY PLAN
--------------------------------------
 Index Scan using big_bucket_idx on big
   Index Cond: (bucket = 42)
(2 rows)
sql
EXPLAIN (COSTS OFF)
SELECT * FROM demo.big WHERE bucket = demo.m_volatile()::integer * 0;

函数调用留在过滤条件里,逐行执行——这就是「默认最保守」的代价。

详细说明 ​

三类语义的准确定义 ​

官方定义值得逐字记住(对照理解,不要只记名字):

类别官方语义(意译)
VOLATILE可以做任何事,包括修改数据库;用相同参数连续调用可以返回不同结果;优化器不做任何假设,结果在每一行需要时都会重新求值
STABLE不能修改数据库;保证在单条语句内用相同参数返回相同结果;允许优化器把多次调用合并为一次;可以安全地用于索引扫描条件
IMMUTABLE不能修改数据库;保证用相同参数永远返回相同结果;允许优化器在计划期就把常量参数的调用预先算好

文档里给的那句建议要照做:应该给函数标注「对它成立的最严格的那一类」。标注过弱只是慢,标注过强就是错。

默认是 VOLATILE ​

不写任何易变性关键字时,函数就是 VOLATILE:

sql
CREATE FUNCTION demo.no_mark(a integer) RETURNS integer
LANGUAGE sql
AS $$ SELECT a * 2 $$;

SELECT proname, provolatile
FROM pg_proc
WHERE proname = 'no_mark' AND pronamespace = 'demo'::regnamespace;
text
 proname | provolatile
---------+-------------
 no_mark | v
(1 row)

这意味着:一个纯计算函数如果忘了标注,就永远不可能被内联后的常量折叠优化到。这是自定义函数「上线后莫名变慢」的头号原因,因为创建时一切正常,问题只在被放进大查询时才暴露。

优化器对易变性的利用集中在四个方面:常量折叠、跨行复用、表达式索引与生成列、MVCC 快照。

利用一:常量折叠(只对 IMMUTABLE) ​

参数是常量、函数是 IMMUTABLE 时,函数在计划期就被算成字面量:

sql
CREATE FUNCTION demo.tax_included(p_amount numeric, p_rate numeric) RETURNS numeric
LANGUAGE sql IMMUTABLE
AS $$ SELECT round(p_amount * (1 + p_rate), 2) $$;

EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount = demo.tax_included(100.00, 0.06);
text
              QUERY PLAN
---------------------------------------
 Seq Scan on orders
   Filter: (amount = 106.00)
(2 rows)

Filter 里出现的是 106.00,函数名彻底消失。把它改成 STABLE,折叠不会发生(STABLE 只能在语句执行期求一次性值,不能在计划期定值):

sql
CREATE FUNCTION demo.tax_included_stable(p_amount numeric, p_rate numeric) RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT round(p_amount * (1 + p_rate), 2) $$;

EXPLAIN (COSTS OFF)
SELECT * FROM demo.orders WHERE amount = demo.tax_included_stable(100.00, 0.06);
text
                            QUERY PLAN
--------------------------------------------------------------------
 Seq Scan on orders
   Filter: (amount = tax_included_stable(100.00, 0.06))
(2 rows)

区别在「计划期」与「执行期」:STABLE 版本每次执行还会调用一次函数(每行如果参数不同就更多),而 IMMUTABLE 版本连调用都不存在。

利用二:跨行复用(STABLE 与 IMMUTABLE) ​

STABLE 的承诺是「单条语句内同参数同结果」,所以优化器可以把一个参数恒定的 STABLE 调用提升出循环,只算一次:

sql
-- 参数与行无关:优化器只调用一次
SELECT count(*) FROM demo.orders WHERE created_at >= demo.week_start();

而 VOLATILE 函数没有这个保证,每一行都会重新调用:

sql
-- 参数与行无关,但因为函数是 VOLATILE,每行都会重新调用
SELECT count(*) FROM demo.orders WHERE created_at >= demo.week_start_volatile();

索引扫描条件下尤其致命

索引扫描只会在开始时算一次比较值。VOLATILE 函数参与索引条件时结果不可预测(每次调用可能不同),因此不能用于索引扫描条件的安全推演;STABLE 与 IMMUTABLE 才可以。这就是「日期边界函数必须标 STABLE」的原因。

利用三:表达式索引与生成列(只对 IMMUTABLE) ​

表达式索引与生成列的值会被持久化,一旦写入就不再随函数重算,因此 PostgreSQL 强制要求表达式必须是 IMMUTABLE:

sql
-- 正确:IMMUTABLE 表达式可以建索引
CREATE INDEX idx_orders_email_lower ON demo.orders (lower(customer));
sql
-- 错误!STABLE 表达式不能建索引
CREATE INDEX idx_orders_week ON demo.orders (date_trunc('week', now()));
text
ERROR:  functions in index expression must be marked IMMUTABLE

生成列的限制同理,报错文本不同:

sql
-- 错误!生成列同样要求 IMMUTABLE
ALTER TABLE demo.orders
  ADD COLUMN created_year integer
  GENERATED ALWAYS AS (extract(year FROM now())::integer) STORED;
text
ERROR:  generation expression is not immutable

注意 date_trunc('week', now()) 这个例子——即使函数是 STABLE 的,now() 的结果也会在语句内变化(跨语句变化更不用说),所以它不可能成为索引键。

利用四:MVCC 快照可见性 ​

STABLE 与 IMMUTABLE 函数使用调用它的那条查询开始时建立的快照,VOLATILE 函数则在每次执行时取新快照:

类别快照行为后果
VOLATILE每次执行都取新快照能看到调用方在同一事务中刚做的修改
STABLE沿用调用查询的快照看不到调用方在同一查询中做的修改
IMMUTABLE沿用调用查询的快照同上

因为这条快照规则,只含 SELECT 的函数即使读取正在被并发修改的表,也可以安全地标 STABLE:它在整条语句中看到的是一个固定视图。

反过来,一个 STABLE 函数如果调用了会写库的 VOLATILE 函数,它看不到对方写入的结果——数据被快照挡住了。这是排查「函数读不到刚写入的数据」问题时的第一怀疑点。

误标一:读表函数标成 IMMUTABLE ​

这是最危险的一类错误。IMMUTABLE 承诺「永远同结果」,但表内容会变:

sql
-- 错误!读表的函数标 IMMUTABLE:表内容变了,函数结果也该变
CREATE FUNCTION demo.tax_rate_bad() RETURNS numeric
LANGUAGE sql IMMUTABLE
AS $$ SELECT value FROM demo.settings WHERE key = 'tax_rate' $$;

创建时 PostgreSQL 不会报错——它不检查函数体是否真的读了表。但后果有两层:

  1. 计划期被折叠成常量:WHERE amount > 100 * demo.tax_rate_bad() 可能在计划里就固化成 WHERE amount > 6.00;settings 改成 0.13 后,同一个预备语句仍然用 6.00。
  2. 变成陈旧值:预备语句(PREPARE)和 plpgsql 缓存的计划会一直复用第一次折叠出的值,直到计划失效。
sql
-- 预备语句会固化折叠出的旧值
PREPARE p_tax AS SELECT demo.tax_rate_bad() AS rate;

UPDATE demo.settings SET value = 0.13 WHERE key = 'tax_rate';

EXECUTE p_tax;   -- 仍然返回旧值 0.06,而不是 0.13

「标 IMMUTABLE 改表」是数据正确性事故

错误的结果比慢更可怕。一旦某个读表函数被标成 IMMUTABLE,所有依赖它的索引、生成列、预备语句都会带上一份已被固化的旧值。修复方式不是改函数标注了事,还必须 REINDEX 所有基于它的表达式索引、重建生成列。

正确做法是把读表的函数标 STABLE:

sql
-- 正确:只读、语句内稳定,标 STABLE
CREATE FUNCTION demo.tax_rate() RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT value FROM demo.settings WHERE key = 'tax_rate' $$;

误标二:把 now() 家族包进 IMMUTABLE ​

now() / current_timestamp 是 STABLE——它们在事务内不变(准确说:在事务开始时确定),但跨事务会变。因此任何以它们为输入的函数都只能是 STABLE:

sql
-- 错误!now() 跨事务会变,包进 IMMUTABLE 会得到冻结的时间
CREATE FUNCTION demo.day_start_bad() RETURNS timestamptz
LANGUAGE sql IMMUTABLE
AS $$ SELECT date_trunc('day', now()) $$;

-- 正确
CREATE FUNCTION demo.day_start() RETURNS timestamptz
LANGUAGE sql STABLE
AS $$ SELECT date_trunc('day', now()) $$;

同类陷阱还有依赖配置参数的函数:

sql
-- 错误!结果依赖 TimeZone 会话设置,不能标 IMMUTABLE
CREATE FUNCTION demo.fmt_local_bad(p_ts timestamptz) RETURNS text
LANGUAGE sql IMMUTABLE
AS $$ SELECT to_char(p_ts, 'YYYY-MM-DD HH24:MI:SS') $$;

-- 正确:与内置 to_char 保持一致,标 STABLE
CREATE FUNCTION demo.fmt_local(p_ts timestamptz) RETURNS text
LANGUAGE sql STABLE
AS $$ SELECT to_char(p_ts, 'YYYY-MM-DD HH24:MI:SS') $$;
sql
-- 直观验证:同一个值,改 TimeZone 后输出不同
SET TIME ZONE 'UTC';
SELECT demo.fmt_local('2026-01-01 00:00:00+08'::timestamptz) AS utc_out;

SET TIME ZONE 'Asia/Shanghai';
SELECT demo.fmt_local('2026-01-01 00:00:00+08'::timestamptz) AS shanghai_out;
text
      utc_out
---------------------
 2025-12-31 16:00:00
(1 row)
text
    shanghai_out
---------------------
 2026-01-01 00:00:00
(1 row)

结果随会话设置变化 → 它不是 IMMUTABLE。凡是结果依赖 TimeZone、DateStyle、search_path、client_encoding 的函数,一律 STABLE。

误标三:random() / nextval() 标成 STABLE ​

random()、nextval()、clock_timestamp() 每次调用都不同,必须是 VOLATILE。标成 STABLE 会同时引发两个问题:结果被错误复用,以及写操作被拒绝。

sql
-- 错误!STABLE 函数里不能做数据修改
CREATE FUNCTION demo.bump_bad() RETURNS bigint
LANGUAGE sql STABLE
AS $$ SELECT nextval('demo.call_seq') $$;

SELECT demo.bump_bad();
text
ERROR:  nextval() is not allowed in a non-volatile function

改成 plpgsql 写表时,报错来自另一条规则:

sql
-- 错误!STABLE 函数里出现 INSERT
CREATE FUNCTION demo.log_bad(p_tag text) RETURNS void
LANGUAGE plpgsql STABLE
AS $$
BEGIN
  INSERT INTO demo.call_log (tag) VALUES (p_tag);
END;
$$;

SELECT demo.log_bad('x');
text
ERROR:  INSERT is not allowed in a non-volatile function

官方对此的说法是:PostgreSQL 要求 STABLE 与 IMMUTABLE 函数只包含 SELECT,以阻止数据修改。注意这个检查不是绝对严密的——它拦不住「STABLE 函数调用会写库的 VOLATILE 函数」,此时写入虽然发生,但受快照影响,调用者是看不到的。

修改易变性 ​

两种方式,效果相同:

sql
-- 方式一:ALTER FUNCTION(推荐,不用重写函数体)
ALTER FUNCTION demo.tax_rate() STABLE;
ALTER FUNCTION demo.tax_included(numeric, numeric) IMMUTABLE;

-- 方式二:CREATE OR REPLACE(易变性不改变签名,可以替换)
CREATE OR REPLACE FUNCTION demo.tax_rate() RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT value FROM demo.settings WHERE key = 'tax_rate' $$;

对比一下「什么能改、什么不能改」:

改动项ALTER / OR REPLACE 能否生效说明
易变性能不改变签名
STRICT / CALLED ON NULL INPUT能不改变签名
SECURITY INVOKER / DEFINER能不改变签名
SET 子句(如 search_path)能不改变签名
函数体能不改变签名
参数名不能(OR REPLACE 报错)参数名虽不进签名,但替换时要求一致
参数类型不能(等于新建函数)属于签名
返回类型不能cannot change return type of existing function

目录里的易变性:provolatile ​

易变性在系统目录里是单字节字段 provolatile:

值含义
iIMMUTABLE
sSTABLE
vVOLATILE

批量审计自定义函数的易变性,是把「标错」变成「可发现」的最有效手段:

sql
SELECT n.nspname                     AS schema,
       p.proname                     AS function,
       pg_get_function_identity_arguments(p.oid) AS args,
       p.provolatile                 AS vol,
       l.lanname                     AS language,
       p.prosrc LIKE '%FROM%'        AS body_reads_table
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
JOIN pg_language  AS l ON l.oid = p.prolang
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY p.provolatile, n.nspname, p.proname;

provolatile = 'i' 而函数体里出现 FROM 的行,就是重点怀疑对象。这个查询抓不出全部问题,但能抓出最典型的那一类。

决策清单 ​

标注易变性时,按下面的问题顺序走,第一个「是」即定:

顺序问题结论
1会写库吗(INSERT/UPDATE/DELETE/nextval/setval)?VOLATILE
2结果会随每次调用变化吗(random()、clock_timestamp())?VOLATILE
3会读表、或 VOLATILE 函数吗?STABLE
4结果依赖 TimeZone / DateStyle / search_path 等会话或配置设置吗?STABLE
5会调用 now() / current_timestamp / current_user 吗?STABLE
6要在表达式索引、生成列、CHECK 约束里用吗?必须IMMUTABLE(且语义真的成立才能用)
7以上都不是,纯函数且只用参数?IMMUTABLE
8拿不准?先标 STABLE,绝不冒险标 IMMUTABLE

「拿不准就 STABLE」的原因

STABLE 最坏只是失去常量折叠;IMMUTABLE 标错则会产生已固化的错误值。两者风险不对称,所以保守方向是明确的:宁可 STABLE,不可 IMMUTABLE。

实战示例 ​

完整演练:建表造数、三份实现、EXPLAIN ANALYZE 实测、表达式索引行为、审计与清理。

第一步:建 schema、表与造数据

sql
CREATE SCHEMA IF NOT EXISTS demo;
SET search_path = demo, public;

DROP TABLE IF EXISTS demo.big;
CREATE TABLE demo.big (
  id     integer PRIMARY KEY,
  bucket integer NOT NULL,
  label  text    NOT NULL
);

INSERT INTO demo.big (id, bucket, label)
SELECT g, g % 100, 'row_' || g
FROM generate_series(1, 200000) AS g;

CREATE INDEX big_bucket_idx ON demo.big (bucket);
ANALYZE demo.big;
text
CREATE SCHEMA
SET
DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
ANALYZE

第二步:建立三种易变性的同一逻辑

sql
-- 纯计算,标 IMMUTABLE:可折叠
CREATE FUNCTION demo.f_imm(p_id integer) RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT p_id % 100 $$;

-- 同样的逻辑,标 STABLE:不能折叠
CREATE FUNCTION demo.f_stab(p_id integer) RETURNS integer
LANGUAGE sql STABLE
AS $$ SELECT p_id % 100 $$;

-- 同样的逻辑,标 VOLATILE:不能折叠,且逐行重算
CREATE FUNCTION demo.f_vol(p_id integer) RETURNS integer
LANGUAGE sql VOLATILE
AS $$ SELECT p_id % 100 $$;

EXPLAIN (COSTS OFF) SELECT count(*) FROM demo.big WHERE bucket = demo.f_imm(42);
EXPLAIN (COSTS OFF) SELECT count(*) FROM demo.big WHERE bucket = demo.f_stab(42);
EXPLAIN (COSTS OFF) SELECT count(*) FROM demo.big WHERE bucket = demo.f_vol(42);
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate
   ->  Index Only Scan using big_bucket_idx on big
         Index Cond: (bucket = 42)
(3 rows)
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate
   ->  Seq Scan on big
         Filter: (bucket = f_stab(42))
(3 rows)
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate
   ->  Seq Scan on big
         Filter: (bucket = f_vol(42))
(3 rows)

IMMUTABLE 版本被折叠成 bucket = 42,走上索引;另外两个版本都退化成全表扫描。这正是「标注决定计划」的直观证据。

第三步:EXPLAIN ANALYZE 量化差距

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*) FROM demo.big WHERE bucket = demo.f_imm(42);
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate (actual rows=1 loops=1)
   ->  Index Only Scan using big_bucket_idx on big (actual rows=2000 loops=1)
         Index Cond: (bucket = 42)
 Planning Time: 0.142 ms
 Execution Time: 12.503 ms
sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*) FROM demo.big WHERE bucket = demo.f_vol(42);
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate (actual rows=1 loops=1)
   ->  Seq Scan on big (actual rows=2000 loops=1)
         Filter: (bucket = f_vol(42))
         Rows Removed by Filter: 198000
 Planning Time: 0.169 ms
 Execution Time: 204.887 ms

同样的逻辑、同样 20 万行,Execution Time 从约 12 ms 变成约 205 ms,差一个数量级以上。注意 Rows Removed by Filter: 198000——这就是「每行都调用一次函数」的证据。

第四步:STABLE 的「单次求值」优势

带参数与行无关时,STABLE 会被提升出循环,VOLATILE 不会:

sql
-- 模拟「今日起点」:STABLE 版本在整条语句内只算一次
CREATE FUNCTION demo.day_start() RETURNS timestamptz
LANGUAGE sql STABLE
AS $$ SELECT date_trunc('day', now()) $$;

CREATE FUNCTION demo.day_start_vol() RETURNS timestamptz
LANGUAGE sql VOLATILE
AS $$ SELECT date_trunc('day', now()) $$;

EXPLAIN (COSTS OFF)
SELECT count(*) FROM demo.big WHERE label > demo.day_start()::text;
text
                            QUERY PLAN
------------------------------------------------------------------
 Aggregate
   ->  Seq Scan on big
         Filter: (label > (date_trunc('day'::text, now()))::text)
(3 rows)

STABLE 版本被内联后,now() 是稳定的,整个表达式在语句开始求值一次即可;而 VOLATILE 版本会以 f(...) 的形式留在 Filter 里逐行执行。

第五步:表达式索引只接受 IMMUTABLE

sql
-- 正确:IMMUTABLE 表达式可以建索引,并且真的被用上
CREATE INDEX big_bucket_expr_idx ON demo.big ((bucket + 0));

EXPLAIN (COSTS OFF) SELECT * FROM demo.big WHERE (bucket + 0) = 42;
text
                                 QUERY PLAN
-----------------------------------------------------------------------------
 Index Scan using big_bucket_expr_idx on big
   Index Cond: ((bucket + 0) = 42)
(2 rows)
sql
-- 错误!用 STABLE 函数做索引表达式,建索引阶段直接失败
CREATE INDEX big_daystart_idx ON demo.big ((demo.day_start()));
text
ERROR:  functions in index expression must be marked IMMUTABLE

第六步:审计当前库里的自定义函数

sql
SELECT p.proname                        AS function,
       pg_get_function_identity_arguments(p.oid) AS args,
       p.provolatile                    AS vol,
       l.lanname                        AS language
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
JOIN pg_language  AS l ON l.oid = p.prolang
WHERE n.nspname = 'demo'
ORDER BY p.provolatile, p.proname;
text
  function   |   args   | vol | language
-------------+----------+-----+----------
 day_start   |          | s   | sql
 day_start_vol |        | v   | sql
 f_imm       | p_id integer | i | sql
 f_stab      | p_id integer | s | sql
 f_vol       | p_id integer | v | sql
(5 rows)

第七步:改错标注(ALTER FUNCTION)

sql
-- 把误标为 STABLE 的纯函数改成 IMMUTABLE
ALTER FUNCTION demo.f_stab(integer) IMMUTABLE;

SELECT proname, provolatile FROM pg_proc
WHERE proname = 'f_stab' AND pronamespace = 'demo'::regnamespace;
text
 proname | provolatile
---------+-------------
 f_stab  | i
(1 row)

第八步:清理

sql
DROP INDEX demo.big_bucket_expr_idx;
DROP FUNCTION demo.f_vol(integer);
DROP FUNCTION demo.f_stab(integer);
DROP FUNCTION demo.f_imm(integer);
DROP FUNCTION demo.day_start_vol();
DROP FUNCTION demo.day_start();
DROP TABLE demo.big;
DROP SCHEMA demo CASCADE;   -- 若 demo 下还有其他对象

注意事项 ​

  • 不写易变性就是 VOLATILE。默认值是最悲观的一档,会阻断常量折叠与跨行复用。每一个自定义函数都应该显式标注。

  • IMMUTABLE 标错不报错,但会返回固化值。这类错误在测试环境几乎不可能发现,只在生产数据变更后才暴露。

  • 读表的函数永远不要标 IMMUTABLE,即使「这张表基本不变」。表会变、行会改、迁移会跑,IMMUTABLE 的承诺是「永远」。

  • 依赖会话设置的函数不能标 IMMUTABLE:TimeZone、DateStyle、search_path、client_encoding、IntervalStyle 全都算。

  • STABLE 与 IMMUTABLE 函数体内只能有 SELECT,出现 INSERT 报 INSERT is not allowed in a non-volatile function,调用 nextval() 报 nextval() is not allowed in a non-volatile function。

  • STABLE / IMMUTABLE 函数看不到调用方在同一查询内做的修改(快照被冻结)。写审计、写日志的逻辑必须放在 VOLATILE 函数里。

  • 表达式索引与生成列强制要求 IMMUTABLE,报错分别是 functions in index expression must be marked IMMUTABLE 与 generation expression is not immutable。

  • IMMUTABLE 表达式索引与生成列一旦被误标,修复时必须重建:先改函数标注,再 REINDEX 索引、重建生成列,否则固化值仍在。

  • 预备语句与 plpgsql 会缓存计划。IMMUTABLE 错误带来的陈旧值会一直复用到计划失效。这正是官方文档强调「标注 IMMUTABLE 但实际不变——在预备语句或会缓存计划的语言(如 PL/pgSQL)中是真危险」的原因。

  • VOLATILE 函数不能用于索引扫描条件的安全推演,它会逐行重算,把索引扫描退化成全表扫描。

  • ALTER FUNCTION ... IMMUTABLE 不会自动重建依赖对象,改完务必检查表达式索引与生成列并重建。

  • CREATE OR REPLACE 可以改易变性(不改变签名),但改参数名与返回类型仍会失败。

  • 内置函数的易变性不可更改。now() 就是 STABLE,random() 就是 VOLATILE,包一层自己的函数也改变不了底层语义。

  • STABLE 是最常用的安全档位。只读、无配置依赖、无时间依赖的函数标 IMMUTABLE;其余读表函数标 STABLE。

最佳实践 ​

  • 把「显式标注易变性」写进函数创建规范,并在代码评审中检查。这是投入产出比最高的一条。

    sql
    -- 模板:易变性紧跟在 LANGUAGE 后,一眼可见
    CREATE FUNCTION app_calc_fee(p_amount numeric) RETURNS numeric
    LANGUAGE sql
    IMMUTABLE
    AS $$ SELECT round(p_amount * 0.006, 2) $$;
  • 优先标 IMMUTABLE,只在「确实读表或依赖配置」时退到 STABLE,绝不在语义不成立时冒险:

    sql
    -- 纯函数:IMMUTABLE
    CREATE FUNCTION app_normalize_email(p_email text) RETURNS text
    LANGUAGE sql IMMUTABLE
    AS $$ SELECT lower(btrim(p_email)) $$;
    
    -- 读表:STABLE
    CREATE FUNCTION app_get_rate(p_currency text) RETURNS numeric
    LANGUAGE sql STABLE
    AS $$ SELECT rate FROM app.fx_rate WHERE currency = p_currency $$;
  • 把时间边界计算函数统一标 STABLE,并把它们用在索引条件里,让索引扫描只算一次:

    sql
    CREATE FUNCTION app_today_start() RETURNS timestamptz
    LANGUAGE sql STABLE
    AS $$ SELECT date_trunc('day', now()) $$;
    
    SELECT count(*) FROM app.events WHERE created_at >= app_today_start();
  • 对「读表但结果确实不变」的小表,用物化视图或缓存表代替 IMMUTABLE 函数,不要靠标注硬凑:

    sql
    CREATE MATERIALIZED VIEW app.v_currency_rate AS
    SELECT currency, rate FROM app.fx_rate;
  • 上线前跑一次易变性审计,把 provolatile = 'i' 且函数体含 FROM 的函数列出来重点复核:

    sql
    SELECT n.nspname, p.proname, p.provolatile
    FROM pg_proc AS p
    JOIN pg_namespace AS n ON n.oid = p.pronamespace
    WHERE p.provolatile = 'i'
      AND p.prosrc ~* '\mfrom\M'
      AND n.nspname NOT IN ('pg_catalog', 'information_schema');
  • 给函数写 COMMENT 说明易变性理由,让后来者知道为什么不能改:

    sql
    COMMENT ON FUNCTION app_get_rate(text) IS
      'STABLE:读取 app.fx_rate,随汇率表更新变化,禁止标 IMMUTABLE';
  • 修改 IMMUTABLE 相关标注后立即 REINDEX 受影响的表达式索引:

    sql
    -- 先改正函数标注,再重建依赖它的索引
    ALTER FUNCTION app_normalize_email(text) IMMUTABLE;
    REINDEX INDEX app_users_email_lower_idx;
  • 性能回归测试里带上 EXPLAIN 断言,确保关键函数仍被折叠、仍走索引:

    sql
    EXPLAIN (COSTS OFF) SELECT * FROM app.users WHERE app_normalize_email(email) = 'a@b.c';
  • 不敢确定时统一降到 STABLE。理由已在上文说明:STABLE 的最坏情况只是慢,IMMUTABLE 的最坏情况是错。

下一节

继续学习:SECURITY DEFINER / INVOKER

参考链接 ​