ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kettle数据比对三方案:全量明细、校验和与SQL下推调优

Kettle数据比对三方案:全量明细、校验和与SQL下推调优

1. 三种比对的选型逻辑:先搞清楚你比的是“差异”还是“一致性”

做数据比对这件事,最容易犯的错不是写错步骤,而是一上来就打开 Kettle 拖两个“表输入”接一个“合并记录”。工具没毛病,问题在于对比对目标没想清楚:你是想知道“两张表哪几行不一样”,还是只想回答“这两张表到底一不一样”,还是要在几十亿行数据里每天跑一次对账、只关心“差异有没有变多”。这三种诉求对应的技术路径完全不同,选错了方案,后面所有的调优都是在给错误的方向做粉刷。

我在实际项目里见过太多类似的场景:同步链路跑完后需要验证源库和目标库是否一致,业务方希望早上一看报表就知道昨天有没有漏数据,运维希望有差异时能定位到具体的行和字段。三个诉求听起来可以共用一套流程,但真跑起来会发现,全量明细比对在千万级以上的数据量下动辄一两个小时,而业务只需要一个“通过/不通过”的绿灯。所以我通常先把需求切成形态,再决定用哪套方案,这一步花十分钟,能省掉后面两周的返工。

1.1 比对需求的三种典型形态

第一种是明细级差异定位。给出源表和目标表,要求输出“只在源库有的行、只在目标库有的行、主键相同但字段值不同的行”,并且精确到具体哪个字段不一致,便于人工修复。这类需求对完整性要求最高,通常用在数据迁移验收、主从一致性核查、批量导入后校验这些场景。

第二种是一致性快照判断。不关心具体哪一行不同,只关心“两边数据是否完全一致”,如果不一致,再决定要不要进一步排查。典型的实现方式是计算整表或分片的校验和,把两边的指纹拿来比。它的优势是数据量再大也能压到很小的比较成本,缺点是发现问题后定位差异还得再跑一遍。

第三种是增量对账。只比对某个时间窗口内发生变化的数据,比如昨天一天新增和修改的记录。这类需求的关键不在比对本身,而在水位线的维护——从哪一刻开始、到哪一刻结束、水位线存在哪里、失败了怎么重跑。

三种形态的取舍决定了后面所有环节:拉全表还是拉增量、在内存里比还是在库里比、结果落明细还是落汇总。我一般会把它们先明确写进转换的命名里,比如cmp_full_detail、cmp_hash_daily、cmp_incr_window,避免半年后自己都分不清哪个转换是干什么的。

1.2 Kettle做比对的三个天然瓶颈

Kettle 本质是一个数据搬运和行级处理的引擎,它的强项是连接各种数据源、做字段映射、做流程编排。但把它用在比对场景,有三个绕不开的瓶颈,理解了这三点,后面所有调优手段都是围绕它们展开的。

第一是数据必须过网络。不管是全表拉过来比,还是拉过来做哈希,数据都要从数据库经过 JDBC 流到 Kettle 进程里。千万行、每行几百字节的表,网络传输加上协议解析的开销就相当可观。这也是为什么方案三(下推数据库端)在大数据量下几乎总是赢家——它把数据搬运量降到最低。

第二是内存与排序。Kettle 的“合并记录”步骤要求两个输入流按关键字段预先有序,排序动作要么在数据库端用 ORDER BY 完成,要么在 Kettle 里用“排序记录”步骤完成。后者会把数据堆在内存或临时文件里,数据量一大就容易出问题。很多“跑着跑着不动了”的现象,追下去都是排序步骤在硬扛。

第三是行级处理的天花板。Kettle 单线程转换的处理速度大致在每秒几万行这个量级,取决于字段数和步骤复杂度。这个速度对于百万级数据够用,对于亿级数据就要靠切分并行,而并行又会带来资源争抢和结果合并的复杂度。

把这三点记住,选方案时心里就有杆秤了:数据量小、需要明细差异,方案一;数据量大、只要一致性结论,方案二;数据量极大或库在同侧、追求性能和准确性的平衡,方案三。

1.3 三套方案的横向对比与选型建议

下面这张表是我自己项目里用的决策表,参数是根据常见配置估的,实际还要看硬件和网络:

