Skip to content

并发刷新(CONCURRENTLY) ​

REFRESH MATERIALIZED VIEW CONCURRENTLY 让物化视图在刷新期间依然可以被 SELECT 读取。它不是增量刷新,底层依旧是「全量重算 + 差异比对(diff-and-merge)」,只是把差异以 DELETE/UPDATE/INSERT 的方式合并回去,从而避免长时间持 ACCESS EXCLUSIVE 锁。代价是必须存在符合要求的唯一索引、刷新更慢、占用更多临时空间。本节讲清它的原理、限制、失败场景与选择策略。

前置知识

阅读本节前,建议先了解:REFRESH MATERIALIZED VIEW、CREATE MATERIALIZED VIEW

基础概念 ​

什么是并发刷新 ​

text
REFRESH MATERIALIZED VIEW CONCURRENTLY mv;

  1. 对 mv 加 ShareUpdateExclusiveLock(不阻塞 SELECT)
  2. 重跑定义查询,把新结果写入一张临时表
  3. 用「唯一索引列」把临时表与 mv 做外连接比对
  4. 只对差异行执行 DELETE / UPDATE / INSERT
  5. 提交时新数据一次性可见(读旧 → 原子切到新)

一句话理解:普通刷新是「换整张表」,并发刷新是「按主键改行」。后者不改数据量级,但把「锁」换成了「折腾」。

差异合并 vs 全量重建 ​

维度普通刷新并发刷新
应用新数据的方式抛弃旧内容、整体替换逐行 DELETE/UPDATE/INSERT
锁ACCESS EXCLUSIVE(阻塞读写)ShareUpdateExclusiveLock(不阻塞 SELECT)
是否需要唯一索引不需要必须有符合要求的唯一索引
刷新期间读者看到被阻塞,等刷新结束旧数据,提交后原子切到新数据
速度通常更快(大结果集)通常更慢(多一次比对 + 写入开销)
额外空间需要临时堆表需要临时表 + 差异表 + 新版本行
失败后状态保持原内容可能处于部分更新状态

并发刷新的三个前提 ​

前提说明不满足时报错
有合规的唯一索引仅用列名、覆盖全部行(非表达式、非部分索引)cannot refresh materialized view ... concurrently
物化视图已填充不能是 WITH NO DATA 建的空壳materialized view ... has not been populated
不在事务块内不能在 BEGIN; ... COMMIT; 中执行不允许在事务块中运行

读者看到的数据 ​

text
时间轴 ──────────────────────────────────────────────────────►

刷新开始                刷新进行中                    提交
   │                       │                          │
   ├── 读者读到旧数据 ──────┤── 读者仍读到旧数据 ──────┤── 读者读到新数据
                                                      ↑
                                              原子切换,无中间态

并发刷新的关键承诺:在提交那一刻之前,所有读取都是旧数据;提交之后,所有读取都是新数据。不会让读者看到「一半新一半旧」的撕裂状态。

语法与用法 ​

text
REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] name
    [ WITH [ NO ] DATA ]

-- CONCURRENTLY 与 WITH NO DATA 不能同时使用
-- 不能在事务块(BEGIN/COMMIT)中执行

最简示例——先准备一个带唯一索引的物化视图:

sql
CREATE MATERIALIZED VIEW mv_sku_sales AS
SELECT sku_id,
       sum(amount)::numeric(14,2) AS sales_amt,
       count(*)                   AS order_cnt
FROM order_item
GROUP BY sku_id;

-- 并发刷新的前提:唯一索引,仅用列名、无 WHERE、非表达式
CREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);

执行并发刷新:

sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;

观察 psql 输出:

text
REFRESH MATERIALIZED VIEW

没有合规唯一索引时,直接报错:

text
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
ERROR:  cannot refresh materialized view "public.mv_sku_sales" concurrently
HINT:  Create a UNIQUE index with no WHERE clause on one or more columns of the materialized view.

对比普通刷新——读者会被阻塞、刷新更快但代价是停顿:

sql
-- 阻塞式:刷大视图时,所有 SELECT 都会排队
REFRESH MATERIALIZED VIEW mv_sku_sales;

详细说明 ​

差异合并的内部原理 ​

并发刷新在实现上是「先算新结果,再与旧结果做集合差」:

