DEBUG.WIKI
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

PostgreSQL 物化视图有索引,仍报 cannot refresh concurrently

PostgreSQL 并发刷新物化视图需要合格的唯一索引,已有 GIN 搜索索引并不够。核对索引有效性、行粒度和填充状态,再安排建索引与刷新,避免用删除数据或重置视图绕过错误。

物化视图上已经有全文检索或模糊搜索索引,执行并发刷新却失败:

ERROR: cannot refresh materialized view "public.search_catalog" concurrently
HINT: Create a unique index with no WHERE clause on one or more columns of the materialized view.

REFRESH MATERIALIZED VIEW CONCURRENTLY 至少需要一个覆盖全部行、直接使用普通列的唯一索引;GIN 搜索索引、带 WHERE 的部分唯一索引、表达式唯一索引,都不能替代这个条件。

如果应用只显示 SQLSTATE 55000,应取回完整错误信息和 HINT,再确定是否属于本文的索引前提错误。55000 是一类对象状态错误,单凭编号不能判断一定缺少唯一索引。

下面按 PostgreSQL 18 说明,示例对象 public.search_catalog 和列 item_id 都是占位名称。建索引、刷新及删除索引应由具有相应权限的维护角色执行;PostgreSQL 18 对刷新要求物化视图的 MAINTAIN 权限。

核对的是物化视图自己的索引

底表有主键,不代表物化视图自动有同样的唯一索引。查询可能经过连接、聚合,输出行的粒度也可能与底表不同。先确认连接到了正确数据库,并查看目标物化视图是否已经填充:

SELECT schemaname, matviewname, ispopulated
FROM pg_matviews
WHERE schemaname = 'public'
  AND matviewname = 'search_catalog';

ispopulated 表示是否处于已填充状态,不等于“至少有一行”。一次正常刷新即使得到零行,也不同于 WITH NO DATA 留下的未填充状态。

再检查该对象的索引定义和有效性:

SELECT indexrelid::regclass AS index_name,
       indisunique,
       indisvalid,
       indimmediate,
       pg_get_indexdef(indexrelid) AS definition
FROM pg_index
WHERE indrelid = 'public.search_catalog'::regclass;

不能只看索引名字里有没有 unique。应确认 indisunique、indisvalid 和 indimmediate 为真,同时定义中的索引项是普通列,没有表达式,也没有 WHERE 条件。失效的唯一索引不能用来满足刷新前提。

常见情况有:在底表建了唯一索引,却没有在物化视图上建;为避免重复,只对部分行建立唯一索引;或者在 lower(title) 上建唯一索引。它们各有用途,但都不满足这里的要求。已经存在的 GIN 索引仍可服务搜索,不需要为添加刷新所需索引而删掉它。

选择能标识一行结果的列,再建唯一索引

假设业务定义保证物化视图中每个 item_id 只出现一次,且该列不会为空,可以用它作为索引键。先核对当前内容:

SELECT item_id, COUNT(*)
FROM public.search_catalog
GROUP BY item_id
HAVING COUNT(*) > 1;

SELECT COUNT(*) AS null_key_rows
FROM public.search_catalog
WHERE item_id IS NULL;

第一条应不返回重复键,第二条在本示例前提下应为零。这里要求非空是为了使用清晰的业务标识,不应将其误解为并发刷新的额外统一语法要求:PostgreSQL 默认唯一索引允许多个 NULL,不能把“唯一索引创建成功”直接当作所有行都有非空唯一 ID 的证明。

当前结果没有重复还不够。继续核对物化视图定义:如果一个商品连接到多个标签,结果可能是一条商品对应多行。这时商品 ID 本身就不是该结果的唯一键。应根据实际行粒度选用稳定的列组合,或修正产生非预期重复的查询,再维护索引。不要为了建索引随意删除业务行,也不要凭一次查询结果认定未来刷新永远不会重复。

在上述单列前提成立、索引名称未被占用,并已安排维护窗口后,创建普通唯一索引:

CREATE UNIQUE INDEX search_catalog_item_id_uq
ON public.search_catalog (item_id);

这是普通索引构建,不是并发建索引。它需要读取已有数据、占用存储,并取得相应锁;应暂停或协调该物化视图的刷新任务,评估维护期间的等待。不要把下一步刷新命令中的 CONCURRENTLY 理解为前面的建索引也自动无阻塞。

如果创建时报重复键错误,回到结果粒度和重复数据检查。不要把失败脚本改成 IF NOT EXISTS 来掩盖状态:同名对象存在并不能保证它就是需要的唯一索引。若此前尝试过其他索引构建方式,尤其要重新核对 indisvalid 与完整定义。

未填充的视图先完成一次普通刷新

并发刷新还有一个独立前提:物化视图已经填充。若前面的 ispopulated 为假,就不能直接使用 CONCURRENTLY,也无法通过查询该视图检查重复数据。

这种情况应在维护窗口先执行一次普通刷新:

REFRESH MATERIALIZED VIEW public.search_catalog;

随后核对填充状态、结果中的重复键和空值,再按需要创建唯一索引。若视图在未填充时就已经建有该索引,首次填充的数据同样必须满足它的唯一性。

普通刷新可能阻塞对该物化视图的读取,需要安排相应窗口。不要用 WITH NO DATA 作为刷新失败的“重置”手段:它会使物化视图不可查询,而且不能与 CONCURRENTLY 同时使用。

刷新成功后,还要核对数据和任务安排

已经填充且至少有一个合格唯一索引后,才执行:

REFRESH MATERIALIZED VIEW CONCURRENTLY public.search_catalog;

命令应不再报缺少合格唯一索引的错误。再次读取索引状态和 ispopulated,并按业务选取几条已知变动记录核对结果,同时确认全文检索或其他查询仍正常。

CONCURRENTLY 的作用是刷新期间不排斥对物化视图的并发 SELECT。它不保证所有场景都更快,也不表示同一物化视图能同时跑多个刷新:同一时刻仍只允许一个 REFRESH。定时任务频率比刷新耗时短时,应先避免任务重叠,而不是继续增加并发数。

如果下一次刷新出现唯一性冲突,说明新结果不再满足索引约束,应检查数据或查询定义。唯一索引既是当前刷新前提,也是以后结果必须持续满足的约束;建立它不会修正错误的连接关系。

回退只撤回本次新增的索引

变更前保留物化视图定义、已有索引清单和刷新任务配置。如果确认要放弃这次新增索引,先停止使用并发刷新的任务,确认目标正是本次创建、没有其他依赖需要保留的索引,再在维护窗口删除:

DROP INDEX public.search_catalog_item_id_uq;

普通 DROP INDEX 会取得阻塞其他访问的锁,因此也需要安排窗口。不要用 CASCADE 强行跳过依赖检查,更不要删除物化视图来撤回一个索引。

删除唯一索引不会把已经刷新过的内容恢复成旧快照;如果它是唯一满足条件的索引,后续并发刷新也会再次失败。回退后只能在可接受读取阻塞的窗口使用普通刷新,或先修正结果模型、重新建立合格索引,再恢复并发刷新任务。