4. 批量入湖进阶:增量抽取策略(全量覆盖、增量追加、CDC变更捕获)
批量入湖做到这一步,基本的数据搬运已经不成问题了。但真正让数据平台产生价值的,是持续、高效、准确地同步变化数据。说白了,就是怎么把源系统的“新账旧账”一笔笔算清楚,再搬到湖里。
我个人习惯把增量抽取策略分成三个层次:全量覆盖、增量追加、CDC变更捕获。它们不是互斥的,而是根据业务场景和数据量级,逐步演进的关系。你想想看,一个刚起步的数据平台,可能每天跑一次全量就够了;但到了每天几亿条订单的规模,全量覆盖就是灾难,必须上CDC。
核心观点:没有银弹。选择哪种策略,取决于源系统能力、数据量级、业务对延迟的容忍度,以及你的运维成本预算。
4.1 全量覆盖:简单粗暴,但别滥用
全量覆盖是最容易理解的策略:每次抽取时,先把目标表清空,再把源表全部数据写进去。嗯,这里要注意,它不等于“每天跑一次全量”。真正的全量覆盖,是幂等的——不管跑多少次,结果都一样。
我在项目中遇到过一个小团队,每天用全量覆盖同步一张5亿行的订单表。结果呢?每天跑6小时,刚好赶上业务高峰期,把数据库连接池打爆了。后来我帮他们改成增量追加,时间从6小时降到15分钟。
什么时候该用全量覆盖?
- 维度表、码表、配置表——数据量小,变化不频繁
- 源系统没有时间戳,也没有自增ID
- 数据量在百万行以内,且业务允许T-1延迟
代码实现其实很简单,但要注意事务控制:
-- 全量覆盖示例(Spark SQL)
-- 1. 先删除目标分区或整表
TRUNCATE TABLE lakehouse.dim_customer;
-- 2. 全量插入
INSERT OVERWRITE lakehouse.dim_customer
SELECT * FROM source_db.customer
WHERE is_active = 1; -- 只同步有效数据
避坑指南:我曾经见过有人用 DELETE + INSERT 代替 TRUNCATE + INSERT。在Lakehouse里,DELETE不会释放存储空间,久而久之会产生大量小文件。一定要用 INSERT OVERWRITE 或 TRUNCATE。
4.2 增量追加:最常用的策略
增量追加的核心思路是:只抽取从上一次同步之后新增或修改的数据。这需要源表有一个可靠的“水位标记”——通常是时间戳字段或自增ID。
我个人习惯用时间戳 + 自增ID双保险。为什么?因为时间戳可能重复(同一秒内多条记录),自增ID也可能回滚(事务失败后ID跳号)。两个一起用,基本不会漏数据。
| 水位标记类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 时间戳(updated_at) | 直观,业务容易理解 | 时钟回拨、精度不足 | 更新不频繁的表 |
| 自增ID | 单调递增,性能好 | 无法捕获修改 | 只追加不修改的日志表 |
| 时间戳 + ID | 最可靠,几乎不漏数据 | 需要两个字段 | 核心业务表 |
来看一个实际的生产代码片段:
# Python + Spark 增量追加示例
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("incremental_append") \
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
.getOrCreate()
# 读取上次水位
last_watermark = spark.sql("""
SELECT COALESCE(MAX(watermark_value), '1970-01-01') AS last_ts
FROM lakehouse.etl_watermark
WHERE table_name = 'source_db.orders'
""").collect()[0][0]
# 增量抽取
df_incremental = spark.read \
.format("jdbc") \
.option("url", "jdbc:mysql://...") \
.option("dbtable", "orders") \
.option("predicates", f"updated_at > '{last_watermark}'") \
.load()
# 追加到Delta表
df_incremental.write \
.format("delta") \
.mode("append") \
.save("/lakehouse/bronze/orders")
# 更新水位
spark.sql(f"""
INSERT OVERWRITE lakehouse.etl_watermark
VALUES ('source_db.orders', '{last_watermark}')
""")
关键点:水位表本身也要做增量管理。我建议把水位信息存在Delta表里,利用ACID事务保证水位更新和入湖操作的一致性。否则,万一入湖成功但水位没更新,下次就会重复抽取。
4.3 CDC变更捕获:实时入湖的基石
增量追加有个硬伤:它只能捕获“当前状态”,看不到数据的变化过程。比如一条订单从“待支付”变成“已支付”,增量追加只能看到最终状态。但CDC(Change Data Capture)可以告诉你:什么时间、谁、把哪个字段从什么值改成了什么值。
说白了,CDC就是数据库的“黑匣子”。它读取数据库的事务日志(比如MySQL的Binlog、Oracle的LogMiner、PostgreSQL的WAL),把每一次INSERT、UPDATE、DELETE都解析成事件流。
我记得有一次帮一家金融客户做实时风控,他们要求订单状态变更必须在3秒内入湖。增量追加根本做不到,最后上了Debezium + Kafka + Delta Lake的CDC pipeline,延迟压到了1秒以内。
4.3.1 常见的CDC实现方式
| 方式 | 工具 | 延迟 | 对源库影响 | 我的评价 |
|---|---|---|---|---|
| 基于日志 | Debezium, Maxwell, Canal | 秒级 | 极小(仅读日志) | 首选,生产环境最稳 |
| 基于触发器 | 自定义触发器+中间表 | 秒级 | 较大(影响源库写入性能) | 不推荐,除非没得选 |
| 基于时间戳 | 轮询查询 | 分钟级 | 中等(增加查询负载) | 适合非核心系统 |
4.3.2 CDC入湖的数据模型
CDC数据进入Lakehouse后,我建议保留原始变更记录,而不是直接合并成最终状态。这样你可以回溯任意时间点的数据快照。
一个典型的CDC入湖表结构长这样:
-- CDC变更记录表结构
CREATE TABLE lakehouse.cdc_orders (
-- 业务主键
order_id STRING,
-- 变更前数据(Debezium的before字段)
before_status STRING,
before_amount DECIMAL(10,2),
-- 变更后数据(Debezium的after字段)
after_status STRING,
after_amount DECIMAL(10,2),
-- CDC元数据
op_type STRING, -- 'c'=创建, 'u'=更新, 'd'=删除
ts_ms BIGINT, -- 变更发生时间(毫秒时间戳)
file_name STRING, -- 来源Binlog文件名
pos BIGINT, -- Binlog中的位置
-- Lakehouse分区字段
dt STRING -- 按天分区
)
USING delta
PARTITIONED BY (dt)
LOCATION '/lakehouse/bronze/cdc_orders';
实战技巧:CDC数据入湖后,我通常会做两层处理:
- Bronze层(原始层):保留完整的CDC事件流,不做任何合并,用于审计和回溯
- Silver层(清洗层):用MERGE语句将CDC事件合并成当前快照,供下游使用
4.4 三种策略的对比与选择
最后,我用一张表总结一下三种策略的适用场景。你想想看,你的业务属于哪一种?
| 维度 | 全量覆盖 | 增量追加 | CDC变更捕获 |
|---|---|---|---|
| 数据量级 | < 100万行 | 100万 ~ 1亿行 | > 1亿行 |
| 延迟要求 | T-1 | 分钟级 | 秒级 |
| 源系统改造 | 无 | 需要时间戳或自增ID | 需要开启Binlog/归档日志 |
| 数据一致性 | 强一致(幂等) | 最终一致(可能漏数据) | 强一致(基于日志) |
| 运维复杂度 | 低 | 中 | 高(需要维护CDC组件) |
| 典型场景 | 维度表、码表 | 订单表、交易流水 | 实时风控、审计追踪 |
最后提醒一句:不要一上来就上CDC。我曾经见过一个团队,每天只有几万条数据,却搭了一套Debezium + Kafka + Flink的CDC pipeline。结果运维成本比数据价值还高。从全量覆盖开始,等数据量上来了再逐步演进,这才是务实的做法。