text
REFRESH MATERIALIZED VIEW CONCURRENTLY mv;

  ① 创建临时表 tmp_new,把定义查询的最新结果灌进去
  ② 用 mv 的唯一索引列与 tmp_new 做 FULL OUTER JOIN 比对
       - mv 有、tmp_new 没有        → 该行应删除
       - mv 与 tmp_new 都有但值不同 → 该行应更新
       - tmp_new 有、mv 没有        → 该行应插入
  ③ 执行 DELETE / UPDATE / INSERT,把差异落到 mv
  ④ 删除临时表,提交

关键点:它执行的是全量查询——定义查询会被完整重跑一遍,主表该扫多少行还是扫多少行。所谓「并发」只体现在应用结果的阶段(改行而非换表),并没有减少计算量:

sql
-- 无论普通还是并发,下面这条定义查询都会被完整执行
SELECT sku_id, sum(amount), count(*) FROM order_item GROUP BY sku_id;

因此一个常见误解要澄清:CONCURRENTLY 不是增量刷新(incremental refresh)。PostgreSQL 内核不提供基于 WAL 或变更日志的增量维护;想要增量,只能借助第三方扩展(如 pg_ivm)或自己用「位点表 + 增量 SQL」实现。

唯一索引的硬性要求 ​

并发刷新必须能在 mv 上找到至少一个满足全部条件的唯一索引:

条件说明反例
是 UNIQUE 索引普通索引不行CREATE INDEX ... (sku_id)
只使用列名,不含表达式表达式索引不行UNIQUE (lower(sku_id))
覆盖全部行,不含 WHERE部分索引不行UNIQUE (sku_id) WHERE sku_id > 0
索引必须建在物化视图上源表的主键/唯一约束不算基表有 PK,但 mv 上没有索引

正例:

sql
CREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);

反例一:部分唯一索引不被接受。

sql
CREATE UNIQUE INDEX mv_sku_sales_partial ON mv_sku_sales (sku_id) WHERE sku_id > 0;

REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
text
ERROR:  cannot refresh materialized view "public.mv_sku_sales" concurrently
HINT:  Create a UNIQUE index with no WHERE clause on one or more columns of the materialized view.

反例二:表达式索引不被接受。

sql
CREATE UNIQUE INDEX mv_sku_sales_expr ON mv_sku_sales (lower(sku_id));

对于聚合视图(含 GROUP BY),唯一索引必须覆盖全部分组列,否则同一索引键可能对应多行,触发唯一约束冲突:

sql
-- 分组键是 (tenant_id, sku_id),唯一索引就要同时包含两列
CREATE UNIQUE INDEX mv_tenant_sku_pk ON mv_tenant_sku_sales (tenant_id, sku_id);

查询现有索引是否合规:

sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'mv_sku_sales'
  AND schemaname = 'public';
text
        indexname        |                            indexdef
-------------------------+----------------------------------------------------------------
 mv_sku_sales_pk         | CREATE UNIQUE INDEX mv_sku_sales_pk ON public.mv_sku_sales USING btree (sku_id)
(1 row)

锁级别:不阻塞读的代价 ​

普通刷新持 ACCESS EXCLUSIVE 锁(与 DROP TABLE 同级),阻塞一切。并发刷新改用 ShareUpdateExclusiveLock——这是 PostgreSQL 中一个较特殊的锁级别,与 VACUUM(不带 FULL)、ANALYZE、CREATE INDEX CONCURRENTLY、REINDEX 等使用的级别相同:

操作持有锁与 SELECT 冲突?与 INSERT/UPDATE/DELETE 冲突?
REFRESH MATERIALIZED VIEWAccessExclusiveLock冲突(阻塞)冲突(阻塞)
REFRESH MATERIALIZED VIEW CONCURRENTLYShareUpdateExclusiveLock不冲突冲突

要点:

  • 不阻塞 SELECT,所以看板类查询不会因刷新而卡住。
  • 仍与写操作冲突。虽然物化视图本身通常不直接写,但对正在并发刷新的 mv 执行 DELETE/UPDATE 等操作可能被阻塞或与之冲突。
  • 刷新结束时需要短暂拿一下更强的锁来完成切换,若此时有长事务持有 mv 上的锁,刷新可能短暂等待。

观察当前 mv 上的锁:

sql
SELECT l.locktype,
       l.mode,
       l.granted,
       a.pid,
       a.state,
       a.query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation = 'mv_sku_sales'::regclass;
