DataEngineeringStreamingSparkETL

从批处理迁移到微批次流式处理的实战经验

Parveen Saini··原文链接
收录于 2026/5/24 08:43:39

系统范围和用例

该系统负责生成用于搜索和广告检索管道的向量索引:

  • 处理存储在对象存储(S3 风格)中按时间分区的数据
  • 文档规模达数百万份,完整索引约数百 GB,向量索引数十 GB
  • 新的向量数据大约每五到七分钟更新一次

时效性关键:一旦出现延迟,就会导致更新后广告及相关元数据上线推迟,引发检索结果过时,甚至在新版本投入生产前耗尽广告预算,进而导致错失商机。

背景:全量索引和增量索引管道

系统由两个职责截然不同的管道组成:

全量索引管道

  • 从头重建整个 Solr 索引,包含所有广告和元数据
  • 资源消耗大、成本高、耗时长(约两到三小时)
  • 部署采用全索引交换方式,确保更新操作是原子性的

增量索引管道

  • 仅处理广告、广告组和广告活动的增量更新
  • 典型大小约为完整索引的十分之一,可以频繁重新生成
  • 由外部调度,每次都作为独立作业调用

问题核心:调度延迟

增量管道的理论优势是加快更新传播,但实际应用中出现了问题:

  • 计划运行刚结束时到达的增量数据,需要等待几乎一个完整调度周期才会被处理
  • 进度是在作业级跟综的,一旦发生故障,必须重新执行整个计划时间窗口内的所有任务
  • 更新高峰期,批处理时长会增加,可能缩短甚至消除两次运行之间的空闲间隔

核心问题:造成数据新鲜度滞后的主要原因并非处理成本,而是批处理调度延迟和协调开销。

错误尝试:记录级流处理

最初采用记录级流处理(record-level streaming),但很快意识到:

  • 索引逻辑做了批次完整的假设,在产品或商品分组层面进行操作
  • 记录级流处理会引入部分更新状态——部分广告已更新,但分组索引表示尚未完全一致
  • 业务并不需要每条记录都有很好的即时性,真正需要的是不用等待批处理调度完成

结论:记录级流处理正在解决一个我们并不存在的问题,同时引入了我们不希望出现的问题。

收敛于微批次流式处理

最终采用基于 Spark Structured Streaming 的微批次处理模式:

  • 作业配置为约三十秒的固定触发间隔
  • 触发间隔远小于分区到达频率(五到七分钟),确保新数据被迅速捕获
  • 没有外部调度间隙

核心设计原则

仅处理最新的可用分区

  • 列出当前可见分区,根据时间戳排序
  • 与 watermark(已确认的最后分区)进行比较
  • 仅处理比 watermark 更新的分区,忽略所有中间分区

为什么可以跳过中间分区?

  • 向量索引是在一个重叠的滑动窗口上运行的
  • 每次增量运行都会重新计算一个近期的时间窗口
  • 滑动窗口的持续时间远大于分区间隔,确保任何被跳过的分区都会在后续重新计算时得到覆盖

基于时间戳触发器 vs 基于标记检测

将基于成功文件的检测机制替换为基于频率的触发机制(每三十秒执行一次):

优势

  • 不再等待特定完成信号,根据时间和 watermark 的比较结果以确定性的方式推进
  • 没有文件系统通知,没有完成标记,也没有"等待分区足够旧"的逻辑
  • 即使与标记文件相关的对象存储出现可见性故障,管道也不会停顿
  • 最糟糕的数据新鲜度延迟从约十分分钟缩短至三十秒

重启行为

重启时同样应用"新鲜度优先"规则:

  • 重新计算最新可见分区
  • 与持久化的 watermark 进行比较
  • 如果更新,则仅处理该最新分区
  • 否则退出并等待下一个触发周期

重要特性:重启不需要特殊回放逻辑,重启动画与稳态执行期间行为一致。

源与目标:对象存储是关键

管道的源和目标均为对象存储(类似 S3 的语义):

  • 增量数据以分区或文件的形式到达
  • 缺乏原生逐记录进度语义
  • 完成状态是推断出来的,不保证正确
  • 列表有可能不完整

挑战:在持续评估的流式读取过程中缺少一种可靠的方法来解释分区完成信号。

总结

关键挑战不在于计算效率,而在于如何保证进度稳定可靠:

  • 数据以时间分区文件形式到达
  • 完成信号不可靠
  • 数据的新鲜度比严格重放中间状态更重要

最终设计体现了三者的融合:

  • 采用微批次流式处理消除调度延迟
  • 利用确定性进度机制避免不稳定的完成信号
  • 通过显式重启语义确保行为可预测性