本文是对 How Uber Indexes Streaming Data with Pull-Based Ingestion in OpenSearch™ 的整理与翻译。
内容结构概览
本文会围绕 Uber 如何在 OpenSearch 中实现 pull-based streaming ingestion 展开,主要包括:
- Uber 搜索平台为什么需要实时、可扩展、高可用的数据索引能力
- Push-based ingestion 在大规模搜索系统里有哪些问题
- 为什么 Kafka 这类持久化流式缓冲区更适合吸收写入峰值
- OpenSearch 原生 translog 和同步文档复制在流式写入场景下为什么会成为负担
- Uber 如何向 OpenSearch 贡献 pull-based ingestion 框架
- IngestionPlugin、StreamPoller、IngestionEngine 分别承担什么职责
- OpenSearch shard 如何和 Kafka partition / Kinesis shard 一一映射
- 为什么 streaming ingestion 可以使用 no-op translog
- 从 Kafka 消息到 Lucene index 的完整数据流
- blocking queue、message processor、writer threads 如何提升吞吐
- 去掉 translog 后,shard recovery 如何保证不丢数据
- BatchStartPointer 是什么,为什么它是恢复流程的关键
- 多 writer 场景下为什么不能简单从“最后处理 offset”恢复
- external versioning 如何处理乱序消息
- Drop policy 和 Block policy 如何处理消息处理失败
- pause、resume、reset offset 这些 ingestion management API 的作用
- 两种 ingestion modes:segment replication 和 all-active ingestion
- Uber 多区域搜索架构为什么天然需要 pull-based ingestion
- regional Kafka topics、cross-region replication、global aggregated topics 如何形成全局数据视图
- Uber 为什么把 pull-based ingestion 视为 Cloud-Native OpenSearch 的基础
- 后续方向:concurrent pollers、priority-aware ingestion
- 对后端、搜索平台、Kafka/OpenSearch 架构设计的启发
一、背景:Uber 的搜索平台为什么需要流式索引?
Uber 的业务本质上是实时系统。
用户叫车时,需要搜索目的地、司机、路线和实时位置。 用户在 Uber Eats 下单时,需要搜索餐厅、菜单、商品和配送状态。 用户跟踪订单时,需要看到实时更新的位置和状态。
搜索在这些体验里不是一个后台辅助功能,而是很多业务路径的起点。
Uber 的搜索平台需要索引各种数据:
餐厅菜单
目的地
司机位置
配送员位置
订单状态
业务实体更新
推荐和检索相关数据
这些数据不仅规模大,而且变化快。搜索系统必须满足几个目标:
写入吞吐高
数据新鲜度高
可水平扩展
故障时可恢复
跨区域高可用
可以承受流量峰值
可以支持 failover
这也是为什么 Uber 的搜索平台架构建立在两个基础原则上:
pull-based ingestion model
active-active deployment
pull-based ingestion 基于 Kafka 这类流式系统,让搜索集群自己按照能力从数据流里拉取数据,而不是让所有 producer 直接把请求推到搜索集群。
active-active deployment 则意味着 Uber 在多个区域运行搜索集群,每个区域都需要维护新鲜、完整的数据索引。这样某个区域故障时,其他区域可以继续提供搜索服务。
这篇文章重点讲第一个原则:为什么 Uber 选择 pull-based ingestion,以及他们如何把这种能力贡献到 OpenSearch 中。
二、Push-Based Ingestion 的问题
很多搜索系统最常见的写入方式是 push-based。
也就是客户端应用直接调用搜索集群的 HTTP 或 gRPC endpoint,把文档写进去。
简化流程是:
Producer / Client Application
↓ HTTP / gRPC write request
Search Cluster
↓
Index into shards
这种模式一开始非常直观。
业务方要写数据,就调用搜索集群的写入 API。 搜索集群收到请求,解析、路由、写入索引、返回成功或失败。
但在 Uber 这种规模下,push-based ingestion 暴露出几个明显问题。
三、问题一:写入峰值会直接冲击搜索集群
在 push-based 模型里,producer 直接把请求打到搜索集群。
如果某个时间点写入流量突然暴涨,搜索集群必须立即承受这波流量。
如果流量超过集群容量,集群只能拒绝请求。
这会把问题推回给客户端:
客户端要处理 backpressure
客户端要判断是否 retry
客户端要决定 retry 间隔
客户端要避免 retry storm
客户端要保存失败请求
客户端要保证不丢数据
这会让每个 producer 都变复杂。
更糟糕的是,如果客户端重试逻辑写得不好,可能会形成经典的重试风暴:
搜索集群变慢
↓
客户端超时
↓
客户端重试
↓
搜索集群收到更多请求
↓
搜索集群更慢
↓
更多超时和重试
对于搜索平台来说,这不是一个好的责任边界。
写入峰值应该被一个持久化缓冲层吸收,而不是直接打到搜索集群和每个客户端的 retry 逻辑上。
四、问题二:缺少优先级控制
在繁忙系统里,不是所有 indexing request 都同等重要。
例如:
司机实时位置更新
菜单批量刷新
历史数据回放
低优先级后台补数
实时用户可见业务实体更新
这些请求对用户体验的影响完全不同。
如果搜索系统负载很高,应该优先保证关键实时更新,低优先级 bulk writes 可以慢一点。
但普通 push API 很难表达这种优先级。
客户端只是不断发送写入请求,搜索集群只能接收或拒绝。 如果没有统一调度层,就很难做到:
先处理关键实时写入
延后处理低优先级批量写入
在过载时按优先级控制 ingestion
这就是 push-based 模型在大规模多业务场景中的第二个问题:它缺少平台级 ingestion priority control。
五、问题三:数据 replay 很复杂
搜索系统经常需要 replay 数据。
比如:
从 snapshot 恢复一个集群
把一个集群迁移到另一个集群
同时向两个集群写入,做 live migration
修复索引错误后重新构建索引
补处理某段时间的数据
在 push-based 模型下,replay 通常需要额外工具。
你要找到原始数据来源,重新构造写入请求,然后把请求再次推给搜索集群。
这类流程通常:
手工步骤多
工具复杂
容易漏数据
容易重复写
不容易控制速度
不容易和线上写入协调
如果写入请求本身没有天然落在一个可回放的持久化日志里,那么 replay 就会变成一项复杂运维任务。
而 Kafka 这类流式系统天然适合 replay。
只要消息还在 topic 中,你就可以从某个 offset 重新消费。
这就是 pull-based ingestion 的价值。
六、Push 和 Pull 面对流量峰值时的区别
原文用图 1 对比了 push-based 和 pull-based 在写入峰值下的表现。
图里的核心思想可以这样理解。
在 push-based 系统中:
流量 spike
↓
客户端继续推请求
↓
搜索集群容量不足
↓
请求失败
↓
客户端自己处理 retry / backpressure
↓
如果处理不好,可能导致错误、丢数据或重试风暴
在 pull-based 系统中:
流量 spike
↓
消息先写入 Kafka 这样的 durable buffer
↓
搜索集群按自己的能力从 buffer 拉取
↓
短期产生 lag
↓
后续逐渐追上
两者最大的区别是:
push-based 把瞬时压力打到搜索集群;pull-based 把瞬时压力转化成可恢复的消费 lag。
这就是 pull-based ingestion 的核心优势。
lag 当然也不是好事,但 lag 比丢数据、请求失败、客户端重试风暴更可控。
只要流式缓冲区足够可靠,搜索集群后续可以慢慢追上。
七、OpenSearch 中 translog 和同步复制的成本
对 OpenSearch 来说,push-based indexing 原本依赖 translog。
translog 可以理解成事务日志或 write-ahead log。 它的作用是保证未提交的 index changes 在节点故障时仍可恢复。
在普通写入模型中,这是很重要的 durability 机制。
但在 streaming ingestion 模型中,durability 已经由 Kafka 或 Kinesis 这样的 streaming source 提供。
也就是说,消息已经持久化在 Kafka 里了。
如果 OpenSearch 再写一遍 translog,就会产生重复成本:
Kafka 保存消息
OpenSearch translog 再保存写入变化
Lucene segment 后续再持久化
在高吞吐写入场景下,translog 会增加 I/O 和存储压力。 原文还提到,在 heavy ingestion 下,translog 可能增长过大,带来 overflow 风险和稳定性问题。
此外,在 document-replication mode 下,系统还要等待其他节点完成同步复制。 这会增加每次写入请求的延迟。
在 pull-based streaming ingestion 里,Uber 的判断是:
durability 由 Kafka / Kinesis 负责
OpenSearch 应该专注于 indexing
translog 和同步文档复制不再是必要热路径
这就是他们为 OpenSearch 构建原生 pull-based ingestion framework 的原因。
八、OpenSearch Pull-Based Ingestion:Uber 贡献了什么?
Uber 向 OpenSearch 项目贡献了一个原生 pull-based ingestion framework。
这个功能从 OpenSearch 3.0 开始以 experimental release 形式提供。
它的目标是让 OpenSearch 不再只能被动接收外部 push 请求,而可以自己从 Kafka 或 Kinesis 这样的流式系统中拉取数据并索引。
原文图 2 展示了整体 streaming ingestion architecture。
这个框架新增和扩展了几个关键组件:
IngestionPlugin
ingestion-kafka
ingestion-kinesis
StreamPoller
IngestionEngine
no-op translog manager
下面逐个解释。
九、IngestionPlugin:连接不同流式系统的扩展点
Pull-based ingestion 的核心扩展点是:
IngestionPlugin
它允许 OpenSearch 接入不同 streaming platform。
目前支持两个插件:
ingestion-kafka
ingestion-kinesis
这说明设计不是写死 Kafka,而是抽象成“从某种 streaming source 拉数据”。
Kafka 和 Kinesis 的消费模型不同:
Kafka 使用 topic / partition / offset
Kinesis 使用 stream / shard / sequence number
如果把消费逻辑硬编码到 OpenSearch core 里,会让系统很难扩展。
IngestionPlugin 让 OpenSearch 可以用统一框架管理 ingestion,同时把 source-specific consumer logic 放到插件里。
十、StreamPoller:从 Kafka partition 或 Kinesis shard 拉数据
StreamPoller 负责 source-specific consumer logic。
也就是:
从 Kafka partition 拉消息
或从 Kinesis shard 拉消息
它并不直接负责把文档写入 Lucene。
它的职责是从流式系统中不断 poll 新消息,然后把消息交给后续 ingestion pipeline。
可以理解成:
StreamPoller = OpenSearch shard 内部的 Kafka/Kinesis consumer
这一步把 OpenSearch 从“等待外部写入请求”变成“主动从数据流拉取消息”。
十一、Shard 和 Stream Partition 的一一映射
Pull-based ingestion 中有一个重要设计:
每个 OpenSearch shard 映射到一个 stream partition / shard
也就是说:
OpenSearch shard 0 消费 Kafka partition 0
OpenSearch shard 1 消费 Kafka partition 1
OpenSearch shard 2 消费 Kafka partition 2
如果是 Kinesis,则对应 Kinesis shard。
这种静态一一映射让数据流和 OpenSearch shard 的关系更清晰。
在 index creation 时,OpenSearch 初始化每个 shard;与此同时,和这个 shard 关联的 stream consumer 以及专用 IngestionEngine 也会初始化。
这意味着每个 shard 都有自己的 ingestion pipeline。
这种设计的好处是:
数据分区边界清晰
恢复逻辑更可控
每个 shard 可以记录自己的消费进度
消费和索引路径更容易并行化
但它也意味着 Kafka partition / Kinesis shard 的数量需要和 OpenSearch shard 规划协调。
十二、为什么可以使用 no-op translog?
在普通 OpenSearch 写入路径中,translog 负责 durability。
但在 streaming ingestion 模式下,流式系统本身是 durable source of truth。
Kafka 消息已经持久化,并且可以 replay。 Kinesis 也提供类似可恢复消费能力。
所以 IngestionEngine 使用 no-op translog manager 替代传统 translog。
也就是说:
不再把每条 streamed data 的写入变化额外写入 translog
这带来几个收益:
减少写入热路径 I/O
减少存储压力
降低高写入速率下 translog 膨胀风险
让 OpenSearch 更专注于 Lucene indexing
但去掉 translog 也带来一个新问题:
如果 shard 失败,如何恢复未提交的数据?
答案是:从 stream 中 replay。
这就需要一个非常可靠的 recovery checkpoint 机制。后面会讲到 BatchStartPointer。
十三、从消息到 Lucene:完整数据流
原文图 3 展示了 streaming ingestion data flow。
一条消息进入 Lucene index,大致会经过五个部分:
Streaming source
Stream Consumer
Blocking queue
Message Processor
Ingestion Engine
逐个看。
十四、Streaming Source:Kafka 或 Kinesis 是数据源和恢复源
事件先被写入 Kafka topic 或 Kinesis stream。
这个 streaming source 有两个作用。
第一,它是 ingestion 数据源。
OpenSearch 从这里拉消息,并索引到 Lucene。
第二,它是 shard recovery 的数据源。
如果 shard 失败,OpenSearch 可以从某个 offset 或 sequence number 重新消费,恢复丢失的写入。
这就是为什么 pull-based ingestion 可以去掉 translog:恢复不再依赖本地 write-ahead log,而是依赖可回放的持久化数据流。
十五、Stream Consumer:每个 primary shard 拉取新消息
在每个 primary shard 上,会有 consumer 从 streaming source 拉取新消息。
拉到消息后,它不会直接同步写 Lucene,而是先写入 blocking queue。
如果 blocking queue 满了,poller 就等待队列释放空间,再继续从 Kafka 或 Kinesis 拉下一批消息。
这是一种天然 backpressure。
简化理解:
OpenSearch ingestion pipeline 处理不过来
↓
blocking queue 变满
↓
stream poller 暂停拉取
↓
Kafka / Kinesis 中形成 lag
↓
后续处理能力恢复后再追上
这比 push-based 模型更可控。
因为 backpressure 发生在 OpenSearch 自己的拉取速度上,而不是让外部 producer 反复失败和 retry。
十六、Blocking Queue:解耦消费和写入
blocking queue 是 stream consumer 和 message processor 之间的缓冲层。
它的作用是解耦:
从 Kafka / Kinesis 拉消息
处理消息并写入 Lucene
这两个动作的速度可能不同。
如果每次 poll 都同步处理,会降低吞吐。 有了 queue 后,consumer 可以持续拉取,processor 可以独立处理。
更进一步,blocking queue 还可以按 document ID 分区。
每个 queue partition 可以有自己的 writer thread。
比如:
document ID % N
把不同文档路由到不同 queue partition。
这样可以提高并行写入能力。
十七、Message Processor:校验、转换、生成 indexing request
Message Processor 运行在独立线程中。
它从 blocking queue 取出消息,然后做几类工作:
校验消息
处理消息
转换消息
生成 indexing request
最终,它把 indexing request 交给 IngestionEngine。
这里的 processing 可以包括 schema validation、数据转换、字段处理、删除/更新/upsert 等操作。
也就是说,Kafka 中的消息不一定直接就是 Lucene 文档,它需要被转换成 OpenSearch 的 indexing operation。
十八、IngestionEngine:写入 Lucene,并记录消费进度
IngestionEngine 是最终写入 Lucene index 的组件。
它负责:
添加文档
更新文档
删除文档
维护 no-op translog
在 commit 中记录已处理消息指针
对 Kafka 来说,这个消息指针是 offset。 对 Kinesis 来说,是 sequence number。
每次 commit 时,OpenSearch 会把最近处理的 message pointer 相关信息写入 commit metadata。
这对恢复流程至关重要。
因为系统要知道:
从哪个 offset / sequence number 重新开始消费才安全
这就引出 BatchStartPointer。
十九、去掉 translog 后,Shard Recovery 怎么做?
原文图 4 展示了 shard recovery 和 replica promotion。
由于 no-op translog 不再保留本地写入日志,恢复流程必须依赖 streaming source replay。
当 primary shard 失败,一个 replica 被提升为新的 primary 时,需要保证:
不能丢数据
不能产生不可接受的重复写入问题
恢复步骤大致是:
1. 新 primary shard 初始化 IngestionEngine
2. IngestionEngine 读取上次成功 commit metadata
3. 从 metadata 中找到 last known BatchStartPointer
4. 指示 stream consumer rewind 到 BatchStartPointer
5. 从该 offset 开始重新拉取消息
6. replay 并重新 index crash 期间可能丢失的 documents
这里最关键的是 BatchStartPointer。
二十、BatchStartPointer 是什么?
BatchStartPointer 是每次 commit 时保存的一个 checkpoint。
它表示:
当前 shard 中所有 active writer threads 正在处理的最小 offset
也可以理解成:
为了安全恢复,系统应该从哪个最早 offset 开始重新消费。
为什么不是记录“最后处理成功的 offset”?
因为 ingestion pipeline 可能有多个 writer threads 并行处理消息。 不同 writer thread 的速度不同。 如果只记录最快线程处理到哪里,很可能会跳过慢线程还没处理的消息。
BatchStartPointer 的核心是取最小安全点。
二十一、多 writer 示例:为什么不能从 offset 6 恢复?
原文图 5 用一个例子说明多 writer 场景。
假设 Kafka 里有文档 1 到 8。
blocking queue 按 document ID 分成两个 partition:
奇数文档进入 writer thread 1
偶数文档进入 writer thread 2
可能出现这种情况:
writer thread 1 只处理了 document 1
writer thread 2 已经处理了 document 2、4、6
如果此时 shard 失败,系统能不能从 offset 6 恢复?
不能。
因为 document 3 和 5 可能还没被 writer thread 1 处理。 如果从 offset 6 之后恢复,就会漏掉 document 3 和 5。
所以系统必须看所有 writer threads 的进度。
在这个例子里:
writer thread 1 最后安全处理位置 = 1
writer thread 2 最后安全处理位置 = 6
minimum = 1
因此 BatchStartPointer 应该是 1。
恢复时从 offset 1 开始重新消费,才能保证不会漏掉慢线程还没处理的消息。
那重复处理已经写入的 document 2、4、6 怎么办?
靠 document-level versioning 处理。
如果同一文档被重复处理,版本机制可以保证最终视图一致。
二十二、External Versioning:处理乱序和重复
流式系统中,消息不一定严格按业务生成顺序到达。
原因包括:
网络延迟
producer retry
跨区域复制
不同 producer 并发写入
恢复和 replay
如果不处理乱序,一个旧版本更新可能覆盖新版本,导致数据回退。
Pull-based ingestion 支持 external versioning。
也就是说,用户可以在每条消息里设置 document version。
OpenSearch 根据外部版本判断某次更新是否应该生效。
例如:
document A version 10 已经写入
后来收到 document A version 8
系统应该拒绝 version 8 覆盖 version 10
这对 streaming ingestion 非常关键。
因为系统提供的是 at-least-once processing guarantee。 在恢复或 replay 中,同一消息可能被重复处理。 有 external versioning,重复处理和乱序处理都不会轻易破坏最终一致视图。
可以总结为:
at-least-once 负责不丢
external versioning 负责最终文档状态一致
二十三、消息处理失败:Drop Policy 和 Block Policy
真实数据流不可能永远干净。
消息可能处理失败,原因包括:
schema mismatch
字段类型错误
无法解析
业务转换失败
文档格式不符合预期
Pull-based ingestion 提供两种处理策略。
1. Drop Policy
Drop policy 表示:
如果一条消息处理失败,就丢弃它,consumer 继续处理下一条消息
原文说,这是 OpenSearch pull-based ingestion 当前推荐策略。
原因是目前只支持 full-document upserts。
如果后续版本的完整文档更新会提供最新状态,那么丢弃某条坏消息通常可以接受。 后面的更新会把文档推进到最新状态。
这种策略更适合实时搜索索引场景,因为它避免了单条坏消息阻塞整个 partition。
它的取舍是:
牺牲单条失败消息
换取 ingestion pipeline 持续前进
2. Block Policy
Block policy 表示:
如果一条消息处理失败,consumer 会无限重试它
这个 source partition 会被阻塞,直到该消息成功处理
这种策略适合不能丢任何消息的场景。
比如某些业务要求每条消息都必须被处理,消息丢失不可接受。
但它也有风险:
一条 poison message 可能阻塞整个 partition
consumer lag 增长
数据新鲜度下降
所以 Block policy 更适合对完整性要求极高、且有能力快速修复坏消息的场景。
对多数搜索索引场景,Drop policy 更符合实时性目标。
二十四、Ingestion Management APIs:pause、resume、reset offset
除了数据流本身,pull-based ingestion 还提供了一组管理 API。
这些 API 让 operator 可以直接控制 ingestion pipeline:
pause ingestion
resume ingestion
reset consumer to specific offset
这些能力非常实用。
Pause
如果发现某个 index 有问题,例如 mapping 配置错误、下游处理异常、集群压力过高,可以先暂停 ingestion,避免继续扩大影响。
Resume
问题修复后,可以恢复 ingestion,让集群继续从 Kafka / Kinesis 拉数据。
Reset offset
reset offset 更强大。
如果系统因为长时间 outage 积累了巨大 backlog,而业务更关心追上实时数据,可以把 consumer reset 到某个更新的 offset,跳过大量旧消息。
这适合某些搜索场景:
旧数据已经不重要
最新状态更重要
需要快速恢复到实时处理
当然,reset offset 必须谨慎操作,因为它可能意味着跳过部分历史消息。
这也是为什么它属于 operator control,而不是默认自动行为。
二十五、两种 Ingestion Modes
Pull-based ingestion 提供两种运行模式:
segment replication mode
all-active ingestion mode
原文图 6 展示了两种模式。
二十六、Segment Replication Mode:主 shard 写一次,replica 复制 segment
默认模式是 segment replication。
在这个模式下:
只有 primary shard 从 streaming source 拉数据并 index
primary shard 生成 Lucene segments
replica shard 复制这些 segments
也就是说,真正昂贵的 indexing 工作只在 primary shard 上做一次。
replica 不重复执行 indexing,而是下载已经生成好的 Lucene segments。
这个模式的优势是:
节省 CPU
节省重复 indexing 成本
更高效
适合高吞吐写入
原文还提到,为了获得最佳性能和扩展性,推荐使用 remote store 来辅助 segment replication。
当然,这个模式也有一个代价:
replica 可见数据会有一点 replication lag
也就是 primary 已经 indexed 的数据,需要等 segment 复制到 replica 后才能在 replica 上可见。
对大多数场景,这个延迟可以接受。
二十七、All-Active Mode:所有 shard copy 都独立消费和索引
另一种模式是 all-active ingestion。
在这个模式下:
primary shard 从 streaming source 拉数据并 index
replica shard 也从同一个 streaming source 拉数据并 index
每个 shard copy 都独立构建自己的 Lucene segments
这更像传统 document replication mode:每个 shard copy 都执行写入工作。
优点是:
几乎没有 replication delay
文档一旦 indexed,很快在所有副本可见
增加 replica 不会影响 primary 写入吞吐
但代价也很明显:
每个 replica 都重复 indexing
整体计算资源消耗更高
CPU 成本更大
所以两种模式的取舍是:
segment replication:省资源,但有轻微复制延迟
all-active:延迟更低,但重复消耗更多计算资源
平台可以根据业务对延迟、成本、吞吐的要求选择模式。
二十八、Uber 的 Pull-Based Indexing Model
原文图 7 展示了 Uber 自己的 pull-based indexing model。
Uber 的搜索平台是多区域 active-active 架构。
每个区域都运行 OpenSearch cluster。 每个区域都需要完整、新鲜的全局数据视图。
这不是普通单区域搜索集群。
它必须支持:
跨区域高可用
任意区域故障时可 failover
每个区域都有完整索引
用户无论从哪个区域访问,都看到一致全局视图
为了做到这一点,Uber 的数据流大致如下:
Search platform gateway service 或 Flink jobs
↓
预处理数据
↓
按 schema 校验
↓
写入本地 regional Kafka topics
↓
Uber Kafka 基础设施做跨区域复制
↓
聚合成 global aggregated topics
↓
各区域 OpenSearch clusters 独立从 aggregated topics 拉取
↓
每个区域构建完整、最新 index
这套架构里,pull-based ingestion 非常关键。
因为每个 OpenSearch cluster 都可以从同一个一致的 global Kafka source 里拉取数据,独立构建自己的索引。
这样,每个区域的集群都有完整数据副本。
如果某个区域故障,流量可以切到另一个区域,用户仍然能获得一致搜索体验。
二十九、为什么 pull-based model 适合 multi-region active-active?
Push-based 模型在 multi-region 场景下会变复杂。
如果 producer 要把写入推到多个 OpenSearch clusters,就会遇到:
写入 fan-out 复杂
某个区域失败时要处理重试
不同区域写入成功状态不一致
live migration 和双写复杂
需要复杂 replay 工具
客户端承担更多逻辑
Pull-based 模型把问题简化了。
producer 不需要知道每个 OpenSearch cluster 的状态。 它只需要把数据写入 Kafka。
Kafka 的跨区域复制负责把数据带到 global aggregated topics。
然后每个区域的 OpenSearch cluster 自己拉取:
Region A OpenSearch 拉同一份 global stream
Region B OpenSearch 拉同一份 global stream
Region C OpenSearch 拉同一份 global stream
这样做的好处是:
producer 和 search cluster 解耦
每个区域可以独立追赶
某个区域落后只表现为该区域 ingestion lag
恢复后可以从 Kafka replay
区域之间不会因为写入请求同步而互相拖累
这非常符合 active-active 架构。
三十、Cloud-Native OpenSearch:更长期的目标
文章最后提到,Uber 的长期目标是把所有 search use cases 迁移到这种模型,并作为更大战略的一部分,走向 Cloud-Native OpenSearch。
这个下一代架构的方向是:
shared-nothing design
nodes operate independently
native cloud services for storage and compute
pull-based ingestion
也就是说,OpenSearch 节点之间尽量减少强耦合,每个节点更独立地运行,并利用云原生存储和计算服务。
Pull-based ingestion 在这里是基础能力。
因为它把 durability 和 replay 交给 streaming platform,把 OpenSearch 节点从 push-based request handling、translog pressure、同步复制等负担中解放出来。
这种模型更适合:
高吞吐流式写入
多区域高可用
云原生弹性扩缩
索引重建
故障恢复
数据 replay
三十一、后续增强方向:Concurrent Pollers 和 Priority-Aware Ingestion
原文最后提到两个后续增强方向。
1. Concurrent Pollers
当前 pull-based ingestion 已经支持通过 blocking queue partition 和 writer threads 提升写入并行度。
未来 concurrent pollers 可以进一步提升从 streaming source 拉数据的吞吐。
这对大吞吐场景非常重要。
如果单个 poller 成为瓶颈,就需要多个 poller 并行拉取,进一步提高 ingestion capacity。
2. Priority-Aware Ingestion
前面讲过,push-based 模型缺少优先级控制。
Pull-based ingestion 未来可以更自然地支持 priority-aware ingestion。
例如:
实时关键更新优先处理
低优先级 bulk writes 延后
不同 topic 或 message type 设置不同优先级
过载时先处理用户可见数据
这对 Uber 这种多业务搜索平台非常重要。
搜索索引不是所有数据都同等重要。 当系统繁忙时,平台需要按业务价值调度 ingestion。
三十二、从系统设计角度看:pull-based ingestion 的本质
Pull-based ingestion 的本质是改变责任边界。
Push-based 模型中:
producer 控制写入速度
search cluster 被动接收请求
backpressure 和 retry 由 producer 处理
durability 依赖 search cluster translog
replay 需要额外工具
Pull-based 模型中:
producer 写入 durable stream
search cluster 自己控制拉取速度
backpressure 表现为 stream lag
durability 由 stream 提供
replay 通过 offset / sequence number 完成
这不是简单把 HTTP API 换成 Kafka consumer。
它会影响整个系统:
写入峰值处理方式
故障恢复方式
跨区域复制方式
数据 replay 方式
OpenSearch 内部 translog 设计
shard recovery 机制
多 writer checkpoint 逻辑
replica ingestion 模式
operator 管理 API
所以 pull-based ingestion 是架构级变化。
三十三、对 Kafka + OpenSearch 架构的启发
很多团队都会用 Kafka 把数据写入 Elasticsearch 或 OpenSearch。
常见架构是:
Kafka
↓
Flink / Spark / 自研 consumer
↓
OpenSearch Bulk API
这种方式依然是 push-based。
外部消费程序从 Kafka 拉数据,然后调用 OpenSearch Bulk API 推数据。
它已经比 producer 直接写 OpenSearch 好很多,因为 Kafka 作为缓冲层存在。
但它仍然有一些问题:
外部 ingestion job 要维护
Bulk 写入失败和 retry 要处理
checkpoint 和 OpenSearch commit 状态分离
shard recovery 和 Kafka offset 不直接统一
OpenSearch 自己不知道流式数据源语义
translog 仍然可能保留成本
Uber 贡献的 pull-based ingestion 更进一步:
让 OpenSearch 自己成为 Kafka / Kinesis consumer
让 shard 和 stream partition 建立一一映射
让 offset / sequence number 进入 shard commit metadata
让 shard recovery 直接从 stream replay
让 translog 可以 no-op
这是一种更原生的集成方式。
它把 OpenSearch 从“被外部写入的存储系统”,变成“能直接消费流式数据源的索引系统”。
三十四、为什么这对云原生搜索很重要?
云原生架构强调几个方向:
解耦
弹性
可恢复
可重放
计算和存储分离
自动化运维
多区域部署
Pull-based ingestion 非常符合这些方向。
解耦
producer 不再直接依赖 OpenSearch cluster 的写入能力。 Kafka / Kinesis 作为 durable stream 解耦生产和索引。
弹性
OpenSearch 可以按照自己的能力拉取数据。 短期压力变成 lag,而不是请求失败。
可恢复
shard 失败后可以从 stream offset replay。
可重放
数据修复、索引重建、迁移都可以基于 stream replay。
多区域
多个区域可以从同一 global stream 独立构建索引。
降低热路径成本
no-op translog 减少额外 I/O。
这些都是云原生搜索系统需要的能力。
三十五、对后端工程师的启发
即使你不维护 OpenSearch,也能从这篇文章里学到几个架构原则。
1. 写入峰值最好变成 lag,而不是错误
如果系统能把短期峰值吸收到 durable buffer 中,后续慢慢追上,可靠性会更好。
这适用于很多系统:
搜索索引
异步任务
数据管道
事件处理
日志处理
推荐特征更新
2. Producer 和 consumer 应该解耦
producer 不应该知道所有下游消费细节。 它最好只写入持久化日志,下游按自己的能力消费。
3. Backpressure 要集中管理
如果让每个客户端自己处理 backpressure,系统会变复杂。 更好的方式是在平台层通过队列、lag、优先级控制来管理。
4. Replay 能力应该从第一天设计
很多系统后期才发现需要 replay。 但如果数据一开始就在 Kafka 这类可回放日志里,恢复和迁移会简单很多。
5. 去掉一个组件后,必须补上它原来的职责
OpenSearch 去掉 translog 后,不是简单删除日志。 它必须重新设计 shard recovery。 BatchStartPointer 就是为了解决这个问题。
6. 并行写入会让 checkpoint 变复杂
多 writer 场景下,不能记录最快线程的 offset。 必须记录最小安全进度,避免恢复时漏消息。
7. At-least-once 需要配合版本控制
只保证不丢消息还不够。 重复处理和乱序处理也要有版本机制保证最终状态正确。
8. 架构取舍要看业务目标
Drop policy 和 Block policy 没有绝对对错。
如果实时性更重要,Drop 可能更适合。 如果每条消息必须处理,Block 更适合。 关键是让 operator 能按场景选择。
三十六、我的理解:这是把 OpenSearch 从“被写入”变成“自己消费”
我觉得这篇文章最核心的一句话是:
Pull-based ingestion 让 OpenSearch 从一个被动接收写入请求的系统,变成一个主动消费流式数据的系统。
这背后是职责变化。
传统模型中,OpenSearch 像一个服务端:
你调用我
我写入
我返回结果
Pull-based 模型中,OpenSearch 更像一个流处理 consumer:
我知道自己负责哪个 shard
我知道对应哪个 Kafka partition
我按自己的节奏拉消息
我把 offset 写入 commit metadata
我失败后从 offset 恢复
这让 OpenSearch 更接近流式平台的一部分,而不是单纯的搜索数据库。
对 Uber 这种实时、多区域、高吞吐搜索场景来说,这种变化非常自然。
因为数据本来就在 Kafka 里流动。 搜索索引只是这些流式数据的一个 materialized view。
既然搜索索引是从流中构建出来的,那让 OpenSearch 直接消费这条流,比让外部服务不断调用 Bulk API 更贴近系统本质。
三十七、这篇文章和 Uber 其他 OpenSearch 文章的关系
Uber 最近还有一篇关于 OpenSearch gRPC 的文章,讲的是如何用原生 gRPC 提升 OpenSearch 的 Search 和 Bulk API 性能。
那篇文章关注的是:
client-server API
REST/JSON 到 gRPC/Protobuf
搜索和写入请求的传输效率
而这篇文章关注的是:
ingestion architecture
push-based 到 pull-based
Kafka/Kinesis 作为 durable stream
OpenSearch shard 自己消费数据
两者目标不同,但都服务于同一个方向:
让 OpenSearch 更适合 Uber 的大规模、实时、云原生搜索平台。
gRPC 解决的是 API 表示形式和传输性能。 Pull-based ingestion 解决的是数据写入模型、恢复模型和多区域索引架构。
它们结合起来,构成 Uber 推动 OpenSearch 云原生化的重要组成部分。
三十八、总结
Uber 这篇文章讲述了他们如何在 OpenSearch 中实现 pull-based ingestion,用于索引大规模流式数据。
Uber 的搜索平台支撑叫车、餐厅搜索、配送跟踪、司机和配送员实时位置等关键体验,因此必须满足高吞吐、低延迟、数据新鲜度、可扩展性和多区域高可用要求。为此,Uber 的搜索架构建立在两个基础原则上:pull-based ingestion model 和 active-active deployment。
传统 push-based ingestion 让客户端直接把写入请求推到搜索集群。这个模型在大规模场景下有几个问题:写入峰值会直接冲击集群,backpressure 和 retry 逻辑被推给每个客户端;缺少优先级控制,难以在高负载下优先处理关键实时更新;数据 replay 复杂,恢复集群、live migration、双写等场景需要额外工具。
Pull-based ingestion 的思路是把数据先写入 Kafka 或 Kinesis 这样的 durable streaming buffer,然后由 OpenSearch 按自己的能力拉取数据。这样,流量峰值不再表现为请求失败,而是表现为可恢复的 ingestion lag。Kafka / Kinesis 也成为数据恢复和 replay 的 source of truth。
为此,Uber 向 OpenSearch 贡献了原生 pull-based ingestion framework。它从 OpenSearch 3.0 开始以 experimental release 形式提供。框架新增 IngestionPlugin 扩展点,目前支持 ingestion-kafka 和 ingestion-kinesis。StreamPoller 负责从 Kafka partitions 或 Kinesis shards 拉取数据。每个 OpenSearch shard 与一个 stream partition / shard 建立静态一一映射。index 创建时,每个 shard 会初始化对应 stream consumer 和专用 IngestionEngine。
在 streaming ingestion 模式下,传统 translog 不再承担 durability,因为持久化和 replay 由底层 streaming platform 提供。IngestionEngine 使用 no-op translog manager,减少写入热路径上的 I/O 和存储压力,让 OpenSearch 专注于 Lucene indexing。
完整数据流是:
Streaming source
↓
Stream Consumer
↓
Blocking queue
↓
Message Processor
↓
Ingestion Engine
↓
Apache Lucene index
Stream Consumer 从 Kafka 或 Kinesis 拉取消息,写入内存 blocking queue。blocking queue 解耦 consumer 和 processor,并可按 document ID 分区,每个分区由独立 writer thread 处理,以提高写入吞吐。Message Processor 校验、处理并转换消息,生成 indexing request。IngestionEngine 执行 add、update、delete,并在 commit 中保存最近处理的 message pointer。
去掉 translog 后,恢复机制变得非常关键。OpenSearch 使用 BatchStartPointer 作为 checkpoint。它表示当前 shard 所有 active writer threads 中正在处理的最小 offset。primary shard 失败后,新的 primary 会读取最近 commit 中的 batchStartPointer,让 Kafka consumer rewind 到该 offset,从这里 replay 数据,确保不漏消息。
在多 writer 场景下,不能简单从最快线程处理到的 offset 恢复。比如一个 writer 只处理了 document 1,另一个 writer 已处理 document 2、4、6。如果从 offset 6 恢复,就会漏掉 document 3 和 5。因此必须取所有 writer thread 的最小安全进度作为 batchStartPointer。重复处理则通过 document-level external versioning 保证最终状态一致。
Pull-based ingestion 还提供了几个关键控制能力。external versioning 用于处理乱序和重复消息,避免旧版本覆盖新版本。处理失败时可以选择 Drop policy 或 Block policy:Drop policy 丢弃失败消息并继续处理,适合 full-document upserts 和实时搜索场景;Block policy 无限重试失败消息,适合消息不可丢的场景。管理 API 还支持 pause、resume 和 reset offset,方便运维在故障、积压和恢复场景中控制 ingestion。
在 ingestion mode 上,OpenSearch 支持两种模式。默认 segment replication mode 中,只有 primary shard 从 stream 拉取并 index,replica 复制 primary 生成的 Lucene segments,节省 CPU,但会有轻微 replication lag。all-active mode 中,primary 和 replicas 都独立从同一 stream 拉取并 index,几乎没有复制延迟,但每个 replica 都重复 indexing,计算成本更高。
在 Uber 的多区域搜索架构中,pull-based ingestion 是基础要求。Search platform gateway service 或 Flink jobs 会先预处理和校验数据,然后写入本地 regional Kafka topics。Uber 的 Kafka 基础设施负责跨区域复制,将 regional streams 聚合成 global aggregated topics。每个区域的 OpenSearch cluster 独立消费这些 aggregated topics,从同一份全局数据源构建完整索引。这样每个区域都有完整、新鲜的数据副本,可以支持 seamless failover,也能让不同区域用户看到一致的全局搜索视图。
Uber 已经开始把小规模 use cases 迁移到 OpenSearch pull-based ingestion model。长期目标是将所有搜索场景迁移到这种模型,并推动 Cloud-Native OpenSearch 架构:shared-nothing design,节点独立运行,并使用云原生存储和计算能力。未来他们还计划增强 concurrent pollers 以提升吞吐,并支持 priority-aware ingestion,让系统能优先处理关键实时数据,延后低优先级 bulk writes。
这篇文章最大的启发是:
对高吞吐、实时、多区域搜索平台来说,写入模型比写入 API 更重要。 Push-based ingestion 把压力和恢复复杂度推给客户端;pull-based ingestion 则用 durable stream 解耦 producer 和 search cluster,把流量峰值转化为可追赶的 lag,把故障恢复转化为 offset replay。
从这个角度看,OpenSearch pull-based ingestion 不只是一个新功能,而是搜索系统向云原生、流式化、高可用架构演进的重要基础。