text
 locktype |           mode           | granted |  pid  | state  |                    query
----------+--------------------------+---------+-------+--------+---------------------------------------------
 relation | ShareUpdateExclusiveLock | t       | 18422 | active | REFRESH MATERIALIZED VIEW CONCURRENTLY mv_...
(1 row)

并发刷新的开销 ​

并发刷新用「更慢、更费资源」换取「不阻塞读」。四类额外开销:

1. 至少多一次全量比对。 定义查询要重跑,结果要写临时表,再与 mv 做外连接:

sql
-- 差异比对大致等价于(简化示意,实际由内核内部生成)
SELECT ...
FROM mv_sku_sales m
FULL OUTER JOIN tmp_new t USING (sku_id)
WHERE m.sku_id IS NULL OR t.sku_id IS NULL OR m.sales_amt IS DISTINCT FROM t.sales_amt;

2. 额外临时空间。 需要容纳「新结果临时表 + 差异结果 + 被更新的行版本」。大视图并发刷新时磁盘用量可能翻倍。

3. 产生死元组(dead tuple)。 差异更新是真实的 UPDATE/DELETE,会在 mv 上留下死元组,需要 VACUUM 回收——并发刷新比普通刷新更容易让 mv 膨胀:

sql
-- 检查 mv 的死元组与膨胀情况
SELECT relname,
       n_live_tup,
       n_dead_tup,
       last_vacuum,
       last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'mv_sku_sales';
text
    relname    | n_live_tup | n_dead_tup | last_vacuum |     last_autovacuum
---------------+------------+------------+-------------+-------------------------
 mv_sku_sales  |      84210 |      39217 |             | 2026-09-27 03:12:41+08
(1 row)

4. 变更行数少时反而更快。 官方文档明确指出:当只有少量行受影响时,并发刷新可能更快;而当大量行变化时,普通刷新通常更省资源、完成更快。

并发刷新的限制清单 ​

限制具体内容
必须有合规唯一索引见「唯一索引的硬性要求」
必须已填充WITH NO DATA 建的空壳不能用并发刷新
不能与 WITH NO DATA 同用CONCURRENTLY 与 WITH NO DATA 互斥
不能在事务块内不能在 BEGIN; ... COMMIT; 中执行
不能改定义并发只刷新数据,改定义要 DROP + 重建,或先 CREATE OR REPLACE 配合重建索引
同一 mv 同时只能有一个刷新即使加了 CONCURRENTLY,同一物化视图同一时刻只能有一个 REFRESH 在跑
不保证保留 ORDER BY 顺序定义查询里的 ORDER BY 不会在刷新后得到保证

空壳(未填充)视图上并发刷新的报错:

text
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_empty;
ERROR:  materialized view "public.mv_empty" has not been populated
HINT:  Use the REFRESH MATERIALIZED VIEW command.

正确做法是先普通刷新一次:

sql
-- 先用普通刷新填充
REFRESH MATERIALIZED VIEW mv_empty;
-- 之后再启用并发刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_empty;

事务块内执行会被拒绝——因为并发刷新需要自行管理提交时机:

sql
BEGIN;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;   -- 报错:不能在事务块内运行
COMMIT;

改为自动提交(关掉事务块)即可:

sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
ANALYZE mv_sku_sales;

刷新期间读到旧数据还是新数据 ​

读到旧数据。 差异合并是在当前事务内逐步完成的,只有 COMMIT 时新数据才对外可见:

text
会话 A(刷新方)                          会话 B(业务方)
────────────────────────────────────────────────────────────
BEGIN;
  ... 重算 + 差异比对 + 改行 ...
  (此时 A 未提交)                        SELECT * FROM mv_sku_sales;
                                          → 仍看到旧数据
COMMIT;  ────────────────────────────►   SELECT * FROM mv_sku_sales;
                                          → 看到新数据(原子切换)

这一点非常重要:

  • 没有撕裂读:不会出现「一部分 sku 是新值、一部分是旧值」。
  • 读者延迟感知:大视图刷新期间,读者会持续看到过期数据,直到刷新提交。
  • 失败即回滚:并发刷新在事务中执行,如果中途报错,事务回滚,mv 保持原内容(与普通刷新一致;但要注意某些失败场景下的一致性风险,详见下文)。

验证刷新后数据已切换:

