预计算汇总结果并不是简单地把大查询拆成几个小查询,而是改变数据组织方式:提前把明细表里的聚合值算出来,让在线查询碰到的是一张已经很小的汇总表。很多报表慢查询优化到后期都会走到这一步,因为当明细数据量持续增长,单纯靠索引已经很难让GROUP BY、COUNT、SUM这类操作保持低延迟。

例如一张订单表orders里存了千万级记录,产品需要展示最近90天每日销售额趋势。如果每次都直接扫描最近90天的全部明细,做分组和求和,查询时间很容易涨到数秒。预计算的目标,就是提前按天生成汇总行,查询时只扫描几十到几百行结果。
一、聚合查询为什么越来越慢
在PostgreSQL中,一个典型的按天汇总查询会经历扫描、分组、聚合、排序等步骤。执行计划里常见的是对orders表的顺序扫描或索引扫描,然后是HashAggregate或GroupAggregate,最后是Sort。数据量越大,需要读入的页面越多,HashAggregate还需要在内存或临时文件中维护分组状态,这些成本都会随着明细行数线性甚至超线性增长。
很多人会先想到在created_at和total_amount上建索引,但对聚合查询来说,索引的作用有限。普通B-tree索引能加速WHERE过滤和排序,却不能让COUNT和SUM直接跳过明细读取。即使数据库只走索引,也要访问每一行索引条目,行数没有减少。所以当报表场景中汇总口径比较固定时,预计算往往比继续堆索引更有效。
下面这条SQL就是一个典型的慢查询示例,它在90天窗口内对订单做按天汇总:
SELECT
date_trunc('day', created_at) AS order_date,
count(*) AS order_count,
sum(total_amount) AS total_sales
FROM orders
WHERE created_at >= now() - interval '90 days'
GROUP BY date_trunc('day', created_at)
ORDER BY order_date DESC;
执行计划里可能显示一个较大的并行顺序扫描,或者高成本的有序聚合。解决思路就是把这条SQL的结果提前保存下来,并保证后续增删改能够更新汇总数据。
二、用物化视图快速落地预计算
PostgreSQL提供了物化视图用于保存查询结果。和普通视图不同,普通视图每次查询时都会展开并重新执行底层SQL,而物化视图创建后会把结果持久化到磁盘,之后查询直接读取已经算好的数据。对于按天汇总这种目标明确的报表查询,物化视图是落地成本最低的方案之一。
创建物化视图的语法和普通查询很接近。示例如下:
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT
date_trunc('day', created_at) AS order_date,
count(*) AS order_count,
sum(total_amount) AS total_sales
FROM orders
GROUP BY date_trunc('day', created_at);
创建完成后,业务查询可以直接写成SELECT * FROM mv_daily_sales WHERE order_date >= ...。如果数据发生变化,需要执行刷新命令让物化视图重新计算。普通刷新会锁住物化视图,查询会阻塞;如果建了唯一索引,可以使用并发刷新:
CREATE UNIQUE INDEX idx_mv_daily_sales_date ON mv_daily_sales (order_date); REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales WITH DATA;
并发刷新允许刷新期间继续读取旧数据,适合对可用性有要求的报表。但物化视图也有局限:每次刷新都是全量重新执行聚合查询。如果orders表非常大,刷新本身可能耗时较长,适合按小时或按天刷新,而不是每分钟执行。
三、手动汇总表配合触发器实现增量更新
如果业务要求汇总数据接近实时,物化视图全量刷新就太重了。此时可以采用手动汇总表加触发器的方案。核心思路是:新建一张很小的汇总表,在orders表上创建AFTER INSERT、UPDATE、DELETE触发器,每发生一行变更,就同步修改对应日期的汇总行。
先创建汇总表,主键直接使用业务日期:
CREATE TABLE daily_sales_summary (
order_date date PRIMARY KEY,
order_count bigint NOT NULL DEFAULT 0,
total_sales numeric(12,2) NOT NULL DEFAULT 0
);
接着写一个触发器函数,使用INSERT ... ON CONFLICT来处理新增和累加。当新订单插入时,如果当天汇总行已存在,就把订单数和销售额累加;如果不存在,就新建一行。更新和删除则需要先减去旧值,再加上新值或只减旧值。
CREATE OR REPLACE FUNCTION fn_update_daily_sales() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO daily_sales_summary (order_date, order_count, total_sales)
VALUES (NEW.created_at::date, 1, NEW.total_amount)
ON CONFLICT (order_date) DO UPDATE
SET order_count = daily_sales_summary.order_count + 1,
total_sales = daily_sales_summary.total_sales + EXCLUDED.total_sales;
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE daily_sales_summary
SET order_count = order_count - 1,
total_sales = total_sales - OLD.total_amount
WHERE order_date = OLD.created_at::date;
INSERT INTO daily_sales_summary (order_date, order_count, total_sales)
VALUES (NEW.created_at::date, 1, NEW.total_amount)
ON CONFLICT (order_date) DO UPDATE
SET order_count = daily_sales_summary.order_count + 1,
total_sales = daily_sales_summary.total_sales + EXCLUDED.total_sales;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
UPDATE daily_sales_summary
SET order_count = order_count - 1,
total_sales = total_sales - OLD.total_amount
WHERE order_date = OLD.created_at::date;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_orders_daily_summary
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION fn_update_daily_sales();
这种方式的优点是查询永远直接读汇总表,不会随着明细表增长而变慢。代价是每次写入orders都要额外执行触发器中的UPSERT或UPDATE,会拉高写入延迟。如果写入已经非常频繁,触发器还可能成为新的瓶颈。它更适合读多写少、但写入后希望汇总尽量实时的报表场景。
四、刷新策略与一致性权衡
预计算方案无法回避的问题是数据一致性。物化视图是某个时间点的快照,手动汇总表虽然由触发器同步,但也可能因为触发器逻辑遗漏、手工改库、批量导入未触发等原因出现偏差。因此在生产环境中,通常需要一套定期校准机制,例如每天凌晨用明细表重新全量刷新一次汇总表或物化视图。
PostgreSQL没有内置的定时任务调度器,但可以通过pg_cron扩展或应用自身的调度模块来触发刷新。例如使用pg_cron每天凌晨三点做一次物化视图并发刷新:
SELECT cron.schedule(
'refresh-daily-sales',
'0 3 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales'
);
如果使用手动汇总表校准,可以写一个幂等的回填SQL。假设允许短暂停写或业务低峰期执行,可以用事务先删除指定日期范围的汇总行,再重新插入聚合结果:
BEGIN;
DELETE FROM daily_sales_summary
WHERE order_date BETWEEN '2025-01-01' AND '2025-01-31';
INSERT INTO daily_sales_summary (order_date, order_count, total_sales)
SELECT created_at::date,
count(*),
sum(total_amount)
FROM orders
WHERE created_at >= '2025-01-01'
AND created_at < '2025-02-01'
GROUP BY created_at::date;
COMMIT;
不过这里要注意回填期间不要和触发器产生交叉更新。更稳妥的做法是选择没有业务写入的窗口执行,或者先暂停触发器。对于绝大多数报表场景,允许汇总数据有分钟级或小时级延迟,可以显著降低实现复杂度。
最后还要关注汇总表自身的索引和统计信息。汇总表虽然小,但查询条件可能包含多个维度、排序字段和过滤条件。应该根据实际报表SQL为汇总表设计合适的复合索引,并在数据更新后执行ANALYZE,让优化器获得准确的基数估计。预计算不是终点,它只是把昂贵工作从查询时转移到了写入时或后台任务中,围绕刷新窗口、写入成本、一致性要求做好权衡,才能真正解决PostgreSQL慢查询。
PostgreSQL慢查询优化预计算汇总修改时间:2026-09-25 01:14:39