PostgreSQL 性能优化(六):物化视图、汇总表与分区
本文沿用第 1 篇的订单系统案例,聚焦预计算与大表生命周期管理。
先判断问题是重复计算还是数据规模
报表慢,先确认成本来自哪里:若同一批源数据被反复扫描、连接和聚合,应考虑把结果预先计算;若难点是查询只需要近期数据、旧数据却难以归档或删除,应考虑按访问范围管理数据。物化视图和汇总表解决前一个问题,分区解决后一个问题。
两者可以同时使用,但不能互相替代。分区不会自动消除跨月聚合,物化视图也不会让历史明细的清理变得便宜。先用执行计划、调用频率和保留策略确认瓶颈,再选择维护机制。
物化视图适合重复聚合
当相同的大范围聚合被报表、定时任务和接口反复执行,而且业务允许结果延迟时,可以保存聚合结果。下面按上海时区汇总每日已支付订单,避免 timestamptz 转日期时依赖会话时区:
CREATE MATERIALIZED VIEW daily_order_summary AS
SELECT
(created_at AT TIME ZONE 'Asia/Shanghai')::date AS order_date,
count(*) AS order_count,
sum(total_amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY (created_at AT TIME ZONE 'Asia/Shanghai')::date
WITH NO DATA;
CREATE UNIQUE INDEX idx_daily_order_summary_date
ON daily_order_summary (order_date);
-- 第一次填充不能使用 CONCURRENTLY。
REFRESH MATERIALIZED VIEW daily_order_summary;
WITH NO DATA 将定义和初次计算拆开,但未填充的物化视图不能查询。第一次刷新后,接口读取 daily_order_summary,不必重复扫描订单明细。代价是结果只新鲜到最近一次成功刷新,源数据修正也要纳入重算策略。
普通视图、物化视图与汇总表
| 方案 | 更新方式 | 适合场景 | 主要代价 |
|---|---|---|---|
| 普通视图 | 查询时执行底层 SQL | 封装查询并始终读取当前数据 | 不减少底层计算 |
| 物化视图 | 显式完整刷新 | 可容忍延迟的重复聚合 | 刷新消耗资源,存在陈旧窗口 |
| 业务汇总表 | 应用、CDC 或任务增量维护 | 更低延迟、需要按区间补算 | 一致性、幂等、补偿和回放更复杂 |
PostgreSQL 原生刷新会替换物化视图的完整内容,不是通用的增量刷新方案。若源表很大而刷新间隔很短,完整刷新可能成为新的 I/O 与 CPU 高峰;这时应评估按日期维护的汇总表,而不是只把刷新调得更频繁。
并发刷新与数据时效
填充后可以在刷新期间保留并发读取:
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_order_summary;
并发刷新要求物化视图至少有一个只使用普通列、覆盖全部行的唯一索引;部分唯一索引和表达式唯一索引不满足要求。它不能与 WITH NO DATA 同用,且同一物化视图同一时刻只能执行一次刷新。
调度前要定义可接受延迟、刷新超时、失败告警与重试、旧数据标识,以及刷新耗时超过调度间隔时的互斥。CONCURRENTLY 主要保护读取可用性,并不消除刷新本身的计算成本。
分区适合数据生命周期管理
分区把一张逻辑表路由到多个物理子表。持续追加且按时间保留的订单事件,适合按月范围分区:
CREATE TABLE order_events (
id bigint GENERATED ALWAYS AS IDENTITY,
order_id bigint NOT NULL REFERENCES orders (id),
event_type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL,
PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE order_events_2026_07
PARTITION OF order_events
FOR VALUES FROM ('2026-07-01 00:00:00+00')
TO ('2026-08-01 00:00:00+00');
CREATE TABLE order_events_2026_08
PARTITION OF order_events
FOR VALUES FROM ('2026-08-01 00:00:00+00')
TO ('2026-09-01 00:00:00+00');
CREATE INDEX idx_order_events_order_created
ON order_events (order_id, created_at DESC);
在分区父表上创建索引,会为现有分区创建匹配的子索引,后续新建或挂载的分区也遵循该结构。运维流程还要提前创建未来分区,并监控落不到任何分区的写入错误。
旧数据可以先从在线表分离,再导出、校验或删除:
ALTER TABLE order_events
DETACH PARTITION order_events_2026_07;
DROP TABLE order_events_2026_07;
分离后子表仍存在,因此归档与最终删除可以拆开;操作仍涉及锁、依赖与并发查询,需要先在接近生产的数据量上验证。
分区裁剪与分区内索引
查询条件与分区键的边界匹配时,规划器或执行器可以裁剪无关分区:
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, event_type, created_at
FROM order_events
WHERE created_at >= TIMESTAMPTZ '2026-07-10 00:00:00+00'
AND created_at < TIMESTAMPTZ '2026-07-11 00:00:00+00';
裁剪依据分区边界,不依赖子表是否有普通索引;索引负责在保留下来的分区内部继续缩小扫描范围。上线前应使用真实参数检查计划实际访问的分区数量,并确认 enable_partition_pruning 没有被关闭。
分区不是普通表的性能开关
表不大、索引已经能快速定位,或常用查询不包含分区键时,分区往往只增加规划、元数据和维护成本。分区过多还会扩大建表、索引、备份与统计信息管理的工作量。
分区表上的主键或唯一约束必须包含全部分区键列,所以示例使用 (created_at, id)。序列仍可生成跨分区不重复的 id,但各分区的局部索引不能单独提供一个不含分区键的全局唯一约束。改造已有订单主表前,还要评估外键、迁移窗口和应用查询形态。
选型决策表
| 观察到的问题 | 优先方案 | 决策证据 |
|---|---|---|
| 只想复用 SQL,结果必须实时 | 普通视图 | 底层查询成本可接受 |
| 同一聚合反复执行,可接受分钟级或小时级延迟 | 物化视图 | 读取收益高于完整刷新成本 |
| 聚合要求更低延迟或按时间段增量修正 | 汇总表 | 团队能承担幂等、补偿与重算逻辑 |
| 查询常带分区键,旧数据按时间归档或删除 | 分区 | 裁剪范围与保留策略都清晰 |
| 既有重复聚合又有长期明细 | 分区加预计算 | 分别验证生命周期收益与刷新收益 |
最终判断不是“表大就分区”或“报表慢就建物化视图”,而是明确要减少的是重复计算,还是在线数据范围与生命周期操作成本。