1. 实时流处理为什么绕不开这两条路线
这几年做实时数仓,被问得最多的一个问题就是:Kafka 都上了,后面到底接 SparkStream 还是走 ETL 写 Kudu?看起来是在比两个组件,其实背后是整条链路设计思路的取舍。Kafka+SparkStream 这条路,核心是把计算搬到流里,数据到了就处理,适合秒级指标、实时告警这类场景。ETL+Kudu 这条路,核心是先把数据清洗成一个可查询的明细层,Kudu 负责高速写入和主键更新,适合后面接报表、自助查询、核对类任务。
很多人以为它是二选一,实际做下来两边都会部署。而且不管走哪边,中间的 Kafka 基本不会拆。这个话题适合正在搭实时数仓、实时指标平台的人看,也适合准备把离线 Hive 数仓升级成近实时链路的人参考。下面先把两条路线的边界讲清楚,再做配置和踩坑复盘,避免你在会议室里又为"到底用流计算还是用 Kudu"吵一下午。
1.1 "实时"其实分两个层次
"实时"这个词被用得太滥了。业务方说实时,有时候指大屏上的数字三秒一刷,有时候指报表数据比昨天快一个小时。这是两回事。
秒级实时,比如成交金额滚动统计、在线用户数、风控拦截率,这类需求通常要 Kafka+SparkStream。数据进 Kafka 之后被流式计算引擎持续消费,窗口到点就出结果,端到端延迟可以压到几秒到十几秒。它强调的是"算得快"。
另一种是分钟级实时,比如订单明细能在 5 分钟内落到数仓,业务同学拿到这张表做即席筛选,查某个订单为什么状态异常。这类需求用 ETL+Kudu 更顺手。ETL 任务每几分钟拉一次 Kafka 里的增量,做字段补齐、去重、类型转换,然后 upsert 到 Kudu。强调的是"查得快、查得到"。
把这两个层次分清楚,后面选型就少一半纠结。
1.2 两条链路不是互斥关系
Kafka+SparkStream 偏重计算,ETL+Kudu 偏重存储和查询,它们不是互斥的。比较常见的做法是让 SparkStream 消费 Kafka 做指标聚合,同时把清洗后的明细写到 Kudu;或者独立跑一个微批 ETL,定时把 Kafka 增量同步进 Kudu。两条链路可以在同一个项目里共存。
我见过不少团队一开始非要二选一,结果第一条路搭完发现明细没法查,第二条路搭完发现秒级指标还得另起炉灶。做得稳的团队通常这样处理:把明细和指标拆开,Kafka 负责分发,流计算负责实时结果,Kudu 负责可查询的明细。接下来的几节,我把两条路线的机制、参数和坑分别拆开讲。
2. 路线一:Kafka + SparkStream 的完整拆解
2.1 Kafka 在链路里究竟承担什么
先别急着写 Spark,Kafka 的定位要清楚。它在这条链路里不只是消息管道,还承担了三件事:削峰填谷、多副本持久、多消费者组分发。业务高峰期写入量瞬间暴涨时,下游系统扛不住,Kafka 可以把数据暂时堆在磁盘上,让消费端按自己的节奏去处理。
Kafka 的 topic 又被拆成多个 partition,数据写入时按 key 哈希或者轮询落到不同分区。每个分区内部有序,跨分区不保证顺序。你可以把分区想象成高速公路的多条车道,每条车道里的车不能超车,但不同车道之间没有顺序约定。流计算处理离线能力不足时,分区数基本决定并行上限。
对消费端来说,同一个消费组里,一个分区同一时刻只能被一个消费者线程占用。Kafka 如果只建了 6 个分区,就算 Spark 给 100 个 executor 也没用,一次最多 6 个并行任务。这个约束在规划 topic 时就要想清楚,后面单独说。
另一个绕不开的是 offset。Spark 消费 Kafka,offset 可以交给 Kafka 自动提交,也可以由 Spark 的 checkpoint 管理。我建议在流任务里用 checkpoint 统一管 offset,不要依赖 enable.auto.commit。自动提交一旦在数据处理完成前提交了 offset,任务挂掉重启就会丢数据;checkpoint 恢复虽然不保证严格一次,但配合下游幂等写入,能做到业务上的精确一次。
2.2 DStream 和 Structured Streaming,别选错了
SparkStream 这个词在社区里有点模糊,它同时指两代东西。老一代是 DStream,底层是 RDD 流,按固定微批间隔切数据,写起来像在操作 RDD。新一代是 Structured Streaming,把流当成一张无界表,可以直接写 DataFrame/SQL,支持事件时间、水位、窗口聚合和状态管理。
新项目直接选 Structured Streaming,不要再碰 DStream。原因很直接:DStream 的算子能力弱,调优参数多,现在社区新增功能基本都往 Structured Streaming 倾斜;Structured Streaming 的 API 和离线 DataFrame 几乎一致,团队成员上手成本低。
选型时可以简单对照一下:
| 对比项 | DStream | Structured Streaming |
|---|---|---|
| 底层抽象 | RDD | DataFrame / Dataset |
| 事件时间支持 | 弱,要靠自己处理 | 原生支持 watermark |
| 窗口聚合 | 有限 | 支持 append/update/complete |
| 精确一次语义 | checkpoint + 外部幂等 | 同样需要外部幂等 |
| 推荐度 | 仅存量维护 | 新项目优先 |
下面给一个 Structured Streaming 消费 Kafka 的示例,伪代码只保留关键链路。
from pyspark.sql import SparkSession from pyspark.sql.functions import from_json, col, window, to_timestamp, sum spark = SparkSession.builder \ .appName("realtime_order_agg") \ .config("spark.sql.shuffle.partitions", "24") \ .getOrCreate() input_df = spark.readStream \ .format("kafka") \ .option("kafka.bootstrap.servers", "kafka1:9092,kafka2:9092") \ .option("subscribe", "ods_order") \ .option("startingOffsets", "latest") \ .load() order_schema = "order_id STRING, user_id STRING, amount DOUBLE, pay_time STRING" parsed = input_df.select( from_json(col("value").cast("string"), order_schema).alias("data") ).select("data.*") result = parsed.withColumn("pay_ts", to_timestamp("pay_time")) \ .withWatermark("pay_ts", "10 minutes") \ .groupBy(window("pay_ts", "1 minutes"), col("user_id")) \ .agg(sum("amount").alias("pay_amount")) query = result.writeStream \ .outputMode("update") \ .format("console") \ .option("truncate", "false") \ .trigger(processingTime="30 seconds") \ .start() query.awaitTermination()这段代码里值得注意的点有三个。withWatermark 设了 10 分钟,意思是允许事件时间最多迟到 10 分钟,超过这个范围的数据会被丢弃;outputMode 用了 update,窗口更新后会把最新结果发出来,适合实时监控;trigger 设成 30 秒一个微批,就能把调度开销控制在合理范围。
2.3 背压、延迟和容错的几个关键参数
流计算最常见的现象是高峰一来,任务积压越来越大。Kafka 边的数据一直在涨,Spark 边处理不过来,最后延迟从几秒变成几小时。
老一代 DStream 可以用背压参数来解决。spark.streaming.backpressure.enabled 设为 true,再配 spark.streaming.kafka.maxRatePerPartition,系统会根据处理速率动态调整每个分区每秒最多拉多少条,避免把消费端打爆。新版 Structured Streaming 对应的参数是 maxOffsetsPerTrigger,控制每个 trigger 最多消费多少条 offset。这个参数在压测时很实用,可以先压到下游能承受的速率,再逐步往上提。
微批间隔和延迟之间也要平衡。trigger 设 1 秒,理论上延迟更低,但频繁调度会让任务启动开销占比变大。实测下来,如果业务能接受 5 秒延迟,trigger 设 5 秒会比 2 秒稳定很多,尤其在下游还要写外部存储时。
容错方面,checkpoint 目录必须放在可靠的分布式文件系统上,别放本地临时盘。任务重启时,Structured Streaming 会从 checkpoint 恢复 offset 和状态。另外记住一句话:流计算多数场景是 at least once,要得到精确一次,就得让下游写入幂等。比如结果最终要写 Kudu,那 Kudu 表主键就能天然帮你去重。
3. 路线二:ETL + Kudu 的完整拆解
3.1 Kudu 的设计逻辑,和 HDFS、HBase 有什么不同
Kudu 是列式存储,同时支持主键约束和快速更新。这个组合在大数据生态里比较少见。传统上分析大明细用 HDFS 上的 Parquet 文件,够快,但更新一条记录几乎不可能,只能重写整个分区。HBase 倒是支持随机读写,但行式存储做范围扫描和聚合分析效率不如列式。
Kudu 正好夹在中间:列式存储让分析查询高效,主键模型让更新、删除、点查都能跑起来。你可以把它理解成一个能改明细的列存仓库,Parquet 是只读归档库,HBase 是 KV 型仓库,Kudu 是既适合分析又支持更新的明细库。
Kudu 内部把表按主键范围分成多个 tablet,每个 tablet 分布在不同的 tablet server 上。写入会先进内存,再刷成列文件,更新和删除也有专门机制处理。频繁更新会产生后台合并开销,这个特性决定了表分区和写入频率都得提前设计好。
3.2 ETL 环节怎么落地:微批清洗和写 Kudu
ETL+Kudu 这条路里的 ETL,不是传统 T+1 的 MapReduce 批量任务,而是每隔几分钟跑一次的微批任务。使用 Spark 消费 Kafka,处理上一个周期产生的增量数据,做字段解析、脏数据过滤、维表补全,然后按业务主键去重,最终 upsert 到 Kudu。
一个典型链路是这样。数据源进 Kafka 后,Spark 微批任务被调度平台按 5 分钟或 10 分钟触发,每次消费近一段时间的最新分区数据。先过滤掉明显异常记录,比如金额为负、时间戳缺失,再统一字段格式。去重逻辑要选好业务主键,比如订单 ID 加支付事件类型,然后把批次数据写入 Kudu。Kudu 的 upsert 模式会按主键判断插入还是更新,天然不会产生重复主键。
写 Kudu 的代码大致长这样:
from pyspark.sql import SparkSession df.write \ .format("kudu") \ .option("kudu.master", "kudu-master:7051") \ .option("kudu.table", "ods_order_detail") \ .option("kudu.operation", "upsert") \ .mode("append") \ .save()这里 kudu.operation 有三个常用值:insert、update、upsert。insert 遇到主键重复直接报错,update 只更新已存在记录,upsert 则存在就更新、不存在就插入。实时明细场景基本都选 upsert,省去提前判断主键的麻烦。
微批的好处是简单可控。任务跑到一半挂了,可以直接重跑这一批次;数据对不上,可以按时间段回刷。和流计算比,它少了很多状态管理和水位的问题,团队维护成本明显更低。
3.3 Kudu 表设计与写入优化的 5 条建议
第一,主键不要用自增数字。Kudu 表设计主键要贴近业务查询,比如订单号、事件 ID、设备号加时间戳。自增主键在分布式系统里既没法去重,还会把写入压力打到一个 tablet 上。
第二,分区建议用 range 分区加 hash 分区结合。比如按时间做 range 分区,保证每天的数据落在不同区,方便清理历史;再对用户 ID 或订单 ID 做 hash 分区,把写入压力分散到多个 tablet。只按时间分区容易出现写热点,高峰期所有流量都压在最新那个 tablet 上。
第三,批量写入时控制批次大小。实际测试中,一个批次塞几千条到一万条是比较舒适的范围。批次太小,网络往返太多;批次太大,Kudu 内存 flush 压力大。Spark 端调整 partition 数量也能配合,目标分区数和 tablet 数大致匹配就行。
第四,列编码可以按数据特征选择。低基数字段用字典编码效果好,字符串字段用字典或普通编码看长度和重复度。一般不强制手动指定,但能帮你减少存储占用,尤其这种表通常都很大。
第五,别把 tablet 数量做得太夸张。每个 tablet server 上的 tablet 数量控制在一定范围内,少了并发不足,多了元数据和合并开销变大。搭建时先按集群规模估算,再通过压测微调。
4. 两条路线对比与选型逻辑
4.1 一张表看透两套方案的差异
把两条路线放在同一个表里,决策会清楚很多。
| 维度 | Kafka+SparkStream | ETL+Kudu |
|---|---|---|
| 本质 | 流式计算 | 近实时存储与查询 |
| 数据新鲜度 | 秒级到十几秒 | 分钟级 |
| 主要产出 | 指标、告警、事件流 | 明细表、宽表、拉链表 |
| 点查能力 | 不擅长,结果要另写存储 | 强,主键点查毫秒级 |
| 更新能力 | 依赖外部存储 | 原生支持 upsert |
| 参数与状态管理 | 较复杂 | 相对简单 |
| 运维依赖 | Kafka、Spark 集群 | Kudu 集群、调度平台 |
| 典型场景 | 大屏、风控、实时统计 | 自助查询、报表、对账 |
这套对比下来,你会发现两者根本不是同一个层面的东西。Kafka+SparkStream 是"计算引擎怎么跑",ETL+Kudu 是"数据落在哪、怎么查"。业务方问"我要实时",你得先反问:你要的是实时算,还是实时查?
4.2 三个典型场景该怎么组合
场景一,实时大屏和风控告警。要求端到端延迟控制在几秒,Kafka+SparkStream 是最合适的。SparkStream 消费 Kafka 后做窗口聚合,指标结果写到 Redis、ClickHouse 这类存储,大屏直接读。这时候 Kudu 不是必需组件。
场景二,实时明细自助查询。业务同学要能按订单号、用户 ID 在几亿条明细里快速筛选,还要支持状态更新。这种需求直接用 ETL+Kudu。Kafka 先缓冲增量,微批 ETL 清洗后写入 Kudu,业务侧挂一个 SQL 引擎就能查。Kudu 的 upsert 能力让更新状态成为常规操作。
场景三,既有实时指标又有明细底账。典型是账单核对和运行监控。Kafka 同时分发两路,一路给 SparkStream 算核心指标,另一路给微批 ETL 写 Kudu 留底账。流计算负责发现异常,Kudu 负责事后查证。两边的数据还可以定期做交叉比对,任何一边漏了都能及时发现。
4.3 混合链路怎么落地,先做哪一步
混合链路最稳的落地顺序是:先 Kafka 到 ETL 到 Kudu,把明细查询跑通,再评估是否有必要上 SparkStream。很多场景的"实时"其实接受分钟级,第一段落地就能满足大部分需求。等业务明确说秒级指标不够用了,再加上流计算,这样不至于一上来就背两套系统的运维成本。
我见过有些团队第一周就搭全套,结果流任务和 Kudu 任务两边同时出问题,排查链路特别痛苦。先做简单的,再补快的,节奏会健康很多。
5. 实操过程中的坑与排查
5.1 分区数怎么定,不要凭感觉拍脑袋
Kafka 的 topic 分区数、Spark 的分区数、Kudu 的 tablet 数,这三组数字在实时链路里互相影响。最常见的失误是 Kafka 分区数太少,或者 Spark 写 Kudu 时并行度不够。
一个经验公式可以初步估算:所需分区数约等于峰值 TPS 除以单消费者实测吞吐,再乘一个 1.2 到 2 的冗余系数。比如峰值两万条每秒,单消费者实测能稳定处理两千条每秒,那最少需要 10 个分区,留冗余就建 16 到 24 个。分区太多也不是好事,提交 offset 和元数据同步都会变慢。
Kudu 侧也要关注 tablet 数量。Spark 写 Kudu 时,每个 Spark 分区和一个 tablet 对应写入会更快,如果两边数量差太多,就会出现某个 tablet 被反复写、其他 tablet 闲着的情况。压测时重点观察 tablet server 的 CPU 和磁盘负载是否平均。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 重复数据突然变多 | offset 提交时机不对 | 查日志里的提交点 | checkpoint 统一管理 offset,下游按主键去重 |
| 流任务延迟一直涨 | 消费速率超过下游处理能力 | 看 Kafka lag、Spark batch duration | 开启背压或调低 maxOffsetsPerTrigger |
| 写 Kudu 特别慢 | 分区热点或 tablet 数不匹配 | 看 tablet server 负载分布 | 改用 range+hash 组合分区 |
| 查询结果比预期时间晚 | 窗口水位设置过大 | 检查事件时间分布 | 缩小 watermark,或改用 update 模式 |
| 任务重启后 offset 错乱 | checkpoint 目录被清或损坏 | 检查 checkpoint 是否正常 | checkpoint 放分布式文件系统,别放本地 |
| 写 Kudu 报主键重复 | ETL 批次内没去重 | 检查 ETLL 清洗逻辑 | 先按业务主键去重,再 upsert 写入 |
这些坑在我实际部署里基本都踩过。尤其是 checkpoint 放本地盘那次,机器重启后整个流任务从最早 offset 重新消费,把下游存储打挂了,排查了一整天才定位到原因。
5.3 搭实时链路前,先问三个问题
个人经验里,动手前问清楚三个问题,能省一大半返工。
第一,业务要的实时是秒级还是分钟级?如果分钟级能接受,直接走 ETL+Kudu 就够了,别一上来就上流计算。
第二,数据要被长期查询还是只算指标?要长期留明细、要支持更新、要按任意维度筛选,Kudu 这类存储不可替代;如果只是算个总额,流计算产出去 Redis 就够了。
第三,万一出错能不能回放?保证 Kafka topic 有足够保留时间,Kudu 表主键有明确业务语义,这样无论哪条链路出问题,你都能把数据拉回来重刷重算。
我会在每次接实时需求时都先过一遍这三个问题,确认以后再做分区设计和集群规划。这套做法帮我避开了不少"上线后才发现架构不对"的尴尬局面,也是我在这两条路线上最想分享的经验。