3、增量计算的基石:不可变数据与事件溯源

聊增量计算,绕不开两个核心概念:不可变数据和事件溯源。

说白了,它们是增量计算能跑起来的底层契约。没有它们,你做的增量就是个伪命题。

我刚开始接触数据湖时,也踩过这个坑。当时团队用传统方式,每天全量覆盖一张订单表。数据量一上来,跑不动了。我们想改成增量,结果发现——数据源头根本没法区分哪些是新增、哪些是修改。为什么?因为原始表里的记录会被直接 UPDATE 覆盖,历史信息全丢了。

嗯,这就是典型的「可变数据」问题。

3.1 可变数据 vs 不可变数据

先看一个简单对比:

特性 可变数据(Mutable) 不可变数据(Immutable)
写入方式 UPDATE / DELETE 直接修改 只 APPEND,不修改已有记录
历史可追溯 丢失旧值 完整保留所有版本
增量计算友好度 极差,需要额外 CDC 机制 天然支持,每条记录自带时间戳
存储成本 相对低 相对高,但可压缩/归档
典型场景 传统关系型数据库 OLTP 事件驱动架构、数据湖、日志系统

你想想看,如果数据是可变的,你每次增量计算前都得问:「上次处理到哪了?中间有没有数据被改过?」这问题在分布式系统里几乎无解。

但换成不可变数据,事情就简单了:每条记录一旦写入,就永远不会被修改。你只需要记住一个偏移量(比如时间戳或序列号),下次从那个位置继续读就行。

核心原则:增量计算的本质,是在不可变的数据流上做「可重复的偏移量追踪」。没有不可变性,就没有可靠的增量。

3.2 事件溯源:把状态变成事件流

事件溯源(Event Sourcing)是「不可变数据」在业务层的落地实践。

它的思路很简单:不存当前状态,只存导致状态变化的事件。

举个例子。传统方式存「用户余额」:

-- 可变表:直接覆盖
UPDATE user_balance SET balance = 100 WHERE user_id = 1;

事件溯源方式存「余额变更事件」:

-- 事件表:只追加
INSERT INTO balance_events (user_id, event_type, amount, timestamp)
VALUES (1, 'DEPOSIT', 50, '2024-01-01 10:00:00');

INSERT INTO balance_events (user_id, event_type, amount, timestamp)
VALUES (1, 'WITHDRAW', 20, '2024-01-01 11:00:00');

要算当前余额?把事件按时间顺序重放一遍就行。要算某个时间点的余额?重放到那个时间点停止。

我个人习惯把事件溯源理解为「数据库的 Git 版本控制」。你每次 commit 的不是最终文件,而是 diff。想回到任意历史版本?随时 checkout。

实战经验:我在项目中遇到过,业务方要求「回滚到三天前的某个状态」。传统可变表根本做不到,但事件溯源只需要重放到那个时间点。那次之后,团队对事件溯源的信任度直接拉满。

3.3 不可变数据如何支撑增量计算

增量计算的核心流程,其实就三步:

  1. 定位增量边界:记录上次处理到的位置(时间戳、偏移量、批次号)
  2. 读取增量数据:从边界位置往后读,只取新增的不可变记录
  3. 执行增量逻辑:对这批新数据做计算,更新结果

这三步能成立,前提就是数据不可变。如果数据会变,第二步就永远无法保证「只读新增」。

来看一个实际的数据湖增量读取示例:

-- 假设事件表按时间分区,不可变
-- 上次处理到 '2024-01-01 12:00:00'
-- 本次增量读取:

SELECT *
FROM balance_events
WHERE timestamp > '2024-01-01 12:00:00'
  AND timestamp <= '2024-01-01 13:00:00'
ORDER BY timestamp ASC;

这段 SQL 能稳定返回正确结果,前提就是 balance_events 里的数据不会回滚、不会修改。如果中间有一条记录被 UPDATE 了,时间戳变了,那你的增量边界就全乱了。

避坑指南:我曾经接手过一个项目,说是「事件表」,但底层为了省存储,用了 MERGE 操作合并重复事件。结果增量计算时,同一批事件被重复消费了三次。嗯,从那以后我定了个铁律:事件表只 APPEND,不允许任何 UPDATE/DELETE/MERGE。

3.4 事件溯源在数据湖中的落地模式

数据湖里,事件溯源最常见的落地方式是 Lakehouse 架构 + Delta Lake / Apache Iceberg 这类支持 ACID 和版本管理的存储格式。

它们本质上就是「不可变数据 + 元数据版本控制」的组合:

  • 每次写入生成一个新版本文件(不可变)
  • 元数据层记录版本间的差异(类似事件流)
  • 查询时可以指定版本号,实现时间旅行

下面这张图展示了事件溯源在数据湖中的核心逻辑:

事件溯源在数据湖中的增量计算流程 业务事件流 APPEND ONLY 不可变事件表 分区 + 版本控制 增量读取 基于偏移量 事件重放 按时间顺序聚合 状态快照 物化视图 / 结果表 增量更新 合并到结果表 关键设计要点 1. 事件表只追加,不修改已有记录 2. 增量读取基于时间戳或序列号,保证幂等性 3. 状态快照定期物化,避免每次全量重放 4. 事件重放支持时间旅行,可回溯任意历史状态

3.5 不可变数据带来的额外收益

除了支撑增量计算,不可变数据和事件溯源还带来了几个「意外之喜」:

  • 审计友好:每笔变更都有完整记录,谁在什么时间改了什么都查得到。合规审计时,这简直是救命稻草。
  • 调试方便:出问题时,可以直接重放事件流复现现场。我在项目中用这招定位过好几个「只在特定时间窗口出现」的 bug。
  • 并行计算友好:不可变数据天然支持分区并行。不同分区之间没有写冲突,Spark 之类的引擎可以放心并发读。
  • 数据回滚:想回到三天前的状态?不需要备份恢复,只需要把重放偏移量调到三天前就行。

个人建议:如果你正在设计一个新的数据管道,从一开始就把事件溯源作为默认模式。虽然前期多写几行代码,但后期维护成本会直线下降。我见过太多「先快速上线,后面再改」的项目,最后都没改成。

3.6 什么时候不适合事件溯源

当然,任何技术都有边界。事件溯源也不是银弹:

场景 为什么不适合 替代方案
高频状态覆盖 事件量爆炸,重放成本高 使用快照 + 增量 CDC
强一致性要求极高 最终一致性模型,重放有延迟 传统数据库事务
存储成本敏感 事件表体积远大于状态表 定期归档 + 压缩
简单 CRUD 应用 过度设计,增加复杂度 普通可变表 + binlog

嗯,这里要注意:事件溯源不是用来替代数据库的,而是用来补充数据湖的增量计算能力的。选不选它,取决于你的核心诉求是什么。

总结一下这章的核心:不可变数据是增量计算的物理基础,事件溯源是它的逻辑实现。两者加在一起,才让「只读新增、可重复计算、时间旅行」这些能力成为现实。