业务系统跑得越久,数据库里的数据就越多。订单表、日志表动辄上亿行,这时候哪怕只是一个简单的按天统计订单量的报表,执行一次GROUP BY都可能扫上几分钟。更麻烦的是,这类统计查询往往是高频操作,运营后台每隔几秒刷新一次,数据库CPU直接被打满。解决这个问题的核心思路就是增量聚合:不去每次全量扫描原表,而是把聚合结果预先算好存起来,数据变化时只更新变化的部分。本文围绕两种主流实现方案展开:触发器和物化视图,帮你把统计查询压到毫秒级。

为什么全表聚合在大数据量下走不通
先看一个典型的场景。假设有一张订单表,结构如下:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_created (created_at)
);当这张表只有几十万行时,下面这条按天统计的SQL毫秒级就能返回:
SELECT DATE(created_at) AS stat_date,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE(created_at);但数据涨到一亿行后,情况完全不同。即使created_at上有索引,GROUP BY DATE(created_at)这种对列套函数的写法会让索引直接失效,数据库只能走全表扫描,把一亿行数据逐行读取、排序、分组。这个过程涉及大量磁盘IO和CPU计算,查询时间轻松突破几十秒,而且会挤占其他正常业务的资源。
更本质的问题在于:全表聚合的计算结果其实大部分是不变的。昨天之前的统计数据每天都不会变,变的只有今天新写入的数据。每次查询都重算一遍所有历史数据,是纯粹的浪费。增量聚合的思路就是把不变的部分固化下来,只对增量部分做计算。
方案一:用触发器实时同步汇总表
触发器是最直接的增量方案。思路是建一张汇总表,然后在原表上挂INSERT、UPDATE、DELETE三个触发器,任何数据变动都会同步修正汇总表中的对应行。
先建汇总表:
CREATE TABLE orders_daily_stats (
stat_date DATE PRIMARY KEY,
order_cnt BIGINT NOT NULL DEFAULT 0,
total_amount DECIMAL(16,2) NOT NULL DEFAULT 0
);然后编写三个触发器,以MySQL语法为例:
-- 新增订单时,累加对应日期的统计值
CREATE TRIGGER trg_orders_ai AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO orders_daily_stats (stat_date, order_cnt, total_amount)
VALUES (DATE(NEW.created_at), 1, NEW.amount)
ON DUPLICATE KEY UPDATE
order_cnt = order_cnt + 1,
total_amount = total_amount + NEW.amount;
END;
-- 删除订单时,反向扣减
CREATE TRIGGER trg_orders_ad AFTER DELETE ON orders
FOR EACH ROW
BEGIN
UPDATE orders_daily_stats
SET order_cnt = order_cnt - 1,
total_amount = total_amount - NEW.amount
WHERE stat_date = DATE(OLD.created_at);
END;
-- 更新订单时,可能需要先扣旧值再加新值
CREATE TRIGGER trg_orders_au AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
IF DATE(OLD.created_at) <> DATE(NEW.created_at) OR OLD.amount <> NEW.amount THEN
UPDATE orders_daily_stats
SET order_cnt = order_cnt - 1,
total_amount = total_amount - OLD.amount
WHERE stat_date = DATE(OLD.created_at);
INSERT INTO orders_daily_stats (stat_date, order_cnt, total_amount)
VALUES (DATE(NEW.created_at), 1, NEW.amount)
ON DUPLICATE KEY UPDATE
order_cnt = order_cnt + 1,
total_amount = total_amount + NEW.amount;
END IF;
END;这套方案的最大优点是实时性极高,汇总表和原表几乎不存在延迟,任何一条订单写入后,查询统计结果立即可见。查询时直接读汇总表,一亿条订单的统计也只是扫描几百行日期记录,毫秒级返回。
但触发器的代价也必须正视。首先,它会给每一次写入操作额外增加一次汇总表的读写,在高并发写入场景下,汇总表本身可能成为热点行——比如所有当天的订单都要更新同一行记录,这行数据会被频繁加锁,写入吞吐明显下降。其次,触发器逻辑和业务代码耦合在数据库层,排查问题时不容易被发现,维护成本不低。还有一个隐蔽的坑:如果有人用批量LOAD DATA或者分区操作修改原表,某些数据库的触发器可能不会被触发,导致统计数据悄悄失真。
针对热点行问题,一个实用的优化是把汇总粒度细化,比如按小时甚至按十分钟建一行,查询时再向上聚合,这样写入压力被分散到更多行上;针对数据失真问题,建议定期(比如每周)跑一次全量校验任务,比对原表真实聚合结果和汇总表,发现偏差及时修复。
方案二:物化视图定时刷新保持准实时
物化视图是另一种思路:把聚合查询的结果直接存成一张物理表,由数据库定期刷新。原生的物化视图在Oracle和PostgreSQL中支持较好,MySQL本身不支持,但可以用事件加汇总表模拟出同样效果。
PostgreSQL中创建和使用物化视图非常简洁:
CREATE MATERIALIZED VIEW mv_orders_daily AS
SELECT DATE(created_at) AS stat_date,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE(created_at);
-- 创建唯一索引,这是增量刷新的前提
CREATE UNIQUE INDEX idx_mv_stat_date ON mv_orders_daily(stat_date);
-- 定时刷新,CONCURRENTLY表示不锁查询
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_orders_daily;其中CONCURRENTLY选项允许刷新期间继续对外提供查询服务,代价是必须有唯一索引且刷新耗时略长。可以配合pg_cron扩展做成定时任务:
SELECT cron.schedule('refresh-orders-stats', '*/5 * * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_orders_daily$$);MySQL用户则需要手动模拟。核心技巧是利用INSERT ... ON DUPLICATE KEY UPDATE配合时间条件,只重算最近一段有数据变动的窗口:
-- 每五分钟执行一次,只处理最近两小时的数据(留冗余应对延迟写入)
INSERT INTO orders_daily_stats (stat_date, order_cnt, total_amount)
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders
WHERE created_at >= NOW() - INTERVAL 2 HOUR
GROUP BY DATE(created_at)
ON DUPLICATE KEY UPDATE
order_cnt = VALUES(order_cnt),
total_amount = VALUES(total_amount);注意这里的窗口要留够冗余。如果只刷新最近一小时的窗口,而某条订单因为消息队列延迟在一小时后才落库,它的数据就永远漏统计了。把窗口设为两小时甚至一天,代价只是多扫一点数据,但可靠性大幅提升。
物化视图方案的优点是对原表写入零侵入,不会拖慢业务写入速度,所有重算逻辑集中在一处,维护清晰。缺点是数据有一定延迟,刷新间隔五分钟就意味着统计结果最多滞后五分钟。对于报表类场景这通常完全够用,但对于需要秒级实时大屏的场景就不合适了。
两种方案怎么选:对比与实践建议
把两种方案的关键维度放在一起对比:
| 对比维度 | 触发器方案 | 物化视图方案 |
|---|---|---|
| 数据实时性 | 准零延迟,写入即可见 | 取决于刷新间隔,通常分钟级 |
| 对写入性能的影响 | 明显,每次写入额外触发汇总操作 | 几乎无影响 |
| 实现复杂度 | 高,需处理增删改三种场景 | 低,SQL集中易维护 |
| 热点行风险 | 有,高并发下汇总行成瓶颈 | 无,刷新时批量计算 |
| 数据一致性 | 依赖触发器覆盖所有写入路径 | 窗口设计合理即可保证 |
选型的判断标准其实很简单:看业务对实时性的要求和对写入延迟的容忍度。如果是交易大屏、实时监控告警这类秒级敏感场景,触发器或更上层的流式计算(比如Flink)是必选项;如果是运营报表、日结统计这类允许分钟级延迟的场景,物化视图明显更省心,实现简单、不影响业务写入、出问题重刷一遍就能恢复。
实践中还有两个通用建议。第一,无论用哪种方案,都建议保留一条全量校准的后路,定期用原表真实数据核对汇总结果,增量逻辑再严谨也可能有遗漏,校准任务能兜住底。第二,如果数据量已经大到单机聚合都吃力,应该考虑把增量聚合上移到应用层或流式计算引擎,数据库只负责存储和简单查询,这已经是超出触发器和物化视图范畴的架构级话题了。对于绝大多数中小规模场景,本文两种方案足以把统计查询稳定控制在毫秒级。