sql
SELECT max(sales_amt) AS max_amt, count(*) AS skus FROM mv_sku_sales;
text
   max_amt   | skus
-------------+-------
 9284130.55  | 84210
(1 row)

并发刷新失败的常见场景 ​

并发刷新并非「万无一失」,以下场景会导致失败或数据异常:

1. 唯一索引缺失或不合规(最常见)。 见前述报错。检查:

sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'mv_sku_sales'
  AND indexdef ILIKE '%UNIQUE%';

2. 唯一索引列存在 NULL。 差异比对依赖唯一索引列匹配行,若索引列允许 NULL,匹配会变得不可靠,可能出现重复行或唯一约束冲突。生产环境建议把唯一索引列声明为 NOT NULL:

sql
-- 建 mv 时就保证键列非空
CREATE MATERIALIZED VIEW mv_sku_sales AS
SELECT sku_id, sum(amount)::numeric(14,2) AS sales_amt, count(*) AS order_cnt
FROM order_item
WHERE sku_id IS NOT NULL      -- 排除 NULL,保证索引列非空
GROUP BY sku_id;

3. 唯一键冲突。 若差异合并过程中与既有行发生唯一键冲突,会报 duplicate key value violates unique constraint——通常意味着定义查询的结果并不满足该唯一索引(例如 GROUP BY 漏了列)。

4. 并发 DML 干扰。 刷新期间若有其他会话对 mv 做 UPDATE/DELETE,可能出现 could not lock updated tuple in materialized view 一类错误。

5. 被取消或超时。 statement_timeout、lock_timeout、手动 pg_cancel_backend 都可能导致刷新中断,事务回滚。

6. 磁盘空间不足。 差异表与临时表需要额外空间,大视图刷新时可能触发「no space left on device」。

对刷新做超时保护(避免无限等待或过久占用):

sql
-- 只保护最后的锁等待,不限制整体执行时间
SET lock_timeout = '5s';
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
RESET lock_timeout;

普通刷新 vs 并发刷新的选择 ​

判断维度选普通刷新选并发刷新
读取是否必须不间断可接受短暂停顿(低峰期)必须不阻塞读(7×24 看板)
变更行占比大量行变化少量行变化(差异小)
是否有合规唯一索引无所谓必须有
是否有维护窗口有(凌晨批处理)无(随到随刷)
磁盘余量紧张充足(要放下临时表与差异表)
刷新耗时希望尽量短可接受更长

决策口诀:

  • 有维护窗口 → 优先普通刷新,简单、快、无索引要求。
  • 读取不能停 → 用并发刷新,并接受更慢、更费空间、产生死元组。
  • 变更很少 → 并发刷新往往比普通刷新还快(差异小、写入少)。
  • 没有合规唯一索引 → 要么补索引,要么退回普通刷新 + 维护窗口。
sql
-- 可以在同一套流程里按时间窗口二选一
DO $$
BEGIN
  IF extract(hour FROM now() AT TIME ZONE 'Asia/Shanghai') BETWEEN 2 AND 5 THEN
    REFRESH MATERIALIZED VIEW mv_sku_sales;              -- 低峰:普通刷新更快
  ELSE
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales; -- 高峰:不阻塞读
  END IF;
END$$;

观察并发刷新的进度 ​

PostgreSQL 没有为 REFRESH MATERIALIZED VIEW 提供专用的进度视图。官方的进度上报(progress reporting)目前只覆盖 ANALYZE、CLUSTER、VACUUM、CREATE INDEX、COPY 和 BASE_BACKUP,pg_stat_progress_* 系列里没有 pg_stat_progress_refresh_matview 之类的视图。

因此只能通过 pg_stat_activity 观察刷新「在不在跑、跑了多久、卡在什么状态」:

sql
SELECT pid,
       now() - query_start              AS elapsed,
       wait_event_type,
       wait_event,
       state,
       query
FROM pg_stat_activity
WHERE query ILIKE 'REFRESH MATERIALIZED VIEW%';
text
  pid  |    elapsed     | wait_event_type | wait_event | state  |              query
-------+----------------+-----------------+------------+--------+-----------------------------------
 18422 | 00:00:42.318   | IO              | DataFileRead | active | REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales
(1 row)

结合 pg_locks 判断是否在等锁:

sql
SELECT a.pid,
       a.wait_event_type,
       a.wait_event,
       l.mode,
       l.granted
