2、核心概念与原理:不可变数据与事件溯源、状态与状态快照、增量处理与幂等性、水印与乱序处理
好,咱们正式开始聊增量计算的核心概念。说实话,这些概念单独拎出来都不难,但把它们串在一起理解,才是真正能上手干活的关键。我见过不少团队,工具用得挺熟,一遇到数据对不上、结果算不准,就抓瞎了——说白了,就是底层这几个概念没吃透。
这一章,咱们就把地基打牢。我会结合自己踩过的坑,把四个核心概念掰开揉碎了讲。
2.1 不可变数据与事件溯源
先说说「不可变数据」。这个概念听起来有点学术,其实很简单:数据一旦生成,就永远不允许修改。你只能追加新的数据,不能回头去改旧的。
为什么要这么干?我举个例子。几年前我维护过一个交易系统,每天凌晨跑批处理。有一次因为上游数据源的一个字段被「修正」了,导致当天所有下游报表全对不上。排查了整整两天,最后发现是有人直接 UPDATE 了数据库里的原始记录。从那以后,我对「可变数据」就深恶痛绝。
不可变数据的好处很明显:
- 可追溯:每一条数据都有完整的历史,出了问题能回放。
- 可重算:只要原始事件还在,随时可以重新计算结果。
- 无副作用:不会因为修改旧数据而影响已经算好的结果。
事件溯源(Event Sourcing)就是基于这个思想的一种架构模式。它不存储系统的「当前状态」,而是存储所有发生过的事件。想知道当前状态?把事件从头到尾重放一遍就行。
核心要点:事件溯源 = 存储事实,而非状态。事实不可篡改,状态可以随时从事实推导出来。
你想想看,这和增量计算有什么关系?关系大了。增量计算处理的就是「新到达的事件」,而不是「被修改的旧数据」。如果数据是可变的,增量逻辑会变得极其复杂——你得知道哪些数据变了、怎么变的。而不可变数据让增量处理变得纯粹:来一个新事件,算一次增量。
2.2 状态与状态快照
聊完事件,咱们聊聊「状态」。状态是什么?简单说,就是系统在某个时间点上的「快照」。比如一个计数器的当前值、一个窗口的聚合结果、一个用户会话的上下文。
在增量计算里,状态是必须的。因为没有状态,你就没法知道「增量」相对于什么来算。我习惯把状态理解成一个「累加器」——每来一个新事件,累加器更新一次。
但这里有个问题:状态会越来越大。比如你要统计过去24小时的独立访客数,状态里就得存所有访客的ID。如果系统崩溃了,状态丢了怎么办?
这就引出了「状态快照」。快照就是定期把当前状态保存下来,存到可靠的存储里(比如 HDFS、S3)。下次系统重启时,先加载最近的一个快照,然后从快照之后的事件开始重放,就能恢复完整状态。
| 概念 | 说明 | 典型存储 |
|---|---|---|
| 事件日志 | 所有原始事件的追加序列 | Kafka、Pulsar |
| 状态快照 | 某个时刻的完整状态副本 | HDFS、RocksDB |
| 增量状态 | 当前正在更新的运行时状态 | 内存、本地磁盘 |
我的经验:快照频率不是越密越好。太密浪费存储和IO,太疏恢复时间太长。我一般建议根据状态大小和恢复时间目标(RTO)来算。比如状态10GB,从Kafka重放速度是100MB/s,那快照间隔可以设为2-3分钟。
嗯,这里要注意:快照和事件日志是互补的,不是替代关系。快照帮你快速恢复,事件日志保证你数据不丢。
2.3 增量处理与幂等性
增量处理,说白了就是「只算变化的部分」。全量计算是每次从头算一遍,增量计算是只处理新来的数据,然后更新结果。
举个例子。你要统计每分钟的页面访问量。全量做法:每分钟把过去所有日志读一遍,重新算。增量做法:维护一个计数器,每来一条新日志,计数器加1。哪个效率高?一目了然。
但增量处理有个大坑:幂等性。什么是幂等?同一个操作执行一次和执行多次,结果一样。
为什么增量处理需要幂等?因为分布式系统里,消息可能重复。比如 Kafka 的「至少一次」语义,会导致同一条消息被处理两次。如果你的增量逻辑不是幂等的,结果就错了。
我曾经踩过的坑:做一个实时计费系统,用了 Flink 的 exactly-once 语义,但下游的数据库更新没做幂等。结果网络抖动导致一次扣费操作重试了两次,用户被多扣了一笔钱。后来加了个唯一键约束,才解决问题。
实现幂等性的常见方式:
- 去重:用事件ID做去重,重复的事件直接丢弃。
- 幂等更新:比如用「INSERT ON DUPLICATE KEY UPDATE」,或者用版本号做乐观锁。
- 状态机:每个事件只允许状态按特定方向流转,重复事件不会改变状态。
我个人习惯,在设计增量处理逻辑时,先把「如果这条消息来了两次」这个场景想清楚。想清楚了,幂等性就自然有了。
2.4 水印与乱序处理
最后聊聊「水印」和「乱序」。这两个概念在流处理里特别重要,增量计算也经常遇到。
先说乱序。现实世界的数据,到达顺序和发生顺序往往不一致。比如用户点击发生在10:00:01,但因为网络延迟,10:00:03才到达。而另一个发生在10:00:02的事件,反而先到了。这就是乱序。
水印(Watermark)就是用来处理乱序的。它是一个时间戳,表示「在这个时间之前的事件,应该都已经到了」。如果水印是10:00:05,那所有事件时间小于等于10:00:05的事件,系统就认为不会再来了。
水印怎么用?举个例子。你要算「每5分钟的窗口聚合」。窗口是[10:00:00, 10:05:00)。如果水印到了10:05:00,系统就触发这个窗口的计算,输出结果。水印之后才到的、属于这个窗口的事件,就被当作迟到数据,可以丢弃或者单独处理。
关键点:水印是一个「进度信号」,不是精确的边界。它告诉系统:「到这个时间点了,可以放心地做决定了。」
水印的生成策略通常有两种:
- 周期性水印:每隔一段时间生成一个水印,比如每5秒生成一次。
- 事件驱动水印:根据事件中的时间戳来推进水印,比如取最近100个事件的最大时间戳减去一个延迟阈值。
我建议在实际项目中,水印的延迟阈值不要设得太小。设小了,很多迟到数据会被丢掉;设大了,窗口结果出得慢。一般我会根据业务容忍度和数据延迟的P99来设。比如P99延迟是10秒,那阈值就设15秒,留点余量。
下面这张图,把本章四个核心概念的关系梳理了一下:
总结一下这章的核心:不可变数据是基础,事件溯源是模式,状态与快照是手段,水印与乱序是挑战,幂等性是底线。把这几个概念理清了,后面讲具体场景的时候,你就能快速理解「为什么这么设计」。