对比维度方案一:合并记录全量比对方案二:校验和指纹比对方案三:SQL下推比对
数据搬运量全字段全量主键+指纹(字段数少)仅差异结果或汇总
是否需要排序必须,两端都要有序不需要全局有序,可按主键分片由数据库执行计划决定
输出内容精确到行和字段的差异只能定位到主键或分片取决于SQL写法,可精确到行
百万行典型耗时几分钟到十几分钟一到三分钟十几秒到一分钟
千万行以上表现吃力,需切分可接受,需分片较优
实现复杂度低中中到高
主要风险排序内存溢出、排序不正确报错指纹碰撞、字段拼接歧义库端资源占用、语法差异
适用场景迁移验收、明细修复日常一致性巡检大表对账、同构库比对

选型上我的建议很直白:日常巡检用方案二做第一道闸,发现不一致再用方案一定位;同一数据库实例内的两张表比对,直接用方案三;跨库跨实例且要求明细的,方案一切分后跑。

还有个常被忽略的点:三种方案不是互斥的。成熟的做法是串起来用——方案二每天跑一次出绿灯,红灯时自动触发方案一的明细比对,把差异行落到结果表并通知到人。这条链路我在好几个项目里落地过,日常开销很小,出问题时又能立刻拿到证据。

2. 方案一:合并记录做全量差异定位(最经典也最容易踩坑)

“合并记录”这个步骤是 Kettle 里做比对最直接的抓手,几乎所有教程都会先讲它。但我发现很多人对它的理解停留在“拖两个输入接上去就行”,结果一跑就报错,或者结果看起来对、实际漏了数据。这一节把它的原理、搭建流程和边界讲透。

2.1 合并记录步骤的工作原理

这个步骤做的事情其实很简单:它同时消费两个输入流,按顺序逐行推进,比较两个流当前行的关键字段。如果关键字段相同,就比较指定的比对字段列表,全部相同则标记为“相同”,有任何一个不同则标记为“修改”;如果关键字段只在旧数据源出现,标记为“删除”;只在新增数据源出现,标记为“新增”。

听起来是个归并算法的思路,事实也确实如此。它像拉链一样把两个有序流咬合在一起,所以两个输入流必须按关键字段升序排列,而且排序规则要完全一致——同样的字段、同样的升序、同样的比较规则。这一点是硬性前提,不满足就会报“排序不正确”或者干脆丢行。

我踩过最典型的一个坑:源端用 SQL 的ORDER BY id排序,目标端用 Kettle 的“排序记录”步骤按 id 排序,看起来都排了,但两边的比较规则不一样。字符串类型的 id 在数据库里可能按二进制排,在 Kettle 里按本地化比较器排,遇到大小写混杂或带符号的字符串,顺序就会岔开,结果就是明明存在的行被判成“一增一删”。

正确的做法是两端都用同一种方式排序。要么都在 SQL 里排,并且确认两边的字符集和排序规则一致;要么都在 Kettle 里用“排序记录”排,字段和方向完全对齐。别一边用 SQL 一边用组件,那是给自己埋雷。

2.2 从零搭一条可用的比对链路

我把一条可复用的明细比对转换拆成七步,这里按顺序说清楚每一步的意图:

第一步,参数化。转换属性里定义几个命名参数:源表名、目标表名、主键字段、比对字段列表、批次号、差异阈值。参数化的好处是同一个转换能复用到不同表,维护成本直线下降。字段列表这种不确定项,可以用逗号分隔的字符串传入,再在“字段选择”里动态拆——不过 Kettle 的字段元数据是不可变的,动态拆字段比较别扭,我一般退一步:每张表配一个转换,或者用元数据表驱动生成。工程上后者更干净,但前期投入大,看团队习惯。

第二步,两端表输入。两个“表输入”步骤分别连源库和目标库。SQL 里直接写SELECT 主键, 字段1, 字段2 FROM 表 WHERE 条件 ORDER BY 主键。这里有个细节必须强调:两端的 SELECT 字段顺序、字段别名、数据类型要严格一致。我在一个项目里遇到过源端是整型、目标端是数值型(带小数位),比对步骤把它们当不同类型处理,结果恒为“修改”,白跑了两小时才发现。

