简介
许多被描述为批处理系统的数据管道,实际上都已经是以近乎不间断的方式运行。它们频繁地处理增量数据——通常采用重叠窗口机制,其主要作用是缩小两次大规模、低频率全量重新计算之间的数据时效性差异。
本文描述了这样一个系统的迁移过程。该系统包含一组定时执行的批处理作业,负责生成用于搜索和广告检索管道的增量索引。
这些作业已经迁移到一个持续运行的微批处理模型,使用了基于Spark Structured Streaming的微批处理模式,其目的不是实时地处理每条记录,而是为了消除调度延迟,提高运维可预测性。
虽然 Spark Structured Streaming 是作为执行引擎,但进度跟踪并未依赖其原生的检查点机制或事件时间 watermark 语义,因为管道是根据分区的进度推进,而不是基于连续的事件流。相反,系统在外部维护了一个逻辑 watermark,基于分区时间戳来表示最新处理过的分区。
该系统处理存储在对象存储(S3 风格)中按时间分区的数据,而不依赖于 Kafka 等事件流。虽然曾经考虑过基于日志的摄入模型,但该管道处理的是面向批处理的快照式数据,分区层面的完整性比每条记录的顺序更为重要,因此,基于对象存储的摄入方式更为合适。
因此,进度取决于对分区数据的列举和解析,而非处理有序事件。
关键挑战不在于计算效率,而在于如何在以下环境中保证进度的稳定可靠:
数据以时间分区文件的形式到达。
完成信号并不可靠。
数据的新鲜度比严格重放中间状态更为重要。
该系统并未采用记录级流式处理,而是最终采用了一个基于以下内容的更简单的模型:
基于时间的执行。
基于时间戳的进度跟踪。
始终仅处理最新的可用分区。
本文重点探讨了本次转型中的实践经验:哪些做法失败了,哪些行之有效,以及这些设计选择适用哪于些限制条件。
系统范围和用例
这项工作针对的是负责摄取新广告、营销数据以及产品、商品及客户信号(如转化率、表现和联购行为)的生产级批处理任务。这些输入数据经过处理后会生成供在线检索服务使用的倒排索引。该系统处理的文档规模达数百万份,完整索引的大小约为数百 GB,增量索引的大小通常有数十 GB。
这些任务并非通用的流式处理基础设施,它们是功能固定的处理管道,对对象存储中按时间分区的数据进行处理,不涉及事件流或按记录处理的语义。它们在时效性关键路径上,一旦出现延迟,就会导致更新后的广告及相关元数据上线推迟,从而引发检索结果过时,甚至在最新版本投入生产前耗尽广告预算,进而导致错失商机。
在状态稳定的情况下:
新的增量数据大约每五到七分钟更新一次,具体时间取决于新广告的数量以及现有营销活动的最新情况。
每次增量更新大致涵盖过去五小时的数据。
每小时进行多次增量更新既实用也符合预期。
尽管任务数量不多,但它们的行为会直接影响数据的新鲜度。主要的瓶颈并非计算本身,而是调度延迟和协调开销,尤其是在有突发负载和故障时。
背景:全量索引和增量索引管道
我们的索引系统由两个职责截然不同的管道组成。
全量索引管道会从头重建整个 Solr 索引,其中包含所有广告和元数据,以及供下游相关性与质量模型使用的商品、商品详情、客户、转化、展示次数和行为信号(如“一起购买”)。这个过程资源消耗大、成本高、耗时长,因此无法频繁执行。一次完整重建大约需要两到三个小时,随后还需要进行验证和部署,总耗时可达约五个小时。部署过程采用了全索引交换方式(可以确保更新操作是原子性的),而非通过部分段更新来实现。
为了在两次完整索引运行之间将变更推送到生产环境,我们采用了增量索引管道。该管道仅处理广告、广告组和广告活动的增量更新,而且刻意保持一个比较小的规模。一个典型的增量索引大小约为完整索引的十分之一,因此可以频繁地重新生成。
理论上讲,这种方法本应加快更新的传播。但在实际应用中,增量管道是由外部调度的,每次都作为独立的作业调用来执行。随着时间的推移,逐渐显现出来一些问题。在计划运行刚结束时到达的增量数据,往往需要等待几乎一个完整的调度周期才会被处理。由于进度是在作业级跟踪的,所以即使只有部分任务未完成,一旦发生故障,也必须重新执行整个计划时间窗口内的所有任务。在更新高峰期,批处理时长会增加。在外部调度模型中,这种增加会缩短甚至消除两次运行之间的空闲间隔,甚至可能导致计划任务被跳过或延迟。这样一来,进度不仅受处理时间限制,还受固定调度边界限制,导致数据新鲜度滞后的问题加剧。
问题的核心并不在于批处理本身,而在于粗粒度的调度边界与作业级进度语义的组合。最终,人们逐渐认识到,造成数据新鲜度滞后的主要原因并非处理成本,而是批处理调度延迟和协调开销。
流式处理为何在公司内部引发了争议
当流式处理首次被提出时,反对的声音并非源于其正确性或性能,而是源于运维方面。
资深工程师们提出了一些完全合理的担忧:
长期运行的流式处理任务在运维层面更难把握。
恢复行为可能难以预测。
故障往往会持续存在,而非干净利落地终止。
值班负担往往会增加,而非减轻。
实际上,其中一些问题确实出现了,特别是在长期运行的作业行为和内存方面;而当我们简化了系统的执行流程和重启处理机制后,其他问题则不再那么突出。
这些问题至关重要。我们的目标绝不是用一个运行机制不透明、会出现新型故障且更难调试的系统,来取代可预测的批处理作业。
任何向流式处理的转型,都必须直接解决这些运营风险。
错误的开始:记录级流处理
与许多团队一样,我们最初采用的是记录级流处理(record-level streaming)。从理论上讲,这种方法看起来是最“正确”的解决方案。但在实践中,我们很快意识到,对于这条数据管道而言,这种做法既没有必要,又存在风险。
索引逻辑做了批次完整的假设,而且是在产品或商品分组层面进行操作,而非在单条广告记录的层面。这种分组主要源于中间处理管道的架构选择,数据会在更高层级的实体中进行聚合,然后在最终索引中展开回单条记录。通常,一条广告的变更需要重新计算增量索引中该产品或商品的分组表示。若改用记录级流式处理,将会引入部分更新状态——即部分广告已更新,但分组索引表示尚未完全一致。实际上,这种做法要么迫使整个受影响的增量分区重新生成,要么冒着搜索结果暂时不一致的风险。
针对这一问题进行重构将需要做大量的改动,而且收益不明。更重要的是,业务并不需要每条记录都有很好的即时性。它真正需要的是不用等待批处理调度完成。这是第一个重要的认识:记录级流式处理正在解决一个我们并不存在的问题,同时引入了我们不希望出现的问题。
收敛于微批次流式处理
我们是有意转向微批次流式处理的。我们的目标不是连续地处理记录,而是实现持续可用性。微批次处理使我们能够消除调度间隙,并在关键环节保留批处理语义。
在运维层面,作业被配置为约三十秒的固定触发间隔。该配置是通过 Spark Structured Streaming 的微批次处理模式实现的,并且采用了固定的处理时间触发机制。我们特意将触发间隔设置得远小于五到七分钟的分区到达频率,为的是确保新数据能被迅速捕获,而且不存在外部调度间隙。
每次触发操作都按照一个有界且确定的顺序进行:
确定当前的挂钟时间。
根据时间和分区规则,计算应被视为符合条件的最新分区。
将该分区与当前的 watermark(最后一个已确认的分区)进行比较。
如果有多个分区处于待处理状态,则直接跳转到最新的符合条件的分区,而不处理中间的分区。
每个触发周期最多处理一个有界分区。
符合条件的判定完全基于分区排序和 watermark 比较,并未引入延迟阈值或缓冲机制来确保存储可见性。因此,处理进程是时间驱动且面向数据新鲜度的,而不是严格按顺序进行。这种方法有别于依赖 watermark 进行顺序重放的传统流处理模型——为确保完整性,后者会按顺序处理所有中间数据。相比之下,该管道优先考虑数据新鲜度,直接跳转至最新的可用分区,并且接受中间状态将由重叠窗口计算隐式覆盖这一事实。
重启时,同样的逻辑依然适用。作业会重新计算最新的可见分区,并将其与持久化的 watermark 进行比较。如果最新分区更新,则仅处理该最新分区,从而与稳态执行期间“新鲜度优先”规则保持一致。
由于增量窗口在设计上存在重叠,所以跳过中间分区不会导致永久性数据丢失。后续在滑动窗口上运行的作业会自然而然地恢复任何必要的状态。滑动窗口的持续时间远大于分区间隔,可以确保任何被跳过的分区都会在后续重新计算时得到覆盖。
值得特别说明一下的是,这种方法既不是希望通过提高运行频率来模拟批处理,也不是因为“全流式处理感觉很难”而采取的捷径。我们最初实现了基于记录的流式处理,但在看到它给面向批处理的索引系统所带来的运行成本和语义不匹配后,放弃了这一方案。
最终设计体现了三者的融合:采用微批次流式处理来消除调度延迟,利用确定性进度机制来避免不稳定的完成信号,并通过显式的重启语义来确保行为的可预测性。
源与目标:对象存储绝非可有可无
该管道的源和目标均为对象存储。增量数据以分区或文件的形式到达,经过处理并写回后才被下游消费。该系统依赖按时间划分的路径(如/year/month/day/hour/minute 分区),并利用列表来确定数据是否符合条件。对象存储缺乏原生的逐记录进度语义。完成状态是推断出来的,并不保证正确。而且由于最终一致性的原因,列表有可能不完整。
这一区别至关重要。该系统运行在对象存储(类似 S3 的语义)之上。尽管现代对象存储提供了强写后读一致性,但这里的挑战并非一致性保证,而是缺乏一种可靠的方法在持续运行的管道中根据列表推断分区的完整性。
错误的开始:成功文件与完成标记
在首次尝试流式摄取时,我们复用了批处理管道中现有的成功文件(success-file)和完成标记(completion-marker)逻辑。在定时调度的批处理模型中,这种方法的效果还可以,因为列表更新频率比较低,而且评估的时间节点粒度比较粗。然而,在持续运行的流式作业中,该模型失效了。
我们遇到了以下问题:
由于在重复列出过程中数据文件与完成标记之间存在非原子可见性,导致完成标记出现延迟或不一致。
即使数据已经写入,分区列出结果仍然会暂时不完整。
流式处理作业会反复轮询那些已经存在但尚不可见的标记。
如果列出结果和标记在变得可见时略有时间差,就会导致重复处理或过早处理。
比如,在时间点 T,一个分区开始写入数据文件,而在时间点 T+dt 写入了一个完成标记。在此时间间隔内进行轮询的流式处理作业可能会观察到没有标记的局部数据,或者观察到标记但无法完整查看所有文件。由于列表与事务边界不相关联,所以在不同轮询之间,数据和标记的可见性可能会出现偏差。
如果没有基于时间的触发器,处理过程就依赖于检测分区何时完成,通常是通过标记或启发式方法来实现。在基于重复列出的流式读取中,这种完成概念并不可靠。基于时间的触发器基于时间而非完成状态来推进处理,因此可以避免这种依赖性。
实际上,这些问题频繁出现会影响可靠性。更重要的是,核心问题不仅在于存储一致性,还在于在持续评估的流式读取过程中缺少一种可靠的方法来解释分区完成信号。
此外,当时没有一种可靠的方法能同时保证标记检测的准确性和效率。激进的轮询会增加系统负载,却无法消除竞争条件;而保守的轮询则会导致数据新鲜度滞后时间延长,从而背离了流式处理的初衷。
这些问题并非偶发性的错误,而是将基于标记的完成语义与持续评估的流式引擎相组合所导致的结构性后果。至此,很明显,对于一个需要确定性且基于时间的进度跟踪系统而言,基于文件的完成信号机制过于脆弱。
模式:利用基于速率的触发器实现确定性进度
我们将基于成功文件的检测机制替换为基于速率的触发机制,每十秒钟执行一次。虽然这种方法仍然需要定期评估存储状态,但它与基于标记的轮询有着本质的区别:系统不再等待特定的完成信号,而是根据时间和 watermark 的比较结果以确定性的方式推进。
没有文件系统通知,没有完成标记,也没有“等待分区足够旧”的逻辑。每个触发周期都执行同样的简单操作:
列出对象存储中当前可见的分区。
根据时间戳排序确定最新的可见分区。
将其与 watermark(管道已确认的最后一个分区)进行比较。
处理最新分区,如果最新分区更新,则推进 watermark。
否则,立即退出。
分区排序基于编码在分区路径中的时间戳,后者代表上游快照生成的完成时间,而非事件时间或摄入时间。该时间戳是由单个上游作业分配的,因此排序会始终保持一致,且不依赖于各生产者之间的时钟同步。每个分区对应一个有限时间窗口内的数据,这使得进度实质上是由快照驱动而非事件时间驱动的,其正确性通过重叠重处理来实现,而非依赖严格的事件时间完整性。
仅处理最新的分区。这一特性使得进度具有确定性:每当出现比较新的分区时,管道便会向前推进,而且绝不会因为等待特定标记文件出现而阻塞。
这种方法实现了多重目标。每个微批次处理一个有限的工作单元(一个分区)。进度通过 watermark 比较推进,而非基于文件的完成信号。
即使与标记文件相关的对象存储出现可见性故障,管道也不会因此而停滞。新数据可以被快速捕获,而且不存在外部调度间隙。通常情况下,最糟糕的数据新鲜度延迟从约十分钟缩短至三十秒。
模式:通过选择新鲜度来处理延迟
延迟引发了一个不那么显而易见的问题:如果在触发器周期之间出现了多个新的分区,该如何处理?在严格的顺序流处理模型中,系统会按顺序处理每个未处理的分区。但我们故意没有采用这种方法。取而代之的是一条简单且一致的规则:
列出最新的可见分区。
将其与 watermark 进行比较。
如果该分区更新,则仅处理该最新分区。
忽略所有中间分区。
没有延迟阈值,也没有重放窗口。管道总是直接跳转到最新的可见分区。因此,在设计上,该系统就是以数据新鲜度为驱动的。如果当 watermark 位于 P0 时,分区 P1、P2 和 P3 变得可见,那么下一次执行将处理 P3,而不是 P1 和 P2。
这种行为是安全的,因为增量索引是在一个重叠的滑动窗口上运行的,而这种机制之所以可行,是基于几个实际的假设。每次增量运行都会重新计算一个近期的时间窗口,而非应用微小的增量变更。由于这些时间窗口存在重叠,所以大多数被跳过的分区在后续的运行中会自然地覆盖到。系统不需要处理每个中间状态,只需要到达最新的正确快照即可。在极少数情况下,如果跳过的内容过多而且重叠不足,那么任何遗漏的更新都会在下一次完整的索引重建时被捕获——作为有界恢复机制,重建过程每隔几小时会运行一次。
在需要按顺序重放每个事件的系统中,这种方法是不对的。但在这个用例中,重放中间增量不仅增加了延迟,还不能提升正确性。这一规则在稳态执行期间和重启过程中同样适用。
模式:通过跳转到最新内容重新开始
由于进度被严格定义为“最新可见分区与 watermark 的对比”,所以重启行为不需要特殊的回放逻辑。重启时,该任务将:
重新计算对象存储中的最新可见分区。
将其与持久化的 watermark 进行比较。
如果更新,则仅处理该最新分区。
否则,退出并等待下一个触发周期。
实际上,该逻辑采用了两种变体:部分作业会与存储在对象存储中的持久化 watermark 进行比对(该 watermark 以轻量级元数据的形式存储,通过原子覆盖操作进行更新),而其他作业在重启时则直接快进,将最新可见的分区视为已处理,并从该处继续处理。这种方法避免了对流式处理框架检查点机制的依赖,因为该机制专为顺序重放而设计,并不契合“新鲜度优先”的非顺序处理模型。
系统不会尝试重放中间分区或重建积压任务。无论在稳态执行还是恢复过程中,它都会始终一致地应用“新鲜度优先”规则,这使得重启过程既可预测又快速。重启不会引入补进阶段,而是直接恢复正常运行期间使用的确定性 watermark 比较机制。
持续执行和内存压力
在基于 JVM 的长期流式处理作业中持续运行,暴露出了计划批处理作业从未遇到过的问题。随着时间的推移,这种方法带来了以下后果:
尽管输入速率保持稳定,堆内存使用量却逐渐增加。
垃圾回收暂停的频率越来越高。
微批处理的完成时间变得难以预测。
这些后果很可能是长期运行特性与应用层状态积累(主要发生在索引构建逻辑中,而非 Spark 管理的状态)共同作用的结果,而非任何单个特定于平台的问题。虽然这些后果并不会立即显现出来,但它们会在长时间运行过程中逐渐累积,增加在运维层面对系统进行合理分析的难度。
模式:将计划性重启作为运营工具
我们没有试图对抗这种行为,而是主动接纳了它。该作业被设计为每 24 小时自动重启一次,即使在运行正常时也是如此。我们将这一周期选为了实用的运维基准,但可在每次部署时进行配置,并通过垃圾回收开销、长寿内存增长以及代码更新的自然周期等运维信号进行验证,而非基于严格的分析阈值得出。计划性重启实现了以下效果:
释放了积压的内存。
重置内部执行状态。
无需人工干预即可加载新代码。
试图让作业无限期运行,比将其干净地重启要困难得多。这一设计选择解决了资深工程师最初提出的许多运维顾虑。
模式:由看门狗管理的流式处理任务
为了使系统运行可预测,我们引入了一个轻量级的看门狗机制。该看门狗在流式处理运行时外部实现,它可以控制 API(在我们的部署环境中实现为调度器级控制器)与流式处理平台的交互,并监控和管理作业执行。看门狗负责监控作业的活跃状态,在作业意外终止时重启作业,强制执行周期性重启,并统一不同环境中的行为规范。
故障不再是破坏性的紧急事件,而是变成了可恢复的常规情况。
对端到端延迟的影响
迁移后,端到端延迟降低了约 50%。这里说的延迟是指数据新鲜度延迟,即从数据在对象存储中可用到索引可提供服务之间的时间间隔,通常以在正常运行条件下最坏情况的延迟来衡量,而非基于百分位数的指标。
例如,最坏情况下的延迟从大约 10 分钟缩短到了 30 秒,这主要得益于消除了调度器和编排延迟,而非每次批处理成本的变化。在正常负载下,增量更新没有再错过预期的数据新鲜度窗口。这一改进并非源于处理速度的提升,而是源于消除了批处理调度延迟、编排开销以及执行间隙的空闲等待时间。
生产环境应用效果
在生产环境的系统中,数据新鲜度的变化会直接影响广告展示效果和广告主的预期,我们观察到:
增量更新的传播速度更快了。
恢复行为可预测了。
与内存相关的故障减少了。
代码变更的部署更加便捷了。
最坏情况下的数据新鲜度延迟从大约十分钟缩短至三十秒,显著减少了此前因调度间隙导致的更新窗口错失情况。
这些结果是在未采用记录级流式处理的情况下取得的。这种方法最适合于面向批处理的系统。这类系统注重增量更新时效性,但并不那么注重单条记录的即时性。当数据摄取依赖于对象存储时,这种方法的效果尤为显著——在这种场景下,完成信号是推断出来的,而最终一致性使得基于文件的协调变得脆弱。
相比之下,对于那些需要严格保证每条记录的处理,有序重放所有历史事件,或停机后精确补全语义的系统,这种模式并不适用。在这些情况下,即使会带来更高的运维成本,记录级流式处理和完整重放也是必不可少的。
小结
这次迁移之所以成功,并非因为我们采用了流式处理这一概念,而是因为我们愿意摒弃那些在理论上看似正确、但在实际生产应用中却行不通的设计。通过放弃基于记录的流式处理、移除脆弱的完成信号、采用基于时间的进度机制,并将重启视为运行过程中的正常环节,我们最终构建了一个更简单、更可预测且更易于运行的系统。
当数据天然适合于有限快照,而目标是保持接近最新状态而非处理每个中间步骤时,这种模式的效果非常显著。但对于需要严格排序、完整重放所有事件,或对处理每个更新有强保证的系统,它并不适用。我们从中得到的主要启示是:最佳的流式处理设计是能在生产环境中可靠运行的设计,而非理论上看起来最优雅的设计。
原文链接:https://www.infoq.com/articles/micro-batch-streaming-lessons-learned/





