作者:来自 Elastic Milton Hultgren

Streams 是 Elastic Stack 中一种全新的统一数据管理方式。它将一组现有的 Elasticsearch 构建模块 ------ 数据流、索引模板、摄取管道、保留策略 ------ 封装成一个单一且一致的原语:Stream。用户无需再分别按正确顺序配置这些部分,而是可以依赖 Streams 安全且自动地编排它们。通过 Kibana 中统一的 UI 和简化的 API,Streams 降低了认知负担,减少了错误配置的风险,并支持更灵活的工作流,如延迟绑定------用户可以先摄取数据,再决定如何处理和路由。
但在这个简洁的用户体验背后,是一个快速演进的代码库。在这篇文章中,我们将探讨我们如何重新思考其架构,以跟上产品需求的步伐 ------ 同时为未来的灵活性和可扩展性奠定基础。
快速试验常常导致代码混乱 ------ 但在发布给客户之前,我们必须问:如果这个项目成功了,我们还能继续改进它吗?这个问题让代码健康成为首要考量。要想长期保持快速迭代,我们需要一个支持持续演进的基础。
大约六个月前我加入 Streams 团队时,项目正处于高度不确定性下的高速探索阶段。这种速度与不确定性的结合,为 "意大利面式代码" 创造了完美条件 ------ 由我们最资深的工程师编写,在缺少完整配方的情况下尽力而为。
这些代码务实且有效:它确实完成了目标。但它变得越来越难以理解和扩展。相关逻辑分散在多个文件中,缺乏关注点分离,使得很难安全地识别该在何处以及如何引入变更。而项目的路还很长。
最近,我们对底层架构进行了重构 ------ 不仅是为了让代码库更清晰、更有结构,还为了建立清晰的阶段划分,使调试和扩展更容易。我们的主要目标是构建一个能让我们继续快速且自信前进的基础。次要目标是支持新功能,如批量更新、试运行和系统诊断。
在这篇文章中,我们将简要探讨促使我们采用新方法的挑战,分享激发我们灵感的架构模式,解释新设计的底层工作原理,并强调它为未来带来的新可能。
我们面临的挑战
Streams 的目标是成为一种声明式的数据管理模型。用户只需描述数据应如何流动:它应该去哪里、在过程中需要进行哪些处理、以及应应用哪些映射。在后台,每个 API 请求都会导致一个或多个 Elasticsearch 资源发生变化。

在重构之前,底层代码越来越难以理解。每个请求都没有清晰的生命周期。数据只有在 "恰好需要时" 才会被加载,验证逻辑分散在不同函数中,而级联更改(例如子流响应父流更新)是递归且隐式发生的。Elasticsearch 请求可能在流程的任意阶段触发。
这带来了几个主要挑战:
- 没有清晰的验证位置
- 由于缺乏集中式的验证步骤,工程师们不确定该在哪里添加新的检查,或者现有的验证是否会可靠运行。有些验证发生得很早,有些则很晚。
- 无法清晰掌握系统整体状态
- 因为无法整体管理系统状态,很难推理或验证全局状态。我们无法轻易判断某个更改在所有现有流或依赖关系的上下文中是否合法。
- 副作用不可预测
- 由于 Elasticsearch 操作可能在流程的不同阶段发生,错误更难处理或回滚。我们没有一个明确的"提交点"来执行这些更改。
- 流逻辑交织
- 不同类型流的逻辑混杂在同一代码路径中,通常通过条件判断区分。这使得行为难以隔离、单独测试,或在添加新类型时避免意外影响。
这些挑战表明:我们需要一个更有结构的基础架构,既能支撑当前的复杂度,也能适应未来的扩展。
我们需要的前进方向
为了在保持信心的同时加快前进速度,我们需要一个能够优雅演化的基础架构,使行为更容易理解,并减少意外副作用的可能性。
我们围绕几个关键目标达成了一致:
- 清晰的请求生命周期
- 每个请求都应经过明确定义的阶段:加载当前状态、应用更改、验证结果状态、确定 Elasticsearch 操作、执行操作。这样的结构有助于工程师理解事情发生的时机和原因。
- 统一的状态模型
- 我们希望有一个清晰的 "期望状态 vs 当前状态" 模型 ------ 一个能集中推理变更结果的地方。这样可以实现更安全的验证、更高效的更新、更容易的调试,因为我们可以直接计算两者之间的差异。
- 单一提交点
- 所有 Elasticsearch 更改都应集中在一个地方执行,在一切验证通过并明确知道需要修改的内容后再进行。这能减少副作用,使错误处理更容易,并支持"试运行"(dry run)。
- 隔离的流逻辑
- 我们需要在不同流类型之间建立更清晰的分隔,使每种类型都能独立开发和测试。这会简化新类型的添加、减少意外影响,并明确某个改动属于流类型本身还是状态管理层。
- 批量操作与系统自省
- 最后,我们希望支持批量更新、试运行和健康诊断等功能------这些在旧架构中很难甚至无法实现。一个更明确、可检视的系统状态模型将使这些成为可能。
这些目标成为我们探索新架构模式的北极星,而核心思想就是 ------ 对比当前状态与期望状态。
灵感来源
我们的新设计借鉴了两个知名开源项目:Kubernetes 和 React 。虽然它们在领域上截然不同,但都共享一个核心概念:调和(reconciliation)。