第三步,类型标准化。在表输入之后各接一个“字段选择”,把两端的类型统一。数值统一成同一种精度,日期统一成同一种格式,字符串统一长度和字符集。这一步看起来啰嗦,但它是保证比对结果可信的关键。特别是金额字段,两端精度不同会导致 100.00 和 100 被判为不同。

第四步,空值与默认值处理。这是比对里最容易出错的地方。数据库里 NULL 和空字符串是两个概念,但很多业务系统混着用。如果一端存 NULL,另一端存空串,比对步骤会认为是不同值。我的做法是先明确业务规则:要么在 SQL 里用COALESCE统一成空串,要么在 Kettle 里用“如果字段值为空”步骤统一填充。规则定下来后写进文档,别每个转换各写一套。

第五步,合并记录。关键这一步的参数:旧数据源选源端分支,新数据源选目标端分支,标志字段起名diff_flag,比对字段列表把你关心的字段全部勾上。输出的标志值一般是 identical、changed、new、deleted 四种。这里要注意,标志字段名不同版本可能有差异,搭好后先拿小样本验证一遍。

第六步,过滤与分流。接一个“过滤记录”或者“Switch / Case”,按diff_flag分流。identical 的行直接丢弃或者只计数;changed、new、deleted 的行进入差异输出分支。差异数据量通常很小,随便怎么存都行。

第七步,落结果表。差异行写入结果表,字段至少包括:批次号、比对表名、差异类型、主键值、差异字段名、源端值、目标端值、发现时间。其中“差异字段名”需要额外处理——合并记录只告诉你这行变了,不告诉你是哪个字段变了。要拿到字段级差异,得在比对后加一段“字段比较”逻辑,把每个比对字段逐个对比,输出不等的那几个。这一步用“JavaScript 代码”或者多个“过滤记录”串联都能做,我倾向用脚本,维护起来直观。

2.3 差异结果的落地与二次加工

差异表设计得好不好,直接决定了后面排查的效率。我见过只存一个“不一致行数”的结果表,出问题时完全没法定位,只能重新跑一遍带明细的转换。所以结果表要一次设计到位。

推荐三张表的结构:批次汇总表记录每次比对的元信息(批次号、表名、开始结束时间、源行数、目标行数、差异行数、状态);差异明细表记录每一行的差异(批次号、主键、差异类型、差异字段、两端值);字段统计表按字段聚合差异次数,方便一眼看出是哪一列的问题——实际项目里经常出现某个字段因为格式转换问题导致大面积不一致,按字段统计能立刻暴露出来。

二次加工的价值还在于告警。差异行数为零是绿灯,超过阈值是红灯,落库后接一个作业判断,红灯就发通知。通知里带上汇总表的关键信息,值班的人不用打开 Kettle 就能知道发生了什么。

2.4 这一方案的边界在哪里

方案一的优点是直观、精确、能定位到字段,缺点是它把全部数据都搬到了 Kettle 里。百万行级别没问题,千万行就要开始切分,亿级基本不建议直接上。

判断边界有个粗略的经验值:单表行数乘以字段数再乘以平均字段长度,算出需要搬运的总字节数,再按你实测的吞吐量估时间。比如一千万行、每行二十个字段平均三十字节,就是六百兆左右的原始数据,算上 JDBC 协议开销和 Kettle 行对象的内存占用,实际压力大概是原始数据的三到五倍。如果这个量在你的内存和网络承受范围内,方案一可以用;超了,就该考虑方案二或方案三。

还有一个隐性边界是比对频率。每天跑一次的批处理,慢一点可以接受;每十分钟跑一次的准实时校验,方案一的资源消耗会把整个调度系统拖垮。频率越高,越应该往下推方案。

3. 方案二:校验和指纹比对(先粗筛后细查,跑得快)

这个方案的思路借自文件同步工具:不逐字节比内容,先算出一个短小的指纹,指纹相同就认为内容大概率相同,指纹不同再细查。放到数据库比对里,就是给每行算一个校验和,把两边的校验和集合拿来比。

3.1 指纹比对的思路与收益来源

为什么它快?两个原因。一是传输量小:只需要把主键和校验和传过来,二十个字段的行压缩成一个几十字节的指纹,网络传输和内存占用直接降一个数量级。二是不需要全局排序:校验和可以在数据库端算好,也可以按主键分片在 Kettle 里算,两边只需要按主键对齐即可,对齐方式灵活得多。

