2. 增量计算核心概念:数据变更捕获、增量视图、状态管理与时间语义
好,咱们进入正题。增量计算听起来高大上,但拆开来看,核心就四个东西:怎么知道数据变了、怎么维护变化后的结果、中间状态怎么管、时间怎么对齐。把这四个点吃透,增量计算你就懂了一半。
我个人习惯把这四个概念比作一个实时数据管道的心脏、大脑、肌肉和脉搏。缺一个,系统就跑不起来,或者跑起来也是歪的。
2.1 数据变更捕获(CDC)——增量计算的起点
CDC 说白了就是「怎么知道数据变了」。没有 CDC,增量计算就是无源之水。
我在项目中遇到过最典型的场景:业务库是 MySQL,每天几亿条订单数据。全量同步一次要跑 6 小时,业务方等不了。后来上了 CDC,延迟压到秒级,效果立竿见影。
常见的 CDC 实现方式有几种:
| 方式 | 原理 | 延迟 | 对源库影响 | 典型工具 |
|---|---|---|---|---|
| 基于日志 | 解析 binlog / WAL | 毫秒级 | 极小 | Debezium、Canal |
| 基于触发器 | 数据库触发器写变更表 | 秒级 | 较大 | 自研 |
| 基于时间戳 | 轮询 updated_at 字段 | 分钟级 | 中等 | DataX、Sqoop |
| 基于 API | 调用业务系统变更接口 | 秒级~分钟级 | 取决于实现 | Kafka Connect |
我的建议:生产环境优先选基于日志的方式。它最轻量,对源库几乎无侵入。我曾经见过一个团队用触发器做 CDC,结果业务高峰期触发器把数据库拖垮了——触发器里写了复杂的跨表逻辑,血的教训。
CDC 输出的数据格式也很关键。通常是一条变更事件,包含:
- 操作类型:INSERT / UPDATE / DELETE
- 变更前数据(可选):旧值快照
- 变更后数据:新值快照
- 元数据:表名、时间戳、事务 ID 等
// 一个典型的 CDC 事件(JSON 格式)
{
"op": "u", // 操作类型:c=create, u=update, d=delete
"before": { // 变更前数据
"id": 1001,
"status": "PAID",
"amount": 299.00
},
"after": { // 变更后数据
"id": 1001,
"status": "SHIPPED",
"amount": 299.00
},
"source": { // 元数据
"table": "orders",
"ts_ms": 1698000000123
}
}
避坑指南:CDC 的「 exactly-once 」语义是个大坑。我曾经在项目中遇到 binlog 重复投递导致下游重复计算,后来靠 Kafka 的幂等生产者 + 下游去重才解决。记住:CDC 只能保证 at-least-once,exactly-once 需要上下游配合。
2.2 增量视图——让结果「动」起来
增量视图是什么?简单说,就是每次数据变更后,视图自动更新,而不是重新算一遍。
你想想看,传统做法是:每天凌晨跑批,全量聚合,出报表。增量视图的做法是:来一条订单,更新一次销售额。前者是「拍照」,后者是「直播」。
增量视图的实现方式主要有两种:
- 物化视图增量刷新:数据库原生支持,比如 PostgreSQL 的 MATERIALIZED VIEW + REFRESH CONCURRENTLY。但刷新粒度是表级,不是行级。
- 流式计算中的持续查询:Flink、Kafka Streams 等引擎维护一个「结果表」,每条数据进来后,只更新受影响的那几行聚合结果。
举个例子,计算每分钟的订单金额总和:
-- 传统批处理(每天跑一次)
SELECT TUMBLE(event_time, INTERVAL '1' MINUTE) AS window,
SUM(amount) AS total_amount
FROM orders
WHERE event_time >= '2024-01-01' AND event_time < '2024-01-02'
GROUP BY TUMBLE(event_time, INTERVAL '1' MINUTE);
-- 增量视图(持续更新)
-- 每条订单到来时,只更新对应分钟窗口的 SUM
-- 引擎内部维护一个 Map<窗口, 累计值> 的状态
关键点:增量视图不是「重新计算」,而是「增量更新」。这意味着引擎需要知道:来了一条新数据,它应该影响哪些旧结果?这背后依赖的是增量计算模型,比如 Flink 的增量聚合算子、Kafka Streams 的 KTable 更新。
2.3 增量状态管理——引擎的「记忆」
增量计算离不开状态。为什么?因为你要记住「到目前为止算到哪了」。
我记得刚接触流计算时,以为每条数据都是独立的。后来才发现,大部分计算都需要跨数据的状态:窗口聚合要记住窗口内的数据、Join 要记住一侧的数据、去重要记住已经见过的 key……
状态管理要解决三个核心问题:
- 存什么:聚合中间值、窗口缓存、Join 缓冲区、去重集合
- 存哪里:内存(快但有限)、RocksDB(磁盘但慢)、外部存储(Redis、HBase)
- 怎么恢复:Checkpoint + 一致性快照
| 存储后端 | 读写速度 | 容量 | 适用场景 |
|---|---|---|---|
| Heap(堆内存) | 纳秒级 | 受限于 JVM | 小状态(<10GB) |
| RocksDB | 微秒~毫秒级 | 磁盘容量 | 大状态(TB 级) |
| 外部 KV 存储 | 毫秒级 | 可扩展 | 跨作业共享状态 |
我曾经踩过的坑:状态膨胀导致 OOM。当时一个去重作业,状态里存了所有历史 key,一个月后状态涨到 50GB,RocksDB 频繁 compaction,作业延迟飙升。后来加了 TTL 和 Bloom Filter,才稳住。记住:状态不是无限存的,一定要设过期策略。
2.4 事件时间与处理时间——时间到底听谁的?
这是增量计算里最容易搞混的概念。我见过不少新手,甚至老手,在这里翻车。
事件时间(Event Time):数据实际发生的时间。比如用户下单的时间,由业务系统打戳。
处理时间(Processing Time):数据到达计算引擎的时间。比如 Flink 收到这条数据的时间。
为什么会有两个时间?因为数据会迟到。网络延迟、重试、离线补录……你永远不知道一条数据什么时候到。
举个例子:
- 用户 10:00:00 下单,事件时间 = 10:00:00
- 网络抖动,这条数据 10:00:05 才到 Flink,处理时间 = 10:00:05
- 如果按处理时间算窗口,这条数据会被归到 10:00:05 的窗口,错了!
我的原则:能用事件时间,就别用处理时间。处理时间只适合对时间不敏感的场景,比如简单的过滤、转换。但凡涉及窗口、聚合、Join,一律用事件时间。
事件时间引入了一个新问题:Watermark(水位线)。Watermark 是引擎用来判断「数据是否到齐」的机制。比如 Watermark = 10:00:10,意味着事件时间 <= 10:00:10 的数据已经到齐了,可以触发窗口计算了。
// Flink 中设置事件时间和 Watermark
DataStream<Order> stream = env
.addSource(kafkaSource)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Order>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
);
// 按事件时间开窗口
stream
.keyBy(order -> order.getUserId())
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new OrderAggregator());
避坑指南:Watermark 设置太短,大量迟到数据被丢弃;设置太长,窗口迟迟不触发,延迟飙升。我一般建议先设 5-10 秒的乱序容忍度,上线后根据实际迟到率调整。另外,迟到数据要单独处理——用 side output 输出到延迟队列,做补偿计算。
2.5 四个概念如何协同工作?
最后,咱们串一下这四个概念在一条增量计算链路中是怎么配合的:
- CDC 从 MySQL binlog 捕获一条订单 UPDATE 事件
- 事件被发送到 Kafka,携带事件时间(订单更新时间)
- Flink 消费 Kafka,按事件时间分配窗口,Watermark 控制触发时机
- 引擎读取状态中该用户的历史聚合值,与新值做增量计算
- 更新增量视图(比如实时大屏上的销售额数字)
- 定期做 Checkpoint,保存状态快照,保证故障恢复
你看,一环扣一环。任何一个环节出问题,整个链路都会受影响。所以选型时,这四个能力必须一起评估,不能只看单点性能。
一句话总结:CDC 是增量计算的「眼睛」,增量视图是「脸面」,状态管理是「大脑」,时间语义是「心跳」。四者缺一不可。