FROM pg_stat_activity a
LEFT JOIN pg_locks l ON l.pid = a.pid AND l.relation = 'mv_sku_sales'::regclass
WHERE a.query ILIKE 'REFRESH MATERIALIZED VIEW%';
text
  pid  | wait_event_type | wait_event |           mode           | granted
-------+-----------------+------------+--------------------------+---------
 18422 | Lock            | relation   | ShareUpdateExclusiveLock | t
(1 row)

再用 pg_stat_activity 里的 backend_xmin 结合 pg_stat_activity/pg_locks 排查是否有长事务在阻塞刷新的收尾阶段:

sql
-- 找出可能长时间持有 mv 锁或快照的事务
SELECT pid, xact_start, now() - xact_start AS xact_age, state, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

进度感知的替代方案

既然没有原生进度视图,可以在刷新前后打点记录到监控表,用「预计行数 / 已耗时」来间接估计进度(见「实战示例」)。

无法并发刷新时的降级方案 ​

当物化视图没有合规唯一索引、或差异合并无法稳定工作时,可用双表切换(dual-table swap)实现「刷新不阻塞读」:

思路:准备两份物化数据,一份在线服务、一份离线刷新,刷完后用 CREATE OR REPLACE VIEW 把入口视图切到新的一份。

sql
-- ① 两份物化视图(普通刷新即可,无需唯一索引)
CREATE MATERIALIZED VIEW mv_report_a AS
SELECT region_id, sum(amount)::numeric(14,2) AS sales_amt FROM orders GROUP BY region_id;

CREATE MATERIALIZED VIEW mv_report_b AS
SELECT region_id, sum(amount)::numeric(14,2) AS sales_amt FROM orders GROUP BY region_id;

-- ② 应用只查这个入口视图
CREATE OR REPLACE VIEW v_report AS SELECT * FROM mv_report_a;
sql
-- ③ 离线刷新非在线的那一份(此时会阻塞,但没人读它)
REFRESH MATERIALIZED VIEW mv_report_b;
ANALYZE mv_report_b;

-- ④ 切换入口视图(只需毫秒级的 DDL,切走即生效)
CREATE OR REPLACE VIEW v_report AS SELECT * FROM mv_report_b;

要点与代价:

  • 无需唯一索引,普通刷新即可,规避了并发索引的所有硬性要求。
  • 切换是原子的:CREATE OR REPLACE VIEW 瞬间生效,读者不会看到中间态。
  • 代价:入口从物化视图变成普通视图,应用需改为查询 v_report;并且切换瞬间需要短暂获取 AccessExclusiveLock(视图级别,通常极短)。
  • 下次刷新要对调:这次刷 b,下次刷 a,交替进行。
sql
-- 下次轮到刷 a,再切回
REFRESH MATERIALIZED VIEW mv_report_a;
ANALYZE mv_report_a;
CREATE OR REPLACE VIEW v_report AS SELECT * FROM mv_report_a;

另一种更细粒度的降级:分区表 + 分区切换。把物化结果按时间分片写入独立表,用 ALTER TABLE ... ATTACH/DETACH PARTITION 替换单个分片,实现新数据「按片上线」。

sql
-- 示意:以分区为单位替换,只锁定单个分区
ALTER TABLE report_part DETACH PARTITION report_p202609;
ALTER TABLE report_part ATTACH PARTITION report_p202609_new FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

实战示例 ​

场景:电商看板的 SKU 销量物化视图 mv_sku_sales,要求 7×24 不阻塞读取,且需要监控刷新健康度。

第一步:建立带合规唯一索引的物化视图。

sql
CREATE MATERIALIZED VIEW mv_sku_sales AS
SELECT sku_id,
       sum(amount)::numeric(14,2) AS sales_amt,
       count(*)                   AS order_cnt,
       now()                      AS refreshed_at
FROM order_item
WHERE sku_id IS NOT NULL
GROUP BY sku_id;

CREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);
ANALYZE mv_sku_sales;

第二步:建刷新日志表,记录每次刷新的耗时与结果。

sql
CREATE TABLE mv_refresh_log (
  id            bigserial PRIMARY KEY,
  mv_name       text        NOT NULL,
  mode          text        NOT NULL,        -- 'normal' | 'concurrent'
  started_at    timestamptz NOT NULL,
  finished_at   timestamptz,
  duration_ms   integer,
  row_count     bigint,
  succeeded     boolean,
  err_message   text
);