这个方案在什么场景最舒服?日常巡检。每天凌晨跑一次,几十张大表挨个过一遍,每张表只输出“一致/不一致”和一个差异计数,整体耗时控制在几十分钟内,运维早上看一张绿灯表就够了。发现问题再针对性地跑方案一。

代价也很明确:它只能告诉你“哪一行的指纹不一样”,不能告诉你“哪个字段不一样”。指纹不同的行仍然需要回查原始数据做字段级比较。所以它天然是一个两段式结构,第一段粗筛,第二段细查。

3.2 校验和字段怎么拼才不会误判

这是整个方案里技术含量最高、也最容易翻车的部分。校验和算法本身很简单(CRC32、MD5、SHA-1 都行),难的是拼接规则,规则一旦有歧义,就会出现“数据变了但指纹没变”或者“数据没变但指纹变了”两种误判。

防拼接歧义。最典型的错误是把字段直接连起来:字段A || 字段B。如果 A 是 “ab”、B 是 “c”,和 A 是 “a”、B 是 “bc”,拼出来都是 “abc”,指纹相同但数据不同。加分隔符可以解决,但分隔符本身也可能出现在数据里。稳妥的做法是长度前缀:每个字段先拼上它的长度再拼内容。比如ab变成2|ab。这样任何组合都不可能撞上。

统一 NULL 与空值。数据库里 NULL 参与字符串拼接的结果是 NULL,直接导致指纹为 NULL,两边都没法比。必须先用COALESCE(字段, '')或者等价函数统一成空串,同时约定空串和 NULL 在业务上视为等价。如果业务要求区分 NULL 和空串,那就把 NULL 编码成一个不可能出现的哨兵值,比如一个特殊的转义序列。

统一数值与日期的文本表示。同一个逻辑值在不同库里的文本形式可能不同。数值 1 可能输出成1、1.0、1.00;日期可能输出成2024-01-01、2024-01-01 00:00:00、带时区的字符串。指纹是在文本层面计算的,格式不同就是不同的指纹。解决办法是在拼接前先做规范化:数值统一转成固定小数位的字符串,日期统一转成固定格式的字符串,字符串统一去掉首尾空格并统一大小写(如果业务不区分大小写)。

注意字符编码。MD5 是对字节序列算的,同一个字符串用不同编码转成字节,结果完全不同。如果两端数据库的字符集不同(比如一个 UTF-8 一个 GBK),中文数据算出来的指纹必然不一样。处理方式有两种:一是在 SQL 里统一转成同一种编码再算,二是不在数据库端算,把数据拉到 Kettle 里统一转码后算。前者快但受限于库的函数支持,后者慢但可控。

下面这段是通用思路的伪代码示例,实际写法要按你所用的数据库函数调整:

SELECT id, MD5( CONCAT( LENGTH(COALESCE(TRIM(name), '')), '|', COALESCE(TRIM(name), ''), LENGTH(COALESCE(CAST(amount AS DECIMAL(18,4)), '')), '|', COALESCE(CAST(amount AS DECIMAL(18,4)), ''), LENGTH(COALESCE(DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s'), '')), '|', COALESCE(DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s'), '') ) ) AS row_fp FROM source_table WHERE id BETWEEN ? AND ?

注意:上面的写法只是示意,不同数据库的字符串拼接函数、长度函数、日期格式化函数都不一样。有些数据库支持直接的||拼接,有些必须用函数。移植到新库之前,先拿几条已知数据手工验算一次指纹,确认规则一致再批量跑。

3.3 两段式转换的完整搭建

第一段:指纹比对。两端各拉一个SELECT id, row_fp FROM 表,在 Kettle 里做全外连接或者用“合并记录”按 id 对齐(这里仍然要排序,但只有两个字段,压力小得多)。对齐后比较两个指纹,输出三类结果:只有左、只有右、两边都有但指纹不同。把这三类的 id 收集起来,写入一个“待细查”表。

如果数据量特别大,第一段也可以用 SQL 完成。两端都能连到同一个库或者能做数据联邦的话,直接一条外连接 SQL 就出结果了,Kettle 只负责触发和收尾。这也是后面方案三的思路。

