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秒,留点余量。

下面这张图,把本章四个核心概念的关系梳理了一下:

增量计算核心概念关系图 增量计算核心概念关系图 增量计算 不可变数据 事件溯源 状态与快照 水印与乱序 幂等性 说明: 不可变数据是事件溯源的基础,事件溯源天然产生不可变事件流。 状态与快照解决增量计算的「记忆」问题,水印与乱序解决「时序」问题。幂等性是所有增量逻辑的底线保障。

总结一下这章的核心:不可变数据是基础,事件溯源是模式,状态与快照是手段,水印与乱序是挑战,幂等性是底线。把这几个概念理清了,后面讲具体场景的时候,你就能快速理解「为什么这么设计」。