第三步:封装带日志与异常捕获的并发刷新函数。

sql
CREATE OR REPLACE FUNCTION refresh_mv_concurrent(p_mv text)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
  v_start  timestamptz := clock_timestamp();
  v_rows   bigint;
  v_id     bigint;
BEGIN
  -- 先记开始
  INSERT INTO mv_refresh_log (mv_name, mode, started_at)
  VALUES (p_mv, 'concurrent', v_start)
  RETURNING id INTO v_id;

  BEGIN
    EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I', p_mv);
    EXECUTE format('ANALYZE %I', p_mv);
    EXECUTE format('SELECT count(*) FROM %I', p_mv) INTO v_rows;

    UPDATE mv_refresh_log
    SET finished_at = clock_timestamp(),
        duration_ms = (extract(epoch FROM clock_timestamp() - v_start) * 1000)::int,
        row_count   = v_rows,
        succeeded   = true
    WHERE id = v_id;
  EXCEPTION WHEN OTHERS THEN
    UPDATE mv_refresh_log
    SET finished_at = clock_timestamp(),
        duration_ms = (extract(epoch FROM clock_timestamp() - v_start) * 1000)::int,
        succeeded   = false,
        err_message = SQLERRM
    WHERE id = v_id;
    RAISE;   -- 继续抛出,让调用方/调度器感知失败
  END;
END;
$$;

调用:

sql
SELECT refresh_mv_concurrent('mv_sku_sales');

第四步:验证索引合规性——上线前自检脚本。

sql
-- 列出所有物化视图,并标记是否具备合规的唯一索引
SELECT c.relname AS matview,
       EXISTS (
         SELECT 1
         FROM pg_index i
         JOIN pg_class ic ON ic.oid = i.indexrelid
         WHERE i.indrelid = c.oid
           AND i.indisunique
           AND i.indisvalid
           AND i.indpred IS NULL                      -- 非部分索引
           AND ic.relam = (SELECT oid FROM pg_am WHERE amname = 'btree')
       ) AS has_usable_unique_index
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'm'
  AND n.nspname = 'public'
ORDER BY matview;
text
    matview    | has_usable_unique_index
---------------+-------------------------
 mv_sku_sales  | t
 mv_report_a   | f
 mv_report_b   | f
(3 rows)

第五步:监控告警 SQL——刷新失败或过久自动暴露。

sql
-- 最近 24 小时内失败或耗时超阈值的刷新
SELECT mv_name,
       mode,
       started_at,
       duration_ms,
       succeeded,
       err_message
FROM mv_refresh_log
WHERE started_at > now() - interval '24 hours'
  AND (succeeded = false OR duration_ms > 60000)
ORDER BY started_at DESC;
text
   mv_name    |    mode    |          started_at           | duration_ms | succeeded |                err_message
--------------+------------+-------------------------------+-------------+-----------+-------------------------------------------
 mv_sku_sales | concurrent | 2026-09-27 14:02:11.402+08    |       73420 | t         |
 mv_report_a  | concurrent | 2026-09-27 09:15:03.118+08    |             | f         | cannot refresh materialized view...
(2 rows)

第六步:数据新鲜度检查——now() 快照会随刷新更新,可用于判断数据有多旧。

sql
SELECT max(refreshed_at)              AS latest_snapshot,
       now() - max(refreshed_at)      AS staleness
FROM mv_sku_sales;
text
       latest_snapshot        | staleness
------------------------------+------------
 2026-09-28 03:00:02.117+08    | 00:14:57.883
(1 row)

第七步:对比两种刷新的实测表现(示意)。

text
场景:mv_sku_sales,约 8.4 万行,约 3% 的行发生变化

普通刷新    REFRESH MATERIALIZED VIEW              : 428 ms(期间 SELECT 全部阻塞)
并发刷新    REFRESH MATERIALIZED VIEW CONCURRENTLY : 613 ms(期间 SELECT 不受影响)

结论:并发刷新多花约 185ms,换取刷新期间读请求零阻塞。

