Skip to content

物化视图概述 ​

普通视图每次查询都重算,遇到「多表 JOIN + 大聚合」的报表会慢得无法接受。物化视图(Materialized View)把查询结果真正存下来,让重查询变成一次简单扫描——代价是数据不再实时、需要手动刷新。本节讲清两者的本质区别、适用与不适用场景、存储与权限模型,以及它和「缓存表 / 汇总表」的取舍。

前置知识

阅读本节前,建议先了解:CREATE / DROP VIEW、聚合函数

基础概念 ​

本质区别:存结果 vs 存定义 ​

维度普通视图(View)物化视图(Materialized View)
存什么只存查询定义存查询结果(一份真实数据)
占空间几乎为 0与结果集成正比
查询时重新执行底层查询直接扫描已存的结果
数据新鲜度实时取决于上次刷新时间
能否建索引不能能(关键优势)
能否被写入仅可更新视图可写不能直接 DML(只能 REFRESH)
pg_class.relkindvm
刷新无需刷新REFRESH MATERIALIZED VIEW

一句话概括:物化视图 = 一张由查询自动填充的表。

text
普通视图:  查询 ──▶ 展开 ──▶ 每次都重跑 JOIN + 聚合  (慢,但实时)

物化视图:  查询 ──▶ 直接扫描预存结果                    (快,但可能过期)
                       ▲
                       └── REFRESH 才更新

底层就是一张堆表 ​

这是理解物化视图全部行为的钥匙:物化视图在存储层是一张普通的堆表(heap table),只是 relkind = 'm' 而不是 'r'。

sql
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';
text
 relname | relkind | bytes
---------+---------+-------
 mv_demo | m       |     0
(1 row)

因为底层是表,所以:

结论原因
可以建索引表上的能力它都有
可以被 VACUUM / ANALYZE是正常的关系对象
ANALYZE 能收集统计信息优化器会对它做正常估算
刷新相当于「重建这张表的内容」所以会长时间持锁
可以指定表空间、存储参数与 CREATE TABLE 一致

relkind 取值对照 ​

relkind含义
r普通表
v视图
m物化视图
i索引
S序列
tTOAST 表
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 限制完全自由完全自由

选择建议:只要需求是「一条查询的结果定期刷新」,就用物化视图——它自带定义、依赖、并发刷新和原子切换。只有当需要真正的增量更新或多源合并写入时,才考虑手写缓存表。

语法与用法 ​

text
-- 创建
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 ]

最简可运行示例:

sql
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;
text
CREATE TABLE
INSERT 0 3
CREATE MATERIALIZED VIEW
sql
SELECT * FROM mv_sales_by_product ORDER BY product_id;
text
 product_id | total  | cnt
------------+--------+-----
          1 | 300.00 |   2
          2 | 300.00 |   1
(2 rows)

详细说明 ​

数据是非实时的:这是最重要的一条认知 ​

物化视图只在 REFRESH 时更新。基表变了,物化视图不会自动跟着变:

sql
-- 再插入一条
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;
text
 product_id | total  | cnt
------------+--------+-----
          1 | 300.00 |   2
(1 row)
sql
-- 刷新后才变
REFRESH MATERIALIZED VIEW mv_sales_by_product;

SELECT * FROM mv_sales_by_product WHERE product_id = 1;
text
 product_id | total   | cnt
------------+---------+-----
          1 | 1299.00 |   3
(1 row)

这个「过期窗口」必须由业务接受。报表、看板、排行榜通常没问题;余额、库存、订单状态这类强实时数据不能放物化视图。

物化视图能建索引(核心优势) ​

这是物化视图相对普通视图最大的价值点。在物化视图上建索引,等于给「聚合结果」建索引:

sql
-- 在物化视图上建索引
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);
text
CREATE INDEX
CREATE UNIQUE INDEX

对比「每次重算聚合」:

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT * FROM mv_sales_by_product WHERE product_id = 1;
text
                                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)

而等价的手写聚合查询必须扫描基表:

sql
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;
text
                                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:查看物化视图清单 ​

sql
SELECT schemaname, matviewname, ispopulated, hasindexes, tablespace
FROM pg_matviews
WHERE schemaname = 'public'
ORDER BY matviewname;
text
 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 是关键信号:这种物化视图查询时会直接报错。

sql
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;
text
ERROR:  materialized view "mv_empty" has not been populated
HINT:  Use the REFRESH MATERIALIZED VIEW command.

权限模型 ​

物化视图的权限规则与表一致,与普通视图的「按定义者权限执行」不同:

对象查询权限的判定
普通视图视图定义者权限 + 视图 ACL(security_invoker 可改)
物化视图调用者对物化视图本身的 ACL;不涉及底层表
sql
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;
text
 product_id | total   | cnt
------------+---------+-----
          1 | 1299.00 |   3
          2 |  300.00 |   1
(2 rows)
sql
-- 不需要底层表权限,物化视图已存好数据
SELECT * FROM sales;
text
ERROR:  permission denied for table sales
sql
RESET ROLE;

另外,REFRESH 需要物化视图的所有者权限(或有相应特权)。

依赖关系:物化视图也进依赖图 ​

物化视图对基表/视图有依赖,删除基表同样需要 CASCADE:

sql
DROP TABLE sales;
text
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.

物化视图可以基于另一个物化视图(多级物化),依赖链会逐层记录:

sql
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;
text
 count
