很多人看到 CONCURRENTLY,会直觉认为它代表“在线且更聪明”的物化视图刷新方式;但在 PostgreSQL 16 里,它解决的其实主要是读锁问题,并不等于增量刷新。真正决定命令能不能跑起来的,是物化视图本身有没有一条满足全部约束条件的唯一索引。
如果你正遇到 does not have a unique index 报错,或者想判断并发刷新是否值得上生产,下面这篇可以直接帮你拆清楚:先看入口校验为什么会拦截,再看失败场景、性能代价,以及什么时候该考虑自己控制刷新范围。
为什么加了 CONCURRENTLY 还是报 unique index 错误
这不是权限问题,也不是简单的配置疏漏。PostgreSQL 16 会在命令入口先做严格校验,只要物化视图不满足并发刷新的硬性前提,就会直接拒绝执行,根本不会进入刷新阶段。
典型报错如下:
ERROR: cannot refresh materialized view "xxx" concurrently, because it does not have a unique index
REFRESH MATERIALIZED VIEW CONCURRENTLY 能执行的前提很明确:物化视图上必须显式存在一个可被识别的唯一索引,而且这个索引还要符合 PostgreSQL 的一整套限制。
唯一索引必须满足哪些硬条件
- 索引必须直接建在物化视图上,例如:
CREATE UNIQUE INDEX ON my_mv (id)。源表有主键、唯一约束,或者上游表天然不重复,都不能替代这个要求。 - 所有索引列都必须是
NOT NULL。如果列本身允许NULL,就算业务上几乎不会为空,也不满足这里的校验要求。 - 如果物化视图是聚合结果,例如定义中包含
GROUP BY tenant_id, event_type,那么唯一索引必须覆盖全部分组列。 - 列顺序也要一致,
(tenant_id, event_type)不能换成(event_type, tenant_id)。 - 表达式索引、函数索引、带
WHERE条件的部分索引都不会被接受,例如(UPPER(name))这类写法不行。 - 新建索引后如果仍处于
INVALID状态,PostgreSQL 也不会认可,通常需要执行一次VACUUM my_mv或ANALYZE my_mv让它进入可识别状态。
这也是为什么很多人明明“建了索引”,却还是被挡在刷新命令外面:问题不在有没有索引,而在这个索引是不是完全符合并发刷新的校验规则。
NULL、表达式和分组列为什么会卡住刷新
并发刷新并不是简单地重新跑一次查询,而是要拿“旧结果”和“新结果”做差异比对,再把变化写回物化视图。要让这个过程稳定执行,PostgreSQL 必须能用一组确定、可唯一定位的键来匹配每一行。
NULL会破坏唯一定位的确定性,所以索引列不能允许为空。- 表达式索引和函数索引不被接受,是因为刷新过程要求的是可直接、稳定映射到底层结果行的列结构,而不是经过额外计算后的键。
- 聚合类物化视图必须把所有
GROUP BY列都纳入唯一索引,否则 PostgreSQL 无法可靠判断哪一组旧结果对应哪一组新结果。
文中提到“如果字段允许 NULL,得用 COALESCE(col, 0) 或类似兜底后再建索引”,这类做法本质上是在物化视图定义阶段就把键值规范化;但要注意,最终被接受的前提仍然是物化视图上存在满足规则的唯一列组合,而不是随意补一个表达式索引。
索引建对了,为什么刷新仍可能失败
满足唯一索引条件,只代表你通过了命令入口校验;它并不保证整个刷新过程一定成功。并发刷新在执行中仍可能因为数据竞争或定义不稳定而中途失败,失败后物化视图还可能处于“部分更新”状态:旧数据没删完,新数据也没写完,这时结果不能直接拿来用。