第二段:差异回查。从待细查表里读取 id 列表,用它去两端分别SELECT * WHERE id IN (...),把两批数据拉回来,用方案一的合并记录做字段级比较,输出具体哪个字段不同。因为 id 数量通常很少(正常情况下是零),这一段的开销可以忽略。

这套两段式结构有个额外好处:第一段可以每天跑,第二段按需触发。日常巡检只看第一段的结果,红灯了再手动或自动触发第二段,资源利用率最高。

3.4 性能收益与代价的权衡

实测下来,指纹比对相比全字段比对,在字段数多、行数大的场景提升非常明显。一张两千万行、三十个字段的表,方案一跑下来要一个多小时,方案二第一段能压到三到五分钟,主要时间花在两端算指纹上(这部分是数据库在做,可以用并行查询加速)和数据传输上。

代价除了前面说的定位能力弱之外,还有两个:一是数据库端多了一次计算开销,如果表上有索引覆盖还好,没有的话就是一次全表扫描加函数计算,对生产库有压力,建议在从库或低峰期跑。二是规则维护成本高,拼接规则一旦定下来,所有相关表都得遵守,任何一张表用了不同的规则,比对结果就会大面积误报。我的做法是把规则封装成数据库端的视图或者函数,所有比对统一从视图取数,规则只维护一份。

4. 方案三:把比对下推到数据库端(大数据量下的正解)

前面两个方案的本质都是“把数据搬出来比”。方案三反过来:把比较逻辑写进 SQL,让数据库自己算,Kettle 只负责发指令、收结果、做编排。这是数据量上去之后唯一能稳住的做法。

4.1 什么时候必须放弃在Kettle里比

判断标准很实在:当你开始考虑给 Kettle 加内存、加线程、改 JVM 参数的时候,就应该停下来问一句“这些活能不能让数据库自己干”。数据库在排序、哈希、连接这些操作上有专门的优化,还有并行执行、索引、内存管理,这些能力 Kettle 是不具备的。

三种情况我会毫不犹豫地选方案三:两端在同一个数据库实例上,这是最理想的情况,一条 SQL 就能出差异,数据完全不出库;数据量在千万级以上且只做巡检,直接把 SQL 写死在转换里,配个调度就行;比对逻辑复杂且需要复用,比如多表关联后才能判断一致性,用 SQL 表达比用 Kettle 组件拼要清晰得多。

反过来,两端是异构数据库、跨网络、甚至有一端是文件或接口,那下推就受限了,只能回到方案一或者方案二。

4.2 差集、交集与重复数据的SQL写法

比对的核心操作其实就三类:找出只在A里的、只在B里的、两边都有但内容不同的。不同数据库提供的语法不完全一样,思路是通用的。

标准的差集操作,有的数据库写EXCEPT,有的写MINUS,语义相同:返回第一个结果集中存在、第二个结果集中不存在的行。查双向差异就是两个方向各来一次:

-- 只在源表中存在的行 SELECT id, col1, col2 FROM source_table EXCEPT SELECT id, col1, col2 FROM target_table; -- 只在目标表中存在的行 SELECT id, col1, col2 FROM target_table EXCEPT SELECT id, col1, col2 FROM source_table;

这种写法简洁,但要注意两点。一是字段顺序和类型必须完全对齐,否则要么报错要么结果诡异。二是 NULL 的处理规则:在某些实现里 NULL 与 NULL 被视为相等,另一些不是,跨库时这一点必须验证。稳妥的做法还是先COALESCE成确定值。

不支持差集语法的数据库,用NOT EXISTS或LEFT JOIN ... IS NULL替代:

-- 用左连接找差异,再把比对字段放进条件里 SELECT s.id, s.col1 AS s_col1, t.col1 AS t_col1, s.col2 AS s_col2, t.col2 AS t_col2 FROM source_table s LEFT JOIN target_table t ON s.id = t.id WHERE t.id IS NULL OR s.col1 <> t.col1 OR s.col2 <> t.col2;

这里有个 NULL 陷阱:s.col1 <> t.col1在任一列为 NULL 时结果是 UNKNOWN,条件不成立,差异会被漏掉。必须写成COALESCE(s.col1,'') <> COALESCE(t.col1,''),或者加上(s.col1 IS NULL AND t.col1 IS NOT NULL) OR (...)这样的显式判断。这个坑我见人踩过不止一次,漏掉的恰好是空值数据,排查了半天才发现 SQL 写错了。