-------
     2
(1 row)

多级物化的刷新顺序必须从下往上,否则上层会基于旧数据重算。

存储参数与表空间 ​

物化视图支持表上的存储参数(fillfactor、autovacuum_* 等)与表空间:

sql
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';
text
 relname        |  reloptions
----------------+--------------
 mv_sales_month | {fillfactor=90}
(1 row)

物化视图是只读的(除 REFRESH 外不能写入),所以 fillfactor 的影响与只读表相同——主要影响刷新时的写入行为。刷新会整表重写,因此 fillfactor 对物化视图的实际意义有限。

物化视图的 ANALYZE 行为 ​

REFRESH 完成后不会自动 ANALYZE(这在不同版本上有细节差异,但都不保证统计信息新鲜)。建议刷新后手动 ANALYZE,否则优化器可能对它产生错误估算:

sql
REFRESH MATERIALIZED VIEW mv_sales_by_product;
ANALYZE mv_sales_by_product;

一个观察统计信息是否过时的手段:

sql
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'mv_sales_by_product';
text
       relname        |         last_analyze         | last_autoanalyze
----------------------+------------------------------+------------------
 mv_sales_by_product  | 2026-09-28 10:45:12.345+08   |
(1 row)

注意物化视图在 pg_stat_user_tables 里是可见的(因为它底层是表)。

用系统函数查看定义 ​

sql
-- 等价于 \d+ 里的 View definition 部分
SELECT pg_get_viewdef('mv_sales_by_product'::regclass, true);
text
                                  pg_get_viewdef
------------------------------------------------------------------------------------
  SELECT sales.product_id,                                                          +
     sum(sales.amount) AS total,                                                     +
     count(*) AS cnt                                                                 +
    FROM sales                                                                       +
   GROUP BY sales.product_id;
(1 row)
sql
-- 列出物化视图上的索引
SELECT indexrelid::regclass AS index_name, indisunique, pg_get_indexdef(indexrelid) AS def
FROM pg_index
WHERE indrelid = 'mv_sales_by_product'::regclass;
text
       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)

唯一索引是并发刷新的硬性前提(见并发刷新)。

监控刷新新鲜度 ​

物化视图没有内建的「刷新时间」元数据,需要自己记录。常见做法是建一张元数据表:

sql
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 万行的销售表

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 2000000
CREATE INDEX
CREATE INDEX
ANALYZE

第 2 步:先测实时聚合的耗时

sql
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;
text
                                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 步:创建物化视图(全量预聚合)

sql
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;
text
SELECT 2000

注意返回的是 SELECT 2000——创建时立即执行了查询。这在大表上可能耗时数十秒到数分钟。

sql
-- 建唯一索引(并发刷新的前提)
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;
text
 total_size
------------
 344 kB
(1 row)

第 4 步:同样的查询,走物化视图

sql
EXPLAIN (ANALYZE, TIMING ON, SUMMARY ON, COSTS OFF)
SELECT product_id, total, cnt FROM mv_product_sales WHERE product_id = 777;
text
                                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 ms994 行索引项 + 堆读取
物化视图 + 唯一索引0.041 ms1 行

提速约 2800 倍,代价是 344 kB 存储和「数据可能过期」。

第 5 步:验证过期窗口

sql
-- 基表新增数据
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;
text
  total
----------
 4763.58
(1 row)
sql
-- 刷新后更新
REFRESH MATERIALIZED VIEW mv_product_sales;
SELECT total FROM mv_product_sales WHERE product_id = 777;
text
  total
----------
 9763.58
(1 row)

第 6 步:验证权限隔离

sql
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;   -- 成功
text
 count
-------
  2000
(1 row)
sql
SELECT count(*) FROM sales_big;          -- 失败
text
ERROR:  permission denied for table sales_big
sql
RESET ROLE;

第 7 步:清理

sql
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),它底层是表,会膨胀。

最佳实践 ​

  • 只在「查询很重 + 容忍延迟」两个条件同时满足时才用物化视图,否则收益为负。

  • 创建时就建好唯一索引,为将来的并发刷新留后路:

    sql
    CREATE MATERIALIZED VIEW mv_x AS SELECT ... ;
    CREATE UNIQUE INDEX uq_mv_x ON mv_x (key_col);
  • 刷新后立即 ANALYZE,让优化器有正确估算:

    sql
    REFRESH MATERIALIZED VIEW mv_product_sales;
    ANALYZE mv_product_sales;
  • 给物化视图建与查询模式匹配的索引(过滤列 + 排序列),这是它最大的性能杠杆。

  • 记录刷新时间到元数据表,让业务能感知数据新鲜度:

    sql
    CREATE TABLE mv_refresh_log (mv_name text PRIMARY KEY, refreshed_at timestamptz NOT NULL DEFAULT now());
  • 命名统一用 mv_ 前缀,与表、视图一眼区分。

  • 用 ispopulated 做健康检查,避免「空壳物化视图」被误用:

    sql
    SELECT matviewname FROM pg_matviews WHERE NOT ispopulated;
  • 大表刷新安排在业务低峰期,或使用并发刷新降低锁影响。

  • 定期对物化视图做 VACUUM ANALYZE,它与普通表一样会积累死元组。

  • 优先考虑「是否能直接优化基表查询」,物化视图是最后一招而非第一招。

下一节

继续学习:CREATE MATERIALIZED VIEW

参考链接 ​