做ETL开发的朋友应该都有过这种经历:一个跑了大半年的同步任务,某天突然接到下游报表组的投诉,说某个维度字段“空得莫名其妙”。打开作业一看,上游接口调整了,把“未填写”从原来的不传(null)改成了传一个空字符串"",而你在清洗环节只处理了null。一行数据从“空(NULL)”变成了“空(空串)”,肉眼看起来都是没内容,但Kettle的数据流里就是两个完全不同的东西。
这篇文章主要围绕Kettle里null与空字符的恩怨展开:它们到底差在哪、不同源头数据里分别长什么样、清洗组件应该怎么选怎么配,再给两个真实业务场景的完整解决方案,最后聊聊那些容易被忽略但反手就是事故的隐性坑。准备接手这类清洗任务或者已经被空值折磨过的朋友,建议收藏后用起来。
平时我用Kettle(也就是常说的PDI)做数据同步、数仓分层和数据质量清洗,时间越久越发现一个问题:大部分所谓的数据异常,最后都归结到“空值形态没统一”。null和空字符串这件事,看起来简单,实际上牵一发动全身,把它想透了,能避掉不少加班。
1. null与空字符串,差的不只是一个引号
1.1 本质区别:一个是“没有值”,一个是“有一个空值”
null代表“这个字段没有值”,在Kettle的底层Java实现里,它是一个空引用,任何对这个值的直接运算都会产生null。举个例子,字符串拼接时,空字符串拼上别的字符串结果不变,但如果其中一个值是null,整个结果就是null。这不是Kettle的怪癖,而是Java对null语义的严格实现。
空字符串""则是一个实实在在的字符串对象,只是长度为0。它参与运算时是“存在但为空”的值,你可以对它调用trim()、length()、substring()这些方法,都不会报错。而null一旦执行这些操作,大概率就是空指针异常。
在Kettle转换流里,字段是强类型的。数据库读出的BigDecimal、Integer、String等字段,在流里都是Java对象,所以null可以被赋值给任何类型的字段。空字符串却只存在于字符串类型字段中。这个差异在“字段选择”组件里做类型转换时会暴露得很明显:null转成数值型可以配置成0或保持null,空字符串转成数值型则很可能被标记为转换失败。
1.2 界面里它们长得几乎一样,行为却天差地别
Kettle的“数据预览”里,null通常显示为一个空白单元格,空字符串显示为一个看起来也是空白的字符串,肉眼很难区分。如果不刻意用ISNULL这样的函数去验证,或者把字段拖到“分组”组件里看分组结果,根本发现不了问题。
之前遇到一个真实例子:同事在“过滤记录”组件里写条件[字段] = "",想过滤掉所有空的记录,结果发现测试数据里有一批“空”记录怎么都过滤不掉。我过去看了一眼,那批记录的值是null,而null用=号去比较空字符串,结果永远是false。反过来,如果用ISNULL([字段])去过滤,空字符串又不会匹配。
这里我做了一个差异对照表,平时排查问题可以直接对照:
| 场景 | null | 空字符串"" |
|---|---|---|
| 字符串拼接 | 结果为null | 拼接结果正常 |
| trim / substring操作 | 抛异常 | 正常执行 |
过滤条件= "" | 不匹配 | 匹配 |
过滤条件IS NULL | 匹配 | 不匹配 |
| 数值类型转换 | 可按设置转0或null | 通常转换失败 |
| GROUP BY分组 | 独立一组 | 独立一组 |
| 写入Oracle | 写入NULL | 自动转为NULL |
| 写入MySQL | 写入NULL | 保留空字符串 |
这个表格的价值在于:在做判断、拼接、转换、排序、关联之前,先想清楚这个字段到底会以哪种形态出现,再决定组件怎么写。
1.3 清洗之前,必须先把“空”的业务规则定下来
不做规则就写转换,等于蒙眼开车。同一个“手机号为空”,A源库用null表示“未填写”,B源库用""表示“未填写”,C接口干脆不传这个字段自然就是null,D接口更奇葩,传了但值是一个字符串“null”。
如果不把“空”的业务含义定义清楚,清洗组件只是机械地替换,最后得到的还是一个混乱的结果。
动手之前建议先回答三个问题:
- 目标库字段是nullable还是not null?如果是not null,空值必须给默认值;如果是nullable,可以考虑保留null,也可以统一成默认值。
- 业务上“未填写”和“空串”有区别吗?绝大多数业务场景没有区别,没有区别就统一。
- 数值型字段的空值怎么办?转0还是保留null?要和业务方确认。0参与平均值统计会拉低均值,null会被数据库聚合函数忽略,结果完全不同。
这三个问题想清楚了,后面的组件配置才有依据。否则今天把空串转成UNKNOWN,明天又有人写逻辑判断空串,规则永远统一不起来。
2. 源头数据的长相:文件、数据库、接口进来的“空”各不同
2.1 文本与CSV输入:空字段进入流的方式并不固定
CSV文件输入和文本文件输入是两类最常用的组件,但空字段的默认处理方式在不同版本里不太一样。“文本文件输入”的内容页签有一个选项控制空字段是否被视为null,有些版本叫“允许空字段”,有些版本则是在字段页签为每个字段配置“Null if”或默认值。
我踩过的坑是:从同一个CSV读数据,字段之间用逗号分隔,最后一列经常有空值。预览里看着是空,但流里面到底是空串还是null,取决于页签里“Null if”有没有配置成""。默认情况下很可能不是null,这导致我后续写过滤逻辑时,明明写了= ""的条件,null行还是漏掉了。
所以拿到文本文件,我的习惯是:先看每一列有没有可能为空,然后在字段页签把需要参与条件判断的字段的“Null if”显式配置成""。这样空串在入口处统一转成null,后续写分支逻辑时,只需要判断一种空形态。
如果业务上确实需要保留空串,也可以反过来设置,但要明确做这个选择,而不是任由默认值摇摆不定。
2.2 Excel输入:空单元格与“空串单元格”常常被混淆
Excel输入组件读取工作簿时,空白单元格通常会被读成null,但如果单元格里是公式算出来的"",进入流就会是空字符串。一个经典事故:某张表“备注”列在多数行是空的,读进来都是null,偏偏有几行用户用了=IF(条件,"","有内容")这样的公式,结果写成了"",导致后续用== ""判断时漏掉了这些行。
处理建议很简单:Excel输入之后立刻挂一个“清除空字符串”组件,或者用“字段选择”的元数据页设置Null if,让空串和null尽早归一。
不要指望所有源头都规范,ETL的价值恰恰在于吸收不规范的源头。一个合格的数据同步流程,应该在入口就把这些差异消化掉,而不是把问题留给下游的报表和模型。
2.3 数据库输入:null和''明明白白,但要注意SQL写法
从表输入组件读数据,SQL查出来是NULL,流字段就是null;查出来是空串,流字段就是""。大多数时候驱动返回得比较规矩,不容易出问题的是源库自身的数据质量。
最常见的情况是老数据里空日期是NULL,新数据里业务系统改成了''。如果SQL的WHERE条件写的是= '',老数据就会被漏掉;反过来写IS NULL,新数据又进不来。这时候最稳妥的办法是在SQL查询阶段就做一次归一,比如:
SELECT COALESCE(NULLIF(TRIM(客户编号), ''), NULL) AS 客户编号, ... FROM 客户表用NULLIF把空串先转成null,再配合TRIM把空格去掉,进入Kettle的数据源就是干净的。这样做的前提是,你要清楚源表字段的业务含义,不要为了统一而误伤真实数据。
2.4 拿到任何数据源,第一件事先做“空值体检”
不管数据来自哪里,我强烈建议在开发转换时保留一个“数据体检”分支:先用“计算器”组件给可疑字段生成标记列,再用“分组”统计,十分钟内看清到底混了多少种“空”。
具体做法是:拖一个“计算器”组件,公式选ISNULL,字段选“手机号”,输出列叫“手机号_IS_NULL”;再用一个“JavaScript代码”组件生成一列,判断“手机号 == ''”,输出“手机号_IS_EMPTY”。最后接一个“分组”组件,按这两个标记列分组计数。
跑完之后你会看到类似这样的结果:
- 手机号_IS_NULL = true 的有 1200 行
- 手机号_IS_EMPTY = true 的有 30 行
- 两个都是 false 的有 8000 行
看到“空串”只有30行的时候,多数人会选择只处理null,而那30行往往就是日后的隐患。不要怕麻烦,体检这一步花的时间,远比将来排查线上任务省的时间少。
3. 清洗手段对比:替换NULL、清除空串、值映射到底怎么选
| 组件 | 处理对象 | 适用场景 | 注意事项 |
|---|---|---|---|
| 替换NULL值 | null | 需要给null一个默认值 | 不会处理空字符串 |
| 清除空字符串 | 空串 | 把空串统一成null | 不会处理纯空格字符串 |
| 字段选择-元数据 | null / 空串 / 占位符 | 在源头定义“哪些值算空” | 改字段后容易遗漏检查 |
| 值映射 | 任意值 | 特殊占位符转统一值 | 需确认映射关系完整 |
| JavaScript代码 | 任意 | 复杂多条件组合 | 大量数据时性能较差 |
3.1 替换NULL值:最基础的组件,但很多人用错
“替换NULL值”是处理null的首选。它替换的只有null,空字符串完全不会动。很多朋友把它当成“空值替换”来用,替换完发现还有“空”,以为是组件坏了,其实是空串根本没被处理。
参数方面,可以选单个字段、多个字段或“所有字段”。选“所有字段”时,所有null都会被替换成同一个值,这往往不合理。数值型字段替换成“未知”会出类型问题,字符串字段替换成0也会很奇怪。
我建议按字段分类型处理:字符串字段替换成业务统一的默认值(如“未知”),数值字段替换成0或-1,日期字段通常建议保留null或者替换成默认日期,不要一刀切。配置方法很简单,选中字段,填上替换值,跑一遍预览看结果即可。
3.2 清除空字符串:把空串转成null的最快方式
“清除空字符串”这个组件的作用很纯粹:把字段值等于""的改成null。适合的场景是,你已经确定空串和null在业务语义上没有区别,希望把两者统一成null。
默认情况下它会清除所有字符串字段的空串,我建议在配置里只勾选需要处理的字段。有些字段业务上明确区分“未填写”和“填写后清空”,盲目统一会丢失信息。
注意,这个组件不会处理纯空格字符串,比如" "这种只有空格的值,它不会转换成null。如果源头数据里可能存在这种带空格的情况,需要在前面加一个“字符串操作”组件,做trim操作,或者用JavaScript脚本统一处理。
3.3 字段选择-元数据:在源头定义“什么算空”
“字段选择”组件的“元数据”页签可以为每个字段配置“Null if”,也就是“哪些值算作空值”。使用方法很直观:把某个字段的“Null if”设置为"",源数据里出现空串时,进入流就自动转换成null。
这个设置的优点是能按字段定义规则,而且作用时机更早,后续组件拿到的就是处理后的值。比如某些文本字段,可能还要把“NULL”这个字符串、"--"、"unknown"等特殊占位符一并转成null,都可以在这里配置。
缺点是要记得维护。如果后面给这个组件添加了新的字段,元数据页签不一定自动带上配置,需要回头检查。我见过好几次因为加字段导致新字段没配“Null if”,空串悄悄漏到下游的情况。
3.4 JavaScript代码处理:复杂规则下的兜底方案
当清洗规则特别复杂时,前面几个组件需要串联好几步,配置起来很繁琐。比如“手机号字段要么是null,要么是空串,要么是'未知',统一映射成'未提供'”,这种多条件分支逻辑,用JavaScript代码组件写反而更清晰。
简单示例:
var phone = 手机号; if (isNull(phone) || trim(phone) == "" || phone == "未知") { return "未提供"; } else { return trim(phone); }注意一个重点:如果字段是null,直接调用trim()会遇到异常,所以必须先判断isNull,通过短路运算让后面的trim不执行。这个顺序错误是我见过最多的问题。
JS组件的好处是逻辑集中、可读性好,坏处是对性能有一定影响。批处理到了千万级数据量时,尽量少用JS组件,或者只在数据量可控的分支上使用。
4. 实战案例一:三套系统合并,空值各说各话
4.1 场景描述与问题定义
一次做数据仓库整合,客户明细数据来自三个系统。A系统的客户编号在“未填写”时存NULL,B系统存空字符串,C系统最奇葩,存的是字符串“null”(四个字母,作为文本)。
三份数据合并到同一张宽表后,“客户编号”成了重灾区:有的行是NULL,有的行是"",还有的行是“null”。下游客户维度表做关联时永远关联不齐,客户维度表里凭空多出好几个“空客户”,报表直接没法看。
业务方明确表示:这三个值在语义上都表示“客户编号未知”,而且目标表要求非空字段必须有一个可读的默认值。于是清洗目标定为:把所有形态的空都转成统一默认值“UNKNOWN”。
4.2 完整方案:规范化流程搭建
我搭的清洗流程分三步:
第一步,用“字段选择”组件的元数据页签,把A系统和B系统的空串统一成null。这一步把""和null合并成同一种形态。
第二步,用“替换NULL值”组件,把null替换成“UNKNOWN”。
第三步,用“值映射”组件,把还是“null”字符串的值映射成“UNKNOWN”。
顺序很重要:如果先做值映射,再去替换NULL,那“null”文本会被提前处理,但空串和NULL还是散的;如果先替换NULL再值映射,空串又会漏掉。所以严格按照“空串→null→默认值→特殊文本→默认值”的顺序,一环扣一环。
第二步替换NULL值时,“Include all”一定不要勾,手动选择“客户编号”这一个字段。第三步值映射时,除了“null”映射成“UNKNOWN”,还可以把""也加进映射表,作为双重保险。
4.3 验证清洗结果
清洗完成后,我在流程末端挂了一个“分组”组件,按“客户编号”分组计数。预期结果是:除了业务上真实存在的客户编号,只能看到一个“UNKNOWN”,不能再出现NULL、""和“null”。
如果分组结果里还有单独的NULL或"",说明中间某个环节的顺序或配置有问题,需要回头逐个组件检查。验证通过后,这个流程才敢进调度。
另外,为了给下游排查留后路,我在清洗后的“客户编号”旁边保留了一个“客户编号_原始值”,把原始形态原封不动地留在旁边。清洗结果用于关联和分析,原始值用于溯源和排错,两条线互不干扰。这是清洗类任务的一个好习惯。
5. 实战案例二:分组统计与关联JOIN的空值翻车实录
5.1 按字段分组,null和空串被拆成两组
第二个案例来自一个销售明细分析任务。数据表里有个“完成时间”字段,业务上有三种状态:有具体时间表示已完成;null表示未开始;空串表示已取消(系统写入成了空串)。
分析任务是按“完成时间”维度统计每天的单量,结果发现“未开始”和“已取消”在报表里被拆分成了两个“空”维度行。因为按时间字段做GROUP BY时,null会被归到一组,空串会被归到另一组,业务方看到两个空行,一头雾水。
问题本质和案例一相同:两种空的语义应该合并。但这次不能简单替换成某个默认字符串,因为分组维度里需要展示“未标注”这种人工可读标签更合适。
修复思路是:在分组前加一步“清除空字符串”,把空串统一成null,然后再用“值映射”把null映射成“未标注”。这样分组结果里只有一组“未标注”,不会再出现两个空行。
5.2 表关联时,null永远不等于空串
另一个同事遇到的问题更有代表性:事实表和维度表按“客户编号”关联,维度表用“客户编号 + 渠道”做主键,事实表里某些客户编号是null,另一些是空串。
做“数据库查询”或“流查询”时,null和空串永远不会和维度表里的有效值相等,这还好理解。真正诡异的是两个表都为空的记录也关联不上:事实表null关联维度表null,查不出来;事实表""关联维度表"",在SQL等值关联里其实可以匹配,但在Kettle的“流查询”组件里,比较逻辑基于对象相等性,null和null或者""和""在部分实现中也不一定匹配,结果就是这批记录直接丢失。
所以最保险的思路还是那句话:关联键在关联前必须规范化。要么都把空变成“UNKNOWN”再关联,要么在源头过滤掉空关联键再做关联,不要让两种空混在关联键里。
5.3 修复思路与可直接抄的步骤
这里给出一套直接可复用的修复流程:
- 表输入阶段,用SQL把两边关联键的空值统一,例如
NULLIF(TRIM(客户编号), ''),这样null和''在源头就变成同一个值。 - 如果关联键必须保留原始值,就在流里加“清除空字符串”组件,再接“替换NULL值”把null替换成“UNKNOWN”。
- 关联完成后,如果要还原显示,可以在最后加一个“值映射”,把“UNKNOWN”再映射成“未填写”,但关联键在关联时保持统一即可。
注意:不要在关联做完之后才去统一关联键,因为关联动作发生在统一之前,记录已经丢了。所有“按某字段做关联或分组”的任务,先清洗再聚合或关联,可以当成一条铁律来记。
6. 容易被忽略的隐性坑:顺序、正则、排序、写库
6.1 “先过滤空”还是“先洗空”:顺序决定生死
很多人写流程时,习惯先“过滤记录”把空值排除,再做后续清洗。如果过滤条件写的是[字段] = "",null的行会被漏掉;如果写的是ISNULL([字段]),空串的行会被漏掉。只有当你确认这个字段进入流时100%是null,才能放心先过滤。
最安全的顺序永远是:先把字段统一成一种空形态,再决定过滤还是保留。我自己的流程习惯是:入口处一律先“清除空字符串”把空串转成null,然后用“过滤记录”判断ISNULL([字段])来决定是剔除还是保留。这样逻辑里只需要处理一种“空”,形态完全可控。
有一次排查线上任务,发现某个同步作业在数据量翻倍后突然多处理了两小时,原因就是入口没有统一空值,下游多个分支组件反复在判断两种空形态,导致计算量暴增。统一之后,任务跑完时间缩短了一半。
6.2 正则匹配null:不报错不代表正确
在“正则表达式”组件、“过滤记录”里用正则,或者写脚本用matches()方法匹配时,null字段的表现很尴尬:有些版本里null匹配正则直接返回false,有些可能抛出空指针异常。
新手容易踩的坑是:写了一个“判断手机号是否合法”的规则,结果null和""都进入“不合法”分支,但两个分支的处理逻辑不同,null在“不合法”分支里又做了一次类型转换,最后整个任务报错。
应对方式很简单:在做任何正则判断之前,先把null转成"",或者先做个空值判断分流。清空字段再匹配是我常用的做法:
if (手机号 == null) { return false; } else { return 手机号.matches("^1[3-9]\\d{9}$"); }这样逻辑清晰,不会因为null触发异常,也不会把null和""混为一谈。
6.3 排序、去重、合并记录中的null位置
排序时null排在哪,取决于用的是JDBC排序还是内存排序,以及排序字段本身的设置。如果业务上要求“空值排最后”或者“空值排最前”,你得显式处理。一种做法是排序前生成一个辅助字段标记是否为空,再按这个辅助字段排序,最后删掉辅助字段。
去重组件“唯一行”按字段去重时,null和空串会作为两个不同的值分别保留,去重不彻底。这解释了为什么很多人“去重后还有重复”——表面都是空,实际一个null一个""。数据对账任务里经常出现这种情况。
“合并记录”组件也类似,两行数据其他字段完全一样,只是一个null一个"",会被标记为“changed”,导致数据比对结果差异数虚高。先把两边的空值统一,再去比较,差异数才会真实。
6.4 数据库写入:目标库的“空值行为”各不相同
最后一个大坑是目标库行为。同一个空串字段,写入MySQL会保留为'',写入Oracle会自动变成NULL,写入SQL Server又是''。
如果你的任务从文本文件读入数据,空串没做处理就写Oracle,可能不会报错,但源库里的''在目标库里变成了NULL。下游通过= ''查询这条数据,怎么也查不到。
反过来,如果目标表字段是NOT NULL,把null写进去会直接报约束违反。所以写库之前一定要确认:目标字段是否允许null,空串和目标库的NULL语义是否一致。
最稳的写法是“输入侧统一空形态,输出侧按目标库选择保留null或替换默认值”。做完之后,一定要在目标库里跑几个抽查SQL,看看写入后的实际值,而不是只看转换日志显示成功。日志成功只代表流程跑完了,不代表数据是对的。
7. 一套可以直接复用的空值处理规范
7.1 入口定型:所有输入在第一步完成空值统一
我后来所有ETL转换的开发规范里都加了这一条:任何输入组件后面,第一步必须做“空值形态定型”。可以是一组“清除空字符串 + 替换NULL值”,也可以是用“字段选择”的元数据配置好Null if,但绝不能把源头形态一路往下带。
好处是后续所有步骤只用面对一个规则:字段为null代表空,没有空串这个形态。判断条件一律用ISNULL,不需要到处写“等于''”这种容易漏的表达式。整个数据流的逻辑复杂度直接降一个量级,排查问题时也不需要再猜这个字段到底是null还是空串。
这个规范听起来简单,但坚持下来的项目,数据质量问题的发生率明显下降。不是因为这些项目没有脏数据,而是脏数据在入口就被消化了。
7.2 字段级规则字典:清洗规则要沉淀
如果项目里有十几个转换,每个都自己写一套空值处理,规则一定发散。我建议团队维护一张“字段级空值规则字典”,至少包含这些列:
| 字段信息 | 说明 |
|---|---|
| 表名 | 源表或目标表名 |
| 字段名 | 字段英文名 |
| 业务含义 | 这个字段在业务上代表什么 |
| 空值定义 | 哪些值算空,例如null、""、"null" |
| 统一形态 | 统一成null还是默认值 |
| 默认值 | 如果替换,替换成什么 |
| 目标库约束 | 目标字段是否not null等 |
做新转换时直接查这张表,把规则落地成标准转换片段。这个字典不用很复杂,Excel或者Wiki都能维护。关键是让团队对“什么算空”达成共识,否则今天有人把空串转成UNKNOWN,明天有人保留空串,下游永远在打架。
7.3 一点个人体会
和null、空字符串打交道这么多年,我最深的感受是:ETL里约70%的“诡异数据问题”,最后查出来都是“空值形态没统一”造成的。与其在每一个步骤里反复打补丁,不如从源头把规则定死。把“清洗”当成一个独立、有明确输入输出契约的环节来设计,而不是散落到各个转换里,你会省下大量排障时间。
如果你现在正被某个“看起来是空但怎么都对不上”的问题困扰,不妨按这篇文章的思路做一次数据体检,看看你的流里到底混了几种“空”。很多坑我只遇到过一次,但每次排查的过程都让人印象深刻。