PostgreSQL 物化视图(Materialized View)是把复杂查询的结果预先计算并落盘存储的特殊对象。当你的报表 SQL 要 JOIN 十几张表、做多层聚合,每次打开都要跑 8 秒时,物化视图能把这些计算提前算好,查询时直接读取那份”快照”,把响应压到毫秒级。本文用一套日销报表的完整案例,讲清物化视图的创建、REFRESH 刷新、并发刷新 CONCURRENTLY 与定时更新策略,并给出一份踩坑清单,帮你把慢报表真正救活。
一、什么是物化视图:和普通视图、缓存的区别
普通视图(View)只是”保存的查询语句”,每次被访问都会重新执行底层 SQL;物化视图则是”把查询结果存成一张真实表”,数据在刷新前是静止的快照。它和 Redis 这类应用层缓存的区别在于:物化视图由数据库自身管理、与事务一致语义更贴近、无需在业务代码里维护失效逻辑,查询优化器还能在它上面建索引。
| 方案 | 数据是否落盘 | 查询开销 | 一致性 | 维护成本 |
|---|---|---|---|---|
| 普通视图 | 否(每次重算) | 高(等于原 SQL) | 实时 | 低 |
| 物化视图 | 是(快照) | 低(直接读表) | 刷新后一致 | 中(需刷新) |
| 应用层缓存 | 是(外部) | 低 | 易陈旧 | 高(手写失效) |
一句话判断:查询重、更新不频繁、对实时性要求不极端的报表类场景,物化视图几乎总是最优解。它和PostgreSQL 与 SQL Server 的语法差异无关,是 PG 原生的标准能力。
二、创建你的第一个物化视图
语法和普通视图类似,只是把 VIEW 换成 MATERIALIZED VIEW。下面的例子把”近一年按天、按地区汇总”的订单金额预计算出来:
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT
date_trunc('day', o.created_at) AS day,
o.region,
COUNT(*) AS orders,
SUM(oi.amount) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= now() - interval '365 days'
GROUP BY 1, 2;
-- 并发刷新(CONCURRENTLY)要求物化视图上必须有唯一索引
CREATE UNIQUE INDEX uk_mv_daily_sales
ON mv_daily_sales (day, region);
-- 还可以在物化视图上建普通索引进一步加速
CREATE INDEX ix_mv_daily_sales_revenue
ON mv_daily_sales (revenue DESC);
创建即完成一次全量填充。之后业务方直接 SELECT * FROM mv_daily_sales 即可,数据库读的是落盘的物理数据,不再碰源表的大 JOIN。需要特别注意:CONCURRENTLY 并发刷新强制要求唯一索引,否则会报错,这点我们在第三节展开。
三、刷新策略:REFRESH MATERIALIZED VIEW
物化视图不会随源表自动更新,必须显式执行刷新。PG 提供两种刷新方式:
-- 全量刷新:重写整张物化视图,期间加 ACCESS EXCLUSIVE 锁,不可读
REFRESH MATERIALIZED VIEW mv_daily_sales;
-- 并发刷新:仅加较弱锁,刷新期间仍可正常查询(必须先有唯一索引)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;
3.1 并发刷新 CONCURRENTLY 的细节
CONCURRENTLY 的原理是:PG 额外做一次全量计算,与旧快照逐行比对,只替换变化行。因此它需要唯一索引来定位每一行,且耗时比全量刷新更久、更吃资源,但不会阻塞线上读请求。对于 7×24 小时服务的报表库,应始终用 CONCURRENTLY。
3.2 增量刷新与全量刷新怎么选
PG 原生不支持”只刷新变化分区”的增量刷新(这点不像某些商业库)。如果数据量极大、全量刷新太慢,常见做法是按时间分区建多个物化视图,或只在低峰期对历史分区做一次全量、对当日分区用触发器维护。对绝大多数中小业务,每天一次 CONCURRENTLY 全量就够用。
四、定时自动刷新:pg_cron 与 CI 两条路线
手动刷新不现实,生产环境要用调度器。首选数据库内调度扩展 pg_cron:
-- 需 superuser 安装一次
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- 每天 02:30 并发刷新(cron 表达式为服务器时区)
SELECT cron.schedule(
'refresh_daily_sales',
'30 2 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;'
);
如果你的托管库没有 pg_cron 权限,也可以用外部调度器触发,例如借助GitHub Actions 搭建 CI/CD 流水线,用定时任务连库执行刷新:
name: refresh-materialized-views
on:
schedule:
- cron: '30 2 * * *' # UTC,对应北京时间 10:30
workflow_dispatch:
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- name: Refresh PG materialized views
env:
PG_URI: ${{ secrets.PG_URI }}
run: |
psql "$PG_URI" -c \
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;"
五、真实场景:把 8 秒报表压到 80 毫秒
某运营后台的原生报表 SQL 跨 4 张表、按天聚合近一年数据,冷缓存下 EXPLAIN ANALYZE 显示耗时约 8.2 秒。改为物化视图后,查询退化为一次对落盘表的顺序扫描:
-- 优化前:直查源表
EXPLAIN ANALYZE
SELECT date_trunc('day', created_at), region, SUM(amount)
FROM orders JOIN order_items USING (order_id)
GROUP BY 1, 2;
-- Planning Time: 0.4 ms Execution Time: 8213.7 ms
-- 优化后:查物化视图
EXPLAIN ANALYZE
SELECT day, region, revenue FROM mv_daily_sales;
-- Planning Time: 0.1 ms Execution Time: 82.1 ms
| 指标 | 优化前(直查) | 优化后(物化视图) |
|---|---|---|
| 执行时间 | 8213 ms | 82 ms |
| 扫描行数 | 约 1.2 亿 | 约 365 |
| 源表锁 | 共享锁占用久 | 零(只读快照) |
| 数据新鲜度 | 实时 | 最近一次刷新后 |
百倍提速的关键不是魔法,而是”把计算从查询时移到刷新时”。如果报表对新鲜度要求宽松到”最多延迟一天”,这套方案几乎零成本。若仍嫌慢,可参考PostgreSQL 慢查询优化的执行计划解读进一步给物化视图本身加索引。
六、踩坑清单
6.1 并发刷新前必须建唯一索引
没唯一索引就执行 REFRESH ... CONCURRENTLY 会直接报错 must have a unique index。务必在创建物化视图后立即补上唯一索引,否则低峰期脚本一跑就挂。
6.2 刷新期间的锁与陈旧数据
全量刷新会短暂阻塞读,务必用 CONCURRENTLY 并在低峰期跑。另外物化视图本质是快照,刷新间隔内的写入不会反映到查询结果里——要在产品上明确标注”数据截至 XX 时间”,避免运营误判。
6.3 它不会随源表自动更新
新手常以为建好就一劳永逸,结果源表数据涨了、物化视图还是旧值。记住:物化视图没有”自动同步”开关,刷新调度(pg_cron 或 CI)是必选项,不是可选项。刷新任务挂了要有告警,否则报表会静默陈旧。
七、什么时候不该用物化视图
物化视图不是银弹。以下场景应绕开:① 要求秒级实时数据(如交易风控、库存扣减),请用普通索引或缓存;② 源表高频小批量更新且查询极频繁,频繁刷新反而更贵;③ 结果集和源表几乎一样大,落盘没有收益。在这些情况下,先把慢 SQL 本身优化好,比上物化视图更根本。
总结:物化视图是用”空间换时间、用陈旧换吞吐”的标准 PostgreSQL 能力。把握”建唯一索引 → 用 CONCURRENTLY 刷新 → 配定时调度 → 标注数据时效”四步,就能把一批跑不动的报表稳稳救回来,把工程精力留给真正需要实时性的业务。