还有一类需求是查重复:主键本应唯一,但某天出现了重复行。这个用分组统计最直接:

SELECT id, COUNT(*) AS cnt FROM target_table GROUP BY id HAVING COUNT(*) > 1;

4.3 Kettle在这套方案里的角色转变

下推之后,Kettle 不再是“比对引擎”,而是“调度器和结果处理器”。它要干的活变成了:在“表输入”步骤里放一条比对 SQL,把结果拉到结果表里,加上批次号和时间戳,根据行数判断是否告警,然后串进作业里定时跑。

有个细节值得单独说:比对 SQL 的结果可能很大,如果两边差异是一万行,把一万行拉到 Kettle 里再写入结果表是可以的;如果差异上百万行,说明数据出了大问题,这时候更合理的做法是只拉计数和样本,而不是把全部差异搬来搬去。所以我在转换里通常配一个阈值判断:差异行数超过阈值就只写计数和报警,不再写明细,避免结果表被瞬间撑爆。

另外,下推方案里“表输入”步骤的 SQL 建议写到文件里通过参数加载,而不是硬编码在转换里。一是方便做版本管理,二是不同数据库语法差异大,切换环境时改文件比改转换快得多。

4.4 下推方案的正确性陷阱

下推虽然快,但有三个必须验证的点,任何一个没验证都可能得出“数据一致”的错误结论。

第一是排序规则和字符集。当两边表使用不同的字符集或比较规则时,'abc'和'ABC'在某些规则下被判定为相等,差异就被吞了。跨库比对必须显式指定比较规则,或者统一转大小写后再比。

第二是隐式类型转换。源端字段是字符串'00123',目标端是整数123,某些数据库在比较时会做隐式转换,结果判为相等。看起来省事,实际是掩盖了数据质量问题——真正一致的数据不应该出现类型不匹配。我的原则是显式转换后再比,转换不了的就是差异。

第三是浮点数与定点数。浮点数在比较时会有精度问题,0.1 + 0.2不等于0.3这种事在比对里很常见。金额类字段一定用定点数比较,或者在比较前统一四舍五入到固定小数位。

提示:下推方案上线前,务必构造一批“已知差异”的测试数据,包括仅一侧存在、字段值不同、NULL 与空串、大小写不同、类型不同这几类,跑一遍比对,确认每一类都能被正确识别出来。这个验证花的半小时,比事后排查三天划算得多。

5. 实战调优:把两小时压到八分钟的几个动作

方案定下来之后,真正决定体验的是调优。我在一个项目里把一张千万行表的比对从接近两小时压到八分钟,过程没什么黑科技,就是按顺序把几个瓶颈逐个拆掉。这一节把这套动作按执行顺序说清楚,照着做基本能复用。

5.1 先算清楚数据量与时间构成

调优前先做定量估算,别凭感觉。要算的有四项:行数(SELECT COUNT(*),两端都算,顺便验证一下行数差多少)、平均行长(主要字段长度之和,字符串字段按实际平均长度估)、搬运量(行数乘平均行长,换算成兆)、预期耗时(搬运量除以实测吞吐)。

实测吞吐怎么做?拿一张小表跑一次完整比对,记录耗时和行数,两者相除就是每秒处理行数。这个数字受字段数、步骤数、网络延迟影响很大,最好用同结构的中等规模表来测,别拿几百行的小表测,误差太大。

算完之后你会得出一个结论:如果预期耗时远小于业务容忍的时间窗,那不用调优,能跑就行;如果接近或超过,就按下面的顺序逐个优化。我见过有人上来就调 JVM 堆大小,结果真正瓶颈是没加索引的全表扫描,白折腾。

5.2 排序与主键:最容易被忽视的时间黑洞

方案一里,排序往往是最耗时的环节,甚至比数据搬运还慢。优化排序有三个方向。

在数据库端排序,别在 Kettle 里排。数据库有索引,如果有主键索引,ORDER BY 主键几乎是免费的。Kettle 的“排序记录”步骤则要把全部数据读进来排,内存不够还落盘,慢得多。所以只要 SQL 里能加ORDER BY,就别用组件排序。前提是两端排序规则一致,前面已经强调过了。

