并发刷新(CONCURRENTLY)
REFRESH MATERIALIZED VIEW CONCURRENTLY 让物化视图在刷新期间依然可以被 SELECT 读取。它不是增量刷新,底层依旧是「全量重算 + 差异比对(diff-and-merge)」,只是把差异以 DELETE/UPDATE/INSERT 的方式合并回去,从而避免长时间持 ACCESS EXCLUSIVE 锁。代价是必须存在符合要求的唯一索引、刷新更慢、占用更多临时空间。本节讲清它的原理、限制、失败场景与选择策略。
前置知识
阅读本节前,建议先了解:REFRESH MATERIALIZED VIEW、CREATE MATERIALIZED VIEW
基础概念
什么是并发刷新
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; 中执行 | 不允许在事务块中运行 |
读者看到的数据
时间轴 ──────────────────────────────────────────────────────►
刷新开始 刷新进行中 提交
│ │ │
├── 读者读到旧数据 ──────┤── 读者仍读到旧数据 ──────┤── 读者读到新数据
↑
原子切换,无中间态并发刷新的关键承诺:在提交那一刻之前,所有读取都是旧数据;提交之后,所有读取都是新数据。不会让读者看到「一半新一半旧」的撕裂状态。
语法与用法
REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] name
[ WITH [ NO ] DATA ]
-- CONCURRENTLY 与 WITH NO DATA 不能同时使用
-- 不能在事务块(BEGIN/COMMIT)中执行最简示例——先准备一个带唯一索引的物化视图:
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);执行并发刷新:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;观察 psql 输出:
REFRESH MATERIALIZED VIEW没有合规唯一索引时,直接报错:
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.对比普通刷新——读者会被阻塞、刷新更快但代价是停顿:
-- 阻塞式:刷大视图时,所有 SELECT 都会排队
REFRESH MATERIALIZED VIEW mv_sku_sales;详细说明
差异合并的内部原理
并发刷新在实现上是「先算新结果,再与旧结果做集合差」:
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
④ 删除临时表,提交关键点:它执行的是全量查询——定义查询会被完整重跑一遍,主表该扫多少行还是扫多少行。所谓「并发」只体现在应用结果的阶段(改行而非换表),并没有减少计算量:
-- 无论普通还是并发,下面这条定义查询都会被完整执行
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 上没有索引 |
正例:
CREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);反例一:部分唯一索引不被接受。
CREATE UNIQUE INDEX mv_sku_sales_partial ON mv_sku_sales (sku_id) WHERE sku_id > 0;
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.反例二:表达式索引不被接受。
CREATE UNIQUE INDEX mv_sku_sales_expr ON mv_sku_sales (lower(sku_id));对于聚合视图(含 GROUP BY),唯一索引必须覆盖全部分组列,否则同一索引键可能对应多行,触发唯一约束冲突:
-- 分组键是 (tenant_id, sku_id),唯一索引就要同时包含两列
CREATE UNIQUE INDEX mv_tenant_sku_pk ON mv_tenant_sku_sales (tenant_id, sku_id);查询现有索引是否合规:
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'mv_sku_sales'
AND schemaname = 'public'; 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 VIEW | AccessExclusiveLock | 冲突(阻塞) | 冲突(阻塞) |
REFRESH MATERIALIZED VIEW CONCURRENTLY | ShareUpdateExclusiveLock | 不冲突 | 冲突 |
要点:
- 不阻塞
SELECT,所以看板类查询不会因刷新而卡住。 - 仍与写操作冲突。虽然物化视图本身通常不直接写,但对正在并发刷新的 mv 执行
DELETE/UPDATE等操作可能被阻塞或与之冲突。 - 刷新结束时需要短暂拿一下更强的锁来完成切换,若此时有长事务持有 mv 上的锁,刷新可能短暂等待。
观察当前 mv 上的锁:
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; locktype | mode | granted | pid | state | query
----------+--------------------------+---------+-------+--------+---------------------------------------------
relation | ShareUpdateExclusiveLock | t | 18422 | active | REFRESH MATERIALIZED VIEW CONCURRENTLY mv_...
(1 row)并发刷新的开销
并发刷新用「更慢、更费资源」换取「不阻塞读」。四类额外开销:
1. 至少多一次全量比对。 定义查询要重跑,结果要写临时表,再与 mv 做外连接:
-- 差异比对大致等价于(简化示意,实际由内核内部生成)
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 膨胀:
-- 检查 mv 的死元组与膨胀情况
SELECT relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'mv_sku_sales'; 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 不会在刷新后得到保证 |
空壳(未填充)视图上并发刷新的报错:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_empty;
ERROR: materialized view "public.mv_empty" has not been populated
HINT: Use the REFRESH MATERIALIZED VIEW command.正确做法是先普通刷新一次:
-- 先用普通刷新填充
REFRESH MATERIALIZED VIEW mv_empty;
-- 之后再启用并发刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_empty;事务块内执行会被拒绝——因为并发刷新需要自行管理提交时机:
BEGIN;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales; -- 报错:不能在事务块内运行
COMMIT;改为自动提交(关掉事务块)即可:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
ANALYZE mv_sku_sales;刷新期间读到旧数据还是新数据
读到旧数据。 差异合并是在当前事务内逐步完成的,只有 COMMIT 时新数据才对外可见:
会话 A(刷新方) 会话 B(业务方)
────────────────────────────────────────────────────────────
BEGIN;
... 重算 + 差异比对 + 改行 ...
(此时 A 未提交) SELECT * FROM mv_sku_sales;
→ 仍看到旧数据
COMMIT; ────────────────────────────► SELECT * FROM mv_sku_sales;
→ 看到新数据(原子切换)这一点非常重要:
- 没有撕裂读:不会出现「一部分 sku 是新值、一部分是旧值」。
- 读者延迟感知:大视图刷新期间,读者会持续看到过期数据,直到刷新提交。
- 失败即回滚:并发刷新在事务中执行,如果中途报错,事务回滚,mv 保持原内容(与普通刷新一致;但要注意某些失败场景下的一致性风险,详见下文)。
验证刷新后数据已切换:
SELECT max(sales_amt) AS max_amt, count(*) AS skus FROM mv_sku_sales; max_amt | skus
-------------+-------
9284130.55 | 84210
(1 row)并发刷新失败的常见场景
并发刷新并非「万无一失」,以下场景会导致失败或数据异常:
1. 唯一索引缺失或不合规(最常见)。 见前述报错。检查:
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'mv_sku_sales'
AND indexdef ILIKE '%UNIQUE%';2. 唯一索引列存在 NULL。 差异比对依赖唯一索引列匹配行,若索引列允许 NULL,匹配会变得不可靠,可能出现重复行或唯一约束冲突。生产环境建议把唯一索引列声明为 NOT NULL:
-- 建 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」。
对刷新做超时保护(避免无限等待或过久占用):
-- 只保护最后的锁等待,不限制整体执行时间
SET lock_timeout = '5s';
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales;
RESET lock_timeout;普通刷新 vs 并发刷新的选择
| 判断维度 | 选普通刷新 | 选并发刷新 |
|---|---|---|
| 读取是否必须不间断 | 可接受短暂停顿(低峰期) | 必须不阻塞读(7×24 看板) |
| 变更行占比 | 大量行变化 | 少量行变化(差异小) |
| 是否有合规唯一索引 | 无所谓 | 必须有 |
| 是否有维护窗口 | 有(凌晨批处理) | 无(随到随刷) |
| 磁盘余量 | 紧张 | 充足(要放下临时表与差异表) |
| 刷新耗时 | 希望尽量短 | 可接受更长 |
决策口诀:
- 有维护窗口 → 优先普通刷新,简单、快、无索引要求。
- 读取不能停 → 用并发刷新,并接受更慢、更费空间、产生死元组。
- 变更很少 → 并发刷新往往比普通刷新还快(差异小、写入少)。
- 没有合规唯一索引 → 要么补索引,要么退回普通刷新 + 维护窗口。
-- 可以在同一套流程里按时间窗口二选一
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 观察刷新「在不在跑、跑了多久、卡在什么状态」:
SELECT pid,
now() - query_start AS elapsed,
wait_event_type,
wait_event,
state,
query
FROM pg_stat_activity
WHERE query ILIKE 'REFRESH MATERIALIZED VIEW%'; 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 判断是否在等锁:
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%'; 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 排查是否有长事务在阻塞刷新的收尾阶段:
-- 找出可能长时间持有 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 把入口视图切到新的一份。
-- ① 两份物化视图(普通刷新即可,无需唯一索引)
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;-- ③ 离线刷新非在线的那一份(此时会阻塞,但没人读它)
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,交替进行。
-- 下次轮到刷 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 替换单个分片,实现新数据「按片上线」。
-- 示意:以分区为单位替换,只锁定单个分区
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 不阻塞读取,且需要监控刷新健康度。
第一步:建立带合规唯一索引的物化视图。
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;第二步:建刷新日志表,记录每次刷新的耗时与结果。
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
);第三步:封装带日志与异常捕获的并发刷新函数。
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;
$$;调用:
SELECT refresh_mv_concurrent('mv_sku_sales');第四步:验证索引合规性——上线前自检脚本。
-- 列出所有物化视图,并标记是否具备合规的唯一索引
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; matview | has_usable_unique_index
---------------+-------------------------
mv_sku_sales | t
mv_report_a | f
mv_report_b | f
(3 rows)第五步:监控告警 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; 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() 快照会随刷新更新,可用于判断数据有多旧。
SELECT max(refreshed_at) AS latest_snapshot,
now() - max(refreshed_at) AS staleness
FROM mv_sku_sales; latest_snapshot | staleness
------------------------------+------------
2026-09-28 03:00:02.117+08 | 00:14:57.883
(1 row)第七步:对比两种刷新的实测表现(示意)。
场景: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;不推荐:忘记处理失败。 并发刷新失败后若无监控,很容易长期跑在过期数据上而无人察觉。
最佳实践
优先用普通刷新,除非读真的不能停。 有维护窗口时普通刷新更简单、更快。
sqlREFRESH MATERIALIZED VIEW mv_sku_sales; -- 低峰期首选为并发刷新准备合规唯一索引,且尽量用
NOT NULL列。sqlCREATE UNIQUE INDEX mv_sku_sales_pk ON mv_sku_sales (sku_id);始终配合
ANALYZE,或封装进函数一并处理。sqlREFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales; ANALYZE mv_sku_sales;用
lock_timeout保护刷新,避免收尾阶段长时间等锁。sqlSET lock_timeout = '5s'; REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_sales; RESET lock_timeout;把每次刷新的耗时、行数、成功与否记入监控表,用于告警与容量规划(见「实战示例」)。
给频繁并发刷新的 mv 调低 autovacuum 阈值,防止死元组堆积导致膨胀:
sqlALTER MATERIALIZED VIEW mv_sku_sales SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 1000);没有合规唯一索引时,改用双表切换,用
CREATE OR REPLACE VIEW做原子入口切换。sqlCREATE OR REPLACE VIEW v_report AS SELECT * FROM mv_report_b;在刷新前后校验唯一索引存在性,把「索引被误删」纳入上线自检。
监控数据新鲜度(staleness),一旦超过阈值即告警:
sqlSELECT now() - max(refreshed_at) AS staleness FROM mv_sku_sales;刷新失败要有兜底:要么保留旧数据继续服务(并发刷新失败会回滚),要么告警通知人工介入,绝不能静默过期。
下一节
继续学习:物化视图使用场景