concurrently刷新必须有唯一索引,因为其通过唯一索引列join/except计算行级差异实现增量更新;无唯一索引则无法识别对应行而报错,且索引列须not null、显式创建、覆盖全部group by列。

CONCURRENTLY 刷新为什么必须有唯一索引
因为 REFRESH MATERIALIZED VIEW CONCURRENTLY 不是重写整张表,而是通过比对新旧数据的行级差异来增量更新:先构建临时结果集,再用唯一索引列做 JOIN 或 EXCEPT 找出要插入、更新、删除的行。没有唯一索引,PostgreSQL 就无法安全识别“哪一行对应哪一行”,会直接报错:ERROR: cannot refresh materialized view "xxx" concurrently, because it does not have a unique index。
常见踩坑点:
- 只加了
PRIMARY KEY但字段允许NULL—— PostgreSQL 要求索引列必须NOT NULL,否则不满足并发刷新前提 - 用了
UNIQUE约束但没显式建索引 —— 必须是CREATE UNIQUE INDEX,约束本身不触发索引创建(尤其在分区表或某些迁移场景下易漏) - 聚合视图里用
GROUP BY多列,但没把所有分组列都包含进唯一索引 —— 比如GROUP BY a, b,却只对a建唯一索引,会导致逻辑不一致
如何设计适合 CONCURRENTLY 的物化视图结构
核心原则:让物化视图的“主键语义”稳定、可索引、非空。不是照搬源表主键,而是根据业务查询模式反推。
例如统计类视图:
CREATE MATERIALIZED VIEW category_sales AS SELECT c.category_id, c.category_name, SUM(o.amount) AS total FROM orders o JOIN products p ON o.product_id = p.id JOIN categories c ON p.category_id = c.id GROUP BY c.category_id, c.category_name;
这时应建索引:
CREATE UNIQUE INDEX idx_category_sales_pk ON category_sales (category_id);
而不是用 (category_name) —— 名称可能重复或变更;也不要用 (category_id, category_name) —— 冗余且增加索引体积。
关键建议:
- 优先选业务上天然唯一、不可为空、极少变更的字段(如
tenant_id + event_id、date_trunc('day', created_at) + user_id) - 避免在索引中包含表达式或函数调用(如
UPPER(name)),除非你确认刷新时能精确匹配 - 如果源数据本身无自然键,可在物化视图定义中用
row_number() OVER (...) AS mv_rowid生成伪主键,并对其建唯一索引
CONCURRENTLY 刷新的实际性能与资源代价
它不锁表,但不等于零开销。每次 REFRESH MATERIALIZED VIEW CONCURRENTLY 都会:
- 额外占用约 1.5 倍当前物化视图大小的磁盘空间(用于临时表和索引)
- 执行两次全量扫描:一次读源查询,一次读原物化视图做差集
- 在高并发写入场景下,若源表变更频繁,差集计算可能变慢,甚至因事务快照过旧导致重复刷新失败
所以不能盲目高频调用。推荐策略:
- 按数据变更节奏控制刷新频次:比如订单表每分钟新增 200 行,就设为每 2 分钟刷一次;日志类表每秒万级写入,就改用时间分片 + 双表切换
- 监控
pg_stat_progress_refresh_matview视图,看实际耗时是否稳定;若单次超过 5 秒,说明数据窗口太大,该切分条件了 - 避免在大事务中嵌套刷新操作 —— 它会延长自身事务生命周期,拖慢整体提交
调度 CONCURRENTLY 刷新时最容易忽略的细节
用 pg_cron 或应用层定时器触发时,很多人只写 REFRESH MATERIALIZED VIEW CONCURRENTLY xxx,却忘了三件事:
- 没检查物化视图是否已被其他进程锁定(比如另一个
REFRESH正在运行),此时命令会阻塞或报错ERROR: cannot refresh materialized view "xxx" concurrently, because it is being used by another transaction - 没设置超时:长时间运行的刷新可能卡住连接池,应在客户端或
pg_cronjob 中加statement_timeout - 没处理失败重试逻辑:网络抖动、磁盘满、唯一冲突等都会导致刷新中断,而
CONCURRENTLY不支持事务内回滚重试,需外部兜底
一个最小可用的 pg_cron 示例:
SELECT cron.schedule( 'refresh-category-sales', '*/3 * * * *', -- 每3分钟 $$ SET statement_timeout = '30s'; REFRESH MATERIALIZED VIEW CONCURRENTLY category_sales; $$ );
真正难的不是写这一行命令,而是确保 category_sales 的数据边界清晰、索引稳定、失败可观测 —— 否则越自动,越容易半夜告警。










