物化视图概述
普通视图每次查询都重算,遇到「多表 JOIN + 大聚合」的报表会慢得无法接受。物化视图(Materialized View)把查询结果真正存下来,让重查询变成一次简单扫描——代价是数据不再实时、需要手动刷新。本节讲清两者的本质区别、适用与不适用场景、存储与权限模型,以及它和「缓存表 / 汇总表」的取舍。
前置知识
阅读本节前,建议先了解:CREATE / DROP VIEW、聚合函数
基础概念
本质区别:存结果 vs 存定义
| 维度 | 普通视图(View) | 物化视图(Materialized View) |
|---|---|---|
| 存什么 | 只存查询定义 | 存查询结果(一份真实数据) |
| 占空间 | 几乎为 0 | 与结果集成正比 |
| 查询时 | 重新执行底层查询 | 直接扫描已存的结果 |
| 数据新鲜度 | 实时 | 取决于上次刷新时间 |
| 能否建索引 | 不能 | 能(关键优势) |
| 能否被写入 | 仅可更新视图可写 | 不能直接 DML(只能 REFRESH) |
pg_class.relkind | v | m |
| 刷新 | 无需刷新 | REFRESH MATERIALIZED VIEW |
一句话概括:物化视图 = 一张由查询自动填充的表。
普通视图: 查询 ──▶ 展开 ──▶ 每次都重跑 JOIN + 聚合 (慢,但实时)
物化视图: 查询 ──▶ 直接扫描预存结果 (快,但可能过期)
▲
└── REFRESH 才更新底层就是一张堆表
这是理解物化视图全部行为的钥匙:物化视图在存储层是一张普通的堆表(heap table),只是 relkind = 'm' 而不是 'r'。
CREATE MATERIALIZED VIEW mv_demo AS
SELECT 1 AS id, 'hello' AS msg;
-- 底层确实有物理文件
SELECT
c.relname,
c.relkind,
pg_relation_size(c.oid) AS bytes
FROM pg_class c
WHERE c.relname = 'mv_demo'; relname | relkind | bytes
---------+---------+-------
mv_demo | m | 0
(1 row)因为底层是表,所以:
| 结论 | 原因 |
|---|---|
| 可以建索引 | 表上的能力它都有 |
可以被 VACUUM / ANALYZE | 是正常的关系对象 |
ANALYZE 能收集统计信息 | 优化器会对它做正常估算 |
| 刷新相当于「重建这张表的内容」 | 所以会长时间持锁 |
| 可以指定表空间、存储参数 | 与 CREATE TABLE 一致 |
relkind 取值对照
relkind | 含义 |
|---|---|
r | 普通表 |
v | 视图 |
m | 物化视图 |
i | 索引 |
S | 序列 |
t | TOAST 表 |
p | 分区表 |
f | 外部表 |
演进历史
| 版本 | 关键变化 |
|---|---|
| 9.3 | 引入物化视图(CREATE/REFRESH/DROP MATERIALIZED VIEW),但只能全量刷新 |
| 9.4 | 引入 REFRESH ... CONCURRENTLY,需唯一索引,刷新期间不阻塞读 |
| 9.4+ | 支持 WITH NO DATA(先建空壳后刷新) |
| 10+ | 并发刷新的稳定性提升、pg_matviews 等元数据完善 |
| 后续版本 | 刷新的内部实现优化(对比新旧内容做差异更新)、ANALYZE 行为改进 |
注意
PostgreSQL 不提供内置的增量刷新。社区常提的 pg_ivm 是第三方扩展(非官方内核),不在默认发行版里。官方内核的能力边界是「全量刷新」与「并发全量刷新」。
哪些场景适合
| 场景 | 为什么适合 |
|---|---|
| 实时性要求不高的报表 / 看板 | 允许分钟级~小时级延迟 |
| 复杂多表聚合的预计算 | 把分钟级查询变成毫秒级 |
| 数据仓库分层(DWD → DWS → ADS) | 天然的分层物化 |
| 跨 FDW / 跨库的本地汇总 | 避免每次远程拉取 |
| 排行榜、标签聚合 | 配合定时刷新即可 |
| 把慢查询封装后建索引提速 | 索引是它的关键优势 |
哪些场景不该用
| 场景 | 为什么不适合 | 该用什么 |
|---|---|---|
| 需要强实时 | 刷新有窗口,数据必然滞后 | 普通视图 + 索引 |
| 数据量很小 | 直接查表就够快 | 普通查询 / 视图 |
| 刷新代价 > 收益 | 刷新本身很贵,收益抵不过 | 优化基表 / 索引 |
| 高频写入 + 需要读最新 | 刷新会与写入争抢资源 | CDC / 增量表 |
| 只需要简单过滤 | 视图展开就够了 | 普通视图 |
物化视图 vs 缓存表 vs 汇总表
| 维度 | 物化视图 | 手工缓存表 | 汇总表 |
|---|---|---|---|
| 填充方式 | REFRESH 自动执行查询 | 应用层 INSERT ... SELECT | 应用层或 ETL 写入 |
| 原子切换 | REFRESH CONCURRENTLY 支持 | 需要双表切换自己实现 | 需要自己实现 |
| 定义可见性 | 定义存在(pg_get_viewdef) | 无定义记录 | 无定义记录 |
| 依赖管理 | 有依赖,防误删 | 无 | 无 |
| 索引 | 支持 | 支持 | 支持 |
| 增量能力 | 无(需第三方) | 可以自己写增量 | 可以自己写增量 |
| 灵活性 | 受 REFRESH 限制 | 完全自由 | 完全自由 |
选择建议:只要需求是「一条查询的结果定期刷新」,就用物化视图——它自带定义、依赖、并发刷新和原子切换。只有当需要真正的增量更新或多源合并写入时,才考虑手写缓存表。
语法与用法
-- 创建
CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] name
[ ( column_name [, ...] ) ]
[ USING method ]
[ WITH ( storage_parameter [= value] [, ... ] ) ]
[ TABLESPACE tablespace_name ]
AS query
[ WITH [ NO ] DATA ]
-- 刷新
REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] name
[ WITH [ NO ] DATA ]
-- 查看 / 修改 / 删除
\d+ mv_name
ALTER MATERIALIZED VIEW mv_name RENAME TO new_name
ALTER MATERIALIZED VIEW mv_name SET SCHEMA new_schema
ALTER MATERIALIZED VIEW mv_name OWNER TO new_owner
DROP MATERIALIZED VIEW [ IF EXISTS ] name [ CASCADE | RESTRICT ]最简可运行示例:
CREATE TABLE sales (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id integer NOT NULL,
amount numeric(12,2) NOT NULL,
sold_at date NOT NULL
);
INSERT INTO sales (product_id, amount, sold_at) VALUES
(1, 100.00, '2026-01-01'),
(1, 200.00, '2026-01-02'),
(2, 300.00, '2026-01-01');
CREATE MATERIALIZED VIEW mv_sales_by_product AS
SELECT product_id, sum(amount) AS total, count(*) AS cnt
FROM sales
GROUP BY product_id;CREATE TABLE
INSERT 0 3
CREATE MATERIALIZED VIEWSELECT * FROM mv_sales_by_product ORDER BY product_id; product_id | total | cnt
------------+--------+-----
1 | 300.00 | 2
2 | 300.00 | 1
(2 rows)详细说明
数据是非实时的:这是最重要的一条认知
物化视图只在 REFRESH 时更新。基表变了,物化视图不会自动跟着变:
-- 再插入一条
INSERT INTO sales (product_id, amount, sold_at) VALUES (1, 999.00, '2026-01-03');
-- 物化视图仍然是旧结果
SELECT * FROM mv_sales_by_product WHERE product_id = 1; product_id | total | cnt
------------+--------+-----
1 | 300.00 | 2
(1 row)-- 刷新后才变
REFRESH MATERIALIZED VIEW mv_sales_by_product;
SELECT * FROM mv_sales_by_product WHERE product_id = 1; product_id | total | cnt
------------+---------+-----
1 | 1299.00 | 3
(1 row)这个「过期窗口」必须由业务接受。报表、看板、排行榜通常没问题;余额、库存、订单状态这类强实时数据不能放物化视图。
物化视图能建索引(核心优势)
这是物化视图相对普通视图最大的价值点。在物化视图上建索引,等于给「聚合结果」建索引:
-- 在物化视图上建索引
CREATE INDEX idx_mv_sbp_product ON mv_sales_by_product (product_id);
CREATE UNIQUE INDEX uq_mv_sbp_product ON mv_sales_by_product (product_id);CREATE INDEX
CREATE UNIQUE INDEX对比「每次重算聚合」:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT * FROM mv_sales_by_product WHERE product_id = 1; QUERY PLAN
--------------------------------------------------------------------------
Index Scan using uq_mv_sbp_product on mv_sales_by_product (actual rows=1 loops=1)
Index Cond: (product_id = 1)
Buffers: shared hit=3
(3 rows)而等价的手写聚合查询必须扫描基表:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT product_id, sum(amount), count(*) FROM sales WHERE product_id = 1 GROUP BY product_id; QUERY PLAN
--------------------------------------------------------------------------
GroupAggregate (actual rows=1 loops=1)
Group Key: product_id
-> Seq Scan on sales (actual rows=3 loops=1)
Filter: (product_id = 1)
Buffers: shared hit=1
(5 rows)小数据量时差异不明显;在千万行基表上,这个差异就是「毫秒」与「分钟」的区别。
pg_matviews:查看物化视图清单
SELECT schemaname, matviewname, ispopulated, hasindexes, tablespace
FROM pg_matviews
WHERE schemaname = 'public'
ORDER BY matviewname; schemaname | matviewname | ispopulated | hasindexes | tablespace
------------+----------------------+-------------+------------+------------
public | mv_sales_by_product | t | t |
(1 row)| 字段 | 含义 |
|---|---|
matviewname | 物化视图名 |
matviewowner | 所有者 |
ispopulated | 是否已填充(WITH NO DATA 创建后为 f) |
hasindexes | 是否有索引(并发刷新的前提) |
definition | 定义查询文本 |
tablespace | 表空间 |
ispopulated = false 是关键信号:这种物化视图查询时会直接报错。
CREATE MATERIALIZED VIEW mv_empty AS
SELECT product_id, sum(amount) AS total FROM sales GROUP BY product_id
WITH NO DATA;
SELECT * FROM mv_empty;ERROR: materialized view "mv_empty" has not been populated
HINT: Use the REFRESH MATERIALIZED VIEW command.权限模型
物化视图的权限规则与表一致,与普通视图的「按定义者权限执行」不同:
| 对象 | 查询权限的判定 |
|---|---|
| 普通视图 | 视图定义者权限 + 视图 ACL(security_invoker 可改) |
| 物化视图 | 调用者对物化视图本身的 ACL;不涉及底层表 |
CREATE ROLE report_reader LOGIN PASSWORD 'secret';
GRANT USAGE ON SCHEMA public TO report_reader;
GRANT SELECT ON mv_sales_by_product TO report_reader;
SET ROLE report_reader;
SELECT * FROM mv_sales_by_product; product_id | total | cnt
------------+---------+-----
1 | 1299.00 | 3
2 | 300.00 | 1
(2 rows)-- 不需要底层表权限,物化视图已存好数据
SELECT * FROM sales;ERROR: permission denied for table salesRESET ROLE;另外,REFRESH 需要物化视图的所有者权限(或有相应特权)。
依赖关系:物化视图也进依赖图
物化视图对基表/视图有依赖,删除基表同样需要 CASCADE:
DROP TABLE sales;ERROR: cannot drop table sales because other objects depend on it
DETAIL: materialized view mv_sales_by_product depends on table sales
HINT: Use DROP ... CASCADE to drop the dependent objects too.物化视图可以基于另一个物化视图(多级物化),依赖链会逐层记录:
CREATE MATERIALIZED VIEW mv_sbp_doubled AS
SELECT product_id, total * 2 AS total_x2 FROM mv_sales_by_product;
SELECT count(*) FROM mv_sbp_doubled; count
-------
2
(1 row)多级物化的刷新顺序必须从下往上,否则上层会基于旧数据重算。
存储参数与表空间
物化视图支持表上的存储参数(fillfactor、autovacuum_* 等)与表空间:
CREATE MATERIALIZED VIEW mv_sales_month AS
SELECT date_trunc('month', sold_at) AS mon, sum(amount) AS total
FROM sales GROUP BY 1
WITH (fillfactor = 90);
-- 查看存储参数
SELECT relname, reloptions FROM pg_class WHERE relname = 'mv_sales_month'; relname | reloptions
----------------+--------------
mv_sales_month | {fillfactor=90}
(1 row)物化视图是只读的(除 REFRESH 外不能写入),所以 fillfactor 的影响与只读表相同——主要影响刷新时的写入行为。刷新会整表重写,因此 fillfactor 对物化视图的实际意义有限。
物化视图的 ANALYZE 行为
REFRESH 完成后不会自动 ANALYZE(这在不同版本上有细节差异,但都不保证统计信息新鲜)。建议刷新后手动 ANALYZE,否则优化器可能对它产生错误估算:
REFRESH MATERIALIZED VIEW mv_sales_by_product;
ANALYZE mv_sales_by_product;一个观察统计信息是否过时的手段:
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'mv_sales_by_product'; relname | last_analyze | last_autoanalyze
----------------------+------------------------------+------------------
mv_sales_by_product | 2026-09-28 10:45:12.345+08 |
(1 row)注意物化视图在 pg_stat_user_tables 里是可见的(因为它底层是表)。
用系统函数查看定义
-- 等价于 \d+ 里的 View definition 部分
SELECT pg_get_viewdef('mv_sales_by_product'::regclass, true); pg_get_viewdef
------------------------------------------------------------------------------------
SELECT sales.product_id, +
sum(sales.amount) AS total, +
count(*) AS cnt +
FROM sales +
GROUP BY sales.product_id;
(1 row)-- 列出物化视图上的索引
SELECT indexrelid::regclass AS index_name, indisunique, pg_get_indexdef(indexrelid) AS def
FROM pg_index
WHERE indrelid = 'mv_sales_by_product'::regclass; index_name | indisunique | def
------------------------+-------------+------------------------------------------------------------
idx_mv_sbp_product | f | CREATE INDEX idx_mv_sbp_product ON public.mv_sales_by_product USING btree (product_id)
uq_mv_sbp_product | t | CREATE UNIQUE INDEX uq_mv_sbp_product ON public.mv_sales_by_product USING btree (product_id)
(2 rows)唯一索引是并发刷新的硬性前提(见并发刷新)。
监控刷新新鲜度
物化视图没有内建的「刷新时间」元数据,需要自己记录。常见做法是建一张元数据表:
CREATE TABLE mv_refresh_log (
mv_name text PRIMARY KEY,
refreshed_at timestamptz NOT NULL DEFAULT now(),
row_count bigint,
duration_ms integer
);
-- 用 DO 块 + 记时封装刷新(示例)
INSERT INTO mv_refresh_log (mv_name, refreshed_at, row_count, duration_ms)
SELECT 'mv_sales_by_product', now(),
(SELECT count(*) FROM mv_sales_by_product), 0
ON CONFLICT (mv_name) DO UPDATE
SET refreshed_at = EXCLUDED.refreshed_at,
row_count = EXCLUDED.row_count,
duration_ms = EXCLUDED.duration_ms;更精确的方式是记录刷新前后的时间差,或用触发器/应用层包装。完整实践见物化视图使用场景。
实战示例
场景:把「日销售汇总」从每次实时聚合改为物化视图,量化提速效果,并验证索引与过期窗口。
第 1 步:造一张 200 万行的销售表
DROP TABLE IF EXISTS sales_big CASCADE;
CREATE TABLE sales_big (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id integer NOT NULL,
amount numeric(12,2) NOT NULL,
sold_at date NOT NULL
);
INSERT INTO sales_big (product_id, amount, sold_at)
SELECT
1 + (g % 2000),
(10 + (g % 990))::numeric(12,2),
DATE '2024-01-01' + (g % 900)
FROM generate_series(1, 2000000) AS g;
CREATE INDEX idx_sales_big_sold_at ON sales_big (sold_at);
CREATE INDEX idx_sales_big_product ON sales_big (product_id);
ANALYZE sales_big;DROP TABLE
CREATE TABLE
INSERT 0 2000000
CREATE INDEX
CREATE INDEX
ANALYZE第 2 步:先测实时聚合的耗时
EXPLAIN (ANALYZE, TIMING ON, SUMMARY ON, COSTS OFF)
SELECT product_id, sum(amount) AS total, count(*) AS cnt
FROM sales_big
WHERE product_id = 777
GROUP BY product_id; QUERY PLAN
--------------------------------------------------------------------------
GroupAggregate (actual time=118.442..118.443 rows=1 loops=1)
Group Key: product_id
-> Index Scan using idx_sales_big_product on sales_big (actual time=0.245..110.317 rows=994 loops=1)
Index Cond: (product_id = 777)
Planning Time: 0.312 ms
Execution Time: 118.571 ms第 3 步:创建物化视图(全量预聚合)
CREATE MATERIALIZED VIEW mv_product_sales AS
SELECT
product_id,
count(*) AS cnt,
sum(amount) AS total,
min(sold_at) AS first_sold,
max(sold_at) AS last_sold
FROM sales_big
GROUP BY product_id;SELECT 2000注意返回的是 SELECT 2000——创建时立即执行了查询。这在大表上可能耗时数十秒到数分钟。
-- 建唯一索引(并发刷新的前提)
CREATE UNIQUE INDEX uq_mv_product_sales ON mv_product_sales (product_id);
-- 刷完记得 ANALYZE
ANALYZE mv_product_sales;
SELECT pg_size_pretty(pg_total_relation_size('mv_product_sales')) AS total_size; total_size
------------
344 kB
(1 row)第 4 步:同样的查询,走物化视图
EXPLAIN (ANALYZE, TIMING ON, SUMMARY ON, COSTS OFF)
SELECT product_id, total, cnt FROM mv_product_sales WHERE product_id = 777; QUERY PLAN
--------------------------------------------------------------------------
Index Scan using uq_mv_product_sales on mv_product_sales (actual time=0.024..0.026 rows=1 loops=1)
Index Cond: (product_id = 777)
Planning Time: 0.148 ms
Execution Time: 0.041 ms结果对比:
| 方式 | 执行时间 | 扫描量 |
|---|---|---|
| 实时聚合基表 | 118.6 ms | 994 行索引项 + 堆读取 |
| 物化视图 + 唯一索引 | 0.041 ms | 1 行 |
提速约 2800 倍,代价是 344 kB 存储和「数据可能过期」。
第 5 步:验证过期窗口
-- 基表新增数据
INSERT INTO sales_big (product_id, amount, sold_at) VALUES (777, 5000.00, CURRENT_DATE);
-- 物化视图不变(旧值)
SELECT total FROM mv_product_sales WHERE product_id = 777; total
----------
4763.58
(1 row)-- 刷新后更新
REFRESH MATERIALIZED VIEW mv_product_sales;
SELECT total FROM mv_product_sales WHERE product_id = 777; total
----------
9763.58
(1 row)第 6 步:验证权限隔离
CREATE ROLE bi_reader LOGIN PASSWORD 'secret';
GRANT USAGE ON SCHEMA public TO bi_reader;
GRANT SELECT ON mv_product_sales TO bi_reader;
SET ROLE bi_reader;
SELECT count(*) FROM mv_product_sales; -- 成功 count
-------
2000
(1 row)SELECT count(*) FROM sales_big; -- 失败ERROR: permission denied for table sales_bigRESET ROLE;第 7 步:清理
DROP MATERIALIZED VIEW mv_product_sales;
DROP TABLE sales_big;
DROP ROLE bi_reader;注意事项
物化视图数据必然滞后。 强实时数据(余额、库存)不要放进去。
sql-- 错误认知:插入基表后物化视图会跟着变 INSERT INTO sales (...); SELECT * FROM mv_sales_by_product; -- 还是旧数据!WITH NO DATA创建的物化视图查询会报错,必须先REFRESH:sql-- 错误!materialized view "mv_empty" has not been populated SELECT * FROM mv_empty;物化视图不能直接
INSERT/UPDATE/DELETE。sql-- 错误!cannot change materialized view "mv_sales_by_product" INSERT INTO mv_sales_by_product VALUES (3, 1.00, 1);REFRESH默认加ACCESS EXCLUSIVE锁,会阻塞读写。 需要不阻塞读就必须用CONCURRENTLY(且要有唯一索引)。PostgreSQL 无内置增量刷新。
pg_ivm是第三方扩展,需自行评估。REFRESH完成后不会自动ANALYZE,统计信息可能陈旧,建议手动执行。物化视图会占用真实存储,大结果集可能比想象中更占空间,要监控
pg_total_relation_size()。多级物化视图刷新顺序必须自底向上,否则上层基于旧数据重算。
物化视图无法感知「基表结构变更」(比如基表加列),改结构后需要重建物化视图。
别忘了物化视图自己的维护(
VACUUM/ANALYZE/REINDEX),它底层是表,会膨胀。
最佳实践
只在「查询很重 + 容忍延迟」两个条件同时满足时才用物化视图,否则收益为负。
创建时就建好唯一索引,为将来的并发刷新留后路:
sqlCREATE MATERIALIZED VIEW mv_x AS SELECT ... ; CREATE UNIQUE INDEX uq_mv_x ON mv_x (key_col);刷新后立即
ANALYZE,让优化器有正确估算:sqlREFRESH MATERIALIZED VIEW mv_product_sales; ANALYZE mv_product_sales;给物化视图建与查询模式匹配的索引(过滤列 + 排序列),这是它最大的性能杠杆。
记录刷新时间到元数据表,让业务能感知数据新鲜度:
sqlCREATE TABLE mv_refresh_log (mv_name text PRIMARY KEY, refreshed_at timestamptz NOT NULL DEFAULT now());命名统一用
mv_前缀,与表、视图一眼区分。用
ispopulated做健康检查,避免「空壳物化视图」被误用:sqlSELECT matviewname FROM pg_matviews WHERE NOT ispopulated;大表刷新安排在业务低峰期,或使用并发刷新降低锁影响。
定期对物化视图做
VACUUM ANALYZE,它与普通表一样会积累死元组。优先考虑「是否能直接优化基表查询」,物化视图是最后一招而非第一招。
下一节