Reconciliation(协调)意味着比较两个状态,计算它们的差异,并采取必要的操作,使系统从当前状态转变为期望状态。
- 在 Kubernetes 中,你声明资源的期望状态,控制器会持续地让集群与该状态保持一致。
- 在 React 中,每个组件定义自己应该如何渲染,而虚拟 DOM 会高效地更新真实 DOM,使其与预期渲染结果一致。
我们还受到了 Plan/Execute(计划/执行)模式的启发,它旨在将决策过程与执行过程分离。这正符合我们的需求 ------ 在执行任何操作之前完成所有验证,从而能在提交前清楚地理解并检查系统的意图。
这些概念与我们的目标不谋而合。它们让我们意识到,我们需要两个关键部分:
-
一个表示系统状态的模型,用于比较状态并驱动整体工作流程(类似于 Kubernetes 的控制循环);
-
一个由各个 stream 组成的状态表示,每个 stream 处理自身类型的特定逻辑(类似于 React 组件)。
每个 Stream 都在 Elasticsearch 中定义和存储。我们发现原先代码中数据管理与状态变更之间存在脱节,因此我们重新设计了每个 stream,使其同时负责两者。这自然契合了 Active Record(活动记录)模式,其中类同时封装了领域逻辑和持久化逻辑。
为了让系统更易扩展并简化状态模型的接口,我们使用 Template Method(模板方法)模式实现了一个抽象的 Active Record 类,明确规定了新 stream 类型必须遵循的接口。
我们起初担心采用这些更高级的模式(如 reconciliation、Active Record 和 Template Method)可能会让新成员或经验较少的工程师更难上手。虽然对熟悉这些模式的人来说代码会更简洁、更直观,但对新人来说可能会形成理解门槛。
然而,实践结果恰恰相反:代码变得更容易理解,因为这些模式提供了清晰、一致的结构。更重要的是,这些架构选择让团队的注意力集中在领域本身,而非复杂的实现细节上。模式存在,但代码讨论的不是模式,而是业务领域。
我们如何构建这个系统
当请求到达 Kibana 的某个 API 端点时,处理器会先执行基本的请求验证,然后将请求传递给 Streams Client。Client 的职责是把请求转换为一个或多个 Change 对象。每个 Change 表示一个 Stream 的创建、修改或删除。
这些 Change 对象会被传递给我们引入的一个核心类 ------ State,它承担两个关键角色:
-
保存组成系统当前版本的所有 Stream 实例;
-
协调整个变更处理流程,负责应用变更并完成从旧状态到新状态的转换。
下面我们来看看 State 类在应用变更时管理的关键阶段。

