4、预聚合的实现方式:基于物化视图、预计算任务与流式计算

聊到预聚合的具体落地,很多同学第一反应就是「不就是提前算好存起来吗?」。嗯,话是没错,但怎么算、在哪算、什么时候算,这里面的门道可不少。我这些年踩过的坑,十有八九都是因为选错了实现方式。

说白了,预聚合的实现就三条路:物化视图、离线批处理、流式计算。它们各有各的脾气,选对了事半功倍,选错了……嗯,咱们一个个来看。

4.1 基于物化视图(Materialized View)

物化视图这东西,我刚开始接触时觉得它就是「带存储的视图」。后来才发现,它其实是数据库帮你自动维护的预聚合结果集。

你想想看,普通视图就是个查询模板,每次访问都要重新算。物化视图不一样——它把结果实实在在地存成一张表,而且数据库会自动帮你增量刷新。

核心思路:在数据库层面声明「我要按什么维度聚合」,数据库负责维护和更新。

4.1.1 适用场景

  • OLAP 场景:ClickHouse、Doris、Snowflake 等分析型数据库原生支持
  • 维度相对稳定:聚合维度不会频繁变更
  • 查询模式固定:比如按天、按地区统计销售额

4.1.2 实战示例(ClickHouse)

-- 创建物化视图,按天和城市预聚合订单金额
CREATE MATERIALIZED VIEW order_daily_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(day)
ORDER BY (day, city_id)
AS SELECT
    toDate(order_time) AS day,
    city_id,
    sum(amount) AS total_amount,
    count() AS order_count
FROM orders
GROUP BY day, city_id;

这段 SQL 一跑,之后每次往 orders 表写数据,ClickHouse 都会自动把增量数据聚合到 order_daily_mv 里。查询时直接查物化视图,速度能快几十倍。

我的经验:物化视图最适合「写少读多」的场景。我曾经在一个实时看板项目里用了 ClickHouse 的物化视图,把原本 3 秒的查询压到了 50 毫秒以内。但要注意——物化视图不是银弹,维度爆炸时存储会涨得很快。

4.1.3 避坑指南

我曾经在一个项目里对 20 个维度做了全组合物化视图,结果存储膨胀了 30 倍,写入性能直接崩了。后来只保留了 Top-5 高频维度组合,才把问题解决。

  • 不要对所有维度组合都建物化视图
  • 监控刷新延迟,特别是数据量大的时候
  • 物化视图的刷新策略要选对:增量 vs 全量

4.2 基于预计算任务(离线批处理)

如果说物化视图是「数据库帮你干活」,那离线批处理就是「你自己撸起袖子干」。这种方式最灵活,但也最考验架构设计。

我个人的习惯是:能用物化视图解决的,绝不自己写批处理。但现实往往没那么理想——当你的数据源不在同一个数据库,或者聚合逻辑特别复杂时,批处理就成了唯一选择。

4.2.1 典型架构

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  原始数据层   │ ──▶ │  预计算任务    │ ──▶ │  聚合结果层   │
│  (Hive/HDFS) │     │  (Spark/MapReduce) │     │  (MySQL/ES)  │
└─────────────┘     └──────────────┘     └─────────────┘

4.2.2 实现步骤

  1. 数据抽取:从业务库或消息队列拉取原始数据
  2. 维度对齐:统一时间粒度、维度编码
  3. 聚合计算:按预定义的维度组合做 GROUP BY
  4. 结果存储:写入高性能查询引擎(如 ES、Druid)

4.2.3 代码示例(Spark SQL)

// 每日凌晨执行预计算任务
val dailyAggDF = spark.sql("""
  SELECT
    date_format(order_time, 'yyyy-MM-dd') AS day,
    city_id,
    product_category,
    sum(amount) AS revenue,
    count(DISTINCT user_id) AS uv
  FROM orders
  WHERE order_time >= '${yesterday}'
    AND order_time < '${today}'
  GROUP BY day, city_id, product_category
""")