如果排序字段没有索引,考虑换排序键。有时候主键不是自增整型,而是 UUID 或者复合键,排序成本高。这种情况下可以按一个有序的辅助列排,甚至按分区列先切分再排。只要两端用同一个键、同样的规则排序,比对结果就是对的。

分片之后,每片的排序数据量变小,排序也更快。这是一石二鸟的做法,下一小节展开。

5.3 批量提交、抓取条数与内存

这三个参数是 Kettle 性能调优的老三样,在比对场景同样适用,但取值逻辑要说清楚。

抓取条数(fetch size)。表输入步骤的“每次获取记录数”,默认值通常很小。设得太小,每读几百行就往返一次数据库,网络延迟全浪费在握手上了;设得太大,一次拉太多行进内存,容易 OOM。经验值是几百到几千,具体看行宽:窄表可以设到五千,宽表一两千就够了。我一般从一千起步,跑一次看内存曲线,稳定的话往上加。

提交条数(commit size)。写入结果表时的批量提交大小。差异行数通常不多,这个参数影响不大;但如果做的是全量写入(比如把比对结果全量落库),就需要调大,一千到五千之间比较合适。太小会导致频繁事务提交,太大会占用回滚段。

JVM 堆大小。这是最后才动的东西。默认配置对中小数据量够用,一旦出现 OOM 或者频繁 Full GC,再考虑调整。调的时候注意留出操作系统缓存的空间,别把物理内存吃满。另外,堆调大之后要做一次 GC 日志观察,确认没有内存泄漏——如果每次跑完内存都不释放,加多少堆都是治标不治本,得去查是不是有什么步骤把数据缓存住了。

还有一个细节:“排序记录”步骤的缓存行数是独立配置的,如果不得不用组件排序,把这个值调大到能覆盖单批数据量,能避免落盘。不过更推荐的做法还是分片,让每片的数据量天然小于内存容量。

5.4 切分并行:水平拆任务而不是堆线程

单线程跑不动的时候,直觉是加线程。Kettle 确实支持步骤的多副本,但比对这种有状态的操作,多副本容易把结果搞乱——同一行可能被两个副本处理,也可能因为没有全局排序而报错。更靠谱的做法是在数据层面水平切分,把一个转换拆成多个独立的转换并行跑。

切分键怎么选?优先用主键区间。先查出主键的最小值和最大值,按需要的并行度均分成 N 段,每段生成一个参数文件,作业里用“复制记录到结果”把参数分发出去,再用“从结果获取记录”配合“转换”步骤的“执行每一个输入行”模式,并行启动多个转换实例。

切分粒度要控制好:每片的行数最好在几十万到一两百万之间。片太大并行度不够,片太小启动开销占比高。N 的取值还要看数据库能承受多少并发查询——四个并行查询如果每个都做全表扫描,对生产库的冲击是四个叠加的,这个必须提前和 DBA 沟通,最好在从库上跑。

分片的另一个好处是失败隔离。某一片跑挂了,只需要重跑那一片,不用从头再来。这对大表比对尤其重要——跑到百分之九十挂了重来一遍,谁都想摔键盘。

5.5 结果落表、断点续跑与调度

工程化程度决定了这套东西能不能长期用下去。

断点续跑靠的是水位表。每次比对前先查水位表,拿到这批要处理的区间,处理完把区间标记为已完成。失败重跑时只处理未完成的区间。水位表结构很简单:批次号、分片号、区间起止、状态、更新时间。有了它,整个比对流程就变成了幂等的,重跑多少次结果都一样。

结果落表前面讲过三张表的结构,这里补充一个实用字段:执行耗时。每次比对记录耗时,画成趋势图,能提前发现数据量增长或者性能退化。我在一个项目里就是靠这个发现某张表半年内数据量翻了五倍,及时把方案从一换成了三。

调度上,作业层面做三件事:前置检查(数据库连通性、磁盘空间、上次任务是否已结束)、主体执行(分片并行)、后置处理(汇总统计、告警通知、清理临时数据)。前置检查里那条“上次任务是否已结束”很关键,我遇到过因为一次异常退出导致锁没释放,下一次调度直接卡死的情况,加了检查之后会自动跳过并报警。

6. 踩坑实录与速查表

这一节把我在实际项目里遇到过的坑集中列一下,都是那种文档里不会写、但真跑起来一定会遇到的问题。每条都给出表现和排查路径,方便对照。

