增量计算核心原理:从数据变化说起

增量计算,说白了就是「只算变了的那些」。全量计算像把整锅汤重新煮一遍,增量计算则只往锅里加新料、捞走废渣。我做了这么多年 OLAP,最深的体会就是:增量计算的核心,不在于怎么算,而在于怎么知道「什么变了」。

这一章,我们就来拆解增量计算的四个核心环节。嗯,每个环节我都踩过坑,有些坑现在想起来还肉疼。

增量数据识别机制:怎么找到「新数据」?

增量计算的第一步,是识别出哪些数据是新增的、哪些是修改的、哪些是删除的。我见过不少团队,上来就写复杂逻辑,结果连「哪行变了」都搞不清楚。

常见的识别方式有四种:

识别方式 原理 适用场景 我踩过的坑
时间戳字段 记录每行数据的最后修改时间 业务表有统一更新时间 时区没对齐,凌晨数据丢了
自增ID/序列号 新数据ID单调递增 只追加、不修改的场景 分库分表后ID不连续
版本号字段 每次修改版本号+1 需要追踪历史版本 并发写入时版本冲突
标志位 新增/修改后置标志位 ETL过程可控 忘记重置标志位,重复计算
我的建议:优先用时间戳+自增ID组合。单一字段容易出问题,双字段兜底更稳。我在一个金融项目中,就因为只依赖时间戳,结果某次时钟回拨导致重复计算了2小时的数据——那晚的告警电话我现在还记得。

变更数据捕获(CDC)原理:从源头抓变化

CDC,全称 Change Data Capture,翻译过来就是「抓变化」。它不是被动等业务系统通知,而是主动去捕获数据源的每一次变更。

CDC 的实现方式,我按成熟度排个序:

  1. 基于日志的CDC(最推荐)
    解析数据库的 binlog、redo log 或 WAL 日志。比如 MySQL 的 binlog、PostgreSQL 的 WAL、Oracle 的 Redo Log。
    优点:无侵入、低延迟、不丢数据。
    缺点:需要 DBA 配合开启日志,日志格式解析有学习成本。
  2. 基于触发器的CDC
    在源表上建触发器,每次 INSERT/UPDATE/DELETE 时记录变更到一张日志表。
    优点:实现简单,不依赖数据库版本。
    缺点:对源库性能有影响,触发器维护成本高。
  3. 基于查询的CDC
    定时轮询源表,通过时间戳或版本号拉取增量数据。
    优点:无需额外组件。
    缺点:延迟高,对源库有查询压力。
我曾经踩过的坑:在一个实时数仓项目中,我图省事用了基于查询的CDC,每5秒扫一次订单表。结果大促期间,查询把源库CPU打到了95%,业务方直接打电话过来骂人。从那以后,生产环境我坚决不用轮询式CDC。

下面这张图,是我常用的 CDC 架构:

CDC 增量数据捕获流程 CDC 增量数据捕获流程 源数据库 MySQL / PG / Oracle binlog / WAL / Redo Log CDC 采集器 Canal / Debezium / Maxwell 消息队列 Kafka / Pulsar 下游消费 Flink / Spark / ClickHouse 说明: 1. 源数据库写入时,同时记录变更日志(binlog/WAL) 2. CDC采集器实时解析日志,将变更事件推送到消息队列 3. 下游计算引擎消费消息,执行增量合并逻辑

增量与全量数据合并策略:新老数据怎么「拼」?

拿到增量数据后,怎么和已有的全量数据合并?这个问题,我见过至少五种方案,但真正好用的就三种。

策略一:覆盖合并(Upsert)

用增量数据直接覆盖全量数据中对应的行。主键相同就更新,主键不存在就插入。

-- 以 ClickHouse 为例
INSERT INTO dwd_order_fact
SELECT 
    id, 
    status, 
    amount, 
    update_time
FROM stage_orders_incremental
ORDER BY id
-- 注意:ReplacingMergeTree 会在后台去重合并

适用场景:业务表有明确主键,且只关心最新状态。

策略二:追加合并(Append)

增量数据直接追加到全量数据后面,不做覆盖。常用于日志类数据。

-- 追加写入,不做任何去重
INSERT INTO dwd_click_log
SELECT * FROM stage_click_log_incremental;

适用场景:不可变事件流,比如用户行为日志、埋点数据。

策略三:快照合并(Snapshot Merge)

保留全量数据的多个版本,增量数据作为新版本写入。查询时按时间戳取最新版本。

我的经验:快照合并最耗存储,但最安全。我在一个监管报送项目中,要求保留7天内的所有数据变更历史,快照合并是唯一选择。存储成本翻了三倍,但审计一次过。

三种策略的对比:

策略 存储成本 查询性能 数据一致性 实现复杂度
覆盖合并 低 高 中(可能丢历史) 低
追加合并 中 高 高(只增不改) 低
快照合并 高 中(需过滤版本) 极高(全历史可回溯) 高

时间戳与版本号管理:给数据打上「时间烙印」

增量计算最怕什么?数据乱序、重复、丢失。时间戳和版本号就是解决这三个问题的利器。

时间戳管理

我习惯在每个增量表中保留三个时间戳字段:

  • event_time:业务发生时间(业务方写入)
  • process_time:ETL处理时间(系统自动生成)
  • load_time:数据加载时间(入库时间)

为什么要三个?因为业务时间和处理时间经常不一致。有一次,业务系统凌晨补录了昨天的数据,event_time 是昨天,但 process_time 是今天。如果我只用 event_time 做增量判断,那批数据就漏了。

避坑指南:增量判断的基准时间,建议用 process_time 或 load_time,而不是 event_time。业务时间可能乱序,但处理时间是单调递增的。我曾经因为用 event_time 做增量水位,结果补录数据全部丢失,修复了整整一个周末。

版本号管理

版本号有两种常见模式:

  1. 全局版本号:整个表一个版本号,每次增量+1。
    优点:简单,容易判断数据新鲜度。
    缺点:并发写入时版本号可能冲突。
  2. 行级版本号:每行数据独立版本号。
    优点:支持细粒度并发控制。
    缺点:版本号管理复杂,存储开销大。

我个人更倾向行级版本号。虽然实现复杂一点,但能精确控制每一行数据的变更顺序。在电商订单场景中,同一笔订单可能同时发生「支付」和「退款」两个操作,行级版本号能保证最终结果正确。

-- 行级版本号示例(伪代码)
CREATE TABLE dwd_order (
    order_id      String,
    status        String,
    amount        Decimal(18,2),
    version       UInt64,          -- 行级版本号
    update_time   DateTime,
    load_time     DateTime
) ENGINE = ReplacingMergeTree(version)
ORDER BY (order_id);

-- 增量写入时,版本号由业务系统保证递增
INSERT INTO dwd_order VALUES
('O001', 'paid', 100.00, 3, '2024-01-15 10:00:00', now()),
('O002', 'shipped', 200.00, 1, '2024-01-15 10:01:00', now());
一个小技巧:版本号可以用时间戳的毫秒数代替,既保证递增,又省去了单独维护版本号生成器的麻烦。但要注意,高并发下同一毫秒可能产生相同版本号,需要加上随机后缀或自增序列。