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 不可变数据如何支撑增量计算
增量计算的核心流程,其实就三步:
- 定位增量边界:记录上次处理到的位置(时间戳、偏移量、批次号)
- 读取增量数据:从边界位置往后读,只取新增的不可变记录
- 执行增量逻辑:对这批新数据做计算,更新结果
这三步能成立,前提就是数据不可变。如果数据会变,第二步就永远无法保证「只读新增」。
来看一个实际的数据湖增量读取示例:
-- 假设事件表按时间分区,不可变
-- 上次处理到 '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 和版本管理的存储格式。
它们本质上就是「不可变数据 + 元数据版本控制」的组合:
- 每次写入生成一个新版本文件(不可变)
- 元数据层记录版本间的差异(类似事件流)
- 查询时可以指定版本号,实现时间旅行
下面这张图展示了事件溯源在数据湖中的核心逻辑:
3.5 不可变数据带来的额外收益
除了支撑增量计算,不可变数据和事件溯源还带来了几个「意外之喜」:
- 审计友好:每笔变更都有完整记录,谁在什么时间改了什么都查得到。合规审计时,这简直是救命稻草。
- 调试方便:出问题时,可以直接重放事件流复现现场。我在项目中用这招定位过好几个「只在特定时间窗口出现」的 bug。
- 并行计算友好:不可变数据天然支持分区并行。不同分区之间没有写冲突,Spark 之类的引擎可以放心并发读。
- 数据回滚:想回到三天前的状态?不需要备份恢复,只需要把重放偏移量调到三天前就行。
个人建议:如果你正在设计一个新的数据管道,从一开始就把事件溯源作为默认模式。虽然前期多写几行代码,但后期维护成本会直线下降。我见过太多「先快速上线,后面再改」的项目,最后都没改成。
3.6 什么时候不适合事件溯源
当然,任何技术都有边界。事件溯源也不是银弹:
| 场景 | 为什么不适合 | 替代方案 |
|---|---|---|
| 高频状态覆盖 | 事件量爆炸,重放成本高 | 使用快照 + 增量 CDC |
| 强一致性要求极高 | 最终一致性模型,重放有延迟 | 传统数据库事务 |
| 存储成本敏感 | 事件表体积远大于状态表 | 定期归档 + 压缩 |
| 简单 CRUD 应用 | 过度设计,增加复杂度 | 普通可变表 + binlog |
嗯,这里要注意:事件溯源不是用来替代数据库的,而是用来补充数据湖的增量计算能力的。选不选它,取决于你的核心诉求是什么。
总结一下这章的核心:不可变数据是增量计算的物理基础,事件溯源是它的逻辑实现。两者加在一起,才让「只读新增、可重复计算、时间旅行」这些能力成为现实。