视图与性能(视图展开)
很多人以为「把复杂查询包成视图会更慢」或「视图能加速查询」。两种说法都不对:视图在查询时会被展开(merge)成等价 SQL,性能与手写 SQL 几乎一致——但前提是展开真的发生了。本节讲透视图合并的机制、它被阻止的几种情况、EXPLAIN 里的 Subquery Scan 节点,以及如何定位「因视图未合并而变慢」的查询。
前置知识
阅读本节前,建议先了解:EXPLAIN 执行计划、子查询优化
基础概念
视图不是物化的,查询时被「展开」
PostgreSQL 在解析阶段把视图的 _RETURN 规则应用上去:把视图引用替换为视图的定义查询。这个过程叫「视图展开 / 子查询提升」(subquery pullup)。
查询: SELECT * FROM v_orders WHERE user_id = 42;
视图: v_orders = SELECT id, user_id, amount FROM orders WHERE status = 'paid';
展开后: SELECT * FROM (SELECT id, user_id, amount FROM orders WHERE status = 'paid')
WHERE user_id = 42;
优化器提升子查询后:
SELECT id, user_id, amount FROM orders
WHERE status = 'paid' AND user_id = 42;关键结论:如果提升成功,谓词 user_id = 42 会被下推到基表,索引照样能用,性能和直接写 SQL 完全一样。
视图本身不产生性能开销,也不带来加速
| 说法 | 正确性 |
|---|---|
| 「视图会拖慢查询」 | 错误。展开成功时几乎零开销 |
| 「视图能加速查询」 | 错误。它只是一段被命名的 SQL |
| 「视图能建索引」 | 错误。索引只能建在基表上 |
| 「视图展开一定会发生」 | 错误。多种情况会阻止合并 |
真正的性能问题只来自两处:
- 视图合并被阻止 → 谓词无法下推 → 优化器只能先算出完整结果再过滤。
- 视图定义本身写得差(如基表缺索引、用了低效写法)。
何时会「合并」,何时不会
| 视图/子查询的结构 | 能否被提升(合并) |
|---|---|
单表 / 多表 JOIN + 普通 WHERE | 通常可以 |
含 LIMIT / OFFSET / FETCH FIRST | 不可以 |
含 DISTINCT / DISTINCT ON | 不可以 |
含 GROUP BY / 聚合函数 | 不可以 |
| 含窗口函数 | 不可以 |
含 UNION / INTERSECT / EXCEPT | 不可以 |
含 WITH(CTE),且 CTE 被多次引用 | 部分情况不可以 |
含 WITH ... AS MATERIALIZED | 不可以(显式禁止) |
视图带 security_barrier | 不可以(安全优先,显式阻止) |
不合并时,视图/子查询会被「拉出」成一个独立计划节点,EXPLAIN 里表现为 Subquery Scan。
Subquery Scan 节点意味着什么
-- 合并成功(好)
Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
-- 未合并(需警惕)
Subquery Scan on v_orders
Filter: (v_orders.user_id = 42)
-> Seq Scan on orders
Filter: (status = 'paid')看到 Subquery Scan 就要问一句:这个 Filter 能不能下推? 若不能下推,基表可能被全量扫描。
语法与用法
-- 视图定义(决定是否可合并)
CREATE VIEW name [ ( columns ) ] AS query [ WITH CHECK OPTION ];
-- 强制阻止合并(显式)
CREATE VIEW name AS
WITH x AS MATERIALIZED ( SELECT ... ) -- MATERIALIZED 会阻止 CTE 内联
SELECT ... FROM x;
-- 查看实际执行计划
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT ... FROM view_name WHERE ...;
-- 查看视图被哪个节点包裹
EXPLAIN (VERBOSE, COSTS OFF) SELECT ... FROM view_name;最简对照示例:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL DEFAULT 'pending'
);
CREATE INDEX idx_orders_user ON orders (user_id);
-- 可合并视图
CREATE VIEW v_orders_paid AS
SELECT id, user_id, amount FROM orders WHERE status = 'paid';EXPLAIN (COSTS OFF)
SELECT * FROM v_orders_paid WHERE user_id = 42; QUERY PLAN
------------------------------------------------------------
Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
Filter: (status = 'paid'::text)
(3 rows)注意:没有 Subquery Scan。谓词 user_id = 42 已下推,索引被正常使用。
详细说明
合并成功长什么样
合并成功的判据是:EXPLAIN 里看不到视图名作为独立节点,Index Cond / Filter 直接落在基表上,与手写 SQL 的计划一致。
-- 视图查询
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT * FROM v_orders_paid WHERE user_id = 42;
-- 等价手写 SQL
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, amount FROM orders WHERE status = 'paid' AND user_id = 42; QUERY PLAN
------------------------------------------------------------
Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
Filter: (status = 'paid'::text)
Buffers: shared hit=4
(4 rows)两份计划完全一致。这证明视图在合并成功时不带来任何额外开销。
LIMIT 会阻止合并
CREATE VIEW v_orders_top AS
SELECT id, user_id, amount FROM orders ORDER BY amount DESC LIMIT 100;
EXPLAIN (COSTS OFF)
SELECT * FROM v_orders_top WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_orders_top
Filter: (v_orders_top.user_id = 42)
-> Limit
-> Sort
Sort Key: orders.amount DESC
-> Seq Scan on orders
(6 rows)代价分析:LIMIT 100 必须先排序取前 100 行,然后才在外层过滤 user_id = 42——如果那 100 行里没有 user_id = 42,结果就是空。而手写「先过滤再取 TOP 100」语义完全不同:
-- 语义不同!这是「用户 42 的 TOP 100」,不是「全局 TOP 100 里筛用户 42」
SELECT * FROM orders WHERE user_id = 42 ORDER BY amount DESC LIMIT 100;这是使用 LIMIT 视图时最危险的地方:不是性能问题,而是语义陷阱。视图里的 LIMIT 是视图定义的一部分,外层的 WHERE 无法下推过去。
聚合 / GROUP BY 会阻止合并
CREATE VIEW v_order_stats AS
SELECT user_id, count(*) AS cnt, sum(amount) AS total
FROM orders GROUP BY user_id;
EXPLAIN (COSTS OFF)
SELECT * FROM v_order_stats WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_order_stats
Filter: (v_order_stats.user_id = 42)
-> HashAggregate
Group Key: orders.user_id
-> Seq Scan on orders
(5 rows)外层的 user_id = 42 可以下推到 HashAggregate 之下(分组键就是 user_id),因此这里其实还好:
-- 若优化器能推导出等价条件,会变成这样(更优)
EXPLAIN (COSTS OFF)
SELECT user_id, count(*), sum(amount) FROM orders WHERE user_id = 42 GROUP BY user_id; QUERY PLAN
--------------------------------------------------------------
GroupAggregate
Group Key: user_id
-> Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
(4 rows)判断标准很明确:过滤列是不是分组键。是,则谓词能下推;不是,则要先算完整聚合。
DISTINCT 与 UNION 会阻止合并
CREATE VIEW v_paying_users AS
SELECT DISTINCT user_id FROM orders WHERE status = 'paid';
EXPLAIN (COSTS OFF)
SELECT * FROM v_paying_users WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_paying_users
Filter: (v_paying_users.user_id = 42)
-> HashAggregate
Group Key: orders.user_id
-> Seq Scan on orders
Filter: (status = 'paid'::text)
(6 rows)UNION 同理——它内部等价于 DISTINCT,因此也无法提升:
CREATE VIEW v_all_orders AS
SELECT id, user_id FROM orders WHERE status = 'paid'
UNION
SELECT id, user_id FROM orders WHERE status = 'shipped';MATERIALIZED CTE 会阻止内联
PostgreSQL 12 之后,WITH(CTE)默认会被内联(inline)到主查询——这与「CTE 是优化边界」的老观念相反。要阻止内联,显式写 MATERIALIZED:
-- 默认:CTE 被内联(相当于子查询,可能被提升)
CREATE VIEW v_c1 AS
WITH paid AS (
SELECT id, user_id, amount FROM orders WHERE status = 'paid'
)
SELECT * FROM paid;
-- 显式实体化:CTE 结果先算出来(称为优化边界)
CREATE VIEW v_c2 AS
WITH paid AS MATERIALIZED (
SELECT id, user_id, amount FROM orders WHERE status = 'paid'
)
SELECT * FROM paid;对比两者计划:
EXPLAIN (COSTS OFF) SELECT * FROM v_c1 WHERE user_id = 42; QUERY PLAN
------------------------------------------------------------
Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
Filter: (status = 'paid'::text)
(3 rows)EXPLAIN (COSTS OFF) SELECT * FROM v_c2 WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_c2
Filter: (v_c2.user_id = 42)
-> CTE Scan on paid
CTE paid
-> Seq Scan on orders
Filter: (status = 'paid'::text)
(6 rows)MATERIALIZED 是明确的「优化边界」:CTE 会被独立执行并物化,外层谓词无法下推。它适用于「CTE 被多次引用且每次都重算代价高」的场景,但别在视图里随手用。
security_barrier 视图会阻止谓词下推
security_barrier(安全屏障)的设计目的就是阻止外层谓词下推到视图内部:
CREATE VIEW v_orders_secure WITH (security_barrier = true) AS
SELECT id, user_id, amount FROM orders WHERE status = 'paid';
EXPLAIN (COSTS OFF)
SELECT * FROM v_orders_secure WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_orders_secure
Filter: (v_orders_secure.user_id = 42)
-> Seq Scan on orders
Filter: (status = 'paid'::text)
(4 rows)代价是明确的:谓词不再下推,idx_orders_user 也用不上了。这类视图只应用在「安全优先于性能」的场景(如暴露脱敏数据、配合 RLS)。
一个折中写法是把 security_barrier 的视图做得尽可能窄(只暴露必要列)+ 尽量在视图内部把数据量压小:
-- 视图中先做高选择性过滤,再交给安全屏障
CREATE VIEW v_orders_secure2 WITH (security_barrier = true) AS
SELECT id, user_id, amount FROM orders
WHERE status = 'paid' AND created_at >= now() - interval '30 days';多层嵌套视图:可读性与优化器负担
视图套视图很常见,但层层叠加会放大不合并的后果:
CREATE VIEW v_l1 AS SELECT id, user_id, amount, status FROM orders;
CREATE VIEW v_l2 AS
SELECT * FROM v_l1 WHERE status <> 'cancelled';
CREATE VIEW v_l3 AS
SELECT * FROM v_l2 WHERE amount > 100;
CREATE VIEW v_l4 AS
SELECT * FROM v_l3 WHERE user_id > 0;
EXPLAIN (COSTS OFF) SELECT * FROM v_l4 WHERE user_id = 42; QUERY PLAN
------------------------------------------------------------
Seq Scan on orders
Filter: ((status <> 'cancelled'::text) AND (amount > 100::numeric) AND (user_id > 0))
(2 rows)四层视图全部被合并,最终退化为一条普通查询。但如果某一层含 LIMIT 或聚合,整个链条就会被打断:
CREATE VIEW v_l2_bad AS
SELECT * FROM v_l1 WHERE status <> 'cancelled' LIMIT 1000;
CREATE VIEW v_l3_bad AS SELECT * FROM v_l2_bad WHERE amount > 100;
EXPLAIN (COSTS OFF) SELECT * FROM v_l3_bad WHERE user_id = 42; QUERY PLAN
--------------------------------------------------------------
Subquery Scan on v_l3_bad
Filter: (v_l3_bad.user_id = 42)
-> Subquery Scan on v_l2_bad
Filter: (v_l2_bad.amount > 100::numeric)
-> Limit
-> Seq Scan on orders
Filter: (status <> 'cancelled'::text)
(7 rows)结论:视图嵌套本身不贵,但每一层的「不可提升构造」都会截断谓词下推链。视图层数越多,定位性能问题越困难。
视图与 prepared statement 的计划缓存
视图不影响 prepared statement 的机制:计划仍然在 PREPARE 后按前几次执行(或参数无关时立刻)生成并缓存。但有一个与视图相关的特殊情况值得注意:视图定义改变后,已缓存的计划会自动失效重建(PostgreSQL 通过对象依赖与 relcache 失效实现)。
PREPARE p1 AS SELECT * FROM v_orders_paid WHERE user_id = $1;
EXPLAIN (COSTS OFF) EXECUTE p1 (42); QUERY PLAN
------------------------------------------------------------
Index Scan using idx_orders_user on orders
Index Cond: (user_id = 42)
Filter: (status = 'paid'::text)
(3 rows)-- 给视图加 security_barrier 后,机制不变,但计划形状会变(谓词不再下推)
ALTER VIEW v_orders_paid SET (security_barrier = true);
EXPLAIN (COSTS OFF) EXECUTE p1 (42);-- 复原
ALTER VIEW v_orders_paid RESET (security_barrier);实践建议:ALTER VIEW 属于 DDL,会让依赖它的计划失效并触发重编译,避免在高频路径上频繁执行。
视图不能建索引:正确的优化位置
-- 错误!视图不能建索引
CREATE INDEX idx_v ON v_orders_paid (user_id);ERROR: "v_orders_paid" is not a table or materialized view正确做法是在基表上建索引。判断该建什么索引的方法:把视图展开成等价 SQL,再对等价 SQL 做索引优化。
-- 等价 SQL(去掉视图包装)
SELECT id, user_id, amount FROM orders WHERE status = 'paid' AND user_id = 42;
-- 对应索引
CREATE INDEX idx_orders_user_status ON orders (user_id, status);如果视图无法合并(如含聚合),优化方向就变成能否让聚合本身更快:
-- 聚合视图:考虑在基表上建覆盖索引(index-only scan)
CREATE INDEX idx_orders_user_amount ON orders (user_id) INCLUDE (amount);视图作为数据访问层的性能建议
| 建议 | 原因 |
|---|---|
视图里不放 LIMIT | 语义陷阱 + 阻止合并 |
视图里不放 MATERIALIZED CTE | 显式优化边界,阻止下推 |
只在确需安全时用 security_barrier | 阻止谓词下推,可能全表扫描 |
| 把高选择性过滤尽量写在视图内层 | 减少扫描量,尤其对 security_barrier 视图 |
| 视图只做「列裁剪 + 行过滤 + JOIN」 | 保持可合并、易优化 |
| 重聚合场景改用物化视图 | 见 物化视图概述 |
定期 EXPLAIN 关键视图查询 | 视图定义漂移后计划可能悄悄变差 |
实战示例
场景:一个报表视图在数据量增长后变慢,通过 EXPLAIN 定位到是「视图未合并 + 谓词未下推」,并给出修复。
第 1 步:造数据
DROP TABLE IF EXISTS events CASCADE;
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
user_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO events (tenant_id, user_id, event_type, payload, created_at)
SELECT
1 + (g % 20),
1 + (g % 5000),
(ARRAY['click','view','purchase','login'])[1 + (g % 4)],
jsonb_build_object('id', g),
now() - (g || ' seconds')::interval
FROM generate_series(1, 300000) AS g;
CREATE INDEX idx_events_tenant ON events (tenant_id);
CREATE INDEX idx_events_user ON events (user_id);
ANALYZE events;DROP TABLE
CREATE TABLE
INSERT 0 300000
CREATE INDEX
CREATE INDEX
ANALYZE第 2 步:一个有问题的报表视图(含 LIMIT,阻止合并)
CREATE VIEW v_recent_events_bad AS
SELECT id, tenant_id, user_id, event_type, created_at
FROM events
ORDER BY created_at DESC
LIMIT 10000;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM v_recent_events_bad WHERE tenant_id = 7; QUERY PLAN
--------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=2755
-> Subquery Scan on v_recent_events_bad (actual rows=497 loops=1)
Filter: (v_recent_events_bad.tenant_id = 7)
Rows Removed by Filter: 9503
Buffers: shared hit=2750
-> Limit (actual rows=10000 loops=1)
Buffers: shared hit=2749
-> Sort (actual rows=10000 loops=1)
Sort Key: events.created_at DESC
Sort Method: top-N heapsort Memory: 1578kB
Buffers: shared hit=2749
-> Seq Scan on events (actual rows=300000 loops=1)
Buffers: shared hit=2743
(13 rows)诊断:
Subquery Scan说明视图未合并。Filter: tenant_id = 7落在Subquery Scan上,无法下推到基表。- 因此必须全表扫描 + 排序 30 万行,再过滤出 10 万行里的 497 行。
第 3 步:改成可合并的视图,谓词下推
CREATE OR REPLACE VIEW v_recent_events_good AS
SELECT id, tenant_id, user_id, event_type, created_at
FROM events
WHERE created_at >= now() - interval '7 days';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM v_recent_events_good WHERE tenant_id = 7; QUERY PLAN
--------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=1812
-> Bitmap Heap Scan on events (actual rows=8408 loops=1)
Recheck Cond: (tenant_id = 7)
Filter: (created_at >= (now() - '7 days'::interval))
Rows Removed by Filter: 6743
Heap Blocks: exact=1812
Buffers: shared hit=1812
-> Bitmap Index Scan on idx_events_tenant (actual rows=15151 loops=1)
Index Cond: (tenant_id = 7)
Buffers: shared hit=28
(10 rows)改进点:
- 没有
Subquery Scan,tenant_id = 7下推到了基表,走了idx_events_tenant索引。 - 扫描量从 30 万行降到 1.5 万行索引项 + 1812 个堆块。
- 且语义更正确:视图表达的是「最近 7 天的数据」,而不是「全局最新 1 万条里恰好属于该租户的」。
第 4 步:对比两版视图的执行时间
EXPLAIN (ANALYZE, TIMING ON, SUMMARY ON, COSTS OFF)
SELECT count(*) FROM v_recent_events_bad WHERE tenant_id = 7; Aggregate (actual time=142.318..142.319 rows=1 loops=1)
-> Subquery Scan on v_recent_events_bad (actual time=95.204..141.512 rows=497 loops=1)EXPLAIN (ANALYZE, TIMING ON, SUMMARY ON, COSTS OFF)
SELECT count(*) FROM v_recent_events_good WHERE tenant_id = 7; Aggregate (actual time=9.845..9.846 rows=1 loops=1)
-> Bitmap Heap Scan on events (actual time=0.412..8.993 rows=8408 loops=1)从 142 ms 降到 9.8 ms,约 14 倍差距。
第 5 步:验证 security_barrier 的代价
ALTER VIEW v_recent_events_good SET (security_barrier = true);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM v_recent_events_good WHERE tenant_id = 7; QUERY PLAN
--------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Subquery Scan on v_recent_events_good (actual rows=8408 loops=1)
Filter: (v_recent_events_good.tenant_id = 7)
Rows Removed by Filter: 291592
-> Seq Scan on events (actual rows=300000 loops=1)
Filter: (created_at >= (now() - '7 days'::interval))
(6 rows)安全屏障把谓词下推彻底挡死,重新退化为全表扫描。所以 security_barrier 只应加在确实需要它保护的视图上。
-- 复原
ALTER VIEW v_recent_events_good RESET (security_barrier);第 6 步:清理
DROP VIEW v_recent_events_bad, v_recent_events_good;
DROP TABLE events;注意事项
视图不会加速查询,也基本不带来额外开销。 性能问题要么在视图定义里,要么在基表上(缺索引)。
LIMIT视图有语义陷阱,外层的WHERE不下推,得到的结果与「先过滤再 LIMIT」完全不同。sql-- 危险!这不是「用户 42 的最新 10 条」 SELECT * FROM v_recent_events_bad WHERE user_id = 42;聚合视图能否下推,取决于过滤列是否是分组键。 不是分组键就要先算完整聚合。
WITH ... AS MATERIALIZED是显式的优化边界,用它就意味着放弃谓词下推。sql-- 不推荐在视图里随手用 WITH x AS MATERIALIZED ( SELECT ... ) SELECT * FROM xsecurity_barrier会让索引失效,因为它按设计阻止谓词下推。只在安全优先场景使用。视图不能建索引。 报错信息是
is not a table or materialized view:sql-- 错误! CREATE INDEX idx_v ON v_orders_paid (user_id);多层嵌套视图会截断谓词下推链。 层数越多,定位性能问题越难。
ALTER VIEW会让依赖计划失效,避免在高频路径上频繁执行 DDL。不要用视图隐藏「基表缺索引」的问题。
Seq Scan是索引问题,不是视图问题。EXPLAIN一定要看Subquery Scan。 它是「未合并」的信号,不一定是坏事,但必须解释清楚。
最佳实践
视图里不写
LIMIT/DISTINCT/ 聚合,把这些逻辑留给调用方,保持视图可合并:sql-- 推荐:纯过滤 + 列裁剪 CREATE VIEW v_orders_paid AS SELECT id, user_id, amount FROM orders WHERE status = 'paid';把「时间窗口」类条件写成视图内的范围过滤,而不是
ORDER BY ... LIMIT:sql-- 推荐:语义清晰且可合并 CREATE VIEW v_recent_orders AS SELECT id, user_id, amount, created_at FROM orders WHERE created_at >= now() - interval '7 days';用
EXPLAIN (ANALYZE, BUFFERS)核对「视图查询」与「展开后等价 SQL」的计划是否一致,这是判断视图是否拖慢查询的最直接手段。sqlEXPLAIN (ANALYZE, COSTS OFF, BUFFERS) SELECT * FROM v_x WHERE k = 1; EXPLAIN (ANALYZE, COSTS OFF, BUFFERS) SELECT * FROM base WHERE k = 1 AND <视图条件>;security_barrier视图尽量做窄 + 在视图内部先做高选择性过滤,把不可下推的代价降到最低。给基表建索引时,先展开视图再设计索引列顺序:
sql-- 视图展开后常见谓词是 tenant_id + created_at CREATE INDEX idx_events_tenant_created ON events (tenant_id, created_at DESC);控制视图嵌套层数(建议不超过 3 层),超过就考虑合并定义或用物化视图。
重聚合、重 JOIN 的报表场景直接上物化视图,不要指望普通视图能「缓存」结果:
sqlCREATE MATERIALIZED VIEW mv_daily_report AS SELECT tenant_id, date_trunc('day', created_at) AS day, count(*) FROM events GROUP BY 1, 2;定期审计视图查询的计划,用
auto_explain或慢查询日志捕获退化的视图查询。把视图当成「接口定义」而非「性能手段」,性能优化始终落在基表索引与查询写法上。
下一节
继续学习:递归视图