6.1 十类高频异常与排查路径

异常表现常见原因排查与处理
合并记录报“排序不正确”两端排序规则不一致,或只排了一端统一排序方式,字段、方向、比较规则完全对齐
差异行数异常偏多类型不一致、精度不同、空值表示不同抽样查看差异明细,先看差异集中在哪个字段
差异行数恒为零但业务确认有差异SQL 里 NULL 比较失效、字符集导致误判相等构造已知差异数据测试,检查 COALESCE 与比较规则
跑到一半不动了排序步骤内存不足在落盘,或数据库查询阻塞看步骤度量里的读写行数是否还在增长,查库端会话
进程 OOM 退出抓取条数过大、全量数据进内存降 fetch size,加分片,最后再调堆
中文数据大量误报差异两端字符集不同,指纹按不同编码计算统一字符集或在 Kettle 内转码后再算指纹
日期字段全部不一致时区不同或格式不同统一时区,统一格式化成字符串再比
金额字段差几分钱精度不一致或浮点误差统一精度到固定小数位,用定点数比较
结果表写入极慢提交条数太小或目标表没有索引调大提交条数,检查目标表索引与锁竞争
重跑后结果翻倍没有幂等控制,重复写入加批次号唯一约束,写入前按批次删除

挑几个展开说。

“差异行数异常偏多”是最常见的现象,也是最容易误判的。看到几万行差异,第一反应往往是数据真出问题了,实际上八成是格式问题。我的排查顺序是:先按字段统计差异次数,看是不是某一两个字段占了大头;然后抽十行差异数据,把两端的原始值打印出来逐字对比;最后拿一条差异数据在两端各跑一次单行查询,确认是数据本身不同还是处理过程不同。这个顺序能在一刻钟内定位到根因。

OOM 的排查要区分是哪个环节。如果是在表输入阶段就涨内存,说明 fetch size 太大或者结果集没有及时释放;如果是在排序步骤涨,说明排序数据量超过了缓存;如果是在写结果阶段涨,说明批量太大。看日志里最后一个有输出的步骤,基本就能判断。我一般会先降 fetch size,再考虑分片,最后才动 JVM 参数。

“跑到一半不动了”这种情况最让人抓狂,因为没有任何报错。这时候先看步骤度量里的输出行数有没有在涨,不涨的话连数据库看会话状态:是在执行、在等待锁、还是在等网络。我遇到过一次是源库在做备份,查询被阻塞了,等备份结束自己就恢复了。还有一次是目标表上有长事务持有锁,写入被挡住。所以前置检查里加上“目标表是否有长事务”是有必要的。

6.2 上线前的自检清单

最后给一份我自己用的自检清单,每次新建一个比对任务都过一遍,能挡掉大部分低级错误。

  • 两端的 SELECT 字段顺序、别名、类型是否完全一致,有没有做显式类型转换
  • 排序方式和排序规则两端是否一致,是都在 SQL 里排还是都在组件里排
  • NULL 与空串的处理规则是否明确,是否在所有相关字段上都落实了
  • 数值字段的精度是否统一,日期字段的格式和时区是否统一
  • 字符串字段是否做了去空格和大小写处理,业务规则是否明确
  • 比对字段列表是否覆盖了所有业务关心的字段,有没有漏掉新增字段
  • 结果表是否有批次号唯一约束,重跑是否幂等
  • 分片参数是否合理,单片的预估行数是否在可接受范围内
  • 是否有前置检查(连通性、锁、上次任务状态),是否有失败重试
  • 是否构造了已知差异的测试数据做过验证,六类差异是否都能识别出来
  • 告警阈值是否设置,红灯时的通知内容是否包含足够的信息用于排查
  • 执行耗时是否记录,是否能看出趋势

这份清单看着啰嗦,但每一条都对应着我真实踩过的坑。比对这件事的难点从来不是“怎么比”,而是“怎么保证比出来的结果可信”。工具只是手段,规则的一致性和验证的完整性才是这套东西能长期用的根基。

我个人的体会是,做数据比对最忌讳的是“先跑起来再说”。跑起来容易,跑对了难,跑得又快又对更难。花在规则定义和验证上的时间,最终都会以“不用半夜起来排查”的形式还给你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进