加载初始状态
首先,State 类通过读取存储在 Elasticsearch 中的 Stream 定义来加载当前系统状态。这成为后续所有比较的参考点 ------ 用于验证、差异计算和操作计划。
应用变更
我们从克隆初始状态开始。每个 Stream 负责克隆自身。然后处理每个传入的 Change:
-
将 Change 提供给当前状态下的所有 Stream(如有必要,创建新 Stream)。
-
每个 Stream 可以通过更新自身来响应,并可选择性地发出级联变更------对相关 Stream 产生连锁影响的附加变更。
-
级联变更会在循环中处理,直到不再生成新的变更(或达到安全阈值)。
-
然后处理下一个请求的 Change。
如果任何请求或级联 Change 无法安全应用,系统会中止整个请求,以防止部分更新。
验证期望状态
应用所有 Change 及其级联效果后,运行验证以确保最终配置安全且一致。
每个 Stream 会在整个期望状态和初始状态的上下文中进行自我验证。这既允许局部检查(在单个 Stream 内部),也支持跨 Stream 的协调。如果验证失败,系统会中止请求。
确定操作
接下来,每个 Stream 会确定从初始状态到期望状态需要执行哪些 Elasticsearch 操作。这是系统首次需要考虑哪些 Elasticsearch 资源支撑单个 Stream 的点。
如果请求是试运行(dry run),我们在此停止并返回操作摘要。如果是执行请求,则进入下一阶段。
计划与执行
Elasticsearch 操作列表会交给一个专门的类 ExecutionPlan 处理。该类负责:
-
解决单个 Stream 无法独立处理的跨 Stream 依赖;
-
将操作按正确顺序组织,以确保安全应用(例如在路由规则更改时避免数据丢失);
-
在顺序约束下尽可能最大化并行执行。
如果计划成功执行,API 会返回成功响应。
处理失败
如果计划在执行过程中失败,State 类会尝试回滚 ------ 它会计算一个新计划,将系统从期望状态恢复到初始状态,并尝试执行。
如果回滚也失败,我们有一个备用机制:"重置"操作,重新应用存储在 Elasticsearch 中的已知良好状态,完全跳过差异计算。
深入 Stream Active Record 类
State 中的所有 Stream 都是抽象类 StreamActiveRecord 的子类。该类负责:
-
跟踪 Stream 的变更状态;
-
将变更应用、验证和操作确定路由到其具体子类实现的模板方法钩子中。
这些钩子包括:
-
Apply upsert / Apply deletion
-
Validate upsert / Validate deletion
-
Determine actions for creation / change / deletion
通过这种架构,我们创建了从输入到操作的清晰、分阶段、声明式流程 ------ 模块化、可测试且对失败具有弹性。它清晰地将通用 Stream 生命周期逻辑(如变更跟踪和编排)与 Stream 特定行为(如某个 Stream 类型的"upsert"含义)分离,构建了高度可扩展的系统。该结构允许我们隔离副作用、自信地进行验证,并更清晰地推理系统级行为,同时支持试运行和批量操作。
现在我们已经介绍了其工作原理,让我们看看这带来了哪些新能力、安全保障以及可能的新工作流。
这带来的价值
我们采用的基于 reconciliation(协调)的设计不仅更易于理解,还直接解决了早期系统中的许多核心限制。
批量操作与试运行,内建支持
我们的一个关键目标是支持在单次请求中对多个 Stream 进行批量配置更改。旧代码中,由于副作用与决策逻辑交织,批量应用更改存在风险。
现在,批量更改是默认行为。State 类可以处理任意数量的变更,自动跟踪级联效应,并整体验证最终结果。无论你更新一个 Stream 还是五十个,流程都能一致处理。
试运行(dry run)也是一个重要功能。由于操作现在在无副作用阶段计算 ------ 在发送到 Elasticsearch 之前 ------ 我们可以生成完整的预览,包括哪些 Stream 会改变以及具体执行哪些 Elasticsearch 操作。这种可见性帮助用户和开发者做出自信且明智的决策。
调试更容易,诊断更高效
在旧系统中,调试需要重建执行上下文并拼凑副作用。现在,流程的每个阶段都是明确的,可单独测试。
由于验证和 Elasticsearch 操作直接绑定到 Stream 的定义和生命周期,任何不一致或错误更容易追踪到源头。
执行前进行验证和规划
现在,我们在执行任何更改之前进行验证和规划,极大地降低了系统处于不一致或部分更新状态的风险。所有操作都事先确定,只有在确认整个变更集合有效且一致后才执行。
如果执行过程中出现问题,我们可以依赖于内存中完整建模的起始状态和期望状态,自动生成回滚计划;当回滚不可行时,可以回退到存储的完整状态。简而言之:安全性是内建的,而不是事后附加的。
默认可扩展
添加新类型的 Stream 过去意味着需要编辑分散在多个文件中的逻辑。现在,这是一个集中且定义明确的任务:只需继承 StreamActiveRecord 并实现少数生命周期钩子即可。
就这么简单。编排、跟踪和依赖处理已经配置好。这也意味着新开发者更容易上手,或者在不破坏系统其他部分的情况下尝试新的 Stream 类型。
测试更容易
由于每个 Stream 现在被封装,并具有清晰、独立的职责,测试变得更加简单。你可以通过模拟特定输入来测试单个 Stream 类,并断言级联变更、验证结果或 Elasticsearch 操作的正确性。无需启动完整端到端环境即可测试单个验证。
接下来是什么
在 Elastic,我们遵循我们的 Source Code(源码准则),其核心理念是 "Progress, SIMPLE Perfection",提醒我们应偏向稳步、渐进式改进,而非追求完美。
这个新系统是一个坚实的基础 ------ 但这仅仅是开始。到目前为止,我们的重点是清晰性、安全性和可扩展性,虽然解决了一些长期存在的痛点,但仍有大量改进空间。
持续改进
我们有意在一个明确的范围内发布此工作,并已确定在接下来的几周内将添加的几个增强功能:
-
引入锁机制
为安全处理并发更新,我们计划引入锁机制,以防止并行修改时的竞态条件。
-
通过 API 暴露批量和试运行功能
State 类已经支持这些功能------现在是时候让用户也能使用它们。
-
改进调试输出
由于状态转换现在被明确建模,我们可以提供更清晰的诊断信息,帮助用户和开发者理解变更。
-
避免冗余的 Elasticsearch 请求
当前验证过程中存在多次冗余请求。引入轻量级内存缓存可以避免重复加载相同资源。
-
改进访问控制
当前我们依赖 Elasticsearch 来强制执行访问控制。由于一次变更可能涉及多个资源,事先确定所需权限非常困难。我们计划在操作定义中添加权限元数据,使我们能够在执行操作前验证所需权限的完整集合,从而在计划运行前就能检测并报告缺失的权限。
-
添加 APM(应用性能监控)仪表
系统结构现在分为明确阶段,使我们能够添加性能监控,帮助识别瓶颈并随着时间提升响应能力。
-
重新评估职责
随着编排机制变得更健壮,我们也在重新评估其应处的位置。例如,大规模批量操作可能最终更适合靠近 Elasticsearch 执行,以获得更高的原子性和更紧密的性能保障。在早期阶段,深度集成还为时过早------当时我们还在探索系统的正确抽象和阶段。但现在设计已稳定,我们更有条件开始这一讨论。
为演化而设计
我们设计此系统时就考虑了适应性。无论改进是以内部重构、更好的开发者体验,还是与 Elasticsearch 更深层次的协作形式出现,我们都具备持续演化的能力。该架构本身模块化设计------既提供了可靠的稳定性,也保留了灵活扩展的空间。
总结
构建健壮且可维护的系统不仅仅关乎代码 ------ 更关乎将架构与产品不断变化的需求和方向保持一致。我们在重构 Streams 的过程中再次印证了,循序渐进、深思熟虑的方法不仅提升了技术清晰度,也让团队能够更快行动,更自信地创新。
如果你正在处理类似挑战的复杂系统 ------ 无论是逻辑纠结、不可预测的副作用,还是对可扩展性的需求------你并不孤单。我们希望我们的故事能为你提供一些有用的见解和灵感,帮助你规划自己的前进道路。
我们欢迎社区的反馈和协作 ------ 无论是问题、想法还是代码。
想了解更多关于 Streams 的内容,可参考:
-
查看 Streams 网站
-
查看 GitHub 上的 pull request,深入了解代码或加入讨论。