NiFi 数据流实战:FTP→HDFS 与 CSV→Parquet,附坏数据、漏文件与双写

NiFi 数据流实战:FTP→HDFS 与 CSV→Parquet,附坏数据、漏文件与双写

这是「NiFi 生产集群」系列的第 4 篇。

上一篇:NiFi 的权限模型不是 RBAC 本篇:两个真实落地的数据流 下一篇:起不来、登不上、节点被挤掉

这篇除了两条主流程,还写了四个只在生产上才遇到的问题------漏文件、坏数据、Parquet 损坏、双写。

两条主流程写得再漂亮,也不代表数据一定到了 HDFS 上,而且是对的。这四个问题没有一个会出现在"流程连不上"的范畴里:画布全绿、队列是空的、日志里没有 ERROR,业务那边说文件少了。它们的共同点是------只能靠校验和对比发现。

1. 场景

生产上 NiFi 主要干两件事:

  1. 文件搬运:上游业务往 FTP 目录扔文件,NiFi 把文件收进来、解压、落到 Kerberos 化的 HDFS;
  2. 格式转换:落地的 CSV 需要转成 Parquet,供下游 Spark/Hive 直接查。

前者是"通不通"的问题,后者是"对不对"的问题,两类坑完全不同。

两套环境的规模先交代一下,后面所有结论都在这两套上验证过:

环境 规模 跑什么
主集群 25 台(三轮扩容:3 → 7 → 13 → 25) 全部生产接入流程
小集群 5 台 非核心业务、临时验证、灰度新流程
版本 Apache NiFi 1.17.0,Kerberos + 全站 HTTPS + 文件型授权 两套一致

两套集群都干上面那两件事,区别只在于主集群上还挂了一条双写流程(第 6 节)。

2. 流程一:FTP → HDFS

2.1 拓扑

复制代码

用到的处理器:

处理器 作用 关键点
ListFTP 轮询远程目录,把文件名作为 FlowFile 往下传 不传内容,只传"元数据"
FetchFTP 按 ListFTP 给的路径把文件取回 取完默认会删远程文件,配额要小心
CompressContent 解压 gzip Mode=decompress,Update Filename=true
UnpackContent 解包 zip/tar 一个包会拆成多个 FlowFile
MergeContent 小文件合并 按条数/大小/时间三选一触发
PutHDFS 写 HDFS Kerberos 认证 + 目录策略
LogAttribute 记日志/兜底 失败分支统一挂这里

2.2 ListFTP / FetchFTP 的配置

两个处理器的连接信息要一致:

