一条报表 SQL 每次都要扫描千万行再 GROUP BY,接口动辄耗时 8 秒?在 PostgreSQL 里,把这种高频聚合结果预先算好、存成一张物理表,正是 物化视图(Materialized View) 的价值:它把”实时计算”变成”定时刷新”,用极小的存储换几十倍的 查询提速。本文从创建、刷新到并发刷新与自动刷新,带你把物化视图真正用在生产环境,并避开数据陈旧、刷新锁等常见坑。
视图与物化视图:先搞清楚区别
很多人分不清普通视图(View)和物化视图。普通视图只是一个”保存的 SQL”,每次查询都把它展开后实时执行,底表多大就扫多大;物化视图则把查询结果真正落盘成一张表,查询时直接读这张表,不再回算。代价是数据不是实时的,需要你决定何时刷新。
| 维度 | 普通视图 View | 物化视图 Materialized View |
|---|---|---|
| 数据存储 | 不存,仅存定义 | 落盘成物理表 |
| 查询速度 | 等同原 SQL,无加速 | 直接读表,极快 |
| 数据新鲜度 | 实时 | 取决于刷新时机 |
| 适用场景 | 简化权限/复用 SQL | 报表、聚合、跨表 JOIN 缓存 |
创建你的第一个物化视图
语法和普通 CREATE TABLE ... AS 很像,只是换成 CREATE MATERIALIZED VIEW。下面这张视图把”每个用户的累计订单金额”预计算出来:
CREATE MATERIALIZED VIEW mv_user_order_amount AS
SELECT
u.id AS user_id,
u.name AS user_name,
COUNT(o.id) AS order_cnt,
SUM(o.amount) AS total_amount,
MAX(o.created_at) AS last_order_at
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;
-- 验证:直接查询这张物化视图,已无需再扫 orders 大表
SELECT * FROM mv_user_order_amount
WHERE total_amount > 10000
ORDER BY total_amount DESC
LIMIT 20;
刷新:REFRESH MATERIALIZED VIEW
物化视图不会自动跟着底表更新,必须显式刷新。基础命令是 REFRESH MATERIALIZED VIEW:
-- 全量刷新(会先清空再重算,期间视图不可读)
REFRESH MATERIALIZED VIEW mv_user_order_amount;
-- 只重算结构、不填充数据(极少用,常用于先建空壳)
REFRESH MATERIALIZED VIEW mv_user_order_amount WITH NO DATA;
-- 并发刷新:不阻塞读,但要求视图上已建唯一索引
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_user_order_amount;
关键约束:CONCURRENTLY 必须在物化视图上存在一个唯一索引,否则会报错。它的好处是刷新期间旧数据仍然可读,适合线上高频查询的场景;普通 REFRESH 在刷新瞬间会锁住视图、查询直接失败或等待。
并发刷新与唯一索引
给物化视图建唯一索引时,索引列应能唯一标识每一行(通常是 GROUP BY 的分组键)。下面以用户 ID 作为唯一键:
-- 唯一索引是 CONCURRENTLY 刷新的前提
CREATE UNIQUE INDEX unq_mv_user_order ON mv_user_order_amount (user_id);
-- 之后即可安全并发刷新,线上查询不中断
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_user_order_amount;
注意:如果底表数据量极大,第一次建唯一索引本身也会锁表,建议在低峰期执行,或先在小批次验证。
实战一:报表按天聚合提速
最常见的用法是沉淀报表。例如运营要看”最近 90 天每天的下单数与 GMV”,原 SQL 每次扫全量订单表,物化视图把它固化:
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS order_cnt,
SUM(amount) AS gmv,
COUNT(DISTINCT user_id) AS paying_users
FROM orders
WHERE created_at >= now() - interval '90 days'
GROUP BY 1;
CREATE UNIQUE INDEX unq_mv_daily_sales ON mv_daily_sales (day);
-- 前端报表直接读这张小表,毫秒级返回
SELECT day, order_cnt, gmv
FROM mv_daily_sales
ORDER BY day;
原来要扫描几千万行的查询,现在只读 90 行聚合结果。配合 PostgreSQL 慢查询排查 的方法定位到这类聚合慢 SQL,再用物化视图兜底,是性价比极高的优化组合。
实战二:跨表 JOIN 结果缓存
多表 JOIN(尤其带 JSONB、子查询)是另一类重查询。把 JOIN 结果固化,能省下大量连表开销:
CREATE MATERIALIZED VIEW mv_product_detail AS
SELECT
p.id,
p.title,
p.price,
c.name AS category_name,
b.name AS brand_name,
p.attrs->>'color' AS color
FROM products p
JOIN categories c ON c.id = p.category_id
JOIN brands b ON b.id = p.brand_id;
CREATE UNIQUE INDEX unq_mv_product ON mv_product_detail (id);
这里用到了 PostgreSQL JSONB 字段的取值,固化后商品详情页直接读视图,避免每次都连三张表。若商品表按 PostgreSQL 分区表 做了时间分区,视图定义里同样可以基于分区父表,刷新时只针对热分区。
自动刷新:别再手动 REFRESH
物化视图最大的运维风险是”忘了刷新导致数据陈旧”。生产环境一般用定时任务驱动刷新,最干净的做法是用 pg_cron 扩展在数据库内排期:
-- 安装 pg_cron 后,注册每天凌晨 2 点并发刷新
SELECT cron.schedule(
'refresh_mv_daily_sales',
'0 2 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;'
);
-- 也可以用应用层定时任务(crontab / GitHub Actions)远程执行
-- psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;"
刷新频率取决于业务对”新鲜度”的要求:日报类一天一次足够,近实时大屏可能需要每 5 分钟一次——但刷新越频繁,底层写压力越大,需要权衡。
增量刷新:PostgreSQL 没有原生支持怎么办
标准 PostgreSQL 的 REFRESH 都是全量重算。真正的”只更新变化行”需要借助扩展,例如 pg_ivm(Incremental View Maintenance):
-- 安装 pg_ivm 后,用 IVM 方式创建可增量维护的物化视图
CREATE EXTENSION IF NOT EXISTS pg_ivm;
SELECT create_ivm_view(
'mv_user_order_amount',
'SELECT u.id, COUNT(o.id) AS order_cnt, SUM(o.amount) AS total_amount
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id'
);
-- 此后底表 inserts/updates/deletes 会自动增量同步到视图,无需手动 REFRESH
如果暂时不能装扩展,退而求其次的”准增量”方案是:维护一张变更日志表,只针对变化的主键做局部重算并 UPDATE 对应行。代价是应用侧要改造,适合超大规模、全量刷新成本不可接受的场景。
常见坑与排障
| 现象 | 根因 | 修复 |
|---|---|---|
| 查询读到的数据很旧 | 长时间没 REFRESH | 用 pg_cron 定时刷新,或写入后触发刷新 |
| REFRESH CONCURRENTLY 报错 | 视图上无唯一索引 | 先 CREATE UNIQUE INDEX 再并发刷新 |
| 刷新时查询卡顿/失败 | 用了非并发刷新,锁住视图 | 线上一律用 CONCURRENTLY |
| 物化视图占用空间暴涨 | 聚合粒度太细或长期不清理 | 缩短保留窗口,或为基表做分区 |
| 刷新把主库 CPU 打满 | 全量重算 + 高频触发 | 降频、低峰执行、改用 pg_ivm 增量 |
和 PgBouncer 连接池 配合时也要注意:长事务里的 REFRESH 会占用连接,建议把刷新放进独立的短连接任务,避免拖垮连接池。本地验证可以用 Docker 起一个 PostgreSQL 实例先把视图跑通,再上生产。
与 PG 其他能力配合
物化视图不是孤岛,它和 PostgreSQL 的其它能力组合能解决更复杂的性能问题:
- 慢查询优化:先用 慢查询日志 定位重聚合 SQL,再用物化视图兜底。
- 分区表:基表按时间分区后,视图可只聚合热分区,刷新更快(参见 PG 分区实战)。
- 备份与 PITR:物化视图是物理表,PG 的 PITR 备份 同样覆盖它,恢复后记得重新 REFRESH。
- 分析型替代:若聚合需求超出单机 PG 能力,可把结果同步到 DuckDB 等 OLAP 引擎做进一步分析。
上线前 Checklist
| 检查项 | 为什么 |
|---|---|
| 是否已建唯一索引 | CONCURRENTLY 并发刷新的前提 |
| 刷新频率是否匹配新鲜度要求 | 过慢陈旧、过快压主库 |
| 是否用 pg_cron/定时任务自动化 | 避免人为遗忘导致数据陈旧 |
| 线上是否一律 CONCURRENTLY | 避免刷新瞬间锁表阻断查询 |
| 恢复备份后是否重新 REFRESH | PITR 恢复的是刷新时刻的快照 |
小结
物化视图 是 PostgreSQL 里用”空间换时间”的利器:把高频聚合、跨表 JOIN 的查询结果落盘,查询直接从毫秒级返回。记住三条铁律——上线前必建唯一索引(否则无法并发刷新)、线上刷新一律用 CONCURRENTLY(避免锁表阻断查询)、用 pg_cron 把刷新自动化(杜绝数据陈旧)。当标准刷新成本过高时,再考虑 pg_ivm 增量维护或应用侧局部重算。它和 慢查询优化、分区表、连接池 组合,是 PostgreSQL 性能治理的标准动作。本地可用 Docker 快速验证,配合 VS Code 的数据库插件边写边测最高效。



