前几年帮一个数据团队评审他们的离线管道设计,场景很典型:业务库从几十张表涨到上千张,日增量从几十万条涨到上千万条,原先用数据库存储过程做的ETL任务,跑到第二天早上八点都结束不了,BI那边每天早上都在催数。团队负责人问我:是不是该上Hadoop了?该怎么上?
这个问题后来我又被问过很多次。Hadoop确实带来了海量存储和分布式计算的能力,但真正决定数据集成项目成败的,往往不是某个组件多厉害,而是整条ETL管道设计得是否合理——从数据接入、存储布局、加工模型,到调度和监控,每一环都有讲究。这篇就把我在Hadoop ETL项目里沉淀下来的实践判断和踩过的坑整理一遍,给正要搭离线数据管道的团队一个可参考的路径。
1. 先弄清楚:你的数据集成真的需要Hadoop吗
1.1 传统ETL在数据量激增后的三个典型"变形"
很多人以为传统ETL的问题是"跑得慢",其实慢只是表象。我观察到的变化通常有三个阶段。
第一个变形是抽取窗口不够用。业务库日增几百万行的时候,按时间戳增量抽取还算轻松;当单表涨到几亿行,全量抽取需要好几个小时,增量同步的where条件也越来越复杂,每次抽取都在跟业务高峰期抢数据库资源。这时候你会在慢查询日志里看到一堆来自数据抽取任务的SELECT,业务那边已经隐隐有意见了。
第二个变形是转换阶段开始拖垮源库。ETL里最重的操作通常是join、聚合、去重,如果这些转换直接在业务数据库上跑,CPU和IO会剧烈波动。我见过有人把订单表和用户表几亿行数据拉到数据库里做关联,跑了四十分钟,最后把前端业务接口的响应时间拉高了一倍。这种问题靠加索引解决不了,因为问题出在"把转换压在了在线系统上"。
第三个变形是存储成本与保留策略冲突。业务方会不断要求"历史数据能不能多留几个月",但数据库体量膨胀之后,备份、迁移、扩容的成本都在上升,最后只能忍痛删数据。等真需要回溯某天的问题数据时,发现已经没了,那时候的代价远超存储成本。
1.2 Hadoop ETL的适用边界:一张表看懂什么情况该上
Hadoop解决的核心问题,是让"存储"和"计算"从一套固定的系统里解放出来。HDFS提供廉价的横向扩展存储,集群的计算资源可以在离线时段集中发力,转换任务搬到独立集群上跑,不再干扰在线业务。但这不是说所有数据集成都应该无脑迁移到Hadoop。
我通常用下面这张表来快速判断:
| 判断维度 | 传统数据库方案 | Hadoop方案 | 选择倾向 |
|---|---|---|---|
| 数据规模 | GB级到TB级 | TB级到PB级 | 单日增量过千万行、总量过TB时值得考虑 |
| 数据种类 | 结构化表为主 | 多源异构、半结构化、日志、文件 | 数据源越杂,Hadoop优势越大 |
| 计算模式 | 固定报表、固定查询 | 临时探索、复杂关联、反复迭代 | 分析需求多变时更划算 |
| 保留周期 | 数月到一年 | 数年甚至永久 | 需要长期追溯历史数据的场景 |
| 团队能力 | DBA即可维护 | 需要数据工程师和运维协同 | 人力不足时别硬上 |
这里说句实在话:如果数据量一眼看到头,量级就在几百万行以内,业务分析也只有几个固定报表,那就不要为了"技术先进性"上Hadoop。分布式系统的运维复杂度是实打实的,节点监控、小文件治理、任务调度,哪一样都要有人长期维护,小团队硬上的结果往往是给自己找了一堆麻烦。
2. ETL与ELT的路线之争:计算下推如何重塑管道设计
2.1 从"做好菜再上桌"到"食材先进后厨":两种模式的本质区别
传统ETL(抽取-转换-加载)的顺序是:从源系统抽取数据,在进入目标系统之前完成清洗、关联、汇总等转换,然后把结果加载到目标存储。这种模式假设目标系统不擅长复杂计算,所以转换必须在外部引擎完成。问题在于,转换过程通常也依赖中间表,而中间表来回导来导去,管道会越长越笨重。
ELT(抽取-加载-转换)则完全反过来:先把原始数据完整加载到存储系统,把转换推迟到加载之后,由存储系统自身的计算引擎(比如Hive、Spark SQL)来执行。打个比方,传统ETL像"在路边摊把菜收拾干净再进餐厅",ELT像"整车食材直接运进中央厨房,再统一分拣加工"。
Hadoop生态里ELT成为主流,原因很现实。第一,HDFS存储成本远低于业务数据库,原始数据全量保留几乎没有压力;第二,分布式计算引擎的算力可以按需弹性扩展,复杂转换在离线集群上跑,不会影响在线业务;第三,原始数据一直躺在ODS层,哪天发现某个转换规则写错了,可以直接从源头重跑,而不需要重新去源系统抽一遍。数据可追溯性这个优势,做久了数据治理的人都会深有体会。
2.2 混合架构才是常态:一个订单数据集成项目的取舍
理论归理论,实际项目里我没见过纯ETL或纯ELT一条道走到黑的,更多的是混合方式。
举个做过的订单数据集成项目。源端是几个业务系统的MySQL库,日增量千万级,下游既要明细查询,也要T+1汇总报表。我们在抽取端只做最基本的处理:把日期字段统一格式、把明显的NULL值补默认值、丢弃系统标记的软删除记录。然后直接写入HDFS的ODS层,表结构跟源表一一对应,字段名保持一致。
真正的业务转换发生在加载之后。用Hive SQL把多个表的明细做标准化、维度关联、汇总,落到DWD层和ADS层。这个阶段的SQL可以反复调整、反复重跑,不影响已经落地的原始数据。为什么要刻意把抽取端做轻?因为抽取任务一旦做重了,里面塞了各种"当时觉得很重要"的业务判断,之后源头表结构一变更、业务口径一调整,排查问题的时间会成倍增加。
但到最终报表回导关系型数据库时,我们又切回了"转换+加载"的传统模式——从ADS层查出结果,转成目标库需要的结构,再执行写入。这个场景数据量不大,目标库也不适合跑复杂计算,传统ETL反而是最优解。总结一句话:架构不要教条,数据量大的环节用ELT思路,数据量小、目标系统能力弱的环节用ETL思路,学会混搭才是做工程的态度。
3. 技术选型与管道落地:接入层、加工层与存储格式
3.1 数据接入:离线批量、增量同步与日志流怎么配合
数据接入大概分三类,每一类的选型逻辑都不一样。
离线批量抽取,最简单的方案是用Spark SQL直接读JDBC写Hive。很多人还在沿用Sqoop,但Sqoop维护成本高、性能一般,新项目我基本不建议引进了。直接用DataFrame指定分区条件,比如按业务日期分批拉取,写起来还更灵活。这里有一个关键点:抽取SQL一定要带上增量条件,不要每次都全表扫描,不然随着数据量增长会越来越难跑。
增量同步我建议走日志捕获的路线。业务库开启binlog后,用Canal或Debezium这类工具把变更事件解析出来,发送到Kafka,下游消费者负责把变更写成Hive分区表。链路看起来长,但每一步都清晰可控:binlog解析、消息队列削峰、落库幂等,每一环出问题都能单独定位。刚开始没条件上Kafka的话,至少也要保证增量抽取的任务记录好上次同步位点,不然漏数、重数都难排查。
日志流接入相对简单,Flume或Filebeat采集日志推送到Kafka,再由流处理任务攒批写入HDFS。我不建议每条日志都触发一次文件写入,那样会在HDFS上产生海量小文件,后续查询和NameNode压力都受不了。正确的做法是控制攒批间隔和文件滚动大小,比如每5分钟或每128MB滚动一次。
整个接入层落地的统一原则是:所有数据都先进ODS层,表名带业务域和来源标识,分区按天。这样不管源头系统怎么变,下游只认ODS这一层接口,至少能挡住一半的管道维护压力。
3.2 加工层是SQL优先还是编码优先
加工层的技术选型,我内部有一条很明确的原则:能用SQL表达的,绝不写代码。
SQL的优点是声明式、可读性强、维护成本低。Hive SQL和Spark SQL已经把大量分布式计算的复杂度封装掉了,一个join、一个窗口函数,翻译成代码可能需要几十行,而且代码里的bug概率远高于SQL。实际团队里,SQL写得好的人容易招,Spark代码写得明白还能兼顾性能的人难找,从团队可维护性考虑也是SQL优先。
编码的适用场景主要集中在三块:复杂UDF、机器学习特征加工、以及需要细粒度控制Shuffle策略的逻辑。比如要解析一段非结构化的埋点数据、要跑图算法,SQL很难优雅表达,这时候用Spark DataSet写业务逻辑比较合适。但我的建议是,即便用代码,也把中间结果先落到临时表,再用SQL做下一步关联,这样每一阶段都能跑可验证的检查,问题定位会快很多。
两类方式对比:
| 维度 | SQL优先 | 编码优先 |
|---|---|---|
| 维护成本 | 低,业务人员也能看懂 | 高,依赖核心开发 |
| 性能调优 | 依赖引擎优化器,整体稳定 | 可控性强,但容易写歪 |
| 复杂逻辑灵活性 | 一般,复杂场景受限 | 强,几乎无上限 |
| 排查效率 | 直接看执行计划,快速定位 | 需要看日志、断点,成本高 |
3.3 存储格式选择:Parquet、ORC和压缩方案
存储格式这个选择,直接决定了后续所有查询的体验。列式存储是标配,这点没有争议。Parquet和ORC之间怎么选,我按场景来:
| 对比项 | Parquet | ORC |
|---|---|---|
| 生态兼容性 | 几乎所有引擎都支持 | 主要优化在Hive生态 |
| 复杂嵌套类型 | 原生支持好 | 支持但略逊 |
| Hive查询性能 | 优秀 | 更优,尤其扫描和聚合 |
| Spark写入便利度 | 很好 | 也可以,但优化配置多一点 |
如果仓库以Hive为主、查询引擎固定,选ORC不吃亏;如果集群里有Spark、Presto/Trino等多种引擎混用,或者未来可能跨引擎,Parquet更稳,至少不用担心某个引擎对ORC的兼容踩坑。
压缩方案我通常选Snappy或Zstd。Snappy解压速度快、兼容性好,适合日常大多数表;Zstd压缩比更高,能省存储,但需要确认集群所有节点都装了对应库。磁盘紧张、查询频率不高的归档表可以用Zstd,核心高频查询表用Snappy更稳。
建表DDL我习惯把压缩属性直接写进去,避免后人忘记设置:
CREATE TABLE dwd_order_detail ( order_id STRING, user_id STRING, sku_id STRING, pay_amount DECIMAL(10,2), create_time STRING ) PARTITIONED BY (dt STRING) STORED AS PARQUET TBLPROPERTIES ('parquet.compression'='snappy');分区字段放dt,一是符合绝大多数增量场景,二是后续删除、覆盖、重跑都按分区粒度操作,干净利落。
4. 调度、监控与数据质量:让管道长期可靠的三根支柱
4.1 工作流编排:从脚本堆任务到DAG依赖
管道刚起步的时候,我也用过crontab配shell脚本,简单省事。但任务数一多,问题马上来了:上游数据没准备好,下游任务就跑空了;某个任务重试次数没设好,失败后下游还继续跑;临时补数要手动改一堆脚本,稍不注意就把数据算错。
后来切换到工作流调度系统,核心变化是任务之间从"脚本互相调用"变成"DAG依赖"。上下游关系清清楚楚写在配置里,每个任务可以单独设置超时、失败重试、重试间隔。调度层次的典型做法是:ODS层任务完成后触发DWD层,DWD层完成后再触发ADS层。每一层内部可以并行跑,层与层之间严格依赖。
实际使用中有一个细节:不要只看"任务是否执行成功",还要看"数据是否就绪"。有的任务跑完只花了十秒,但读到的上游分区是空的,这种成功是假成功。早期的做法是在任务里加前置检查,比如确认上游表当日分区存在且行数大于阈值,再启动本任务。现在很多调度系统支持依赖检查和分支判断,值得用起来。
4.2 监控指标与数据质量规则怎么定
调度跑起来了,接下来就是监控。我见过不少团队把监控做成"任务失败发邮件",这远远不够。真正有效的监控至少要覆盖三个层面:
- 任务运行指标:耗时、失败率、重试次数、资源使用率
- 数据量波动:每天各表分区行数、大小,同比环比超过阈值要报警
- 数据质量规则:主键唯一性、空值率、枚举值分布、金额字段的负值检测
我举一个实际发生的事。某个明细表某天突然比前一天少了20%的行数,任务却全部显示成功。要不是配置了行数波动告警,这个问题至少要等业务方来反馈才能发现。后来查原因,是上游某个业务系统的历史数据被批量归档,同步位点没覆盖到归档数据,导致少抽了一部分。数据量波动监控能帮你把这类问题从"被动挨骂"变成"主动发现"。
数据质量规则分成两类来定:阻断型和非阻断型。主键唯一性、核心字段空值率这类,一旦不满足说明管道已经出问题,会影响下游,应该阻断任务继续执行;枚举值分布异常、字段值超出合理范围这些,可以先发告警,让数据团队判断是否影响业务,不急着阻断。全堵死的后果就是告警疲劳,最后大家都不看告警了。
4.3 幂等与重跑:最容易被忽视的工程细节
这个坑我踩过很多次,值得单独拿出来讲。所谓幂等,就是同一个任务、同一份数据,无论重跑多少次,最终结果都是一致的。分布式环境里任务失败太常见,而重试机制如果没有幂等做保障,"重试"就会变成"重复"。
先说写入策略。离线表所有写入都应该是"分区覆盖写"而不是"追加写"。先删除目标分区的数据,再写入新数据,这样无论任务重跑几遍,分区里都只有一份结果。我见过有人用Append模式跑任务,某次网络超时导致部分数据写入,任务重试后又写了一遍,结果报表数据直接翻倍,排查了大半夜才找到原因。
还有一个很容易被忽略的场景:上游表重跑之后,下游要不要跟着重跑?答案是必须。上游分区数据变了,下游如果还是旧结果,整条链路的数据就断裂了。比较稳妥的做法是保留ODS层的原始数据快照,上游一重跑,下游所有层按依赖顺序链条式重算。这一点在调度设计之初就要考虑好,否则每张表都是独立任务,没有层间联动,补数的时候只能靠人肉通知。
5. 实战排雷:小文件、数据倾斜与脏数据
5.1 小文件问题:分布式系统嘴里的一粒沙子
小文件问题几乎是Hadoop数据集成里出现频率最高、也最容易让新手焦头烂额的问题。原理很简单:HDFS在NameNode里为每个文件维护元数据,一个128MB的块是条元数据记录,一个1KB的小文件也是一条元数据记录。文件数量上百万之后,NameNode内存先撑不住;处理任务时,每个小文件都至少对应一个Map任务或Spark分区,大量task被空转调度,整体性能断崖式下跌。
小文件从哪里来的?最常见的两个源头:一是动态分区写入,某个字段的枚举值特别多,每个枚举值落了一批文件,每个文件又很小;二是流式任务频繁落地,每隔一两分钟就写一次文件,一天下来几千个分区文件。
治理方案分事前和事后。事前控制分区粒度和攒批逻辑,比如不是所有表都需要小时级分区,T+1报表用日分区就够了;流式落地时把攒批间隔调到5分钟以上,按64MB或128MB滚动文件。事后定期跑合并任务,把同一分区的多个小文件重写成大文件,可以用Spark的repartition或coalesce控制输出文件数。
Spark 3.0之后开启自适应查询执行也能自动处理一部分问题:
SET spark.sql.adaptive.enabled=true; SET spark.sql.adaptive.coalescePartitions.enabled=true; SET spark.sql.adaptive.coalescePartitions.minPartitionNum=1; SET spark.sql.adaptive.advisoryPartitionSizeInBytes=128MB;这套参数能让Spark在shuffle阶段自动合并过小的分区,很多由小文件引起的性能问题能直接缓解。
5.2 数据倾斜:卡在99%的任务不是巧合
另一个高频事故是数据倾斜。表现非常典型:Spark UI里同一个Stage的大多数Task几秒就跑完了,剩下几个Task卡在那儿几十分钟;或者Hive跑一个join,进度停在某个百分比死活不动。
根因就是某些key的分布严重不均。比如日志清洗项目里按用户ID做聚合,少数几个大用户产生的日志量是普通用户的上万倍,计算任务被压在那几个reduce节点上,其他节点空闲等待。
定位的方式比较固定:看Spark UI里某个Stage的Task耗时分布,再看到对应Task处理的数据量;或者看某一个Stage的shuffle read bytes是否严重偏大。只要发现某个key对应的数据量异常,就可以锁定目标。
处理思路也很成熟。对于group by倾斜,用加盐两阶段聚合:
from pyspark.sql import functions as F # 第一阶段:给每个key加一个随机盐,分10个桶 df = df.withColumn("salt", (F.rand() * 10).cast("int")) grouped = df.groupBy("salt", "user_id").agg(F.sum("amt").alias("partial_sum")) # 第二阶段:去掉盐,再做一次聚合 result = grouped.groupBy("user_id").agg(F.sum("partial_sum").alias("total_sum"))对于join倾斜,优先考虑广播小表,把尺寸不大的一方用broadcast广播到每个Executor内存里,彻底避免shuffle。如果大表join大表也有热点,可以把热点key单独提取出来,拆成"正常key的join + 热点key加盐后的join"两条路径,最后union回去。操作起来繁琐一些,但逻辑不复杂,属于标准解法。
5.3 脏数据治理:ODS层原始保留的价值
最后聊脏数据。很多团队喜欢在抽取端就把"看起来不对"的数据过滤掉,比如类型转换失败就丢弃、某个字段值为NULL就补默认值。我特别反对在抽取阶段做这些判断,原因不是这些判断没价值,而是抽取阶段你根本不知道"脏"的定义是什么。
同一份数据,业务A可能觉得空值没关系,业务B觉得空值必须过滤;今天觉得某个字段异常,明天业务规则一变,它又变成正常值了。如果抽取阶段就做了取舍,原始数据一旦被丢弃,后面想重新计算都没有依据。
正确做法是ODS层原样保留,让"脏"与"不脏"的判断发生在DWD层。我在项目里多次遇到这样的情况:某开发说"上游数据太脏了",结果顺着ODS层一查,发现是上游某个业务系统升级后,某个字段的含义发生了变化,数据本身没毛病。ODS层留着原始数据,这种对比排查几分钟就能完成,要是早就被过滤掉了,整个团队可能要为这个问题浪费半天。
清洗规则统一写在DWD层的ETL SQL里,同时配上质量检查:空值率、主键唯一性、枚举值合理性。这样每张表的处理逻辑都是显式的、可审查的,而不是藏在抽取脚本里没人敢动。数据管道这个事,看起来拼的是引擎性能,真正拉开差距的其实是这种原始数据留存和分层治理的工程习惯。现在每设计一条新管道,我都会先问自己三个问题:重跑是否幂等?有没有质量监控?上游原始数据随时可取吗?这三个问题想清楚了,管道基本不会跑偏。