凌晨1点半,手机震动把我吵醒。打开企业微信一看,数据同步任务又挂了,全量抽取跑到第三个小时报“连接超时”,第二天早上管理报表肯定要开天窗。那会儿我还在用最原始的方式做ETL:不管多少数据,每天夜里一把梭全量拉,重跑一次还得再熬三个小时。后来我把这套链路改成了增量同步,同一个任务从3个多小时压到10分钟以内,失败重跑也就是分钟级的事。我把整个落地过程从理论到Kettle实现做个完整复盘,如果你手头也有类似“每天全量抽、越抽越慢”的同步任务,这篇文章应该能帮上忙。
增量机制不是一门新学问,但很多人一开始设计ETL时根本不会想到它,等数据量涨上来了才开始填坑。做增量同步的关键,不只是写个where update_time > 昨天那么简单,还牵扯到同步状态怎么记录、增量窗口怎么算、跑挂了怎么续、重复数据怎么防。这些点如果没设计好,增量方案反而会给你惹出更大的麻烦。这篇文章我会按“为什么需要增量 → 增量设计原则 → Kettle实战案例 → 性能优化 → 问题排查”的顺序来写,理论部分尽量讲得直白,案例部分可以直接照着抄。
1. 先说清楚:为什么一定需要增量机制
1.1 全量抽取的三个“扛不住”
全量抽取在数据量小的时候完全没问题,源表几十万行,每天全量重刷一遍也不过多花几分钟。可一旦表超过千万行,或者到了几亿行,问题就接踵而至。我概括成三个“扛不住”。
第一是时间窗口扛不住。绝大多数数据同步任务都排在凌晨跑批,留给ETL的时间窗口可能就2到4个小时。全量抽一张大表,扫描加上网络传输加上目标库写入,时间直线拉长,一旦超过窗口,就会挤压下游的数仓分层任务,最终导致BI报表早上出不来。
第二是源库压力扛不住。全量抽取相当于每天对业务表做一次全表扫描,如果业务库是一套高并发的在线事务系统,这种大查询会抢掉宝贵的内存、IO和CPU资源。我见过最夸张的情况,是全量抽取任务一跑,线上订单接口响应时间从50毫秒一路飙到800毫秒,运营投诉电话直接打到技术负责人那里。
第三是重跑成本扛不住。全量抽取不仅仅是慢,还脆。半夜网络抖动、数据库连接超时、目标表锁冲突,任何一个意外都会让任务中断。全量重跑一遍又是好几个小时,几乎等于这个夜晚被报废。如果增量同步跑挂了,重跑窗口只有几分钟,这才是质的差别。
| 对比维度 | 全量抽取 | 增量抽取 |
|---|---|---|
| 抽取数据量 | 全表所有行 | 仅变化行 |
| 运行时长 | 与表总量线性相关 | 与变化量相关 |
| 源库影响 | 全表扫描,压力大 | 走索引小范围扫描 |
| 失败重跑成本 | 小时级甚至更久 | 分钟级 |
| 数据一致性 | 天然整体覆盖 | 需要设计保证 |
1.2 增量方案的四种常见实现与选型逻辑
增量抽取实现方式业内早就总结出几大类,我在不同项目里都试过,这里按推荐顺序讲。
时间戳增量,条件允许情况下最推荐。前提是源表里有可靠的更新时间字段,比如update_time、modified_time这类,业务每次修改记录都会更新这个字段。同步时只要取“上次同步时间”到“当前时间”之间的数据即可。实现简单、对源库侵入小、性能也好。绝大多数业务系统都能满足这个前提。
触发器增量,在源库的表上建触发器,把增删改操作记录到一张中间日志表,然后ETL从日志表增量抽取。好处是精确知道哪些行变了,不依赖业务表是否有更新时间字段;坏处是必须对源库做DDL变更,改动审批流程长,而且触发器本身也会带来轻微的性能损耗。很多银行核心系统会这么用,但一般团队不太容易推动。
日志解析(CDC),直接解析数据库的Binlog或Redo Log,从日志中还原变更记录。这个方案对源库零侵入,但落地时要配套专门的工具或中间件才能消费日志,调度链路人。Kettle本身没有内置完整的日志解析能力,通常要结合额外的组件才能实现,我把它定位为“增量方案的终极选项”,不是第一选择。
全表比对,每次拉全量数据到临时表,和目标表做全字段或关键字段比对,找出变化数据再更新。逻辑最简单,但传输和比对成本高,只适合百万行以下的维表、小配置表,大表不建议尝试。
选型顺序也很简单:有可靠时间戳字段就优先时间戳;没有时间戳但能推动加触发器就用触发器;连触发器都做不了再考虑日志解析或全表比对。四类方案的具体差异我整理过一个速查表,写SQL时很容易拍板。
| 方案类型 | 识别变化依据 | 源库侵入 | 实现复杂度 | 适用数据量 |
|---|---|---|---|---|
| 时间戳增量 | 更新/创建时间字段 | 无 | 低 | 大表 |
| 触发器增量 | 中间日志表 | 高(加触发器) | 中 | 需要精确记录增删改 |
| 日志解析 | 数据库日志 | 无 | 高 | 大表,强一致性场景 |
| 全表比对 | 关键字段对比 | 无 | 低 | 小维表 |
2. 增量机制设计的三个决定性细节
2.1 确认源表具备“可增量”的条件
很多人增量方案做失败,不是技术不行,而是源表的数据质量压根不支持。做时间戳增量之前,第一件事是到源库确认:这张表有没有更新时间字段;这个字段是不是每次业务修改都会自动更新;字段精度能达到什么级别。
有些表的update_time只是插入时写入一次,业务修改后值根本不变,这种字段用来做增量就会漏掉大量更新数据。还有的表时间字段精度只到秒级,而业务高频更新时同一秒内可能有多笔操作,光靠秒级时间戳做窗口就很容易漏。这个坑我踩得很深。遇到这种情况,要么想办法让业务在修改时同步维护更新时间,要么换用触发器或日志解析方案,不要在不可靠的时间字段上硬做增量。
2.2 设计同步状态表,解决“跑了半截挂了”的问题
增量同步和全量同步最大的不同,是它必须记住“上次同步到哪里了”。这个状态如果放在本地变量或者jar包里,任务重启就丢了。最稳妥的办法,是把同步状态落到数据库里,形成一张状态表。
我在项目里常用的一张状态表结构很精简,核心就是“任务名 + 目标表 + 上次同步时间 + 状态”:
CREATE TABLE etl_batch_status ( task_name VARCHAR(64) NOT NULL, target_table VARCHAR(128) NOT NULL, last_load_time DATETIME NOT NULL, batch_status VARCHAR(16) DEFAULT 'SUCCESS', update_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (task_name, target_table) );这张表的作用是:每次任务启动时,先读取它拿到last_load_time作为增量起点;任务全部成功后,再把本次的加载时间写入last_load_time;如果任务中途挂了,状态不更新,下次重跑还是从上一次的起点开始拉,天然具备“断点续跑”能力。
2.3 增量窗口怎么算才不出错
增量窗口的起止时间计算是设计中最容易犯错的地方。我见过很多人分页拉取时用between,结果边界数据要么重复要么遗漏。正确做法是左闭右开区间:
start_time <= update_time < end_time左闭右开的意思是,本次取数包含起点那一刻的变更数据,但不包含终点时刻的数据。这样上一次的终点,就是下一次的起点,相邻两个批次之间既不会重复也不会留下空档。
实际操作里,start_time用状态表里的last_load_time,end_time取当前时间。但这里有几个细节要注意:如果应用写入数据和更新时间戳不是严格一致的,直接取当前时间可能漏掉晚到的数据。我会把结束时间再向后延伸一小段,比如向后多取2分钟,重叠部分靠目标表的主键更新去覆盖,这样可以有效防止漏数,代价只是多拉一点点数据,完全值得。
3. Kettle增量同步实战:订单增量抽取案例
3.1 案例背景与整体流程
用一个我实际处理过的案例来演示。某电商订单系统有一张订单明细表,量级在两千万行左右,每天新增约30万行,变更数据约5万行。原本每天全量同步一次,耗时将近3小时。改造目标是:把这张表改成增量同步,每天只需要拉取当天的增量数据,并同步到数仓的目标明细表。
整体任务流程通过Kettle的Job来编排,分成以下几个步骤:
Start:定时触发器,每天凌晨2点启动- 读取状态表:获取
last_load_time - 转换1:增量数据抽取(从源库拉取变化数据)
- 转换2:数据清洗与字段处理
- 转换3:加载到目标表(插入更新)
- 更新状态表:把本次增量窗口结束时间写回状态表
- 任务结束
3.2 转换一:增量数据抽取的SQL写法
转换1的核心控件是“表输入”。新建一个转换,拖入“表输入”,配置源数据库连接,SQL这么写:
SELECT id, order_no, user_id, status, amount, update_time, create_time FROM ods_order_detail WHERE update_time >= '${last_load_time}' AND update_time < '${current_load_time}'Kettle会通过变量替换${last_load_time}和${current_load_time}。注意这里用>=和<,就是前面说的左闭右开区间。实际配置时,记得勾选“替换SQL中的变量”复选框,否则变量不会被解析。
关于时间格式化问题也提一句。不同的数据库对时间字符串的格式要求不一样,MySQL里可以直接用字符串比较,但有些数据库需要TO_DATE或CONVERT转换。我在Kettle里习惯把状态表里的时间统一格式化成yyyy-MM-dd HH:mm:ss字符串传给SQL,这样状态表里可读性好,SQL里也好用。
抽取方向建议把数据库连接设为“只读”,避免误操作。批量拉取不用在“表输入”里手动分页,Kettle流式处理本身就能支撑几百万行的流转,只需要关注下面的性能优化部分。
3.3 转换二:清洗转换环节
增量数据拉出来之后,一般不能直接进目标表。清洗转换环节我用到了三个高频控件:“字段选择”“增加常量”“空值替换”。
“字段选择”用来裁剪字段。源表可能有一堆业务字段参与计算,但目标表只要其中一部分,提前裁剪能显著降低后续流中的数据体积。
“增加常量”用来给每行数据补一个批次标识。比如增加一个etl_batch_id字段,常量为当天任务ID,这样出问题排查时能知道这批数据是哪次任务写进去的。
“空值替换”处理脏数据。订单金额是空的,直接替换成0;订单状态是空的,替换成默认值UNKNOWN。这一步不做,后面写目标表时可能因为NOT NULL约束直接批量报错。
3.4 转换三:加载阶段用插入更新控制幂等
加载阶段最常用的控件是“插入/更新”。这个控件做的事情很简单:按照指定的查询字段去目标表里匹配,如果记录不存在就插入,存在就更新。它天然具备幂等性,即使同一行数据被重复拉取,处理结果也是一样的。
配置“插入/更新”时,有几个关键选项要搞清楚。最上面是“用来查询的关键字”,这里填id,也就是目标表的业务主键;下面是“更新/插入的字段”,要列全部需要写入的字段。字段列表顺序有讲究:要先列查询字段,再列更新字段,这个顺序不能搞反。
“提交记录大小”我一般设置成5000。这个值的意思是每攒够5000行提交一次事务,避免一整个大事务占用数据库锁时间过长。数据量特别大的时候,提交大小设置太大容易导致目标库锁竞争,设置太小又会影响写入吞吐,5000到10000是一个经验区间。
这种通过主键做“存在即更新”的方式,对应的是缓慢变化维里的SCD1,也就是覆盖式更新。如果业务上需要保留历史版本,比如订单状态从待支付变成已支付,想把变更前后都留痕,那就不能用覆盖更新了,需要增加生效时间和失效时间字段,在更新前先把旧记录失效,再插入一条新记录,这就是SCD2的处理思路。当前案例只需要同步最新状态,SCD1够用。
3.5 Job串联:状态更新必须放在最后
三个转换实现后,用Job把它们串起来。这里最核心的一条规则是:**状态表的更新,一定要放在所有转换成功之后。**如果先更新状态再跑转换,转换中途挂了,下次任务就会从新状态继续,漏掉一段数据。
Job里有几种方式传递变量。我常用的是在Job的开始位置加一个“SQL脚本”步骤直接查状态表,然后通过“设置变量”步骤把last_load_time赋给变量。转换1里的“表输入”读取这个变量。最后一个转换跑完之后,再用一个“SQL脚本”步骤执行UPDATE,把本次的current_load_time写回状态表。
Job里还要配置失败重试。在开始步骤上设置重试次数,比如3次,每次间隔30秒。半夜任务偶尔遇到网络抖动,重试比报警后人工重启要高效得多。日志级别建议设置在Basic级别以上,不要直接用Debug,否则日志量大到自己都看不过来。
4. 增量同步的进阶优化与调度策略
4.1 性能瓶颈的三个位置
增量同步从源库到目标库,链路不算特别长,但卡点其实就那么几个地方。第一个是源库查询慢,最常见的原因是源表update_time字段没有索引,导致区间查询变成全表扫描。这个问题太普遍了,我发现很多业务表的更新时间字段并不在索引里,增量任务第一次跑就慢到怀疑人生。效率最高的解决办法是让DBA给update_time加一个普通索引,字段本身有选择性,效果非常明显。
第二个瓶颈是Kettle内存。如果增量数据一次拉几百万行,Kettle默认配置可能撑不住。常见的症状是Kettle界面卡死,或者日志里出现OutOfMemoryError。处理方案在后面的运行参数里细说。
第三个瓶颈是目标库写入。大量更新操作并发去改同一张表的主键记录,锁竞争会非常激烈。目标表的索引数量、提交批次大小、是否分区,都会直接影响写入速度增量任务的成败往往就在这些细节上。
4.2 Kettle运行参数怎么调
Kettle的性能调优,不是界面点点就能完成的,需要改配置文件或者调JVM参数。
如果你用的是Pentaho Data Integration(Kettle的官方名称),内存相关的配置在pdi-ce-xxx/bin/pentaho-env.sh或Spoon.bat里。-Xmx参数决定最大堆内存,增量任务如果单批次数据量大,至少给到2个G以上。我做千万级数据同步时,这个参数设置到4G才稳定。
转换层面的参数,一个是“行集大小”,默认是10000行。行集越大,每一批处理的数据越多,吞吐量越高,但堆内存压力也越大。行记录本身很宽、字段很多时,我把行集大小调低到5000,反而更稳定。另一个参数是并行度。增量抽取因为窗口不同天然可以并行,比如订单表按user_id取模切成4份并行拉取。用Kettle的“分区”功能,可以配置4个分区,每个分区各自执行“表输入”和“插入/更新”,底表不变的情况下,整体速度接近线性提升。
但并行绝对不要无脑开大。每个并行线程都会占用源库和目标库的连接,并发太高可能把两边数据库都打挂。我通常先开2路,稳定后逐步加到4路或8路,以源数据库的CPU和连接数作为观察指标。
4.3 调度策略:全量、增量、定期对账三件套
增量同步并不是完全替代全量同步。增量方案的优势是快,但它的数据完整性依赖源库时间戳的准确性,这是有前提的。所以在生产环境里,我更推荐一个组合打法:首次全量建基座、日常增量来续更、定期全量做对账。
首次建基座很好理解。一张两千万行的表,第一次同步肯定全量拉一次,把目标表灌满。后续日常就用增量同步来更新。定期对账则是兜底手段——每周或者每月选一个低峰期,把源表全量拉到一个临时表,和目标表做一次关键字段的全量比对,找出增量同步漏掉或改错的数据。这套组合既能享受增量同步的轻快,又能保证最终数据不跑偏。
调度配置上,Kettle的Job可以由外部调度器来触发,比如Linux的Crontab或者独立的调度平台。每天定时执行后,通过Kettle的邮箱或者Webhook通知功能,把任务成功或失败状态推送到监控群。增量任务跑挂了不可怕,可怕的是一整夜没人发现,第二天早上报表全错。
5. 常见问题与排查技巧实录
5.1 漏数、重数问题
增量同步最恼人的问题就是漏数。排查这类问题,我有一张固定的检查单。
先查时间字段的可靠性。如果源表的更新时间在业务侧没有得到严格维护,那时间戳增量方案本身就是错的,漏数只是时间问题。这时候只能换方案,没什么可纠结的。再查时间戳精度,秒级时间戳在并发时账很吃亏,两条更新在同一秒内,如果窗口只取到秒级边界就可能漏掉其中一条。处理办法要么用毫秒级字段,要么在窗口上叠加重叠区间。
重复数据问题则相对好解决。增量任务在失败重跑时,重叠区间会让同一批数据被拉两次,但只要目标表加载用的是“插入/更新”步骤,重复拉取最终会被主键唯一性消化掉。这里提醒一点,如果加载流程是“先删除再插入”,一定要把删除和插入放在同一个事务里,否则任务中途挂了会造成目标表半批数据被清空又没补上,恢复起来非常痛苦。
5.2 状态丢失和并发问题
状态表是增量机制的命脉。状态表出了问题,增量任务就等于瞎跑。我把这个问题的典型场景分成两类。
一类是状态表被多任务串了。如果不同同步任务共用了同一个任务名,后一个任务会覆盖前一个任务的last_load_time,表现得像一个任务漏数、另一个任务重复跑。解决方式很直接:状态表主键用“任务名 + 目标表”复合主键,每个任务都有自己的独立状态行。
另一类是并发调度导致的状态竞争。比如同一个任务被调度器重复触发,两个实例同时读取同一个last_load_time,然后各自拉取相同的数据,在目标表里更新两次。处理办法是给任务加一把锁,在Job里先执行SELECT ... FOR UPDATE锁定状态行,确保同时只有一个任务实例在跑。
5.3 性能瓶颈排查思路
增量任务变慢,不要靠猜,要看Kettle的执行日志。Job执行完成后,每个转换里的每一个步骤都会记录输入行数、输出行数和耗时,我在“执行结果”面板里直接对比这些数字,就能快速定位哪个步骤在“拖后腿”。
如果“表输入”步骤显示耗时很高,基本可以断定是源库查询的问题。确认update_time字段索引是否存在,必要时用EXPLAIN看一下执行计划。如果“插入/更新”步骤耗时很高,问题大概率出在目标库。试一下调大提交记录大小,或者看看目标表上是否有大量索引、触发器拖慢了写入。如果整个流程的内存监控一直飙升,加大堆内存、降低行集大小,甚至把宽表拆成多次窄表抽取,都会有效。
还有一个经验:运行日志里如果出现大量的数据库连接超时,优先检查是不是连接池被多个转换并发占满了。每个转换都分配独立的数据库连接,数量一多,数据库端的最大连接数就不够用了。合理控制并行度,并且给Kettle里每个数据库连接都配上合适的连接池上限,是避免这类问题的重要手段。
| 症状 | 主要排查方向 | 实操建议 |
|---|---|---|
| 增量任务漏数 | 时间字段可靠性/精度、窗口边界 | 检查源表更新时间字段;左闭右开;加重叠区间 |
| 重复数据 | 重跑机制、加载幂等 | “插入/更新”幂等;先删后插必须同一事务 |
| 状态被覆盖 | 状态表主键设计 | 改成“任务名+目标表”复合主键 |
| 任务并发互踩 | 调度锁缺失 | 用SELECT FOR UPDATE锁定状态行 |
| 抽取慢 | 源库索引、连接池 | 给update_time加索引;控制并行数 |
| 写入慢 | 目标库锁/索引/批大小 | 调整提交大小;精简目标表索引 |
我在实际做这些增量任务的过程中,心里始终有一条底线:增量同步追求的不是最快,而是“漏了能及时发现、挂了能快速重来、重复也不会出错”。所以从第一天建状态表开始,就把每个批次的执行记录都保留下来。这样就算哪次对账发现数据对不上,也能顺着批次往回倒,知道是哪天哪个任务出的问题。
最后再分享一个小技巧。每次增量任务成功跑完之后,我不把last_load_time直接写成当前时间,而是往回退一分钟再写进状态表。比如本次窗口结束时间是02:00:00,我会写成01:59:00。这样做的目的是人为制造一分钟的重叠区,把应用写库和源库时间戳之间的微小错位全部兜住。代价是每次多拉一分钟的数据,但换来的是不再因为边界问题三天两头排查漏数。数据同步这种活,稳定比炫技重要得多。