属性 值 说明
Hostname ftp.example.com 别写 IP,换机器时好改
Port 21
Username / Password ftp_user / <FTP_PASS> 建议用 Parameter Context 存,别硬编码
Remote Path /data/incoming/biz_a ListFTP 的轮询目录
Search Recursive true 有子目录时必须开
File Filter Regex `.*.(csv txt
Completion Strategy Move File + Move Destination 取完移到 /data/done,比直接删安全

FetchFTP 上还有一个容易忽略的开关:Delete Original = true 会直接删远程文件。如果不希望删,改成 false 并配合上游自己清理,否则一旦下游处理失败,源文件就没了。

2.3 CompressContent:解压并去掉后缀

属性 值
Mode decompress
Compression Format gzip
Update Filename true

Update Filename=true 的作用是把 xxx.csv.gz 改回 xxx.csv。后面 PutHDFS 的目录/文件名模板如果用到 ${filename},这个开关必须开,否则 HDFS 上全是 .gz 结尾的文件。

2.4 PutHDFS:Kerberos 化的 HDFS

PutHDFS 是受限组件,要么用 keytab 直连,要么走 HadoopConfigurationResources。

属性 值
Hadoop Configuration Resources /etc/hadoop/conf/core-site.xml,/etc/hadoop/conf/hdfs-site.xml
Kerberos Principal etl_user_01@EXAMPLE.COM
Kerberos Keytab /opt/nifi/keytab/etl.keytab
Kerberos Password 留空(用 keytab 就不用密码)
Directory /user/etl_user/biz_a/${now():format('yyyyMMdd')}
Conflict Resolution Strategy rename
Block Size 默认 128MB,按集群调

三个注意点:

  1. keytab 权限 。NiFi 进程是以 nifi 用户跑的,keytab 必须 nifi:nifi 600,否则报 Failed to login。
  2. 目录自动创建 。PutHDFS 会创建不存在的目录,但父目录的 HDFS 权限要够,否则报 Permission denied。
  3. Directory 用表达式 时不要以 / 结尾再拼文件名,容易拼出双斜杠。

2.5 失败分支怎么接

NiFi 的处理器每个 relationship 都要处理,否则流程会报"未连接的 relationship"警告。我们的接法:

  • FetchFTP.failure → LogAttribute(记一条,不清空)
  • PutHDFS.failure → LogAttribute,同时把 Penalty Duration 设成 30 sec,让重试拉开间隔
  • PutHDFS.success → 不接(自动丢弃,数据已落盘)

如果要做"失败重试 N 次再进死信队列",用 RetryFlowFile:Retries 设 3,failure 接一个 PutFile 落到本地 /data/deadletter,方便人工补。

上面这套是 2022 年建的,当时只接了 failure。后来发现 failure 只是众多关系里的一个,还有 comms.failure、not.found、permission.denied 这些更细的关系------它们默认去哪、被谁改成了 terminate,直接决定了"文件少了"这类问题的答案。这块单独放在 4.2 节讲,因为它比任何一条流程配置都更容易出事故。

2.6 ListFTP 为什么会漏文件

这是接入侧最贵的一课,因为它的表现形式是"什么都没发生"。

现象

某天上午,业务反馈 biz_a 有三个文件没有进 HDFS。时间点、文件名、大小都给得很清楚。第一反应是流程挂了,登上去一看:

  • 画布全绿,没有红框,没有告警图标;
  • 队列是空的,没有任何积压;
  • nifi-app.log 里没有 ERROR;
  • HDFS 上那个时间段的目录存在,只是少了这三个文件。
日志

值班的常规动作是按业务关键字 + ERROR + 日期三条件过一遍日志:

perl 复制代码
grep -n "biz_a" /opt/nifi/logs/nifi-app.log \
  | grep ERROR \
  | grep "$(date +%Y-%m-%d)"

输出只有几条跟这件事无关的 ERROR(别的流程的 HDFS 权限问题)。再按处理器名过滤:

perl 复制代码
grep -nE "ListFTP|FetchFTP" /opt/nifi/logs/nifi-app.log \
  | grep -E "WARN|ERROR" \
  | grep "biz_a"

同样是空的。这时候可以下一个很关键的判断:NiFi 侧没有失败,是它压根没看见这几个文件。

验证方式是查 Provenance,而不是查日志。UI → Data Provenance → 搜索条件选 Component → 选 ListFTP,时间范围拉到当天全天:

  • 该时段 ListFTP 只产生了 2 个 FlowFile,对应的是另外两个文件;
  • 缺的那三个,在 Provenance 里从来不存在,连 EVENT 都没有。

也就是说,问题不在 FetchFTP 之后的任何环节,而是 ListFTP 这一层就没把它们捞进来。

机制还原

把上游的操作时间线要过来,和自己这边的批次时间一对,原因就出来了:

时间 上游动作 NiFi 这一侧
08:41 文件落盘,产生第一个名字 上一批次(08:40)已经跑完
08:45 --- ListFTP 批次启动,扫到的是"改名前的名字"
08:46 上游重跑,先删后写,文件改名 FetchFTP 拿着旧名字去取,取不到
08:50 --- 下一批次,但这次失败已经被吞掉了

三件事叠在一起:

  1. List 和 Fetch 是两次独立操作 。ListFTP 只把"这一刻看到的文件名"放进队列,FetchFTP 真正去取内容可能已经是几十秒之后。中间这段时间,远端目录是可以变的。
  2. 上游的落盘不是原子的。"先删后写"或者"写临时名再 rename"这两种做法,都会让文件在两个名字之间存在一个"哪个名字都不完整"的窗口。文件在"改名"的瞬间不在它预期的状态里。
  3. Minimum File Age 是默认的 0。这个参数的意思本来是"文件至少存在多久才认为它写完、可以取",设成 0 就是"刚出现就可取"。它保护不了正在被写入或者正在被改名的文件。

再叠加一个我们自己的配置:List 的 Run Schedule 是 5 分钟一批。批次越稀,恰好落在窗口里的概率越低,但一旦落进去就是无声的------因为那次失败走的是 not.found 关系,而它当时被配成了 terminate(见 4.2 节),既不重试也不告警,日志级别还不足以进 ERROR。

c 复制代码
上游 FTPListFTPFetchFTP08:41 落盘 xxx.csv08:45 批次 LIST返回 xxx.csv08:46 删除并改名排入队列(只有文件名)RETR xxx.csv550 not foundnot.found 被配成 terminate失败被静默吞掉上游 FTPListFTPFetchFTP
处置

四条路,按"治本程度"排序。我们最后是 2 + 4 一起上的。

# 方案 怎么做 代价
1 全量拉取 + 客户端取完移走 File Filter Regex 放宽到 .*,取完由 Completion Strategy = Move File 挪到 /data/done,删不删由上游定 最稳,但要上游配合,且要接受目录里会有非目标文件
2 上游落盘后打完成标记 上游写完先叫 xxx.csv.tmp,写完 rename 成 xxx.csv.done;NiFi 的 File Filter Regex 只匹配 .done$,取到后 Update Filename 去掉后缀 一次改造,长期有效;要求上游肯改脚本
3 抬高 Minimum File Age 从 0 调到 10 min,让"刚出现"的文件等 10 分钟再取 治标。端到端延迟变成 15--20 分钟(5 分钟批次 + 10 分钟观察期),业务能不能接受要谈
4 约定落盘时限 要求上游在生成后 5 分钟内落盘到目标目录,且落盘动作原子化(写临时文件 + rename),改名/重跑必须换名字 纯管理手段,零技术成本,靠流程约束

方案 1 和 2 的区别值得说清楚:1 是"我全都要,取完再判断",2 是"你告诉我哪个写完了"。前者把判断权放在自己手里,后者把责任放在上游。生产上如果上游系统多、改造成本高,1 更现实;如果上游只有一个团队、能推得动,2 最干净。

方案 3 我们试过,端到端延迟从 5 分钟变成 20 分钟,业务当天就找来了------他们的下游报表有时限。所以它只适合对延迟不敏感的接入。

结论

抓取类问题里,"没报错"通常等于"没看见",不是"处理失败"。

排查顺序要改:先确认 List 阶段有没有产生过 FlowFile(Provenance 按组件查),再往下游追。只看 ERROR 日志会得出"NiFi 没问题"的错误结论------它确实没报错,但它也确实漏了。

3. 流程二:CSV → Parquet

这个流程有两条支流,取决于源文件有没有表头:

复制代码

3.1 支流一:ConvertRecord(无表头 CSV)

ConvertRecord 是"Record Reader + Record Writer"的组合,不需要写 schema 文件,靠 Reader 自己推断。

CSVReader 控制器服务配置:

| 属性 | 值 | 说明 |
|----------------------------|----------------------|-----------|------------|
| Schema Access Strategy | Infer Schema | 从数据推断字段类型 |
| CSV Parser | Apache Commons CSV | |
| Value Separator | ` | ` | 源文件确实是竖线分隔 |
| Record Separator | \n | |
| Treat First Line as Header | false | 无表头 |
| Character Set | UTF-8 | |

ParquetRecordSetWriter 配置:

属性 值
Parquet Format Version PARQUET_1_0
Compression Type SNAPPY
Schema Access Strategy 继承 Reader 推断结果

Infer Schema 的坑 :它只采样前面一部分数据,遇到"前 100 行都是整数、第 101 行出现空值"就会推断成 int,后面直接报类型转换失败。生产上建议:

  • 源数据字段类型稳定 → 用 Use 'Schema Name' Property 显式指定;
  • 不稳定 → 用 Infer Schema 但把 Max Records for Schema Inference 调大(如 10000),并接受偶发失败。

3.2 支流二:PutParquet(有表头 CSV)

这条支流靠 AvroSchemaRegistry 显式给 schema,稳得多。

第一步,UpdateAttribute 加一个属性:

ini 复制代码
schema.name = class

第二步,AvroSchemaRegistry 里注册 schema,key 就是 class:

json 复制代码
{
  "type": "record",
  "name": "ClassReader",
  "fields": [
    {"name": "id",   "type": "long"},
    {"name": "name", "type": "string"},
    {"name": "age",  "type": "long"}
  ]
}

注意这是标准 Avro schema:fields 是数组 ,不是对象。网上很多抄来的是错的,NiFi 会直接报 Schema Parse Failure。

第三步,CSVReader 改成:

| 属性 | 值 |
|----------------------------|------------------------------|----|
| Schema Access Strategy | Use 'Schema Name' Property |
| Schema Registry | AvroSchemaRegistry |
| Schema Name | ${schema.name} |
| CSV Parser | Apache Commons CSV |
| Value Separator | ` | ` |
| Record Separator | \n |
| Treat First Line as Header | true |

第四步,PutParquet:

属性 值
Hadoop Configuration Resources /etc/hadoop/conf/core-site.xml,/etc/hadoop/conf/hdfs-site.xml
Kerberos Principal etl_user_01@EXAMPLE.COM
Kerberos Keytab /opt/nifi/keytab/etl.keytab
Record Reader CSVReader
Directory /user/etl_user/biz_a_parquet
Compression Type SNAPPY

3.3 为什么两套都要有

  • ConvertRecord 通用,CSV→Avro/JSON/Parquet 都能干,但依赖推断,字段一乱就翻车;
  • PutParquet + Schema Registry 是"强类型"路线,schema 写死,稳定但改字段要改 schema。

我们的分工:表结构固定的业务走 PutParquet,临时/探索性数据走 ConvertRecord。

3.4 坏数据:PutParquet 整批失败

这一节回答业务方问得最多的那个问题:"能不能只把坏的那几行挑出来,剩下的照常入库?"

现象

业务侧的告警长这样:biz_a_20240101.csv failed!------文件名 + failed。同一时间 NiFi 画布上 PutParquet 变成红色,队列里那个 FlowFile 一直重试。

日志

nifi-app.log 里是两族报错,几乎是成对出现的:

vbnet 复制代码
org.apache.nifi.serialization.MalformedRecordException: Error while getting next record
lua 复制代码
java.io.IOException: (startline 328401) EOF reached before encapsulated token finished

第一族是 MalformedRecordException:Record Reader 在某一行解析不下去,通常是列数不匹配(多一个分隔符、少一个字段)、引号没闭合。

第二族是 EOF reached before encapsulated token finished,注意它带的 startline------这是一个六位数的行号。看到"行号很大 + EOF"这个组合,基本可以直接判定:文件本身被截断了,读到某行半截就没了。上游写文件写到一半断了(网络、磁盘、进程被杀),或者传输过程中断了。

根因

Record Reader 是逐行解析的,它没有"跳过这一行继续"的选项。任何一行抛异常,整个 FlowFile 一起失败,不是只丢那一行------列数不符、引号没闭合、文件被截断(写了一半),三种坏法都走同一条路。

这条特性决定了后面所有方案的边界:坏数据必须在进 Record Reader 之前拦,进去之后就只剩"整批重来"。

三种过滤方案对比

业务方最常问的就是"用 ReplaceText 能不能解决"。三个方案的对比:

方案 能不能拦坏行 代价
ExecuteScript(Groovy 等) 能,按 ` ` split 后判断列数,不合格直接 route 到 audit
ReplaceText 只能替换固定模式,判断不了"这行是否合法" 容易误伤,不能当校验用
UpdateRecord 来不及------坏行在解析阶段就失败了,进不到 record 级 无意义,解决不了这个问题
推荐接法
复制代码

ValidateRecord 用同一个 CSVReader + AvroSchemaRegistry,它的 invalid 关系就是"解析或校验失败"的出口。这条路径的好处是配置量最小、不写脚本。

但 ValidateRecord 有个短板:它只能告诉你"这个 FlowFile 不合格",不会告诉你"第几行不合格"。所以我们在它前面串了一个 Groovy 的 ExecuteScript,做三件事:

  1. 按 | split 每一行,和期望列数比,不等就记下行号;
  2. 把不合格的行按原样写到本地 /data/audit/biz_a/<原文件名>.bad,每行前面加 行号|;
  3. 整份文件里坏行占比低于阈值(比如 0.1%)时,把坏行剔掉、把剩下的往下传;超过阈值就整体送 audit 分支,交人工。

第 3 条是关键的设计决定:小比例坏行可以剔,大比例坏行必须停。因为大比例坏行通常意味着上游改了分隔符或者改了字段数,这时候剔完剩下的数据也是错的。

audit 分支落本地文件时一定要带原文件名和行号,否则人工没法回查。我们早期只落了内容,结果拿到一堆裸行,对不上是哪天的哪个文件。

结论

坏数据要在进 Record Reader 之前拦,进去之后就只剩"整批重来"。

这句话还有一层含义:整批重来是有成本的。一个几十万行的文件因为第 30 万行少一个字段,前面 29 万行全部白读,重试三次还是同样位置失败,最后进死信队列,业务当天没有数据。所以预校验那一步的 CPU 开销,是必须付的保险费。

4. 队列与背压:不设就是定时炸弹

NiFi 的队列默认上限是 10000 个 FlowFile / 1 GB。上游一快,队列满了之后上游会被反压(停止调度),表现为"处理器在跑但不动"。

4.1 每个连接都显式设

建议每个连接都显式设:

参数 建议值 说明
Back Pressure Object Threshold 10000 条数上限
Back Pressure Data Size Threshold 1 GB 大小上限
FlowFile Expiration 7 days 超时自动清,防堆积
Prioritizers Newest First 或 Oldest First 按业务定

看队列的快捷方式:画布上每个连接中间的数字就是当前积压条数,点开可以看列表、看内容、清空。

4.2 failure 关系别配 terminate

这一段是本文最想让人记住的配置项,因为它造成的损失最难追溯。

现象

业务说"文件少了几条"。不是整批没有,是少了几个。你去查,HDFS 上有文件、行数也对得上已处理的那些,缺的那几个在 NiFi 侧没有任何痕迹。

根因

FetchSFTP / FetchFTP 除了 success 和 failure,还有几个更细的关系:

关系名 含义 我们早期的接法 后果
comms.failure 通信层失败:建连超时、连接被重置、SSH 协商失败 terminate 远端抖一下,这个文件就永久丢了
not.found List 给了文件名,Fetch 去取时文件已经不在了 terminate 见 2.6 节,漏文件的主要来源
permission.denied 认证通过但没权限读这个文件 terminate 上游改了目录权限,NiFi 一声不吭
failure 其他异常 LogAttribute 至少留了条日志

terminate(自动终止)的语义是"这个 FlowFile 我不要了,丢弃"。它不会重试、不会告警、不会进死信队列,日志里最多一条 DEBUG。把这几个关系配成 terminate,等于"拉不到就丢"。

为什么当初会这么配?因为 NiFi 默认要求每个关系都连或者终止,画布上有六个红色未连接的箭头很刺眼。为了让画布干净,顺手点了 terminate。这个动作在测试环境毫无问题------测试环境的远端不会抖。生产上远端一抖,损失直接体现为"业务少了几个文件"。

处置

三类关系全部改成 auto-terminate = false,接 RetryFlowFile 或者直接回连自身:

关系名 含义 建议接法
comms.failure 通信层失败,通常是瞬时的 → RetryFlowFile(Retries=5),最终 failed 再进死信
not.found 文件已不在(被改名/被删/被别的实例取走) → RetryFlowFile(Retries=2)+ 告警,不要无限重试
permission.denied 权限问题,重试没用 → RetryFlowFile(Retries=1)→ 告警 + 人工,明确要人看
failure 其他 → LogAttribute → 死信目录

not.found 为什么不建议无限重试:它多半意味着文件被上游改名或删了(2.6 节),重试一百次也取不到,只会把日志刷满。给两次机会是为了覆盖"文件正在 rename 的瞬间",两次之后就该告警了。

重试队列本身也要设背压,否则就是另一个坑:

参数 值 理由
Back Pressure Object Threshold 10000 默认值就够,但必须显式写下来
Back Pressure Data Size Threshold 1 GB
Penalty Duration 30 sec 重试要拉开间隔,不要对着已经过载的远端打满
RetryFlowFile 的 Retries 3--5 按远端稳定性定,别设 -1

不设背压的后果我们经历过一次:远端 SFTP 抖了十几分钟,comms.failure 让所有 FlowFile 立刻回连自身,队列在几分钟内堆到几万条,NiFi 节点的堆内存跟着上去,最后触发 GC 告警。这不是远端的问题,是我们自己给自己造了一个雪崩。

Penalty Duration 设 30s 的理由和背压是一回事:远端已经不舒服了,你还在毫秒级重试,只会让它更不舒服。30 秒是我们在"恢复够快"和"别打满远端"之间的取值。

5. 性能调优的几个实际动作

5.1 组件级:五个立刻见效的动作

  1. Concurrent Tasks :PutHDFS 从 1 调到 4,写 HDFS 是 IO 密集,并发能明显提吞吐;但 ListFTP 保持 1,多个并发会重复拉同一个文件。
  2. Run Schedule :ListFTP 用 30 sec 而不是 0 sec(连续跑),避免空转打满 CPU。
  3. 批次 :FetchFTP 的 Batch Size 调到 10~50,减少建连次数。
  4. 小文件合并 :MergeContent 按 Minimum Number of Entries=10 + Maximum Group Size=128MB,能把一堆 KB 级文件合成一个块,HDFS 的 NameNode 会感谢你。
  5. Provenance :nifi.provenance.repository.max.storage.size 默认 10GB,事件多的时候会频繁 rollover,适当调大或缩短保留时间。

这五条是"点状优化",改完立刻能看到某条流程变快。但它们有个共同的天花板:NiFi 里所有处理器共享一个全局调度线程池,组件级别的并发再高,池子满了就得排队。

5.2 集群级并发比组件级更有效

现象

25 台集群,压测和日常都发现一个问题:单条流程的 Concurrent Tasks 已经调到 4 了,但整体吞吐上不去。集群 CPU 长期在 50% 左右晃,队列有一定积压,画布上很多处理器显示"Running"但不推进。

判断

这是一个典型的"CPU 没吃满但活儿干不完"的信号。当时的判断依据:

信号 说明 该做什么
CPU < 60% + 队列积压 有算力没用上,是调度不够 调大全局调度线程数
CPU > 85% + 队列积压 算力已经不够 先查流程本身,别再加线程
CPU 高但队列空 处理得比来得快 不用动
处置

改的是 Controller Settings 里的 Maximum Timer Driven Thread Count------这是全局调度线程池,所有 Timer Driven 的处理器从这个池子里借线程。它的默认值偏保守,我们从默认值调到 24,观察了一周,再调到 28。

效果:集群 CPU 从 50% 左右升到 75% 左右,队列积压明显下降,吞吐跟着上去。这是所有调优动作里收益最立竿见影的一个,因为它改的是"同时能有多少处理器在跑",而不是某个处理器能跑多快。

边界

但它是把双刃剑,有两条硬约束:

  1. 一次不超过 25% 。默认值 10 的时候,一次调到 24 是翻倍,风险很大------我们在小集群上先验证过才动的。从 24 到 28 是加 17%,属于安全范围。
  2. 超过 CPU 承载就变成互相抢。线程多了之后,上下文切换、锁竞争、GC 压力都会上来,CPU 会冲到 85% 以上,但吞吐反而降------因为线程在抢同一批 CPU 时间片。这时候的正确动作是回退,而不是继续加。

所以判断标准不是"能不能调",而是"CPU 有没有富余"。CPU 到 80% 就该停手,剩下的活儿靠加节点或者优化流程解决。

5.3 业务并发过高时先降并发,不要加节点

这一段和 5.2 看起来矛盾,其实讲的是两件事:5.2 是"算力有余但调度不够",这一节是"算力已经被榨干"。

现象

某段时间两个报错同时出现,而且是从不同流程里冒出来的:

ini 复制代码
org.apache.hadoop.hdfs.DataStreamer: slow waitForAckedSeqno took 33541ms (threshold=30000ms)
makefile 复制代码
net.schmizz.sshj.transport.TransportException: Timeout expired: 30000 MILLISECONDS
根因

两个报错指向两个完全不同的方向,但同时出现,说明问题不在某条流程,而在集群整体负载:

报错 含义 单独出现时该查什么
slow waitForAckedSeqno took 33541ms HDFS 写入后等 DataNode 确认花了 33 秒,超过 30 秒阈值 DataNode 侧扛不住:磁盘慢、网络慢、块太多
TransportException: Timeout expired SSH/SFTP 建连 30 秒还没完成 远端 SFTP 服务端连接数打满,或者中间网络

前者是"下游写不动",后者是"上游连不上"。两个一起出现,最合理的解释是:NiFi 集群自己太忙了------线程池被业务并发占满,SSH 握手线程排不上队;同时写 HDFS 的并发太高,DataNode 的确认队列积压。

处置

这一轮我们没有加节点,三个动作:

  1. 让业务降并发。业务方当时是把十几个抽取任务同时触发(整点跑),我们把它们错开到十分钟的窗口里。单这一条就把两个报错都消掉了大半。

  2. 关掉非核心的双写流程。双写(第 6 节)是走两个 HDFS 集群的,占的是双份写入带宽。高峰期先停掉非核心的那几个,等错峰再补。

  3. FetchSFTP 的 Connection Timeout 从 30s 调到 10--20s。这一条反直觉:超时调短了,不是更容易失败吗?

    是,但它失败得更快、更干净。30 秒的超时意味着每次建连失败都要占着一个线程等半分钟,高峰期几十个并发同时等,线程池瞬间见底------挂着的连接比失败的连接更贵。调到 15 秒之后,失败的请求快速返回、快速重试或者快速进死信,线程周转起来了,整体反而更稳。

加节点为什么不是第一选择:加节点能解决容量问题,解决不了"整点几十个任务同时冲进来"的瞬时尖峰。而尖峰恰恰是这两个报错的直接诱因。先削峰,再谈扩容。

6. 双写两个 HDFS 集群

这是 2024 年之后加的一条流程,也是本文唯一一个"跨集群"的场景。

6.1 场景

业务要求同一份数据,同时写进本平台的 HDFS 和另一个云上的 HDFS 集群。两个集群的硬性差异:

维度 本平台 HDFS 第二个 HDFS 集群(云上 EMR)
NameNode hdfs://ns1 hdfs://cloud-ns
Kerberos realm EXAMPLE.COM CLOUD.EXAMPLE.COM
主体 etl_user_01@EXAMPLE.COM etl_user_01@CLOUD.EXAMPLE.COM
配置文件位置 /etc/hadoop/conf/ 由我们单独放,见 6.2

难点不在"能不能写两份"------画两个 PutHDFS 就能写。难点在两个集群的身份和配置不能串味:用错一个 keytab,写进去的是另一个 realm 的票据;读错一份 core-site,Kerberos 直接不生效。

复制代码

6.2 目录与权限约定

这是踩了坑之后定下来的,每一条都对应一次故障:

用途 路径 权限
目标集群配置 /opt/nifi/cluster/<cluster_name>/core-site.xml、hdfs-site.xml nifi:nifi 0640
keytab /opt/nifi/keytab-<cluster_name>/*.keytab nifi:nifi 0600
krb5 两个 realm 的 krb5.conf 不能互相覆盖 两份的 [realms] 段合进同一个文件

krb5 这一行最容易被忽略:两个 realm 的 [realms] 段必须写在同一份 krb5.conf 里,靠 default_realm 指定默认值,处理器里再给带 @REALM 的全限定主体名。各放一份、互相覆盖的症状是"另一个集群的认证时好时坏"------这种间歇性故障最难查。 | 备用 hadoop 依赖 | /opt/nifi/hadoop_lib/ | nifi:nifi 0750 |

6.3 处理器怎么配

两个 PutHDFS 各配各的,不要共用一个 SSLContextService,也不要依赖同一个 krb5 默认值:

属性 本平台那个 PutHDFS 双写那个 PutHDFS
Hadoop Configuration Resources /etc/hadoop/conf/core-site.xml,/etc/hadoop/conf/hdfs-site.xml /opt/nifi/cluster/cloud_emr/core-site.xml,/opt/nifi/cluster/cloud_emr/hdfs-site.xml
Kerberos Principal etl_user_01@EXAMPLE.COM etl_user_01@CLOUD.EXAMPLE.COM
Kerberos Keytab /opt/nifi/keytab/etl.keytab /opt/nifi/keytab-cloud_emr/etl.keytab
Kerberos Password 留空 留空
Directory /user/etl_user/biz_a/${now():format('yyyyMMdd')} /user/etl_user/biz_a/${now():format('yyyyMMdd')}

关键点只有一句:Hadoop Configuration Resources 指向该集群那一对 xml,Kerberos Principal / Kerberos Keytab 指向该集群的 keytab。 两对配置互相独立,任何一对写错,表现出来都是"另一个集群写得进去、这个写不进去"。

Directory 两边写成一样是故意的------方便比对。核对双写是否成功,最直接的办法就是两边 hdfs dfs -ls 比文件数和大小。

6.4 三个坑

坑一:把 hadoop 的 jar 往 lib/ 里堆

现象 :想让双写的 PutHDFS 用目标集群的客户端版本,第一反应是把对方的 jar 拷进 NiFi 的 lib/。

做了什么 :往 lib/ 里加了这么一批:

markdown 复制代码
hadoop-common-3.3.2.jar
hadoop-hdfs-client-3.3.2.jar
woodstox-core-*.jar
stax2-api-*.jar
hadoop-lzo-0.4.21.jar

结果 :两种表现,取决于加了多少。加少了 PutHDFS 报 NoClassDefFoundError(缺传递依赖);加多了 NiFi 直接起不来,nifi-app.log 里是一串 IllegalAccessError / LinkageError,类冲突------因为 NiFi 自带一套 hadoop 客户端,你新加的 jar 和它版本不一致,同一个类被两个 classloader 加载。

处置 :把这些依赖从 lib 移出 ,集中放到 /opt/nifi/hadoop_lib 统一管理,然后回退了这次改动。回退之后双写用的还是 NiFi 内置的 hadoop 客户端 + 目标集群的配置文件,功能正常。

bash 复制代码
mkdir -p /opt/nifi/hadoop_lib
mv /opt/nifi/lib/hadoop-common-3.3.2.jar /opt/nifi/hadoop_lib/
mv /opt/nifi/lib/hadoop-hdfs-client-3.3.2.jar /opt/nifi/hadoop_lib/
chown -R nifi:nifi /opt/nifi/hadoop_lib
# 然后重启确认所有节点能起来

结论 :lib 目录不是放第三方 hadoop 依赖的地方。 能不加就不加;必须加,就记录清楚"哪几个 jar、为什么加、对应哪个目标集群版本",写进变更记录。下一次有人扩容或者升级,这份记录能救命------我们就是因为没记录,第二轮扩容时有节点被同步脚本带上了旧 jar 的副本,排查了一整晚。

坑二:目标集群的配置被本平台的同名文件覆盖

现象 :双写那个 PutHDFS 突然报:

复制代码
Configuration does not have security enabled, keytab and principal will be ignored

根因 :这句话的字面意思就是答案------它读到的那份 configuration 里 hadoop.security.authentication 不是 kerberos,所以 NiFi 认为"这个集群没开安全",把 keytab 和 principal 忽略了。

为什么读到的是没开 kerberos 的配置?因为有人把目标集群的 core-site.xml / hdfs-site.xml 直接放进了 /etc/hadoop/conf/,而那个目录是本平台的标准路径,被运维脚本按"统一配置"同步过一次,两台机器上的同名文件被本平台的版本覆盖了。

处置 :把目标集群的配置放在独立目录 (/opt/nifi/cluster/cloud_emr/),在处理器里显式指定绝对路径,不依赖 /etc/hadoop/conf。这条和 6.2 的目录约定是同一件事------不要用"默认路径"承载"非默认集群"的配置,默认路径迟早会被某个同步脚本覆盖。

坑三:属主不对,进程读不到

现象 :配置和 keytab 都放好了,PutHDFS 还是报 Failed to login 或者 Permission denied。

根因 :文件是用 root 拷过去的,属主是 root:root,权限 644。NiFi 进程以 nifi 用户跑,只能读------644 的读权限是有的,但 krb5.conf 和 keytab 的位置如果放在 700 的目录下,连目录都进不去。keytab 更严格:600 且属主必须是 nifi,否则 Kerberos 库在加载时会直接拒绝。

处置 :所有拷过去的配置文件、keytab 一律 chown nifi:nifi,keytab 600,配置 640,目录 750。这条要写进部署脚本,不能靠人记:

bash 复制代码
chown -R nifi:nifi /opt/nifi/cluster /opt/nifi/keytab-cloud_emr
find /opt/nifi/keytab-cloud_emr -name '*.keytab' -exec chmod 600 {} ;
find /opt/nifi/cluster -name '*.xml' -exec chmod 640 {} ;

6.5 结论

双写的难点不在 NiFi,在两个集群的身份与配置别串味。

两个 PutHDFS 是两套完全独立的身份:独立的配置文件、独立的 keytab、独立的 principal 全限定名。任何一项复用,表现出来都是"其中一个集群写不进去",而且报错信息往往指向认证失败,让人以为是 keytab 过期------实际上只是配置读错了。

7. 传输安全:S2S 与 HTTPS 怎么验

数据落地之后还有第二步:集群之间还要互传。站点到站点(S2S)和 Web 都走 TLS,但排查时经常只看了 9443,忘了 10443。

7.1 三个命令,一分钟验完

javascript 复制代码
# 1) Web 端口:证书链、CN、有效期
openssl s_client -connect nifi-node-01.example.com:9443 \
  -servername nifi-node-01.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# 2) REST 是否活着(-k 仅为跳过本地信任库,生产应导入 CA)
curl -k -s -o /dev/null -w '%{http_code}\n' \
  https://nifi-node-01.example.com:9443/nifi-api/access/config

# 3) S2S 端口握手(10443 是自定义 TLS 协议,能握上就说明证书互信没问题)
openssl s_client -connect nifi-node-01.example.com:10443 </dev/null 2>&1 | head -5

7.2 第三个失败说明什么

如果前两个正常、第三个失败,说明 S2S 用的信任库和 Web 不是同一套 :检查 nifi.remote.input.secure=true,以及处理器里绑定的 SSLContextService 是否指向正确的 truststore。

跨实例传数据时,还要在接收端给发送端的身份授 receive data via site-to-site(见第 3 篇),否则握上手也会被 403。

7.3 文件写完了吗:Parquet 完整性校验

前面所有环节都绿了,文件也出现在 HDFS 上了------但它可能是坏的。这是"数据对不对"里最隐蔽的一类。

现象

下游 Spark 任务失败,报错是这样的:

bash 复制代码
java.lang.RuntimeException: viewfs://ns1/user/etl_user/biz_a_parquet/day=20240101/hh=08/xxx.snappy.parquet
is not a Parquet file. Expected magic number at tail, but found [-48, -19, -53, 94]

注意 Expected magic number at tail, but found [...]:Parquet 文件的头和尾都是 PAR1 四个字节,它在文件的尾部没找到,读到的是四个随机字节。这意味着文件存在、有大小,但内容不完整。

根因

这类文件的共同特征:

特征 说明
文件存在 ls 能看到,不会报 missing
大小为 0 或只有半截 比同批次其他文件小一个数量级
footer 缺失 Parquet 是先写数据再写 footer,footer 没写完 = 文件不可读

成因是写入过程中断:节点重启、磁盘满、HDFS 超时(就是 5.3 节那个 waitForAckedSeqno)、或者 NiFi 处理器被强行停止。PutHDFS 在写的过程中被中断,HDFS 上留下的是已写入的块,不会自动回滚。

值得强调的是:这类故障在 NiFi 侧通常不会留下 ERROR。如果中断发生在连接层,处理器可能只是重试了一次然后成功写了另一份文件,坏的那份留在原地没人管。

三个校验命令
命令 查什么 期望
`hadoop fs -tail -c 4 hexdump -C` 尾部 magic number
`hadoop fs -cat head -c 4` 头部 magic number
spark.read.parquet("<path>") 能不能读出 schema 能读出才算通
bash 复制代码
# 尾部 4 字节,必须是 50 41 52 31
hadoop fs -tail -c 4 /user/etl_user/biz_a_parquet/day=20240101/hh=08/xxx.snappy.parquet \
  | hexdump -C

# 头部 4 字节,同样必须是 PAR1
hadoop fs -cat /user/etl_user/biz_a_parquet/day=20240101/hh=08/xxx.snappy.parquet \
  | head -c 4

部分 Hadoop 发行版的 -tail 不支持 -c 参数。遇到就换成 hadoop fs -cat <file> | tail -c 4 | hexdump -C,效果一样,只是会把整个文件读一遍,大文件慎用。

第三个用 Spark 验:

ini 复制代码
df = spark.read.parquet("viewfs://ns1/user/etl_user/biz_a_parquet/day=20240101/hh=08/")
df.printSchema()
df.count()

printSchema() 能出结果就说明 footer 是好的;count() 再确认一遍行数。对小文件,这一步比前两个命令更彻底------它会把所有 row group 的元数据都读一遍。

结论

NiFi 没有内置的"写完后校验 HDFS 文件"能力,只能靠下游读取校验、或者外挂脚本巡一遍。校验天然有延迟------下游不读,坏文件就一直躺着。

发现坏文件怎么处理:删掉重跑那个批次,不要试图修复。 Parquet 的 footer 缺失意味着你不知道文件里到底有哪些 row group,任何"截断修复"都是在猜。重跑的成本远低于猜错。

补一个 HDFS/Hive 侧的确认动作,用来核对分区与表结构是否对得上:

sql 复制代码
-- 表定义(分区字段、存储格式、压缩方式)
SHOW CREATE TABLE biz_a.biz_a_parquet;

-- 分区元数据:Location、InputFormat、压缩、分区值
DESCRIBE FORMATTED biz_a.biz_a_parquet PARTITION (day='20240101', hh='08');

DESCRIBE FORMATTED 的输出里要重点看三行:Location(是不是你以为的那个目录)、InputFormat(是不是 ParquetInputFormat)、Compressed(和写入端一致)。分区建错位置是另一类"文件明明写了但查不到"的常见原因。

8. 验证

bash 复制代码
# 1) HDFS 上有没有文件
hdfs dfs -ls /user/etl_user/biz_a/$(date +%Y%m%d) | tail

# 2) 文件是不是完整的 Parquet
hdfs dfs -cat /user/etl_user/biz_a_parquet/part-00000.parquet | head -c 4
# 期望输出 PAR1

# 3) 行数对比(源 CSV 行数 vs Parquet 行数)
hdfs dfs -cat /user/etl_user/biz_a/xxx.csv | wc -l
# 用 spark-sql 或 parquet-tools 数 parquet 行数,两者应一致(有表头时差 1)

# 4) NiFi 侧看队列与出处
#    UI → Data Provenance → 按组件过滤,能看到每个 FlowFile 的完整链路

行数对比那一步有个细节:wc -l 数的是换行符,如果文件最后一行没有换行符,会少算一行。源文件是上游生成的,这个不受我们控制,所以行数差 1 的时候先别急着报故障,先确认是不是最后一行没换行。

9. 这一块踩过的坑

  1. gzip 解压后文件名没变 Update Filename 没开,HDFS 上全是 .csv.gz,下游按 .csv 匹配读不到。

  2. 竖线分隔符 源文件是 |,CSVReader 默认逗号,解析出来只有一列。这个错误很隐蔽------不报错,只是所有字段挤在第一个 column 里。

  3. Avro schema 写成对象 fields 写成 {...} 而不是 [{...}],报 Schema Parse Failure。第 3.2 节那份是正确写法。

  4. PutHDFS 用密码而不是 keytab 密码方式在 Kerberos 环境里会走 ticket cache,节点上没 kinit 过就失败。统一用 keytab。

  5. MergeContent 把不同 schema 的文件合了 合完之后 Parquet 写入报 schema 冲突。合并前要用 RouteOnAttribute 按 schema.name 分组。

  6. MergeContent 合并不达预期 配了 Minimum Number of Entries=10,结果 HDFS 上还是一堆小文件;或者反过来,明明只想合 10 个,一次合了几百个。三个参数的语义必须先搞清楚:

    参数 含义 触发逻辑
    Minimum Number of Entries 一个 bin 里至少要几条 满足"所有最小条件"才开始合
    Minimum Group Size 一个 bin 的总字节下限 同上
    Maximum Number of Entries 一个 bin 最多几条 满足"任一最大条件"就立即合
    Maximum Group Size 一个 bin 的字节上限 同上
    Maximum Number of Bins 同时最多开几个 bin 超过上限时先合掉最老的那个 bin
    Max Bin Age bin 最长等多久 到时间强制合,不等最小条件

    规则是:满足所有最小条件才合,满足任一最大条件也合。 所以我们遇到的两种异常各有原因------小文件没合起来,是因为 Minimum Group Size 设得比实际文件总量还大,永远凑不满(后来发现要配合 Max Bin Age 兜底);一次合太多,是因为 Maximum Group Size 给得太大,而 Maximum Number of Entries 没设。

    还有 Concurrent Tasks:它大于 1 时会有多个 bin 并行,合并的"批次感"更弱,表现上像是"没按我设的条数合"。排查合并问题时先把它调回 1,看清楚单线程的行为再往上加。

  7. 坏数据整批失败 一个几十万行的文件,第 30 万行少一个字段,整个 FlowFile 失败、重试、再失败,最后进死信。业务当天没数据。详见 3.4 节------根因是坏行在 Record Reader 阶段就抛异常,没有"跳过这一行"的选项。

10. 小结

  • FTP→HDFS 这条链路,重点是 Update Filename、失败分支、keytab 权限;
  • CSV→Parquet 有两条路:ConvertRecord(推断)和 PutParquet + Schema Registry(强类型),生产上按字段稳定性选;
  • 队列背压、并发数、小文件合并,是三个立刻见效的调优点;
  • 验证不只看 UI 绿灯,要落到 HDFS 上数文件、数行数。

补一句这一轮扩写最想留下的话:漏文件、坏数据、损坏文件、双写,这四类问题都不在"流程连不上"的范畴,只能靠校验和对比发现。

这四类的共同点是:没有一条会在 NiFi 的 UI 上变红。 值班看板再漂亮,也不等于数据是对的。所以运维这套东西,最后拼的不是"会不会配处理器",而是"有没有一套不依赖眼睛的对比手段"。

下一篇是排错清单,把这套环境里遇到过的启动失败、登录失败、节点互挤、证书/票据错误按日志关键字逐条列出来。

相关推荐
数字新视界1 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
wafrdggh2 小时前
2026 商城小程序平台哪个价格合理?把跨境附加成本这个坑算透
大数据·小程序
昂永科技|非标件自动报价系统3 小时前
# 昂永(上海)科技非标件自动核价系统落地无锡先导,助力制造核价数字化
大数据·科技·制造
xiaomuzuirenzhen14 小时前
大专电子商务从事电商运营分析师需要考证吗?
大数据·数据库
大大大大晴天15 小时前
ReAct Agent 从原理到实现:让大模型学会思考、行动与观察
大数据·agent
大大大大晴天17 小时前
每天认识一个组件:Apache StreamPark
大数据
演艺大赏21 小时前
NiFi 的权限模型不是 RBAC:users.xml、authorizers.xml 与组件级策略
大数据
yuanyuan3341 天前
2026枣庄带检测报告伸缩门品牌测评|本地服务
大数据·数据库·人工智能·伸缩门