4、离线特征计算架构:基于Spark的离线特征ETL流程、分区策略、数据倾斜处理与任务调优

在金融特征平台中,离线特征计算承担着历史回溯、批量生成与周期性更新的核心职责。基于 Apache Spark 的 ETL 架构是当前工业界的主流选择。本章聚焦于离线特征 ETL 的完整流程、分区策略设计、数据倾斜的典型场景与解决方案,以及 Spark 任务的参数调优方法。

4.1 离线特征ETL流程

离线特征 ETL 通常遵循“数据接入 → 特征计算 → 特征存储”的三阶段流水线。每个阶段内部包含多个子步骤,确保特征数据的准确性、一致性与时效性。

核心流程节点:
  1. 数据接入层:从 Kafka / HDFS / Hive 读取原始交易、用户行为、风控事件等数据,进行 schema 校验与类型转换。
  2. 特征计算层:执行窗口聚合、滑动统计、跨表 Join、时序特征衍生等逻辑,生成宽表特征。
  3. 特征存储层:将计算结果写入 Hive 分区表或 HBase / Redis 在线存储,供模型训练与实时推理使用。
离线特征ETL流程架构 数据接入层 Kafka / HDFS / Hive Schema校验 · 类型转换 特征计算层 窗口聚合 · 滑动统计 Join · 时序特征衍生 特征存储层 Hive分区表 HBase / Redis 流程说明 • 数据接入层:支持批量(T+1)与准实时(分钟级)两种模式,通过水位线控制数据完整性。 • 特征计算层:采用 Spark DataFrame API 实现,支持自定义 UDF 与窗口函数。 • 特征存储层:离线特征写入 Hive 分区表,同时同步至在线 KV 存储供实时服务。

4.2 分区策略设计

合理的分区策略是离线特征计算性能的关键。金融场景下,特征表通常按 时间 和 业务维度 进行复合分区,以平衡数据扫描范围与文件数量。

分区维度 典型粒度 适用场景 注意事项
时间分区 dt=yyyyMMdd 日级特征回溯、T+1 批量计算 避免跨过多分区扫描;小文件问题
业务分区 product_type / tenant_id 多租户特征隔离、分业务线计算 分区数不宜过多(建议 < 500)
哈希分区 hash(user_id) % N 数据倾斜严重时的重分布 需配合动态重分区使用
最佳实践:在金融风控特征场景中,推荐使用 dt + event_type 双层分区。例如 dt=20250301/event_type=transaction,既支持按天回溯,又能按事件类型裁剪数据量。

4.3 数据倾斜处理

数据倾斜是离线特征 ETL 中最常见的性能瓶颈。金融数据中,头部用户、热门产品、高频交易事件往往导致部分分区数据量远超平均水平,引发 OOM 或任务长时间挂起。

4.3.1 倾斜场景识别

  • Join 倾斜:大表 Join 小表时,小表 key 分布不均;或两张大表 Join 时,部分 key 数据量过大。
  • 聚合倾斜:group by 操作中,少数 key 对应的数据行数远高于其他 key。
  • 窗口函数倾斜:partition by 字段值分布不均,导致单个分区数据量过大。

4.3.2 解决方案

倾斜类型 解决方案 实现方式
Join 倾斜 广播小表 / 倾斜 key 单独处理 spark.sql.autoBroadcastJoinThreshold 调大;或过滤倾斜 key 单独 Join 后 union
聚合倾斜 两阶段聚合(加盐) 先对 key 加随机前缀做局部聚合,再去前缀做全局聚合
窗口函数倾斜 扩大分区粒度 / 使用 bucketing 将 partition by 字段与辅助字段组合;或预先对数据做 bucket 分桶
注意:加盐聚合虽然能缓解倾斜,但会引入额外的 shuffle 开销。建议仅在倾斜 key 数量较少(如 top 10)时使用,且盐值范围不宜过大(通常 10~100)。
数据倾斜处理策略对比 Join 倾斜 广播小表 / 倾斜key单独处理 聚合倾斜 两阶段聚合(加盐) 窗口函数倾斜 解决方案详解 1. Join 倾斜:设置 spark.sql.autoBroadcastJoinThreshold=100MB, 对小表自动广播;倾斜 key 单独取出做 map join 后 union。 2. 聚合倾斜:对倾斜 key 加随机前缀(0~99),先局部聚合, 再去掉前缀做全局聚合,分散单点压力。 3. 窗口函数倾斜:将 partition by 字段与辅助列组合, 或使用 bucket 分桶(如按 user_id 分 200 桶)。 通用原则:优先从数据源头控制倾斜,其次才是计算层优化。

4.4 任务调优

Spark 离线特征任务的调优需要从资源配置、Shuffle 优化、动态执行与数据格式四个维度展开。以下表格总结了关键参数及其推荐值。

调优维度 参数 推荐值 说明
资源配置 spark.executor.memory 8g ~ 32g 根据数据量调整,避免 OOM
资源配置 spark.executor.cores 4 ~ 8 过高会导致 IO 竞争
Shuffle 优化 spark.sql.shuffle.partitions 200 ~ 1000 根据数据量动态调整,避免小文件
Shuffle 优化 spark.shuffle.file.buffer 64k ~ 256k 增大可减少磁盘 IO 次数
动态执行 spark.dynamicAllocation.enabled true 根据负载自动调整 Executor 数量
数据格式 spark.sql.parquet.compression.codec snappy / zstd 平衡压缩比与解压速度
调优案例:某金融风控特征表日增量约 2TB,原始使用默认 200 个 shuffle 分区,每个分区约 10GB,导致频繁 spill 到磁盘。将 spark.sql.shuffle.partitions 调整为 800,同时开启 spark.dynamicAllocation,任务耗时从 4.5 小时降至 1.8 小时,且无 OOM 发生。

4.4.1 代码级优化示例

以下代码片段展示了在 Spark 中处理离线特征 ETL 时,如何结合分区裁剪与广播 Join 来提升性能。

// 1. 分区裁剪:只读取当天数据
val df = spark.read
  .option("basePath", "/data/features")
  .parquet("/data/features/dt=20250301")

// 2. 广播小表:维度表小于 100MB 时自动广播
val dimDF = spark.read.parquet("/data/dim/product")
val result = df.join(broadcast(dimDF), Seq("product_id"), "left")

// 3. 两阶段聚合处理倾斜
val saltedDF = df.withColumn("salt", (rand() * 100).cast("int"))
  .withColumn("salted_key", concat(col("user_id"), lit("_"), col("salt")))

val partialAgg = saltedDF.groupBy("salted_key", "event_type")
  .agg(sum("amount").as("partial_sum"))

val finalAgg = partialAgg.withColumn("original_key",
    split(col("salted_key"), "_")(0))
  .groupBy("original_key", "event_type")
  .agg(sum("partial_sum").as("total_amount"))
提示:在金融场景中,建议对特征计算任务开启 spark.sql.adaptive.enabled=true(Spark 3.0+),AQE 可以自动合并小分区、优化 Join 策略,显著减少手动调优工作量。

4.5 本章小结