4、特征计算引擎:批处理架构(Spark)、流处理架构(Flink)、实时特征计算
特征计算引擎,说白了就是特征平台的「心脏」。你前面把特征定义好了、存储搞定了,但特征数据怎么从原始日志变成模型能用的数值?这就是计算引擎要干的事。
我这些年见过不少团队,上来就选 Flink 做一切,结果批处理场景跑得比蜗牛还慢。也见过死守 Spark 的,流处理需求一来,整个架构推倒重来。嗯,这里的关键是——你得搞清楚三种计算模式的本质差异。
4.1 批处理架构:Spark 的用武之地
批处理,说白了就是「算旧账」。你今天跑昨天的数据,跑上周的数据,甚至跑三个月前的全量数据。这种场景下,Spark 是当之无愧的主力。
我个人习惯把批处理特征计算分成三类:
- 全量回溯:比如新接入一个特征,需要从历史数据里把过去 90 天的值都算出来。这种场景 Spark 的 DataFrame API 配合 Parquet 列存,效率极高。
- 周期性调度:每天凌晨 T+1 跑一次,更新用户画像、聚合指标。我建议用 Spark SQL 写,维护成本低,业务同学也能看懂。
- 批量补数据:上游数据延迟到达,或者发现之前算错了需要重跑。Spark 的容错机制在这里很关键。
多租户场景下的批处理关键点:
- 资源隔离:每个租户的 Spark 任务跑在独立的资源队列里,我用的是 YARN 的 Capacity Scheduler + 动态资源分配
- 数据隔离:输出路径按租户分目录,避免互相覆盖
- 优先级调度:付费高的租户任务优先执行,低优先级任务排队等待
这里贴一段我常用的 Spark 批处理特征计算模板:
// 多租户批处理特征计算 - Scala
val tenantId = spark.conf.get("spark.tenant.id", "default")
val rawData = spark.read
.parquet(s"/data/raw/${tenantId}/events/dt=${yesterday}")
.filter(col("event_type") === "purchase")
val features = rawData
.groupBy("user_id")
.agg(
sum("amount").alias("total_spend_7d"),
count("*").alias("order_count_7d"),
avg("amount").alias("avg_order_value_7d")
)
features.write
.mode("overwrite")
.parquet(s"/data/features/${tenantId}/user_purchase_7d/dt=${yesterday}")
避坑指南:我曾经在批处理里直接用 groupBy 做全量聚合,结果某个大租户的数据倾斜严重,一个 task 跑了 6 小时。后来改成先按租户分区,再在分区内做 salting 打散,问题就解决了。你想想看,数据倾斜在批处理里几乎是必踩的坑。
4.2 流处理架构:Flink 的实时之道
流处理跟批处理完全是两码事。批处理是「算旧账」,流处理是「算新账」——数据一来,马上算,算完马上用。
Flink 为什么适合做特征计算?我个人觉得有几点:
- 真正的逐条处理:不是微批次,是真正的一条一条处理,延迟在毫秒级
- 精确一次语义:特征值不能多算也不能少算,尤其是金融场景,少算一笔交易特征,模型预测可能就偏了
- 状态管理:滑动窗口、计数、去重,这些都需要维护状态。Flink 的 StateBackend 做得比较成熟
多租户场景下,Flink 的配置比 Spark 要复杂一些。我遇到过最头疼的问题是——不同租户的延迟要求不一样。有的租户要求 100ms 内出特征,有的能接受 5 秒。怎么办?
注意:千万不要把所有租户塞进同一个 Flink 作业里。不同租户的数据量级、延迟要求、特征逻辑都不一样,混在一起会导致「一损俱损」。我建议的做法是:每个租户一个独立的 Flink 作业,或者至少按延迟等级分组。
来看一个 Flink 实时特征计算的典型代码:
// Flink 实时特征计算 - Java
DataStream<Event> events = env
.addSource(new KafkaSource<>()
.setBootstrapServers("kafka:9092")
.setTopics("user-events")
.setGroupId("feature-compute-group")
.build());
// 按租户分流
DataStream<Feature> features = events
.keyBy(event -> event.getTenantId() + ":" + event.getUserId())
.window(SlidingProcessingTimeWindows.of(
Time.minutes(10), Time.minutes(1)))
.aggregate(new FeatureAggregator());
// 写入特征存储
features.addSink(new RedisSink<>(
new RedisCommandDescription(RedisCommand.SET)));
经验之谈:Flink 的 Checkpoint 间隔不要设太短。我见过有人设成 100ms,结果状态后端频繁快照,CPU 全耗在序列化上了。对于特征计算场景,5-10 秒的 Checkpoint 间隔比较合理。另外,状态大小要监控——某个租户的滑动窗口状态膨胀到几十 GB,直接把 TaskManager 搞 OOM 了。
4.3 实时特征计算:Lambda 架构的取舍
说到实时特征计算,就绕不开 Lambda 架构。批处理层算全量,流处理层算增量,然后合并。听起来很美好,但实际落地时坑不少。
我画了一张图,帮你理清三种计算模式在特征平台中的定位:
三种模式各有优劣,我整理了一张对比表:
| 维度 | 批处理 (Spark) | 流处理 (Flink) | 实时特征计算 |
|---|---|---|---|
| 延迟 | 分钟~小时级 | 毫秒~秒级 | 秒级 |
| 吞吐量 | 极高(TB/小时) | 高(GB/秒) | 中等 |
| 数据完整性 | 完全(可重跑) | 最终一致 | 取决于合并策略 |
| 适用场景 | 全量回溯、离线训练 | 实时风控、在线推荐 | 需要历史+实时的混合场景 |
| 多租户隔离 | 资源队列隔离 | 独立作业/算子链 | 按租户分合并策略 |
4.4 多租户场景下的计算引擎选型建议
说了这么多,到底该怎么选?我个人的经验是——不要迷信某个引擎,要根据场景来。
我的选型原则:
- 离线特征 → Spark:全量回溯、T+1 调度、批量补数据,Spark 的生态最成熟
- 实时特征 → Flink:毫秒级延迟、精确一次语义、复杂事件处理,Flink 是首选
- 近实时特征 → 看情况:如果延迟容忍在 10 秒以上,可以用 Spark Streaming(微批次);如果要求 1 秒内,必须上 Flink
我记得有一次,一个租户要求「实时特征」但实际延迟容忍是 30 秒。我建议他们用 Spark Structured Streaming,每 10 秒一个微批次。结果成本降了 60%,效果完全满足。你想想看,不是所有「实时」都需要 Flink。
又一个坑:我曾经在同一个 Flink 作业里同时跑批处理和流处理逻辑,想着「Lambda 架构一步到位」。结果作业拓扑复杂到没人敢动,出问题要排查半天。后来拆成两个作业——一个 Flink 做流处理,一个 Spark 做批处理,中间用消息队列解耦。维护成本直线下降。
最后说一句——特征计算引擎不是越新越好,也不是越快越好。适合你的业务场景、团队能力、运维成本的,才是最好的。多租户场景下,隔离性和可观测性往往比极致性能更重要。