物化视图使用场景
物化视图(materialized view)用「空间换时间、新鲜度换速度」,把昂贵查询的结果落成物理表。它不是万能药:用错场景会带来陈旧数据、膨胀、刷新风暴。本节从典型场景出发,讲清什么样的查询适合物化、如何评估收益、如何组织刷新,以及何时应该改用别的方案。
前置知识
阅读本节前,建议先了解:物化视图概述、REFRESH MATERIALIZED VIEW、聚合函数
基础概念
适合物化的查询特征
一个查询是否适合做成物化视图,看五个维度:
① 昂贵 :执行一次很慢(大 JOIN、大聚合、跨库扫描)
② 高频 :被反复执行(每个用户打开页面都跑一次)
③ 结果小 :输出行数远小于扫描行数(汇总/去重后)
④ 可容忍延迟:数据晚几分钟/几小时无所谓
⑤ 相对稳定 :基表变化不会让整个结果作废五条越靠前,收益越大;缺少「可容忍延迟」,就不该用物化视图。
收益来自哪里
| 收益来源 | 说明 | 例子 |
|---|---|---|
| 少扫描 | 不再扫基表,直接读预计算结果 | 日销售汇总从 120 万行扫到 30 行 |
| 少计算 | 聚合/JOIN/窗口只算一次 | sum、count(distinct)、排名 |
| 可索引 | 物化视图能建索引,普通视图不能 | 给汇总结果建唯一索引、GiST 索引 |
| 跨源隔离 | 把远端/外部数据固化到本地 | postgres_fdw 拉取后本地查询 |
| 稳定计划 | 免去复杂查询的计划抖动 | 固定结果集,计划简单 |
不适合的场景
| 场景 | 为什么不合适 | 替代方案 |
|---|---|---|
| 要求强实时 | 刷新前数据必然过期 | 普通视图 / 直接查询 |
| 基表几乎天天全量变 | 每次刷新等于重算,收益低 | 普通视图 + 索引优化 |
| 输出比输入还大 | 扫描开销没省,还多占空间 | 只存索引或分区表 |
| 需要按任意维度过滤 | 预聚合维度固定,用不上 | 汇总表 + 组合索引 / 列存扩展 |
| 需要增量更新 | PG 内核无增量维护 | 第三方 pg_ivm 或自建位点 |
| 结果极小且查询极快 | 优化索引即可,无需固化 | 建索引 |
判定流程
查询慢?
│
├─ 否 → 不需要物化
│
└─ 是 → 查询高频被调用?
│
├─ 否 → 优先优化索引/重写 SQL
│
└─ 是 → 能容忍延迟?
│
├─ 否 → 换实时方案(缓存层 / 读副本 / 优化)
│
└─ 是 → 结果是否远小于扫描量?
│
├─ 否 → 谨慎评估(可能只是"换个地方慢")
│
└─ 是 → ✅ 适合物化视图语法与用法
-- 1. 定义:把昂贵查询固化下来
CREATE MATERIALIZED VIEW mv_name AS
SELECT ... ;
-- 2. 建索引:让物化结果可被高效检索
CREATE [UNIQUE] INDEX idx_name ON mv_name (...);
-- 3. 首次填充 + 统计信息
REFRESH MATERIALIZED VIEW mv_name;
ANALYZE mv_name;
-- 4. 后续按需/定时刷新
REFRESH MATERIALIZED VIEW [CONCURRENTLY] mv_name;最简示例——把「按地区汇总的销售」固化为物化视图:
CREATE MATERIALIZED VIEW mv_region_sales AS
SELECT region_id,
sum(amount)::numeric(14,2) AS sales_amt,
count(*) AS order_cnt
FROM orders
GROUP BY region_id;
CREATE UNIQUE INDEX mv_region_sales_pk ON mv_region_sales (region_id);
ANALYZE mv_region_sales;查询时像读普通表一样直接读它:
SELECT region_id, sales_amt
FROM mv_region_sales
ORDER BY sales_amt DESC
LIMIT 5; region_id | sales_amt
-----------+-------------
03 | 9820314.55
01 | 8411902.10
07 | 7338200.44
02 | 6190855.02
09 | 5502114.77
(5 rows)对比直接用基表聚合——两者结果一致,但代价差很多:
-- 昂贵:每次都要扫描并聚合整张 orders
SELECT region_id, sum(amount)::numeric(14,2) AS sales_amt, count(*) AS order_cnt
FROM orders
GROUP BY region_id
ORDER BY sales_amt DESC
LIMIT 5;详细说明
场景一:报表与看板汇总
最经典、收益最大的场景。看板通常只展示固定维度、固定时间粒度的汇总,且允许分钟级到小时级延迟。
-- 小时级销售汇总(看板每次打开都查它)
CREATE MATERIALIZED VIEW mv_sales_hourly AS
SELECT date_trunc('hour', created_at) AS stat_hour,
region_id,
sum(amount)::numeric(14,2) AS sales_amt,
count(*) AS order_cnt,
count(DISTINCT buyer_id) AS buyer_cnt
FROM orders
WHERE created_at >= date_trunc('day', now()) - interval '30 days'
GROUP BY 1, 2;
CREATE UNIQUE INDEX mv_sales_hourly_pk
ON mv_sales_hourly (stat_hour, region_id);
ANALYZE mv_sales_hourly;收益量化——用一个真实的执行对比来说明(示意数据):
直接查 orders 聚合(近 30 天):
Aggregate + Seq Scan,扫描约 1,240,000 行
Execution Time: 1186.4 ms
查询 mv_sales_hourly:
Index Scan / Seq Scan,读取约 21,600 行
Execution Time: 12.7 ms
提速约 93 倍,且看板并发时不再挤压基表 IO。关键点:给汇总结果加时间窗口(如 WHERE created_at >= now() - interval '30 days'),既控制结果规模,又能让旧数据随窗口滑出而不再重算。
场景二:跨库/外部数据本地化
通过 postgres_fdw 等外部数据包装器(foreign data wrapper, FDW)访问远端数据时,网络往返与远端负载都不可控。把远端数据物化到本地,可显著降低查询延迟,还能建本地索引。
-- 假设已配置好外部服务器与外部表 remote_orders
CREATE MATERIALIZED VIEW mv_remote_orders AS
SELECT order_id,
buyer_id,
amount,
created_at
FROM remote_orders
WHERE created_at >= now() - interval '90 days';
-- FDW 的远端表无法建本地索引,物化视图可以
CREATE UNIQUE INDEX mv_remote_orders_pk ON mv_remote_orders (order_id);
CREATE INDEX mv_remote_orders_buyer ON mv_remote_orders (buyer_id, created_at);
ANALYZE mv_remote_orders;对比效果(官方文档以 file_fdw 拼写检查为例,展示了同一种模式的巨大差异):
-- 直接扫外部表(每次都要读远端/文件)
Foreign Scan on remote_orders
Rows Removed by Filter: 479829
Execution Time: 188.273 ms
-- 走本地物化视图 + 索引
Index Only Scan using mv_remote_orders_pk on mv_remote_orders
Execution Time: 0.117 ms要点:本地物化视图可建索引,这是 FDW 外部表往往做不到的,因此这类场景收益通常比「省网络」本身还大。
场景三:复杂聚合与去重预计算
count(DISTINCT ...)、多列去重、多维交叉汇总等操作非常昂贵。预计算后,日常查询只读结果。
-- 每日各渠道的独立访客数(去重代价高)
CREATE MATERIALIZED VIEW mv_daily_uv AS
SELECT stat_date,
channel,
count(DISTINCT visitor_id) AS uv,
count(DISTINCT session_id) AS pv_session
FROM visit_log
GROUP BY stat_date, channel;
CREATE UNIQUE INDEX mv_daily_uv_pk ON mv_daily_uv (stat_date, channel);
ANALYZE mv_daily_uv;注意:去重类结果无法简单累加(不同天期的 UV 不能相加),所以刷新必须以「完整重算覆盖的目标区间」为单位,而不是只追加增量:
-- 正确:定义查询覆盖要展示的完整区间,刷新时整体替换
-- 错误认知:把每天 UV 相加当作周 UV(结果偏大)
SELECT sum(uv) FROM mv_daily_uv WHERE stat_date >= date_trunc('week', now()); -- ❌ 语义错误场景四:复杂 JOIN 的固化
多层 JOIN、大表关联小表、自关联等查询,计划复杂且易抖动。物化后查询变成简单扫描。
CREATE MATERIALIZED VIEW mv_order_detail_wide AS
SELECT o.order_id,
o.created_at,
b.buyer_name,
p.product_name,
c.category_name,
oi.qty,
oi.price,
(oi.qty * oi.price)::numeric(14,2) AS line_amount
FROM orders o
JOIN buyers b ON b.buyer_id = o.buyer_id
JOIN order_item oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
JOIN categories c ON c.category_id = p.category_id;
CREATE UNIQUE INDEX mv_order_detail_wide_pk ON mv_order_detail_wide (order_id);
CREATE INDEX mv_order_detail_wide_cat ON mv_order_detail_wide (category_name, created_at);
ANALYZE mv_order_detail_wide;选型提醒:宽表物化的膨胀风险高(JOIN 会放大行数)。若输出行数远超单表,先考虑分区表或列存扩展,而不是盲目物化。
场景五:搜索与模糊匹配缓存
模糊匹配、全文本、相似度排序等场景中,物化视图还能配合扩展建立高效索引(例如 pg_trgm 的 GiST 索引),这在外部表上通常不可行。
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE MATERIALIZED VIEW mv_product_dict AS
SELECT product_id, product_name FROM products;
CREATE UNIQUE INDEX mv_product_dict_pk ON mv_product_dict (product_id);
-- 在物化视图上建立三元组(trigram)GiST 索引,加速相似度查询
CREATE INDEX mv_product_dict_trgm
ON mv_product_dict USING gist (product_name gist_trgm_ops);
VACUUM ANALYZE mv_product_dict;-- 相似度排序,走 GiST 索引
SELECT product_name
FROM mv_product_dict
ORDER BY product_name <-> '蓝牙耳机'
LIMIT 10;场景六:作为「汇总表」替代手工 ETL
在没有物化视图时,团队常手写「定时任务 + 汇总表」。物化视图把这一切标准化:定义查询即口径,REFRESH 即调度,无需自建表结构与写入逻辑。
手工汇总表方案 物化视图方案
────────────────────────────────────────────────────────
CREATE TABLE summary (...); CREATE MATERIALIZED VIEW ... AS SELECT ...;
INSERT ... SELECT ... ON CONFLICT REFRESH MATERIALIZED VIEW ...;
自己管定义变更(改表结构) DROP + 重建,定义随查询走
自己管去重与幂等 全量替换,天然幂等对比结论:口径复杂、变更频繁时,物化视图更省心;口径需要按维度增量维护、或要跨库写入下游时,手工汇总表仍有价值。
场景七:多级物化(汇总的汇总)
大报表常见「明细 → 日汇总 → 月汇总」的分层结构。多级物化能逐层压缩数据量,但刷新必须自底向上。
-- 第二级:基于第一级物化视图再做月度汇总
CREATE MATERIALIZED VIEW mv_sales_monthly AS
SELECT date_trunc('month', stat_hour) AS stat_month,
region_id,
sum(sales_amt)::numeric(16,2) AS sales_amt,
sum(order_cnt) AS order_cnt
FROM mv_sales_hourly
GROUP BY 1, 2;
CREATE UNIQUE INDEX mv_sales_monthly_pk
ON mv_sales_monthly (stat_month, region_id);
ANALYZE mv_sales_monthly;刷新顺序:先小时级,再月级。
REFRESH MATERIALIZED VIEW mv_sales_hourly;
ANALYZE mv_sales_hourly;
REFRESH MATERIALIZED VIEW mv_sales_monthly;
ANALYZE mv_sales_monthly;查询依赖关系,可辅助确定刷新顺序:
SELECT dependent.relname AS depends_on
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
WHERE d.refobjid = 'mv_sales_hourly'::regclass
AND dependent.relkind = 'm'; depends_on
---------------
mv_sales_monthly
(1 row)场景八:机器学习特征与离线物化
在 PostgreSQL 内做特征工程时,物化视图是「特征快照」的天然载体:训练与推理读同一份稳定快照,避免每次重算。
CREATE MATERIALIZED VIEW mv_user_features AS
SELECT u.user_id,
count(o.order_id) AS order_cnt_90d,
coalesce(sum(o.amount), 0)::numeric(14,2) AS gmv_90d,
coalesce(avg(o.amount), 0)::numeric(14,2) AS avg_order_amt,
max(o.created_at) AS last_order_at,
now() AS snapshot_at
FROM users u
LEFT JOIN orders o
ON o.buyer_id = u.user_id
AND o.created_at >= now() - interval '90 days'
GROUP BY u.user_id;
CREATE UNIQUE INDEX mv_user_features_pk ON mv_user_features (user_id);
ANALYZE mv_user_features;快照时间戳 snapshot_at 可用于标注特征版本,便于复现:
SELECT snapshot_at, count(*) AS feature_rows FROM mv_user_features GROUP BY snapshot_at; snapshot_at | feature_rows
------------------------------+--------------
2026-09-28 03:00:01.882+08 | 184220
(1 row)场景九:读写分离下的读侧加速
主库承担写入时,读请求常被挤到只读副本(read replica)。副本上做物化视图,可把重查询从主库彻底剥离。
-- 在只读副本上创建物化视图(注意:只读副本需允许写入物化视图)
CREATE MATERIALIZED VIEW mv_heavy_report AS
SELECT ... FROM big_join ...;
CREATE UNIQUE INDEX mv_heavy_report_pk ON mv_heavy_report (...);注意事项:
- 逻辑复制/物理复制下,物化视图本身也会被复制或需在副本上重建,行为取决于复制方式,需按实际拓扑验证。
- 只读事务中无法刷新,副本必须允许
REFRESH写入物化视图。 - 副本上的刷新同样会消耗副本资源,需与同步延迟一并评估。
场景十:反例——这些情况不要用物化视图
❌ 强实时要求的账务余额
刷新间隙读到旧值,可能造成对账差异。
❌ 输出行数接近或超过基表
既没省扫描,又多占存储,还引入了刷新延迟。
❌ 需要按任意维度即时过滤
预聚合维度固定,换个过滤条件就用不上。
❌ 基表每天全量重写
每次刷新都等于全量重算,收益为零。
❌ 需要增量更新、逐条追踪变化
PG 内核无 IVM,见「注意事项」。-- 反例:结果几乎与基表等大的"物化视图"
CREATE MATERIALIZED VIEW mv_orders_copy AS
SELECT order_id, buyer_id, amount, created_at, status, remark
FROM orders; -- ❌ 只是复制了一张表,没有任何聚合收益实战示例
场景:某电商后台「经营看板」,需要展示近 30 天按地区、按日的销售汇总,允许 15 分钟延迟,页面 QPS 较高。
第一步:建立分层物化视图。
-- 日 × 地区汇总
CREATE MATERIALIZED VIEW mv_sales_daily_region AS
SELECT stat_date,
region_id,
sum(amount)::numeric(16,2) AS sales_amt,
count(*) AS order_cnt,
count(DISTINCT buyer_id) AS buyer_cnt
FROM (
SELECT date_trunc('day', created_at)::date AS stat_date,
region_id, amount, buyer_id
FROM orders
WHERE created_at >= date_trunc('day', now()) - interval '60 days'
) t
GROUP BY stat_date, region_id;
CREATE UNIQUE INDEX mv_sales_daily_region_pk
ON mv_sales_daily_region (stat_date, region_id);
CREATE INDEX mv_sales_daily_region_region
ON mv_sales_daily_region (region_id, stat_date DESC);
ANALYZE mv_sales_daily_region;第二步:看板查询改为直接读物化视图。
SELECT stat_date,
sum(sales_amt)::numeric(16,2) AS day_total
FROM mv_sales_daily_region
WHERE stat_date >= current_date - 30
GROUP BY stat_date
ORDER BY stat_date; stat_date | day_total
-------------+------------
2026-08-30 | 842113.20
2026-08-31 | 903442.77
2026-09-01 | 1187210.05
...
(30 rows)第三步:建立刷新日志,便于观测与告警。
CREATE TABLE mv_refresh_log (
id bigserial PRIMARY KEY,
mv_name text NOT NULL,
started_at timestamptz NOT NULL,
finished_at timestamptz,
duration_ms integer,
row_count bigint,
succeeded boolean,
err_message text
);
CREATE OR REPLACE FUNCTION refresh_mv_logged(p_mv text, p_concurrently boolean DEFAULT true)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_start timestamptz := clock_timestamp();
v_rows bigint;
v_id bigint;
v_sql text;
BEGIN
INSERT INTO mv_refresh_log (mv_name, started_at)
VALUES (p_mv, v_start)
RETURNING id INTO v_id;
v_sql := format('REFRESH MATERIALIZED VIEW %s %I',
CASE WHEN p_concurrently THEN 'CONCURRENTLY' ELSE '' END, p_mv);
BEGIN
EXECUTE v_sql;
EXECUTE format('ANALYZE %I', p_mv);
EXECUTE format('SELECT count(*) FROM %I', p_mv) INTO v_rows;
UPDATE mv_refresh_log
SET finished_at = clock_timestamp(),
duration_ms = (extract(epoch FROM clock_timestamp() - v_start) * 1000)::int,
row_count = v_rows,
succeeded = true
WHERE id = v_id;
EXCEPTION WHEN OTHERS THEN
UPDATE mv_refresh_log
SET finished_at = clock_timestamp(),
duration_ms = (extract(epoch FROM clock_timestamp() - v_start) * 1000)::int,
succeeded = false,
err_message = SQLERRM
WHERE id = v_id;
RAISE;
END;
END;
$$;第四步:定时刷新(每 15 分钟),由外部调度器触发。
#!/usr/bin/env bash
# refresh_dashboard.sh —— 由 crontab 每 15 分钟调用
set -euo pipefail
PSQL="psql -X -q -v ON_ERROR_STOP=1 -d appdb"
# 低峰(02:00-05:00)用普通刷新更快,其余时段并发刷新不阻塞读
HOUR=$(date +%H)
if [ "$HOUR" -ge 2 ] && [ "$HOUR" -lt 5 ]; then
$PSQL -c "SELECT refresh_mv_logged('mv_sales_daily_region', false);"
else
$PSQL -c "SELECT refresh_mv_logged('mv_sales_daily_region', true);"
fi# crontab -e
*/15 * * * * /opt/scripts/refresh_dashboard.sh >> /var/log/mv_refresh.log 2>&1第五步:新鲜度与健康度监控。
SELECT mv_name,
max(finished_at) AS last_success,
now() - max(finished_at) AS staleness,
round(avg(duration_ms)) AS avg_ms,
count(*) FILTER (WHERE NOT succeeded) AS failures_24h
FROM mv_refresh_log
WHERE started_at > now() - interval '24 hours'
GROUP BY mv_name; mv_name | last_success | staleness | avg_ms | failures_24h
-----------------------+------------------------------+--------------+--------+--------------
mv_sales_daily_region | 2026-09-28 03:45:12.441+08 | 00:06:31.559 | 842 | 0
(1 row)第六步:收益对比(示意)。
改造前(直接聚合 orders,30 天窗口)
Parallel Seq Scan on orders: 扫描约 3,180,000 行
HashAggregate
Execution Time: 2,143.7 ms
改造后(读 mv_sales_daily_region)
Index Scan / Bitmap Heap Scan: 读取约 480 行
GroupAggregate
Execution Time: 6.9 ms
提速约 310 倍;并发高峰时基表 IO 压力显著下降。注意事项
错误!把物化视图当实时数据源。 刷新前必然过期,强实时业务读到旧值可能出问题。
sql-- 不推荐:账务余额这种强实时数据用物化视图 SELECT balance FROM mv_account_balance WHERE account_id = 1001; -- 可能是旧的错误!认为 PostgreSQL 有内置增量刷新。 内核只有全量刷新(
REFRESH重跑定义查询),增量维护需第三方扩展或自建。sql-- 不推荐:以为只处理变化的数据 REFRESH MATERIALIZED VIEW mv_huge; -- 实际是全量重算错误!输出比输入还大却仍做物化。 既不省扫描,又增加存储和刷新成本。
sql-- 不推荐:几乎等同复制基表 CREATE MATERIALIZED VIEW mv_copy AS SELECT * FROM orders;错误!忘记刷新。 物化视图不会自动更新,没有调度就是一张「冻结的历史快照」。
sql-- 不推荐:建完就不管 CREATE MATERIALIZED VIEW mv_daily AS SELECT ...; -- 之后从未 REFRESH不推荐:在只读事务中刷新。 刷新需要写入物化视图,只读事务里会失败。
sql-- 不推荐 BEGIN READ ONLY; REFRESH MATERIALIZED VIEW mv_daily; -- 报错 COMMIT;不推荐:不经评估就物化多张宽表。 宽表 JOIN 会放大行数,容易造成存储暴涨。
sql-- 不推荐:五表 JOIN 结果上千万行,还物化 CREATE MATERIALIZED VIEW mv_all_wide AS SELECT ... FROM a JOIN b JOIN c JOIN d JOIN e;不推荐:把去重结果直接相加。
count(DISTINCT ...)的日值相加不等于区间值。sql-- 错误口径:日 UV 相加当周 UV SELECT sum(uv) FROM mv_daily_uv WHERE stat_date >= date_trunc('week', now());不推荐:多级物化却不按依赖顺序刷新。 先刷上层会导致上层基于旧的下层数据。
sql-- 不推荐:先刷月级、后刷小时级 REFRESH MATERIALIZED VIEW mv_sales_monthly; -- ❌ 顺序颠倒 REFRESH MATERIALIZED VIEW mv_sales_hourly;不推荐:物化视图不带时间窗口。 结果无限增长,刷新越来越慢。
sql-- 不推荐:无限制历史 CREATE MATERIALIZED VIEW mv_all_history AS SELECT ... FROM orders; -- 永不收敛不推荐:不建索引。 物化视图的可索引优势没用上,查询仍是全表扫描。
sql-- 不推荐:物化后不建任何索引 CREATE MATERIALIZED VIEW mv_daily AS SELECT ...; -- 查询全靠 Seq Scan
最佳实践
先量化收益再物化。 用
EXPLAIN (ANALYZE, BUFFERS)对比直接查询与物化后的耗时,确认值得固化。sqlEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS) SELECT region_id, sum(amount) FROM orders GROUP BY region_id;定义查询加上时间窗口,控制结果规模,让刷新随窗口滑出而稳定。
sqlWHERE created_at >= now() - interval '30 days'建唯一索引 + 按查询模式建辅助索引,兼顾并发刷新与检索效率。
sqlCREATE UNIQUE INDEX mv_pk ON mv_daily (stat_date, region_id); CREATE INDEX mv_region_idx ON mv_daily (region_id, stat_date DESC);刷新后补
ANALYZE,让优化器拿到最新统计信息。sqlREFRESH MATERIALIZED VIEW mv_daily; ANALYZE mv_daily;刷新纳入监控与告警,记录耗时、行数、成功与否、新鲜度。
多级物化严格自底向上刷新,并用依赖查询核对顺序。
按业务紧迫度选择刷新频率,避免「一变就刷」。
低频刷新 + 可容忍延迟的业务,优先普通刷新(简单、快、无索引前提);高频且读不能停时再用
CONCURRENTLY。对超大结果分片物化(按时间或维度拆成多个物化视图),降低单次刷新代价。
定期检查膨胀,必要时
VACUUM/REINDEX。sqlSELECT relname, n_live_tup, n_dead_tup, pg_size_pretty(pg_total_relation_size(relid)) AS size FROM pg_stat_user_tables WHERE relname LIKE 'mv_%' ORDER BY n_dead_tup DESC;把口径集中到少数几个物化视图,避免同一指标被多处重复计算导致不一致。
下一节
继续学习:CREATE RULE 规则