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 Visibility | STABLE / IMMUTABLE 使用调用查询开始时的快照 |
| 计划缓存 | Plan Caching | 预备语句与 plpgsql 复用计划,会固化计划期折叠出的旧值 |
三类对照(本节的核心表):
| 维度 | VOLATILE(默认) | STABLE | IMMUTABLE |
|---|---|---|---|
| 能否修改数据库 | 可以 | 不可以(只允许 SELECT) | 不可以(只允许 SELECT) |
| 同参数结果的稳定范围 | 每次调用都可能不同 | 单条语句内不变 | 永远不变 |
| 优化器常量折叠 | 不可以 | 不可以 | 可以 |
| 跨行复用(同一查询内只算一次) | 不可以,每行重算 | 可以 | 可以 |
| 用于索引扫描条件 | 可以写,但每行重算 | 可以(只算一次) | 可以 |
| 用于表达式索引 | 不可以 | 不可以 | 可以 |
| 用于生成列 | 不可以 | 不可以 | 可以 |
用于 CHECK 约束 | 不可以 | 不可以 | 可以 |
| MVCC 快照 | 每次执行取新快照 | 调用查询的快照 | 调用查询的快照 |
| 典型内置例子 | random()、nextval()、clock_timestamp() | now()、current_user、to_char(timestamptz, text) | abs()、upper()、md5() |
先记住这条:VOLATILE 是默认值,也是最保守的标注。默认值不代表「安全」,它代表放弃所有优化——不写易变性,等于把所有优化机会一并关掉。
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; 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 之后:
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;最小可运行示例——同一个加法,三种标注:
-- 纯计算:标 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() $$;三者在同一查询里的表现完全不同:
EXPLAIN (COSTS OFF)
SELECT * FROM demo.big WHERE bucket = demo.m_immutable(40, 2); QUERY PLAN
--------------------------------------
Index Scan using big_bucket_idx on big
Index Cond: (bucket = 42)
(2 rows)EXPLAIN (COSTS OFF)
SELECT * FROM demo.big WHERE bucket = demo.m_volatile()::integer * 0;函数调用留在过滤条件里,逐行执行——这就是「默认最保守」的代价。
详细说明
三类语义的准确定义
官方定义值得逐字记住(对照理解,不要只记名字):
| 类别 | 官方语义(意译) |
|---|---|
VOLATILE | 可以做任何事,包括修改数据库;用相同参数连续调用可以返回不同结果;优化器不做任何假设,结果在每一行需要时都会重新求值 |
STABLE | 不能修改数据库;保证在单条语句内用相同参数返回相同结果;允许优化器把多次调用合并为一次;可以安全地用于索引扫描条件 |
IMMUTABLE | 不能修改数据库;保证用相同参数永远返回相同结果;允许优化器在计划期就把常量参数的调用预先算好 |
文档里给的那句建议要照做:应该给函数标注「对它成立的最严格的那一类」。标注过弱只是慢,标注过强就是错。
默认是 VOLATILE
不写任何易变性关键字时,函数就是 VOLATILE:
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; proname | provolatile
---------+-------------
no_mark | v
(1 row)这意味着:一个纯计算函数如果忘了标注,就永远不可能被内联后的常量折叠优化到。这是自定义函数「上线后莫名变慢」的头号原因,因为创建时一切正常,问题只在被放进大查询时才暴露。
优化器对易变性的利用集中在四个方面:常量折叠、跨行复用、表达式索引与生成列、MVCC 快照。
利用一:常量折叠(只对 IMMUTABLE)
参数是常量、函数是 IMMUTABLE 时,函数在计划期就被算成字面量:
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); QUERY PLAN
---------------------------------------
Seq Scan on orders
Filter: (amount = 106.00)
(2 rows)Filter 里出现的是 106.00,函数名彻底消失。把它改成 STABLE,折叠不会发生(STABLE 只能在语句执行期求一次性值,不能在计划期定值):
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); QUERY PLAN
--------------------------------------------------------------------
Seq Scan on orders
Filter: (amount = tax_included_stable(100.00, 0.06))
(2 rows)区别在「计划期」与「执行期」:STABLE 版本每次执行还会调用一次函数(每行如果参数不同就更多),而 IMMUTABLE 版本连调用都不存在。
利用二:跨行复用(STABLE 与 IMMUTABLE)
STABLE 的承诺是「单条语句内同参数同结果」,所以优化器可以把一个参数恒定的 STABLE 调用提升出循环,只算一次:
-- 参数与行无关:优化器只调用一次
SELECT count(*) FROM demo.orders WHERE created_at >= demo.week_start();而 VOLATILE 函数没有这个保证,每一行都会重新调用:
-- 参数与行无关,但因为函数是 VOLATILE,每行都会重新调用
SELECT count(*) FROM demo.orders WHERE created_at >= demo.week_start_volatile();索引扫描条件下尤其致命
索引扫描只会在开始时算一次比较值。VOLATILE 函数参与索引条件时结果不可预测(每次调用可能不同),因此不能用于索引扫描条件的安全推演;STABLE 与 IMMUTABLE 才可以。这就是「日期边界函数必须标 STABLE」的原因。
利用三:表达式索引与生成列(只对 IMMUTABLE)
表达式索引与生成列的值会被持久化,一旦写入就不再随函数重算,因此 PostgreSQL 强制要求表达式必须是 IMMUTABLE:
-- 正确:IMMUTABLE 表达式可以建索引
CREATE INDEX idx_orders_email_lower ON demo.orders (lower(customer));-- 错误!STABLE 表达式不能建索引
CREATE INDEX idx_orders_week ON demo.orders (date_trunc('week', now()));ERROR: functions in index expression must be marked IMMUTABLE生成列的限制同理,报错文本不同:
-- 错误!生成列同样要求 IMMUTABLE
ALTER TABLE demo.orders
ADD COLUMN created_year integer
GENERATED ALWAYS AS (extract(year FROM now())::integer) STORED;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 承诺「永远同结果」,但表内容会变:
-- 错误!读表的函数标 IMMUTABLE:表内容变了,函数结果也该变
CREATE FUNCTION demo.tax_rate_bad() RETURNS numeric
LANGUAGE sql IMMUTABLE
AS $$ SELECT value FROM demo.settings WHERE key = 'tax_rate' $$;创建时 PostgreSQL 不会报错——它不检查函数体是否真的读了表。但后果有两层:
- 计划期被折叠成常量:
WHERE amount > 100 * demo.tax_rate_bad()可能在计划里就固化成WHERE amount > 6.00;settings改成0.13后,同一个预备语句仍然用6.00。 - 变成陈旧值:预备语句(
PREPARE)和 plpgsql 缓存的计划会一直复用第一次折叠出的值,直到计划失效。
-- 预备语句会固化折叠出的旧值
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:
-- 正确:只读、语句内稳定,标 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:
-- 错误!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()) $$;同类陷阱还有依赖配置参数的函数:
-- 错误!结果依赖 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') $$;-- 直观验证:同一个值,改 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; utc_out
---------------------
2025-12-31 16:00:00
(1 row) 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 会同时引发两个问题:结果被错误复用,以及写操作被拒绝。
-- 错误!STABLE 函数里不能做数据修改
CREATE FUNCTION demo.bump_bad() RETURNS bigint
LANGUAGE sql STABLE
AS $$ SELECT nextval('demo.call_seq') $$;
SELECT demo.bump_bad();ERROR: nextval() is not allowed in a non-volatile function改成 plpgsql 写表时,报错来自另一条规则:
-- 错误!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');ERROR: INSERT is not allowed in a non-volatile function官方对此的说法是:PostgreSQL 要求 STABLE 与 IMMUTABLE 函数只包含 SELECT,以阻止数据修改。注意这个检查不是绝对严密的——它拦不住「STABLE 函数调用会写库的 VOLATILE 函数」,此时写入虽然发生,但受快照影响,调用者是看不到的。
修改易变性
两种方式,效果相同:
-- 方式一: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:
| 值 | 含义 |
|---|---|
i | IMMUTABLE |
s | STABLE |
v | VOLATILE |
批量审计自定义函数的易变性,是把「标错」变成「可发现」的最有效手段:
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、表与造数据
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;CREATE SCHEMA
SET
DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
ANALYZE第二步:建立三种易变性的同一逻辑
-- 纯计算,标 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); QUERY PLAN
------------------------------------------------------------------
Aggregate
-> Index Only Scan using big_bucket_idx on big
Index Cond: (bucket = 42)
(3 rows) QUERY PLAN
------------------------------------------------------------------
Aggregate
-> Seq Scan on big
Filter: (bucket = f_stab(42))
(3 rows) QUERY PLAN
------------------------------------------------------------------
Aggregate
-> Seq Scan on big
Filter: (bucket = f_vol(42))
(3 rows)IMMUTABLE 版本被折叠成 bucket = 42,走上索引;另外两个版本都退化成全表扫描。这正是「标注决定计划」的直观证据。
第三步:EXPLAIN ANALYZE 量化差距
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*) FROM demo.big WHERE bucket = demo.f_imm(42); 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 msEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*) FROM demo.big WHERE bucket = demo.f_vol(42); 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 不会:
-- 模拟「今日起点」: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; QUERY PLAN
------------------------------------------------------------------
Aggregate
-> Seq Scan on big
Filter: (label > (date_trunc('day'::text, now()))::text)
(3 rows)STABLE 版本被内联后,now() 是稳定的,整个表达式在语句开始求值一次即可;而 VOLATILE 版本会以 f(...) 的形式留在 Filter 里逐行执行。
第五步:表达式索引只接受 IMMUTABLE
-- 正确:IMMUTABLE 表达式可以建索引,并且真的被用上
CREATE INDEX big_bucket_expr_idx ON demo.big ((bucket + 0));
EXPLAIN (COSTS OFF) SELECT * FROM demo.big WHERE (bucket + 0) = 42; QUERY PLAN
-----------------------------------------------------------------------------
Index Scan using big_bucket_expr_idx on big
Index Cond: ((bucket + 0) = 42)
(2 rows)-- 错误!用 STABLE 函数做索引表达式,建索引阶段直接失败
CREATE INDEX big_daystart_idx ON demo.big ((demo.day_start()));ERROR: functions in index expression must be marked IMMUTABLE第六步:审计当前库里的自定义函数
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; 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)
-- 把误标为 STABLE 的纯函数改成 IMMUTABLE
ALTER FUNCTION demo.f_stab(integer) IMMUTABLE;
SELECT proname, provolatile FROM pg_proc
WHERE proname = 'f_stab' AND pronamespace = 'demo'::regnamespace; proname | provolatile
---------+-------------
f_stab | i
(1 row)第八步:清理
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,并把它们用在索引条件里,让索引扫描只算一次:sqlCREATE 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函数,不要靠标注硬凑:sqlCREATE MATERIALIZED VIEW app.v_currency_rate AS SELECT currency, rate FROM app.fx_rate;上线前跑一次易变性审计,把
provolatile = 'i'且函数体含FROM的函数列出来重点复核:sqlSELECT 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说明易变性理由,让后来者知道为什么不能改:sqlCOMMENT 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断言,确保关键函数仍被折叠、仍走索引:sqlEXPLAIN (COSTS OFF) SELECT * FROM app.users WHERE app_normalize_email(email) = 'a@b.c';不敢确定时统一降到
STABLE。理由已在上文说明:STABLE的最坏情况只是慢,IMMUTABLE的最坏情况是错。
下一节