3. 数据修复的哲学:不可变数据与补偿写入
数据修复这件事,说起来简单——把错的数据改对就行了。但真在线上干过的人都知道,这里面的坑比想象的多得多。
我刚开始做实时数仓那会儿,遇到指标算错了,第一反应就是「把那条数据改掉」。结果呢?改了源头数据,下游的聚合结果还是错的。因为实时计算是有状态的,你改源头,状态不会自动回退。
后来我学乖了。数据修复,不能靠「改」,得靠「补」。这背后就是三个核心哲学:不可变数据、补偿写入、幂等性。
3.1 不可变数据:不改历史,只追加新事实
什么叫不可变数据?说白了就是:已经写进去的数据,永远不要修改或删除。
你想想看,在实时系统里,一条数据可能被多个算子、多个窗口引用过。你把它改了,那些已经算完的聚合值怎么办?难道要回溯所有历史窗口重新算一遍?
核心原则:数据一旦写入,就视为「历史事实」。修复不是修改历史,而是追加一条「修正事实」。
我在项目中遇到过这样一个场景:用户下单时,金额字段因为上游系统 bug 写成了 100 元,实际应该是 150 元。传统做法是去数据库里 update 那条订单记录。但在实时流处理中,这种做法会引发连锁反应——
- 已经发出的实时指标(如「今日 GMV」)是 100 元,不会自动变成 150 元
- 下游依赖这条数据的报表、告警、模型,全部用的旧值
- 即使你重新跑一遍流,状态也不一定清得干净
正确的做法是什么?追加一条「金额修正 +50 元」的事件,而不是去改原来的 100 元事件。
我的习惯:每条数据都带一个 event_id 和 event_type 字段。修复数据时,event_type 设为 CORRECTION,并携带 original_event_id 指向被修正的原始事件。这样既保留了完整的历史链路,又方便下游做补偿计算。
3.2 补偿写入:用「增量修正」代替「全量覆盖」
不可变数据是前提,补偿写入是手段。
补偿写入的核心思路是:不重算历史,只计算差值,然后追加一条修正事件。下游的聚合算子收到修正事件后,在原有结果上「加/减」差值即可。
举个例子,假设你有一个实时统计「今日订单金额」的窗口:
原始事件:订单 A,金额 100 元 → 窗口聚合值 = 100
修正事件:订单 A,金额修正 +50 元 → 窗口聚合值 = 100 + 50 = 150
你看,窗口不需要回溯,只需要在收到修正事件时做一次增量计算。这就是补偿写入的精髓。
我曾经在一个金融风控项目里用过这个方案。当时有个交易金额字段经常因为汇率换算出错,导致风控指标误报。我们上线了补偿写入机制后,修复效率提升了 80%,而且不需要停流。
注意:补偿写入要求下游算子必须支持「增量更新」。如果下游是简单的累加器,那没问题。但如果下游是复杂的状态计算(如去重计数、TopN),补偿写入的实现会复杂很多。我后面会专门讲这部分。
3.3 幂等性:修复的「安全锁」
好,现在数据不可变了,补偿写入了。但还有一个问题:如果补偿事件被重复发送了呢?
实时系统里,消息重复是家常便饭。网络抖动、消费者重启、生产者重试……任何一个环节出问题,都可能导致同一条修复事件被处理多次。
如果没有幂等性,重复处理一次补偿事件,指标就多算一次。那修复就变成了「二次破坏」。
幂等性,就是让同一操作无论执行多少次,结果都一样。
幂等性在修复中的核心地位:它是数据修复的「安全锁」。没有幂等性,修复操作本身就是不可控的。
实现幂等性的常见方式有几种:
| 方式 | 原理 | 适用场景 | 我踩过的坑 |
|---|---|---|---|
| 唯一键去重 | 每条修复事件带唯一 ID,下游去重 | 大多数场景 | 唯一 ID 生成策略要全局唯一,否则会误杀 |
| 状态机校验 | 记录每条数据的处理状态,已处理则跳过 | 有状态计算 | 状态存储本身要可靠,否则状态丢了就失效了 |
| 最终一致性补偿 | 不保证实时幂等,但保证最终结果正确 | 对实时性要求不高的场景 | 中间状态可能短暂异常,需要业务容忍 |
我个人最常用的是「唯一键去重」。实现起来简单,而且效果稳定。但要注意一点:去重逻辑要放在状态算子内部,不能依赖外部存储。否则外部存储挂了,去重就失效了。
我的经验:在 Flink 中,我会用 ValueState 或 MapState 来存储已处理的修复事件 ID。状态后端用 RocksDB,这样即使任务重启,状态也能恢复。去重窗口一般设置 7 天,超过 7 天的重复事件基本不会出现。
3.4 三者关系:一个完整的修复链路
不可变数据、补偿写入、幂等性,这三者不是孤立的。它们共同构成了一条完整的修复链路:
从这张图可以看得很清楚:
- 发现错误后,不修改原始事件——这是不可变数据的约束
- 追加一条修正事件——这是补偿写入的动作
- 修正事件自带唯一 ID,下游去重——这是幂等性的保障
- 最终聚合结果正确——三者共同作用的结果
3.5 避坑指南:我踩过的几个坑
讲了这么多理论,最后分享几个实战中容易踩的坑。嗯,都是我曾经的血泪史。
坑一:补偿事件和原始事件的处理顺序
我曾经遇到过补偿事件比原始事件先到达的情况。结果下游先处理了修正,后处理了原始数据,最终结果反而错了。
解决方案:在补偿事件中带上原始事件的 timestamp,下游算子按时间戳排序处理。或者用事件时间(Event Time)来处理,确保时序正确。
坑二:幂等去重的状态膨胀
唯一 ID 去重需要存储所有已处理的 ID。如果修复事件量很大,状态会不断膨胀,最终导致 OOM。
解决方案:设置状态 TTL(Time-To-Live)。一般修复事件在 3-7 天内不会重复发送,超过这个时间可以安全清理。在 Flink 中,用 StateTtlConfig 配置即可。
坑三:补偿写入的精度问题
有些场景下,补偿写入的差值可能因为浮点数精度问题导致最终结果有微小偏差。比如 0.1 + 0.2 ≠ 0.3 这种经典问题。
解决方案:金额类数据用 Decimal 类型,或者用整数表示最小单位(如分)。聚合时用高精度类型,最后再四舍五入。
3.6 小结
数据修复的哲学,说白了就三句话:
- 不改历史——不可变数据,追加新事实
- 只传差值——补偿写入,增量修正
- 重复无害——幂等性,安全锁
这三条原则,我在多个项目里验证过。从金融风控到电商大促,从广告归因到 IoT 设备数据修复,只要遵循这套哲学,修复的准确性和稳定性都有保障。
当然,理论归理论,落地的时候还有很多细节。比如补偿写入怎么和窗口计算配合?幂等去重怎么和状态后端调优?这些我会在后面的章节里结合实际代码展开讲。