Pinterest 的内部搜索引擎 Manas 是一个通用的信息检索平台。正如我们在上一篇文章中讨论的那样,Manas 被设计为兼具高性能、可用性和可伸缩性的搜索框架。如今,Manas 支持大多数 Pinterest 产品的搜索功能,包括广告、搜索、Homefeed、Related Pins、Visual 和 Shopping。
搜索系统的关键指标之一是索引延迟,也就是更新搜索索引以反映更改所花费的时间。随着我们系统的功能不断增加,新的用例持续引入,即时索引新文档的能力变得越来越重要。Manas 之前已经支持了增量索引,能够提供数十分钟数量级的索引延迟。不幸的是,这还不能满足我们来自广告和 following feeds 持续增长的业务需求。我们决定在 Manas 中构建一个新模块,以进一步将索引延迟减少到几分之一秒的水平。
在这篇博客文章中,我们描述了这一系统的架构及其主要挑战,并介绍了我们所做权衡的细节内容。
本文由 Michael Mi 发表在 medium.com,经授权由 InfoQ 中文站翻译并分享
挑战
新的需求伴随着新的挑战。以下是我们面临的几个主要挑战。
索引延迟
对于 Lucene、Vespa 等开源项目来说,小批(tiny batch)方法(又称近实时)是最受欢迎的选择。使用这种方法,只有在调用索引提交时才可以搜索新编写的文档。结果,你需要在索引延迟和吞吐量之间进行权衡。不幸的是,我们无法利用这种方法将索引延迟减少到几秒钟级别。
索引刷新能力
实时服务的缺点之一是缺乏索引刷新敏捷性。对于一个批处理管道来说,重新运行索引作业以立即获取所有模式更改是很简单。但当涉及到实时服务管道时,实现高效的索引刷新支持就是一件很复杂的事情了。
为不断变化的数据实现扩展
为了避免过度配置,系统采用了自动缩放以根据实际查询负载来调整副本。如果索引是不可变的,那么新副本创建起来就相对容易:你只需将索引复制到新节点即可。困难之处在于处理不断变化的索引:如何确保所有副本都具有相同的索引?
错误恢复
Manas 是一项数据密集型服务,其中每台主机可提供的索引高达数百 GB。Manas 也是一个有状态的系统,一个错误的二进制文件可能会导致连回滚都无法解决的数据问题。我们需要构建一个同时支持容错和错误恢复的系统,以便从二进制错误和数据损坏中恢复。
从静态到实时
我们来简要介绍一下常规静态服务和实时服务之间的区别。如上图所示,实时服务的主要工作是将索引管道从离线迁移到在线。
对于静态服务,索引是通过一个批处理工作流离线生成的,然后将它们复制到 Leaf 用以在线服务。对于批处理工作流,由于高昂的框架开销,几乎不可能在几分之一秒内建立可服务的索引。实时服务不是使用脱机工作流,而是在服务中即时处理所有写入。此外,实时索引管道用的是与静态索引管道相同的索引格式来处理写入,从而使我们能够重用整个索引读取逻辑。记住这一点,我们来继续了解实时服务的工作机制。
索引接口
我们不是直接使用 RPC,而是使用了 Kafka 作为我们的高写入吞吐流。Leaf 服务器不断拉取突变以建立增量索引。事实证明,这一决策以多种方式极大简化了我们的系统:
数据复制和写入失败由 Kafka 负责。
借助回查能力,Kafka 队列也可以用作WAL。
在每个分区中都有严格的顺序保证,系统可以随意应用删除操作,而不必担心正确性。
架构概述
由于服务逻辑可以通过共享索引格式重用,因此我们将重点放在索引数据流上。
本质上,实时 Manas leaf 是一个LSM引擎,它将随机 IO 写入转换为顺序 IO,并为读取放大和写入放大应用程序提供高效的服务。如下所示,整个索引流程包括三个关键步骤。我们来一一讨论。
实时段构建
除了现有的静态段(segment)外,我们还引入了实时段。如上所示,系统中有两种实时段:活动实时段和密封(sealed)实时段。
活动实时段是唯一可变的组件,用于累积从 Kafka 拉取的突变(添加/删除)。值得一提的是,将一个文档添加到一个实时段后,在文档级别提交后即可立即搜索。
一旦活动实时段达到一个可配置的阈值,它就会被密封,转为不可变并放入一个刷新队列中。同时,系统创建了一个新的活动实时段以继续累积突变。
在服务重启的情况下,可以通过重播来自 Kafka 的消息来重建各个实时段。
索引刷新
索引刷新是将内存中的数据从一个实时段持久存储到一个压缩索引文件中的过程。当一个实时段被密封时将自动触发一次刷新,并且还可以使用调试命令手动触发刷新。
索引刷新是一种有益的运算符,可确保数据持久性,这样我们就无需在重新启动期间从头开始重建内存中的段。此外,通过压缩的不可变索引,刷新减少了一个段的内存占用,并提高了服务效率。
索引压缩
随着时间的流逝,生成的多个小段会影响服务性能。为了克服这个问题,我们引入了一个后台压缩线程来将这些小段合并为更大的段。由于删除运算符只是将文档标记为已删除,而不是物理删除它们,因此压缩线程还会保留这些已删除/过期的文档。
在每个刷新和压缩运算符之后,将生成一个由所有静态段组成的新索引清单。一些 Kafka 偏移量(用作检查点)也被添加到每个清单中。根据这些检查点,服务就能知道重新启动后在哪里消费消息。
设计细节
在本节中,我们将更具体地介绍几个关键领域。我们从最有趣的部分开始,即并发模型。
并发模型
如前所述,实时段是我们需要同时处理读取和写入操作的唯一可变组件。不幸的是,那些开源项目采用的近实时方法无法满足我们的业务需求。相比之下,我们选择了另一种方法,使我们能够在添加到索引后立即提交文档,而无需等待索引刷新。为了提升性能,我们针对数据结构采用了一个无锁技术,以适应我们的使用状况。现在来深入到细节吧!
实时段
每个实时段都包含一个倒排索引和一个正排索引。倒排索引在逻辑上是从 term 到发布列表(用于检索的文档 ID 列表)的映射。同时,正排索引存储一个用于完整评分和数据提取的任意二进制 Blob。我们只关注实时倒排索引部分,与正排索引相比,它更有趣且更具挑战性。
从高层次上讲,实时段和静态段之间的主要区别是可变性。对于实时倒排索引,从 term 到发布列表的映射必须是并发的。folly 的并发哈希图等开源项目为此提供了很好的支持。我们更关心的是发布列表的内部表示,它可以有效地支持我们的并发模型。
仅附加向量
一般来说,单写入者/多读取者模型效率更高,推理起来也更容易。我们选择了与 HDFS 类似的数据模型,它具有仅附加的无锁数据结构。我们来仔细研究一下读取者和写入者之间的互动方式。
写入者将文档 ID 附加到向量中,然后提交大小(size)以使读取者可以访问它
读取者在访问数据之前获取一个快照(最大到提交的大小)
为了避免随着发布列表的增长而产生的内存复制开销,我们在内部将数据作为一个存储桶列表来管理。当我们的容量用完时,只需添加一个新的存储桶即可,无需接触旧的存储桶。另外,通常搜索引擎使用跳过列表来加快跳过运算符的速度。由于采用了这种格式,我们可以方便地支持一个单级跳过列表,这对于实时倒排索引已经足够了,因为它的大小通常很小。
文档原子性
现在有了仅追加的向量,我们就可以实现单个发布列表的原子性。但是,文档可以包含一个 term 列表,并且我们最终可能会返回带有部分更新索引的意外文档。为了解决这个潜在的问题,我们引入了一个文档级别提交,以保证文档的原子性。在服务管道中使用了一个额外的过滤器来确保仅返回已提交的文档。
说到文档原子性,文档更新是这里值得一提的另一种情况。对于每次文档更新,我们特意将其转换为两个运算符:添加新文档,然后从索引中删除旧文档。尽管每个运算符都是原子的,但加在一起我们就不能保证原子性了。我们认为可以在很短的时间窗口内返回旧版本或新版本,但尽管如此,我们还是在服务管道中添加了重复数据删除逻辑,以在同时返回新旧版本时过滤掉旧版本。
写缩放
一个自然而然的问题是,如果你的数据结构仅支持一次写入和多次读取并发模型,那么如果单个线程不能及时处理所有写入操作该怎么办?盲目添加更多分片只是为了扩展写入吞吐量,这似乎不是一个好主意。虽说这是一个确实存在的担忧,但在我们的设计中已经考虑到了这一点。
用于数据结构的一次写入和多次读取并发模型并不意味着我们不能使用多个线程进行写入。我们计划使用 term 分片策略来支持具有多个线程的写入。如上图所示,对于具有 term 列表的给定文档,每个 term 将始终映射到固定线程,以便为单次写入和多次读取定制的所有数据结构都可以无限制地直接重用。
索引刷新
索引刷新功能是我们产品的一项关键特性,可实现快速周转并提高开发速度。一般可以使用两种方法以高效刷新索引,它们分别是动态回填和从离线构建的索引恢复。
回填索引
我们提供了以合理的吞吐量回填文档的功能。为了避免影响生产的新鲜度,我们需要一个优先级较低的单独流来处理回填流量。结果,两个流中可能会存在文档的两个版本,而旧版本将覆盖新版本。为了克服这个问题,我们需要在实时索引管道中引入一种版本控制机制和一个冲突解决程序,以决定哪个版本更新鲜。
从离线构建索引中恢复
有时,以给定的速度对整个数据集进行回填会非常耗时。我们支持的另一种更快的索引刷新方法是离线构建索引,然后使用离线构建索引和 Kafka 流之间的同步机制来从离线索引中恢复索引。
故障转移和自动扩展
出于各种原因,我们有时会需要启动新实例,例如故障转移和自动缩放等。对于静态服务,使用从索引存储下载的不变索引来启动新实例是很容易的。但是,对于具有不断变化的索引的实时服务而言,这就变得很复杂了。我们如何确保新实例最终具有与其他实例相同的索引副本呢?
我们决定使用基于 Leader 的复制,如上图所示。
我们的流程如下所示:
Leader 定期拍摄新快照并将其上传到持久索引存储中
默认情况下,新实例从索引存储下载最新的快照
新实例根据快照索引中的检查点恢复消费来自 Kafka 的消息
一旦新实例赶上进度,便开始为流量提供服务
这种设计中有一些关键点值得一提:
Leader 选举
Leader 的唯一职责是拍摄快照并定期上传索引。这意味着我们可以在较短的时间内(最多几个小时)无 Leader 或有多个 Leader。因此,我们在选择 Leader 选举算法方面具有一定的灵活性。为简单起见,我们选择使用集群维护作业来静态地选举一个 Leader,在此我们会定期检查我们是否有一个好的 Leader。
快照上传
通常,新实例仅连接到 Leader 以下载最新快照。在这种方法中,从新实例下载快照可能会使 Leader 过载,从而导致级联故障。相反,我们选择将快照定期上载到索引存储,牺牲存储空间和新鲜度以保持稳定性。此外,上载的快照对于错误恢复很有用,稍后将对此介绍。
错误恢复
如上所述,错误恢复是实时服务系统的另一挑战。我们需要处理一些涉及数据损坏的特定场景。
输入数据损坏
我们使用 Kafka 作为输入写入流;不幸的是,这些消息是不可变的,因为生产者只能在其上附加消息,而不能更改现有消息的内容。这意味着一旦将数据损坏引入 Kafka 消息中,它将是永久性的。多亏了上传的快照,我们能够将索引回退到不损坏的状态,跳过损坏的消息,然后使用这个修复来消费新消息。
二进制错误导致数据损坏
尽管我们拥有成熟的静态集群索引验证管道,以确保在换入新版本之前新索引和新二进制文件均不会出现问题,但仍有一些错误会潜入生产环境。幸运的是,我们可以通过回滚二进制或索引来解决此问题。对于实时服务而言,回滚二进制文件无法回滚索引中的错误,这带来了更大的麻烦。使用快照上传机制,我们可以将二进制文件与回退的索引一起回滚,然后从 Kafka 重放消息以修复索引中的错误。
下一步计划
随着越来越多的场景加入 Manas,我们需要不断提高系统的效率、可伸缩性和能力。我们路线图中的一些有趣的项目如下:
共同托管静态和实时集群以简化我们的服务栈
优化系统以支持大型数据集
构建一个基于通用嵌入的检索以支持高级场景
致谢:这篇文章总结了几个季度的工作,涉及多个团队。感谢 Tim Koh、Haibin Xie、George Wu、Sheng Chen、Jiacheng Hong 和 Zheng Liu 的无数贡献。感谢 Mukund Narasimhan、Angela Sheu、Ang Xu、Chengcheng Hu 和 Dumitru Daniliuc 所做的许多有意义的讨论和反馈。感谢 Roger Wang 和 Randall Keller 的出色领导。
原文链接:Manas Realtime — Enabling changes to be searchable in a blink of an eye
评论