注意事项 ​

  • 错误!把 CONCURRENTLY 当成增量刷新。 它依旧全量重算定义查询,只是改行的方式应用差异;数据量大的视图该慢还是慢。

    sql
    -- 错误认知:以为只处理变化的数据
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_huge_sales;  -- 定义查询仍会全表重算
  • 错误!在唯一索引上带 WHERE 或表达式。 部分唯一索引、表达式唯一索引都不满足并发刷新的前提。

    sql
    -- 不推荐:部分唯一索引
    CREATE UNIQUE INDEX mv_pk ON mv_sku_sales (sku_id) WHERE sku_id > 0;
    -- 不推荐:表达式唯一索引
    CREATE UNIQUE INDEX mv_pk2 ON mv_sku_sales (lower(sku_id));
  • 错误!指望源表主键能顶用。 并发刷新的唯一索引必须建在物化视图本身上,基表的 PK/唯一约束不算数。

    sql
    -- order_item 有主键,但 mv 上没有唯一索引 → 仍然报错
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
  • 错误!对未填充的空壳并发刷新。 先用普通刷新填一次,之后才能并发刷新。

    sql
    -- 不推荐:空壳直接并发刷新
    CREATE MATERIALIZED VIEW mv_x AS SELECT ... WITH NO DATA;
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_x;   -- 报错:has not been populated
  • 错误!写在事务块里。 并发刷新需要自行控制提交时机,不能包在显式事务中。

    sql
    -- 不推荐
    BEGIN;
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;  -- 报错
    COMMIT;
  • 不推荐:把 CONCURRENTLY 与 WITH NO DATA 连用。 两者语义互斥。

    sql
    -- 错误组合
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales WITH NO DATA;
  • 不推荐:在唯一索引列允许 NULL。 差异比对依赖索引键匹配行,含 NULL 时容易失配,建议在定义查询里过滤掉 NULL 或改为 NOT NULL。

    sql
    -- 建议:建 mv 时排除 NULL 键
    SELECT sku_id, sum(amount) FROM order_item WHERE sku_id IS NOT NULL GROUP BY sku_id;
  • 不推荐:省略 ANALYZE。 并发刷新后统计信息可能过时,导致查询计划变差。

    sql
    -- 不推荐:刷完就结束
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
    -- 推荐:补一次 ANALYZE
    ANALYZE mv_sku_sales;
  • 不推荐:高频并发刷新大视图。 会持续产生死元组与临时文件,磁盘与 autovacuum 压力都很大。

    sql
    -- 不推荐:每分钟一次
    -- * * * * *  REFRESH MATERIALIZED VIEW CONCURRENTLY mv_huge_sales;
  • 不推荐:忘记处理失败。 并发刷新失败后若无监控,很容易长期跑在过期数据上而无人察觉。

最佳实践 ​

  • 优先用普通刷新,除非读真的不能停。 有维护窗口时普通刷新更简单、更快。

    sql
    REFRESH MATERIALIZED VIEW mv_sku_sales;   -- 低峰期首选
  • 为并发刷新准备合规唯一索引,且尽量用 NOT NULL 列。

    sql
    CREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);
  • 始终配合 ANALYZE,或封装进函数一并处理。

    sql
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
    ANALYZE mv_sku_sales;
  • 用 lock_timeout 保护刷新,避免收尾阶段长时间等锁。

    sql
    SET lock_timeout = '5s';
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
    RESET lock_timeout;
  • 把每次刷新的耗时、行数、成功与否记入监控表,用于告警与容量规划(见「实战示例」)。

  • 给频繁并发刷新的 mv 调低 autovacuum 阈值,防止死元组堆积导致膨胀:

    sql
    ALTER MATERIALIZED VIEW mv_sku_sales
      SET (autovacuum_vacuum_scale_factor = 0.02,
           autovacuum_vacuum_threshold = 1000);
  • 没有合规唯一索引时,改用双表切换,用 CREATE OR REPLACE VIEW 做原子入口切换。

    sql
    CREATE OR REPLACE VIEW v_report AS SELECT * FROM mv_report_b;
  • 在刷新前后校验唯一索引存在性,把「索引被误删」纳入上线自检。

  • 监控数据新鲜度(staleness),一旦超过阈值即告警:

    sql
    SELECT now() - max(refreshed_at) AS staleness FROM mv_sku_sales;
  • 刷新失败要有兜底:要么保留旧数据继续服务(并发刷新失败会回滚),要么告警通知人工介入,绝不能静默过期。

下一节

继续学习:物化视图使用场景

参考链接 ​