dailyAggDF.write
  .mode("overwrite")
  .format("parquet")
  .save("/data/agg/daily/")

关键点:离线批处理的核心是「增量计算」。全量重跑只适合数据量小的场景,否则 T+1 都跑不完。

4.2.4 优缺点对比

维度 物化视图 离线批处理
实时性 秒级~分钟级 T+1 或小时级
灵活性 受限于数据库能力 完全可控
维护成本 低(数据库自动维护) 高(需要调度、监控)
适用数据源 单数据库内 多数据源、异构系统

4.3 基于流式计算(Flink / Spark Streaming)

好,前两种方式都有个共同问题——延迟。物化视图再快也是秒级,批处理更是 T+1。那如果业务要求「数据产生后几秒内就要看到聚合结果」呢?

这时候就得请出流式计算了。我最早用 Flink 做预聚合是在一个实时风控项目里,要求 5 秒内统计出每个用户的交易频次。说实话,第一次跑通时还挺激动的。

4.3.1 流式预聚合的核心逻辑

说白了就是:数据在流动的过程中就完成聚合,而不是等落盘后再算。

// Flink SQL 实现实时预聚合
CREATE TABLE orders_stream (
  order_id STRING,
  user_id STRING,
  amount DOUBLE,
  order_time TIMESTAMP(3),
  WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
) WITH (
  'connector' = 'kafka',
  'topic' = 'orders',
  'format' = 'json'
);

-- 每分钟滚动窗口聚合
CREATE TABLE minute_agg_sink (
  window_start TIMESTAMP(3),
  user_id STRING,
  total_amount DOUBLE,
  order_count BIGINT
) WITH (
  'connector' = 'jdbc',
  'url' = 'jdbc:mysql://...'
);

INSERT INTO minute_agg_sink
SELECT
  TUMBLE_START(order_time, INTERVAL '1' MINUTE),
  user_id,
  SUM(amount),
  COUNT(*)
FROM orders_stream
GROUP BY
  TUMBLE(order_time, INTERVAL '1' MINUTE),
  user_id;

我个人的习惯:流式预聚合一定要做「两阶段聚合」。先在本机做一次局部聚合,再发送到全局做最终聚合。这样可以大幅减少网络 shuffle 的数据量。

4.3.2 三种方式的对比总结

实现方式 延迟 吞吐 开发成本 典型场景
物化视图 秒~分钟 高 低 OLAP 看板、报表
离线批处理 小时~天 极高 中 T+1 报表、数据仓库
流式计算 秒级 中高 高 实时监控、风控、推荐

4.3.3 选型建议

  • 延迟容忍度 > 1 分钟:优先考虑物化视图,省心省力
  • 需要跨数据源聚合:离线批处理更靠谱
  • 延迟要求 < 10 秒:别犹豫,上 Flink 或 Spark Streaming
  • 团队能力有限:物化视图 > 批处理 > 流式(按维护成本排序)

我曾经在一个项目里强行用 Flink 做 T+1 的离线聚合,结果搞复杂了——既要处理乱序数据,又要管理状态后端,最后还不如直接用 Spark 批处理来得稳。记住:不要为了用新技术而用新技术。

预聚合实现方式选型决策图 预聚合需求 物化视图 离线批处理 流式计算 特性 • 延迟:秒~分钟 • 维护:低 • 场景:OLAP 看板 特性 • 延迟:小时~天 • 维护:中 • 场景:T+1 报表 特性 • 延迟:秒级 • 维护:高 • 场景:实时监控 选型建议 延迟 > 1分钟 → 物化视图 | 跨数据源 → 离线批处理 | 延迟 < 10秒 → 流式计算 团队能力有限时:物化视图 > 批处理 > 流式(按维护成本排序)

嗯,三种方式各有千秋。我个人的经验是:先想清楚你的延迟要求,再考虑团队能力,最后才是技术选型。很多时候,最简单的方案就是最好的方案。