1. 两种流处理路线的定位差异:一个像"中转站",一个像"仓储"
先说一个我前段时间的真实经历。当时某公司要上一套实时大屏加实时明细查询,开发群里直接吵起来了。A同学说用Kafka接Spark Streaming,把计算结果写Redis和ES,链路短、见效快;B同学说既然有实时明细查询这种事,干脆Kafka直接进ETL管道,落到Kudu里,后面让Impala跑SQL,想怎么查怎么查。两边谁也说服不了谁,最后架构评审的时候,我发现一个核心问题:大家争论的根本不在一个维度上。
Kafka+Spark Streaming和ETL+Kudu,这两套东西看着都是"大数据常用实时流处理方式",名字也经常出现在同一个项目的技术选型文档里,但它们的侧重点完全不一样。前者解决的是"数据从产生到算完",后者解决的是"数据从产生到能用"。一个偏重计算流转,一个偏重存储服务。
拆开看整个实时链路的通用骨架,其实就三层:数据接入层,负责把业务日志、埋点、binlog这类源源不断产生的数据搬到消息中间件里;计算层,负责把原始数据按业务规则加工成指标、宽表或者清洗后的明细;服务层,负责把计算结果暴露给报表、大屏、算法、下游应用去用。
Kafka+Spark Streaming这套组合的典型结构是:Kafka当中转站,Spark Streaming不断从中拉数据,算完之后推到业务能用的存储里,可能是Redis、ES、MySQL、ClickHouse,也可能是HBase。整个过程强调"算",数据在Kafka和计算引擎之间流动,最终落到哪取决于下游要什么。
ETL+Kudu这套组合的典型结构则是:Kafka照样当入口,但计算引擎(通常是Spark Streaming或Structured Streaming)干完清洗和转换之后,把数据按主键合并进Kudu表。Kudu不只是一个"落地的地方",它本身是列式存储,具备主键更新能力,还能配合Impala或Spark SQL提供近实时的分析查询。数据在这里不是"算完就走",而是"落下来随时能用"。
我打个比方吧。Kafka+Spark Streaming像是航空货运中转站,航班到了,立刻开箱分拣、装车转运,效率很高,但货物在中转站只是过个手,并不负责存放。ETL+Kudu像带冷藏仓库的配送中心,货物到了先入库、上架、贴标,之后不管是门店要补货还是顾客要查单,随时都能翻出来。
这个定位差异不是谁好谁坏,而是决定了后面所有的技术取舍。如果只做实时指标计算,你不一定需要Kudu;如果还要做实时数仓、要查明细、要改数据,那光有Spark Streaming是不够的。这也是为什么总有人问"实时流处理到底用哪种方式",因为答案本来就不在单纯的技术栈里,而在业务场景里。
我先把两条路线各自的原理和落地细节讲透,最后再给出一套可以照着做的选型判断逻辑。这样无论你是刚开始接触实时计算,还是已经在生产环境里被这两种方案折腾过,都能有所参考。
2. Kafka+Spark Streaming:微批架构为什么能在生产环境站稳脚跟
2.1 所谓"实时",其实是攒够一批再算
很多刚接触实时计算的人有个误解,觉得Spark Streaming和Flink一样,是一条数据进来算一条。不是的。Spark Streaming的核心思想叫微批(Micro-Batch):把持续不断的数据流按时间窗口切成一个个小块,每块做成一个RDD,然后交给Spark的批处理引擎去跑。
举个例子,你设置batch interval为5秒,那么Spark Streaming每5秒把Kafka里新到的数据打包一次,触发一次Spark任务。这5秒就是延迟的下限:数据产生到可见,至少要等一个批次攒完。生产环境里我见过把间隔压到500毫秒的,但少见,因为Spark的任务调度本身有开销,批间隔太小,光调度就吃掉大量资源,处理能力反而下降。所以Spark Streaming的实时性通常用"准实时"来描述,不是毫秒级,而是秒级。
但微批有个天然的好处:它借用了成熟稳定的Spark批处理框架。Spark的容错、内存计算、资源调度、SQL支持,全部可以平移过来。相比纯流引擎,这套东西更容易上手,也更容易在已有的Spark离线数仓团队里推广。现在很多公司的实时链路,其实就是"离线团队顺手把Spark Streaming也接了",这也是它至今仍有大量生产环境用户的原因。
2.2 Direct方式与背压机制:Kafka和Spark到底怎么配合
Kafka和Spark Streaming对接,早期版本流行Receiver方式:Spark里起一个Receiver常驻线程,不断从Kafka拉数据,先存到executor的内存里,再由Spark Streaming去处理。这种方式有个毛病——Receiver写入WAL(预写日志)才保险,否则executor挂掉会丢数据,而且Receiver接收入口容易成为瓶颈。
后来生产环境主流都切换到Direct方式,逻辑上很简单:不再有Receiver,Spark Streaming的每个batch直接以Kafka分区为单位创建RDD分区,主动从Kafka拉取对应offset范围的数据。这样有几个直接好处:数据不经过executor内存中转,天然少一层故障风险;Spark的并行度能直接对齐Kafka分区数;offset可以通过checkpoint持久化,容错时能恢复到上次的位置。
Direct方式下,Kafka在这条链路里的角色就非常清晰了——削峰填谷的缓冲池。业务高峰时,下游一时算不过来,Kafka先扛住积压,等服务恢复后再追赶进度,不会直接压垮后端。而且Kafka可以保留一段时间的消息,下游可以随时从任意offset重新消费,做数据回溯和修复非常顺手。
生产环境配置里,有两点值得特别注意。第一是务必开启背压(backpressure)机制,让Spark Streaming根据实际处理能力动态调节消费速率,而不是一股脑把上游数据全拉进来。第二是合理设置spark.streaming.kafka.maxRatePerPartition,这个是单分区每秒最多消费的消息数上限,防止个别批次拉取过多数据导致处理超时。
拿一个真实项目的参数举例:
spark-submit \ --master yarn \ --deploy-mode cluster \ --executor-memory 8G \ --executor-cores 4 \ --num-executors 20 \ --conf spark.streaming.backpressure.enabled=true \ --conf spark.streaming.kafka.maxRatePerPartition=800 \ --conf spark.streaming.backpressure.rateEstimator=pid \ --class com.example.RealtimeIndicatorJob \ realtime-job.jar这个配置里,maxRatePerPartition=800不是拍脑袋定的。当时线上Kafka的topic是24个分区,峰值每秒进入的消息总量约1.2万条,平均每条日志在1KB以内。1.2万除以24,每个分区每秒约500条,再留出50%左右的余量应对突刺,于是取800。如果设得太小,消费能力受限,积压会越来越严重;设得太大,背压机制又失去了保护意义。通常的做法是拿峰值估算值乘以1.2到1.5,先跑几天监控,再看积压和延迟曲线微调。
2.3 恰好一次语义:不是简单靠一个配置就能实现
需要提醒的是,生产级实时链路绕不开"数据会不会重复、会不会丢"这个终极问题。很多人在PPT里写"exactly once",以为是某个参数打开就自动生效,其实它是一条链上多个环节配合出来的结果。
在Kafka+Spark Streaming这套链路里,至少要同时满足三个条件。第一,源端offset要可管理,也就是上面提到的Direct方式,自己维护Kafka offset。第二,计算状态要可恢复,借助Spark Streaming的checkpoint机制,把当前处理进度和状态保存到HDFS或S3。第三也是容易被忽略的——输出端要幂等。比如写Redis可以设置带时间戳的key,写HBase可以用业务唯一键做主键,写MySQL可以在写入时做"先查再插或更新"。
我在实际项目里见过不止一次:前面两台机器都做好了,输出端是普通的消息队列或API,一旦重试就直接重复推送,下游数据翻倍。最后没办法,只能在下游加去重表兜底。所以我在设计这类链路时,都会先问一句:最下游能不能容忍重复?如果不能,那输出端必须选支持幂等写入的系统,否则不要宣称自己做到了exactly once。
Kafka加Spark Streaming这套方案,适合结果以追加型指标为主、不需要频繁修改已有数据、下推算完就能用的场景。告警计算、流量统计、行为分析、简单清洗转发,这些用起来非常顺手。但要涉及明细数据的更新、删除、回查,它就不是最佳答案了,这时候该轮到Kudu登场。
3. ETL+Kudu:从"算完丢掉"到"落得下、查得快"的实时数仓方案
3.1 Kudu到底解决什么问题
先说Kudu是个什么角色。大数据存储里过去有个尴尬的断层:HDFS能存海量数据,跑批量分析很稳,但随机读写和按主键更新基本做不了;HBase支持随机读写,但分析场景不擅长,Scan大范围数据时性能差。你要建一张实时订单表,既要不断按订单号更新状态,又要支持按时间范围跑聚合查询,传统方案得同时维护HDFS上的数仓和HBase里的实时表,数据双写不说,两边的口径还容易对不上。
Kudu就是想把这个断层抹平。它是一种分布式列式存储引擎,底层用的不是HDFS而是自己的Tablet存储格式,按列存储,天然适合分析查询;同时又支持行级的主键更新、插入、删除,随机读的性能比HDFS加Parquet好得多,批量分析能力又比HBase强。放在实时的语境里理解,它就是一个能"边写边查、按主键更新"的分析型存储。
所以要理解标题里的ETL+Kudu,关键不是纠结ETL这个词是不是离线用的,而是要看清楚:这个技术栈里,Kafka负责攒数据,Spark系引擎负责清洗和转换,Kudu负责把结果以可更新、可查询的形式落下来,最后再用Impala或Spark SQL直面报表和分析应用。整个链路本质上是不可断的实时ETL管道,跟传统离线ETL最大的区别是,它持续在跑、持续在更新数据,而且更新后的结果几乎立即可查。
3.2 流式写入Kudu的工程化细节
从Kafka到Kudu,中间的计算引擎既可以用老牌的Spark Streaming,也可以直接用Structured Streaming。两者的核心逻辑差别不大——消费Kafka的topic,做必要的数据解析、格式规范化、字段过滤、维表关联,然后按主键写Kudu,Kudu内部会自动执行upsert:主键命中就更新,不命中就插入。
Kudu有一类坑是HDFS上没有的。比如每个Tablet的分区数在建表时就固定了,之后不能改,所以建表前得认真估算数据量;主键也是建表后不可变,业务主键一旦选错后面很难收拾;写入侧如果不做批量提交,一条一条往里塞,吞吐惨不忍睹。我实际用下来的参数是:Spark任务里用KuduContext配合mode("append"),但这种方式无法更新已有主键,要走upsert必须用mode("upsert")这种语义,把一组DataFrame按主键入库。
生产环境我曾经在订单回流场景里这么建表:
CREATE TABLE realtime_order ( order_id STRING PRIMARY KEY, user_id STRING, status STRING, pay_status STRING, total_amount DECIMAL(12, 2), province_id INT, update_time BIGINT, create_time BIGINT ) PARTITION BY HASH (order_id) PARTITIONS 16;这个表每天大概新增5000万条订单,状态会多次变更,下游要按省份、支付状态、金额区间做实时聚合。这里必须用哈希分区把order_id打散,避免写入热点集中在少数Tablet上。分区数16,是按照当时集群有8个Tablet Server,单个Tablet承担日均300万以上数据量来估的。别贪多,分区数设成64甚至128,看着很"分布式",实际上是让每个Tablet都在空转,反而浪费内存和CPU。
写实时表的时候还有一个关键点:字段类型和精度先定义好。Kudu的列类型调整成本很高,一个DECIMAL精度改小会导致数据写入失败,改大要重组表,生产环境基本只能重建。所以主键选型、枚举字段长度、金额单位这类决策,一定要在上线前跟业务确认清楚,否则后患无穷。
3.3 近实时查询与分析体验
Kudu落地之后,数据服务能力才真正体现出来。Kudu本身不带SQL引擎,但Impala和Spark SQL都能直接读它。日常使用中,报表查询基本都走Impala,因为Impala是常驻进程,查询启动开销小,跑几百毫秒到几秒的分析SQL都很常见。用户要查实时订单明细,直接拼一条SQL:时间范围过滤,加上订单状态、省份等条件,一秒内出结果,体验跟查一个普通数据库差别不大。
这一步对业务的意义非常大。Kafka+Spark Streaming直接算指标,出来的结果是一个固定的汇总值,业务想换个维度看,得改代码、重新上线。而Kudu存的是明细,业务想怎么看就怎么写SQL,自助分析完全放开。这就是"算完丢掉"和"落得下、查得快"的本质差别:前者把答案给你,后者把问问题的能力给你。
我这几年负责的实时数仓项目,只要牵扯到"实时明细可回溯""宽表实时更新"的需求,最终基本都落到Kudu这一层。相比曾经用HBase扛类似业务的方案,Kudu让我最满意的一点是——查询引擎和存储引擎之间不需要自己写复杂的rowkey设计,SQL直接上,开发效率不是一个量级的。
4. 选型决策:什么时候用Kafka+Spark Streaming,什么时候用ETL+Kudu
4.1 先别急着比组件,先对业务做五个排查
很多人做技术选型,喜欢把Kafka、Spark Streaming、Kudu、ETL这几个名词放在一起堆,然后争论哪个框架更强。以我实际评审项目的经验,这种比法经常争不出来结果。真正高效的姿势,是把业务需求拆成下面五个问题逐一核对。
第一,结果形态是追加型还是更新型?实时告警、访问量统计这类,数据只会不断追加,不会回头改老数据,用Kafka加Spark Streaming就够了。订单状态、库存余量、用户画像标签这类,老数据会反复更新,就一定要有一个支持主键变更的存储,Kudu的upsert天然契合。
第二,下游要的是"固定指标"还是"任意切片查询"?如果报表的维度都是提前定死的,Spark Streaming算好指标直接投喂,链路短且稳定。如果业务说"我还没想好要怎么看,反正先存着",那必须有明细层,Kudu是更稳妥的选择。
第三,延迟能不能接受秒级?两条链路整体都是秒级延迟,但Kafka+Spark Streaming如果只算指标,端到端可以更快些;走了Kudu之后,写入和合并会有少量额外开销。如果你的业务要毫秒级延迟,这两条路线都不合适,那是别的水系统的事了。
第四,团队已有的基础设施是什么?如果公司已经有了一套成熟的Spark集群,那两种方案的边际成本都比较低。如果还在早期搭建阶段,多引入一个Kudu集群,意味着运维上多一套东西要盯,tablet滚动重启、compaction压力、master高可用,都是新增的日常工作。
第五,是否要求数据服务能力?Kudu最明显的优势就是查询开放性好,面对临时分析需求不需要每次都发版。如果实时数据的消费方式非常固定,都是下游程序语言里直接调接口,那Kudu的SQL能力对你来说就不是第一优先级。
4.2 两张表看清两条链路的边界
把这些考察维度列成表格,会直观很多:
| 对比维度 | Kafka + Spark Streaming | ETL + Kudu |
|---|---|---|
| 端到端延迟 | 秒级,指标链路可压到秒内 | 秒级,需算上写入合并时间 |
| 结果更新能力 | 无原生更新能力,需外接数据库 | 原生按主键upsert |
| 明细数据回溯 | 基本不保留 | 明细长期保留,支持按条件查 |
| 即席查询 | 不支持,需二次加工 | 配合Impala/Spark SQL直接查 |
| 下游接入方式 | 写固定指标或消息,接口简单 | 提供SQL接口,业务自助分析 |
| 运维复杂度 | 组件较少,调优点集中在Spark | 多一套Kudu集群,需管理tablet |
| 典型适用场景 | 实时告警、指标计算、清洗转发 | 实时数仓、订单状态、实时宽表 |
看完这张表,可以得出一个简单的倾向性判断:如果业务只要求"实时算出一个数",优先Kafka+Spark Streaming,不让存储复杂度掺和进来;如果业务要求"实时数据既要算也能查,还要按主键改",那就别硬挺了,直接上ETL+Kudu。
不过这里要说一句公道话:这两条路在实际生产里不是二选一的互斥关系。我参与过的实时数仓项目里,很多最终是组合拳——Kafka接Spark Streaming,先把高价值指标实时算出来供大屏使用,同时把加工后的明细按主键upsert进Kudu,供业务做自助查询和分析。这样既保住了实时链路的计算速率,又满足了数据服务的灵活性。所谓架构这事,本来就是哪块缺不了就往哪块补,没有谁规定一个项目只能用一种路线。
5. 生产踩坑实录:两条链路各自容易翻车的细节
5.1 Spark Streaming侧:积压、恢复和速率控制
第一个坑是批间隔设置拍脑袋。有的同学为了追求"更实时",把batch interval从5秒调成1秒,本来以为延迟会降低,结果运行半小时后,每个batch的处理时间反而超过批间隔,Spark作业永远在追赶上一批数据,积压越滚越大。从Spark UI上看,processing time一直大于batch duration,lag持续上涨。正确做法是先看单批数据处理耗时,留出30%以上的余量再定间隔。比如单批处理耗时在1.5秒,批间隔至少设到3秒,而不是1秒。
第二个坑是checkpoint目录的兼容性问题。Spark Streaming的checkpoint会把当前应用的序列化对象存下来,恢复时直接反序列化。一旦你的作业代码改了类结构,或者升级了Spark版本,老checkpoint根本加载不了,任务一启动就反序列化失败。而且有些错误的恢复逻辑还会导致从老状态继续跑,数据错乱。这件事我踩过不止一次:新逻辑要上线,我把checkpoint目录换掉了,丢了一次状态,结果从Kafka重新消费老数据,中间一段指标就重算了一遍。所以我现在做这类改造时,会把checkpoint当成"状态灾备"来看待:能保留就保留黄金副本,不能保留就必须接受上游数据重放带来的后果。
第三个坑是背压参数和maxRatePerPartition的关系没理清。开背压是为了在积压时自动降低消费速率,设maxRatePerPartition是为了限制单个分区消费上限。两者可以一起开,但不能互相替代。有一次我把maxRatePerPartition设得很小,以为能靠它挡住流量尖峰,结果背压一启动,消费速率一降再降,直接降到个位数,积压了快一个小时的量。调整思路应该是:maxRatePerPartition按峰值估算留余量,背压负责在突发异常时把速率往回收,两者一个管上限、一个管动态调节。
5.2 Kudu侧:分区、主键和写入吞吐
Kudu这边最常见的坑就是分区数估算失误。哈希分区在建表时就锁死了,后面不能动态调整。分区数少了,单个Tablet数据量和写入压力过高,会被拆得七零八落;分区数多了,每个Tablet都要占内存和CPU,整条写链路性能直线下降。我见过有人把一个预计每天2000万行的表建了128个分区,结果每个Tablet的数据量小得可怜,查询时Task还要在各个Tablet之间穿梭,性能比不分区的HDFS还要差。
主键设计则是最隐蔽的坑。Kudu的主键有个天然约束:建表后不能改,写入时也不支持修改主键本身。有些人以为upsert和HBase类似,主键变了也能覆盖,其实Kudu的更新只能改主键以外的列,主键变了就是一条新数据,旧数据还在。更麻烦的是主键选得太长,比如拿整个JSON字符串当主键,写入时每条数据的索引开销大得吓人,跑的写入任务慢到怀疑人生。所以建实时大表前,务必把主键选短、选业务稳定、选不会反复变化的字段。
第三个事是写入吞吐上不去的排查。最开始用Kudu的Java客户端往里面灌数,run起来发现每秒只能写几千行,后来翻官方文档才发现没做批量flush——客户端默认要攒到一定大小或者达到设定的超时时间才真正提交。调了flush阈值、调大了worker线程数、把tablet数量按数据量重估后,吞吐才正常起来。Kudu的单写路径限制不算宽,很多性能问题都出在表设计和客户端参数上,别一上来就怀疑集群配置。
5.3 一个组合场景的完整排查链路
说一说让我印象最深的一次事故,因为那个场景恰好把两条链路的坑串到了一起。
某项目实时订单表原先跑得好好的,有一天Kafka积压突然飙升到几百万条。我第一时间看了Spark Streaming的batch处理时间,正常;再看Kudu的写入日志,发现Tablet Server频繁刷写,磁盘IO已经冲到接近饱和。再深入查,发现这个表的分区数当时建少了,赶上业务做了一场大促,订单量翻了三倍,单区块写入压力指数级上升,Kudu来不及合并,Spark端往Kudu直插的吞吐被拖到极低,积压就堆到了Kafka上。
排查的顺序说白了就是"先看积压在哪,再看阻塞在哪,最后看源头为什么慢"。当时我们从Kafka的lag往前推,发现消费端offset完全没动,就怀疑是下游写入阻塞,然后切到Kudu的监控页看Tablet写入延迟和刷写次数,才定位到分区数不足这个根因。缓解措施是把临近的大促域数据先分流到另一张临时表,缩减单表压力,中期方案是重新设计分区,按更细的维度打散后再上线。
这套过程里最值得说的经验是:实时链路出问题,不要只看一个组件。Kafka、Spark Streaming、Kudu之间是串联的,任何一个环节的积压,都会层层传导。排查的时候一定从消息积压点开始,逐级往下游摸,看数据卡在哪个环节,再回头分析那个环节的瓶颈参数。
6. 我的个人判断与一个实用小技巧
做了这么多实时项目之后,我个人对这两种流处理方式的体会是:它们不是竞争对手,而是不同需求层次对应的不同工具。
如果你只需要实时算指标、告警触发、快速清洗转发,Kafka+Spark Streaming链路短、上手快、逻辑直观。微批架构虽然不够"纯实时",但它的工程稳定性远大于理论上的毫秒级噱头,而且这套技术栈在很多团队里已经有成熟的运维经验。
如果你要做实时数仓、要保留可更新的明细、要让业务自己写SQL查数据,那Kudu这一层几乎是绕不开的。ETL进Kudu再借助Impala/Spark SQL输出,把"实时"和"分析"粘在了一起,是今天做实时数仓选型时性价比很高的一条路。至于延迟,只要不追求毫秒级,这条路非常够用。
最后分享一个实用小技巧。日常维护这两类链路时,我习惯在Kafka的consumer里多打印一条消费进度日志:当前消费到的offset、最近5分钟的消费速率、topic当前最新的offset。这样在排查积压时,不用每次都翻监控面板,命令行里看一眼日志就能判断是生产端突增还是消费端变慢。进Kudu的任务,我在写入前还会用REFRESH TABLE强制刷新一下元数据缓存,避免新表或新分区在长时间运行后查不到数据,这种小习惯能在关键时刻省下不少排查时间。