物化视图保存定义查询的结果,让后续读取直接访问这些已存储的行。它把每次读取时的计算转移到刷新阶段,适合重复执行的报表查询。相应代价是明确的新鲜度边界,以及另一份需要维护的关系数据;它既不保证读取瞬间完成,也不会自动与源表保持同步。选择时应比较完整的读写成本。
已存储结果有自己的生命周期
普通视图保存查询定义,物化视图还保存查询结果。PostgreSQL 可以给物化结果建立索引,并直接针对它规划读取,而不必每次重跑原来的关联与聚合。源表发生普通插入或更新时,这份结果不会自动变化。应用也需要明确查询物化视图;创建它并不会让所有等价的源表查询自动改走这份缓存。
REFRESH MATERIALIZED VIEW 重新执行定义查询,再替换存储内容。标准 PostgreSQL 的刷新不是自动增量维护;即使源表只变了几行,定义查询仍然需要生成新的结果。比较物化与反复直接查询时,要把刷新计算、临时工作、存储和索引维护都计入,而不能只测读缓存的那条快速查询。
存储结果本身仍可能很大,或者缺少适合的索引,因此物化不代表任意读取都便宜。应根据报表过滤条件和返回字段,确定聚合粒度和索引。需要稳定顺序时,还应在读取查询中使用 ORDER BY;刷新不承诺保留定义查询所产生的物理排列,不能把上次看到的顺序当成输出契约。
并发刷新改变的是读者访问方式
普通刷新在替换物化内容时可能阻塞读取。CONCURRENTLY 允许 SELECT 在刷新期间继续访问已有结果,新内容何时可见仍遵循事务与快照规则。它依然需要锁、消耗资源,并可能等待其他操作;同一个物化视图在同一时刻也只能运行一个刷新任务,并不是允许任意多次刷新相互并行。
并发刷新要求视图已经有数据,并至少存在一个适用的唯一索引:索引只能使用列名,而且必须覆盖全部行。部分唯一索引或表达式索引都不满足这个条件。该索引用于刷新时识别结果行,并不意味着源表因此变得不可变,也不意味着源表在相同聚合粒度上具备同样的唯一性。
WITH NO DATA 会把视图变为未填充、不可扫描的状态。它不等于一个可以正常 SELECT 的空结果,也不会保留旧缓存答案。需要先通过普通刷新填充,再请求并发刷新。CONCURRENTLY 与 WITH NO DATA 不能组合成绕过这些前置条件的捷径,部署时应区分“零行有效结果”和“尚未生成结果”。
坏掉的样子
仅有刷新计划,并不能保证陈旧程度有上限。任务可能晚启动、运行很久、失败,或者排队等待另一个刷新;生成结果所用的源快照,也早于刷新工作完成的时刻。应记录成功刷新及报表所需的源数据覆盖范围,不能把配置了某个时间间隔当成数据已经及时更新的证据。失败任务不能像成功发布新结果一样推进页面的新鲜度标记。
部署预期使用并发读取路径时,缺少唯一索引或索引形态不符,会导致 CONCURRENTLY 直接失败。正式依赖前,应实际测试这条路径。PostgreSQL 十八的刷新还要求适用的 MAINTAIN 权限,所以定时任务所用角色必须确实能够刷新该关系。以所有者身份手工执行成功,不能证明任务角色也拥有所需权限。
刷新期间,search_path 会临时变为 pg_catalog、pg_temp。如果定义查询调用了使用未限定动态 SQL 的函数,交互调用成功也不能证明刷新时能找到相同对象。需要检查函数内部的名称解析,而不只看保存下来的视图定义。本例直接引用表,没有验证依赖这类函数的刷新,也没有验证调度器角色的权限。
快速验证
把下面内容保存为 materialized.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f materialized.sql。角色需要创建测试 schema 的权限。脚本在事务内创建 matview_demo,末尾整体回滚;如果这个名字已经存在,应选择未使用的名字,而不是删除已有 schema。
\set ON_ERROR_STOP on
BEGIN;
CREATE SCHEMA matview_demo;
SET LOCAL search_path=matview_demo,pg_catalog;
CREATE TABLE sales (region text NOT NULL,amount integer NOT NULL);
INSERT INTO sales VALUES ('east',10),('west',5);
CREATE MATERIALIZED VIEW totals AS SELECT region,sum(amount) AS total FROM sales GROUP BY region;
UPDATE sales SET amount=17 WHERE region='east';
SELECT * FROM totals ORDER BY region;
DO $$ BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY totals;
RAISE EXCEPTION 'concurrent refresh accepted without unique index';
EXCEPTION WHEN object_not_in_prerequisite_state THEN
RAISE NOTICE 'concurrent refresh requires an eligible unique index';
END $$;
CREATE UNIQUE INDEX totals_region ON totals (region);
REFRESH MATERIALIZED VIEW CONCURRENTLY totals;
SELECT * FROM totals ORDER BY region;
REFRESH MATERIALIZED VIEW totals WITH NO DATA;
DO $$ BEGIN
PERFORM * FROM totals;
RAISE EXCEPTION 'unpopulated view was readable';
EXCEPTION WHEN object_not_in_prerequisite_state THEN RAISE NOTICE 'unpopulated view cannot be read';
END $$;
DO $$ BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY totals;
RAISE EXCEPTION 'concurrent refresh populated an unreadable view';
EXCEPTION WHEN feature_not_supported THEN RAISE NOTICE 'first population needs a normal refresh';
END $$;
REFRESH MATERIALIZED VIEW totals;
DO $$ BEGIN
IF (SELECT total FROM totals WHERE region='east') IS DISTINCT FROM 17::bigint OR
(SELECT count(*) FROM totals) <> 2 THEN RAISE EXCEPTION 'refresh result wrong'; END IF;
END $$;
ROLLBACK;
本轮 PostgreSQL 18.6 单用户 SQL 重跑中,源表 east 从十变成十七后,物化视图仍显示十;未验证 psql 传输与并发读者。没有适用唯一索引时,并发刷新按预期因前置条件不足而失败;创建索引后,刷新把存储的 east 总额改成十七,west 仍然是五。这说明基表变化与物化结果更新之间有明确的刷新边界。
随后,WITH NO DATA 让读取失败,并发刷新也不能直接填充这个不可扫描的视图。普通刷新恢复可读结果,并通过最终断言。这个实验验证状态转换与前置条件,没有测量刷新吞吐量。实际容量评估还应使用真实报表查询,并在有代表性的刷新期间检查读者行为,同时考虑事务边界和任务失败的影响。
来源
- PostgreSQL 物化视图,结果存储、索引和刷新用途,已核验 2026-10-15。
- PostgreSQL REFRESH MATERIALIZED VIEW,锁、并发刷新前提、权限和未填充状态,已核验 2026-10-15。