几类常见的运行期失败场景
- 业务写入与刷新产生唯一键冲突:
ERROR: duplicate key value violates unique constraint。例如新结果集中准备写入order_id = 123,而业务事务恰好也插入了同一键值的行。 - 某行在 merge 阶段前被业务更新:
could not lock updated tuple in materialized view。 - 物化视图刚创建且处于
WITH NO DATA状态时,不能直接使用CONCURRENTLY。必须先执行一次普通刷新:REFRESH MATERIALIZED VIEW my_mv。 - 定义里包含不可重放表达式,例如
now()、random()、CURRENT_USER,或者用了未配ORDER BY的DISTINCT ON/ 窗口函数,会让新旧结果难以精确比对。
这些问题的共同点是:并发刷新依赖“结果可重复、行可匹配、键可稳定定位”。只要其中任一环节不成立,刷新就可能在运行期出错。
并发刷新不等于更快:性能和资源代价要先算清
CONCURRENTLY 的主要价值是“尽量不阻塞读取”,不是“让刷新更快”。从执行路径看,它仍然是全量逻辑,只是把结果差异化写回,因此成本往往比普通刷新更高。
- 每次都要全量扫描源表。
- 要完整执行一次原始查询。
- 要做两次索引扫描,分别对应新旧数据。
- 还要额外计算差集并执行合并写入。
文中给出的实测结论是:在 400 万行规模下,并发刷新可能比普通刷新慢 3–4 倍。这一点非常关键,因为很多团队在评估方案时,只盯着“在线刷新”这四个字,忽略了背后的 CPU、I/O 和磁盘代价。
容易被忽略的磁盘与锁影响
- 临时副本会占用额外磁盘空间,高频刷新时尤其危险。例如每分钟刷新一次,很容易触发
no space left on device。 - 它不会阻塞
SELECT,但并不代表完全无锁。对正在刷新的物化视图执行UPDATE或DELETE,仍可能被锁住。 - 真正值得优化的,通常不是刷新命令参数,而是物化视图定义本身:去掉
DISTINCT ON、避免无序json_agg、保证聚合字段类型和WHERE条件严格一致。
例如下面这种写法就比隐式类型不一致更稳:
EXTRACT(YEAR FROM ts)::int = 2024
而不是:
EXTRACT(YEAR FROM ts) = 2024
想做“增量刷新”,PostgreSQL 16 不能直接给你
这一点很容易被误解。PostgreSQL 16 内核并没有提供基于 WAL 或变更日志的原生增量物化视图刷新能力,REFRESH MATERIALIZED VIEW CONCURRENTLY 仍然只是“全量查 + 全量比 + 差异写”。
如果你的目标不是减少读锁,而是实打实减少扫描数据量,那就只能自己控制刷新范围。
可行的两类思路
- 手动维护位点。建立一张
matview_refresh_state表,记录上次刷新的最大updated_at,然后在刷新 SQL 中加上WHERE updated_at > ?。 - 使用
pg_matview扩展。它的本质是把物化视图当普通表来维护,通过触发器捕获变更,再手写INSERT ON CONFLICT逻辑;它不会自动推导变化行,更像是提供一层脚手架。
但这两类方案都有共同前提:你必须拥有可靠的单调递增字段,例如自增 ID 或严格递增时间戳。否则边界判定会出错,轻则漏数据,重则重复计算。
真正该提前做的,是在建 MV 时把索引结构设计好
最容易被忽略的一点,不在刷新命令本身,而在建模阶段。物化视图的唯一索引结构,最好在创建 MV 时就一起设计清楚,而不是等 CONCURRENTLY 报错后再回头补。
原因很现实:随着 MV 数据量增长,重建索引、调整键结构、回填数据的成本会迅速上升。相比之下,前期多花十分钟把主键语义、分组列顺序和可空性想清楚,往往能省下后面成倍的维护代价。
如果只记住一句话,可以记这一句:在 PostgreSQL 16 里,并发刷新不是增量刷新,它只是把“阻塞读”换成了“更严格的索引前提”和“更高的执行成本”。










