撰写:Pierre Poitevin,高级软件工程师| Daniel Geng,软件工程师|萧湖李,工程经理
(第一部分)
问题
Tinder Eng团队最近一直在致力于将机器学习(ML)算法集成到Tinder推荐系统中。Tinder推荐系统是用于向用户提供建议的内容,然后用户可以使用滑动右或滑动左特征。本建议书在博客文章中讨论:PoweringTinder? - 我们匹配后面的方法。
首先,我们提出了几个潜在的选择,但它们都依赖于许多更多的特征(或用户特性),而不是我们当时使用的其他算法。当我们测试这些ML算法时,它们并不像非ML算法那么快。Elasticsearch(ES)需要更长的时间来返回我们查询的许多特征的结果。此外,Painless脚本是我们用于ML查询的脚本,具有16384个字符的硬限制(这被改为在写作时的可配置限制,但我们在努力工作时并非如此),我们正在密切接近。我们还注意到Painless有其他问题,例如没有静态变量或方法,这导致了性能问题,因为它强迫ES以一遍又一遍地重新实例化相同的对象。
要解决字符限制问题,我们试图将一个大脚本拆分为多个较小的脚本,但注意到查询性能在我们完成时变得更糟。我们知道Painless脚本,ES插件的替代方案,它允许我们在ES侧安装新功能。这样,我们可以将函数放在Java代码中并安装它而不是使用无痛脚本。但是,我们无法使用Plugin功能,因为插件的每个更新都需要一个完整的群集重启,这不仅是昂贵的,而且还降低了我们系统的可靠性和可操作性。
目标
我们在该项目中的目标是改善当前的推荐系统,以便我们可以支持ML算法而无需性能下降。此外,我们希望能够经常迭代新算法,并且更新是无痛的。
解决方案
大意
为了克服属性限制和性能问题,我们的主要思想是利用ES推荐插件的速度。由于我们经常无法重新启动群集,因此第二种想法是设计一个能够添加和更新新匹配算法而无需强制重启的系统。
架构
在本节中,我们为Java和ES提供了一些背景,以及我们如何利用这些技术构建一个可以在运行时加载匹配算法的脚本管理系统。
背景:Java,动态编译的语言
与c一样,java是一个编译的语言,但与c不同,java编译器不会将代码转换为二进制文件,而是进入字节码。然后在Java虚拟机(JVM)的运行时处理此字节码。JVM允许定义新类或在运行时重新加载新版本的现有类。这就是为什么Java称为动态编译。负责加载和定义JVM中的类的主类是ClassLoader。可以扩展此类以控制加载逻辑。ClassLoader可以称为代码中的任何位置,以请求要加载的新类。
在我们实现的插件中,我们使用此特性来在运行时更新类定义,而无需重新启动。
ES背景
ElasticSearch是索引系统,存储我们用于搜索和提供建议的用户文档。es是开源,可以在github上找到不同的代码版本。有很多方法可以查询Elasticsearch。最简单的方法之一是将脚本或搜索算法存储在Elasticsearch中,然后发送引用脚本的查询。这样,当Elasticsearch解释查询时,它知道用于搜索和返回结果的算法。
在ES搜索中搜索大致发生在两个步骤:过滤和排序。在过滤器步骤中,从结果中排除了不匹配过滤条件的所有文档。在排序步骤中,拟合过滤条件的所有文档都被分配了从最高到最低点的相关性因子,并将响应返回调用者。
在Elasticsearch中,插件是一种增强Elasticsearch基本功能的方法。有许多不同类型的插件,它们允许添加自定义类型,或将新端点暴露给Elasticsearch API。
最感兴趣的插件类型是脚本引擎或脚本插件。这种类型的插件允许我们自定义相关性分配为文档完成的方式。
在以下段落中,我们讨论有关脚本插件的一些详细信息。我们使用了Elasticsearch 6.3;词汇,名称和逻辑可以从版本更改为版本,可能不适用于Elasticsearch的未来版本。
Scriptengine 概述
脚本插件本质上是从查询参数(“params”)和文档(“search”)到相关性因子的“run()”功能。对于每对“(params,查找)”,ES需要调用“run()”以计算与此插件的相关性。
要实现ScriptPlugin,我们大致需要实现或扩展运行Plugin脚本时调用的每个类。
注释:
- scriptengine.compile()方法仅调用一次。它在内存中缓存了搜索性。然后,对于使用相同脚本的所有后续查询,高速缓存将用于提供SearchScript.Factory。由于我们希望在某些情况下更改脚本(例如新版本),我们知道该方法不会再运行,因此我们无法使用该方法中特定于版本的代码
- SearchSiteFactory处理查询级别搜索
- SearchScript.leaffactory处理Lea有关文件部分的相关性计算。
为了能够在脚本中处理不同的版本,我们使用委派模式。
在SearchScriptFactory的NewFactory()方法中,我们调用FactoryCache,它是我们的内存搜索序列.Factory存储系统,它将动态提供需要使用的搜索性实现。
在我们设计的架构中,Scriptengine实际上不是一个简单的脚本提供商,而且是一个抽象的图层处理查询和脚本之间的路由。
加载新脚本
正如我们在上一节中看到的那样,我们在内存缓存中检查查询的参数并返回适当的匹配算法。缓存层加载新的推荐脚本,因为它由查询驱动。
加载新脚本等同于从从存储系统获得的JAR文件加载Java类。如果类已经存在,但我们需要它的新版本,我们将使用新类定义过载类定义。我们需要编写自定义类加载器,以将当前JVM中的类过载,以其新的定义。
例如,让我们假设当前的JVM具有来自前一个jar定义的class myscript.class(v1)和a.class(v1)。在一个新的jar中,我们有myscript.class(v2),取决于a.class(v2)和b.class(v2)。
当我们从新jar请求myscript.class时,ClassLoader将检查新jar以获取MyScript.class的定义。
然后,ClassLoader将覆盖当前定义,同样为a.class,它将从新jar添加b.class。在操作结束时,JVM将具有MyScript.class(v2),a.class(v2)和b.class(v2)。
必要时缓存脚本
一旦我们完成加载脚本类,我们将其存储在缓存中。我们使用查询的“源”字段来通过缓存中的名称和版本存储脚本,以传递我们想要计算的名称和版本。我们在脚本管理器中使用了一个简单的Guava LoadingCache。需要缓存,因为在光盘中的jar或存储中加载脚本,不能在几千Qps中缩放。某些脚本可能已被折旧,或者长时间未使用,并且加载CACHE为此目的支持CacheBuilder中的自定义驱动逻辑。
以下是向ScriptEngine提供正确的SearchScript.Factory所涉及的主要类概述:
查询范围与文档范围
在某些情况下,为每个文档运行相同的代码在资源中浪费,并且我们需要每次查询运行它,并在后方计算每个文档的相关性时使用计算结果。
由于SearchScript.Factory处理查询范围和SearchScript.leAfactory处理各个文档范围。在查询级别计算的所有内容都是在searchscript.factory.newfactory(params,lookup)中完成的一切(参见片段中的CoplEforQuery调用)。另一方面,与个人文档相关性相关的一切都是在SearchScript.leaffactory.newinstance()方法中完成的。
查询客户端的更改
由于Params是ES客户端以JSON格式发送的地图,因此可以通过更改Params的内容来自定义任何行为的更改。例如,如果查询包含Param“USE_NEW_ALGORITM”,我们可以拼接并使用不同的匹配算法而不耦合ES到动态标志系统/管理器。
审查开发流程
在这个项目中,其中一个目标是确保新的匹配算法的发展并不太麻烦。对于开发人员来说,更新脚本的工作流程是以下内容。
- 编写代码,jar中的包,并上传到存储系统
- 在es查询中指定脚本的新版本
- 插件将使用正确的脚本版本并对结果进行排序
这种工作流程是简单的,并且从运营和开发角度都很快速。因此,我们实现了保护系统可维护性和迭代性的目标。
可观察性
ES是建议框架的重要组成部分,因此插件至关重要。虽然es本身具有自己的一组系统指标,但没有一种简单的方法来添加特定于插件的指标。我们使用prometheus来监视我们的微服务,因此更轻松的操作集成来对插件使用它来说是有道理的。对于微服务器,每台计算机都会托管一个公开“_metrics”端点的ProMetheus服务器。外部客户端,可以访问负载均衡器后面的单个计算机调用此端点并聚合结果。但是,我们希望将ES与Prometheus等第三方服务分离,因此我们开发了一种自定义解决方案。
es已有一组用于监控其系统度量的_cat API。例如,如果从任何查询节点访问_cat / nodes api,则它将使用TCP聚合来自群集中的所有节点的指标并返回结果。我们通过使用ActionPlugin添加自己的_cat / pluginmetrics API来利用此现有模式,该ActionPlugin用于在ES上创建自定义API。这样,而不是在每个节点上托管Prometheus服务器,并要求客户端可以访问各个节点,普罗米修斯客户端可以简单地使用负载均衡器端点使用新的ColfInmetrics API。此API返回相当于查询群集中的每个单独计算机的响应,同时将相同的格式保持为Prometheus服务器,因此操作团队安装监控很简单。
安全
我们正在从jar存储系统下载jar数据。此罐可以访问我们在Elasticsearch上存储的敏感数据。即使我们控制存储,我们必须假设当我们收到它时可能会被篡改。出于安全用途,我们实现了3个步骤,允许我们验证JAR文件中的代码来自可靠的源:
- jar用存储在密钥库中的私钥签名
- jar签名在ElasticSearch插件中验证
- 包含该代码的存储库具有具有明确审批人的受保护分支机构
这样,我们控制将在运行时加载的代码的真实性。
整体架构
发布
在我们的插件的第一个版本上,我们选择使用Painless脚本重新实现与匹配脚本相同的匹配脚本,因此我们可以获得苹果到苹果的比较。由于无痛脚本的语法与Java相当类似,因此将其转换为具有次要修改的本机Java代码很简单。
只需这样做,我们会看到延迟的稳固改善,从超过500ms到小于400ms。
很明显,我们通过从无痛到原生Java插件切换出一些解析时间;但是,主要好处实际上来自直接使用Java的灵活性。例如,在我们的匹配脚本中,我们必须对阵列执行二进制搜索,这在所有查询中都是常量的。在无痛脚本的世界中,由于没有静态变量的支持,需要为每次命中进行重建以计算阵列,这意味着JVM需要为其分配内存并之后处理GC。这是考虑我们推荐系统规模的巨大浪费。
另外,通过利用Jenkins自动化管道,每当我们需要推动新的相关函数版本时,它现在更加简化。
为了控制我们的工作质量,我们分别为我们的分期和生产环境设置了两个管道,还有一个测试ES集群。这是它们的样子:
每次我们想推出一个新的排序脚本,这里都是过程:
- 手动暂存测试:我们使用Jenkins暂存管道构建jar,上传到文件存储系统,并在暂存Env中部署我们的服务器侧代码,以调用最新版本的脚本。此步骤是检查我们新脚本的任何明显的语法/加载错误,并确保它可以成功执行,并预期实际计算的相关性。
- 金丝雀环境中的大规模数据验证:由于历史原因,我们的生产集群中存在一些现场级别的架构不一致。例如,某些日期字段是某些文档的ISO字符串,但为其他文档进行UNIX时间戳。这就是为什么我们通过复制我们的生产数据并将我们的服务器发送查询作为下一步来设置单独的金丝雀群集的原因,以确保我们的脚本可以处理所有此类边缘案例。
- 生产中的Dark运行:一旦完成前2个步骤,我们现在对我们脚本的正确性有很大的信心。但是,运行时性能,尤其是延迟仍然不清楚。为避免使用长期延迟和损害我们的用户体验的脚本,我们设置了一个DarkRUN步骤,以向生产服务器发送查询,以便在火灾和忘记的方式中加载脚本。通过这样做,我们能够收集性能指标并决定我们是否应该完全滚动。通常,我们希望保持黑暗运行几天,因为一些性能问题(例如内存泄漏)更有可能以更长的运行暴露。
- 完全切换:如果以前的所有步骤看起来很好,我们将慢慢拨打流量以使用新的查询脚本。
概括
我们发明了一个全新的基础设施,支持Elasticsearch插件的连续开发和集成,这也是高度安全和可观察的。得益于,我们能够在运行时施加更复杂的匹配模型。但是,我们还没有完成 - 在这博客的第2部分中,我们将介绍我们的工程师在这条管道之上实施的一些最天才的想法,这大大提高了我们的查询性能。敬请关注。
(第二部分)
我们介绍了ES插件的架构和基线性能优势。在这篇文章中,我们将专注于特定的定制,从而消除了建议生态系统中最大的瓶颈之一。
问题
当我们查询ES来获取建议时,我们需要发送一个用户列表跳过。例如,您最近已经看到的用户和您已经匹配的用户不应再次向您推荐。对于非常活跃的用户来说,此跳过列表可以合理高。我们在eS上使用术语查询来跳过跳过列表。
术语查询示例:
{
"query": {
"filter": {
"bool": {
"must": [...],
"must_not": [
{
"terms": {
"user_number": [1,2,3,...]
}
}
...
]
}
}
}
}
但是,我们怀疑“Term”查询对于非常大的列表效率低下。我们使用“Term”过滤使用具有不同大小的跳过列表的查询进行了性能测试。从以下结果中,性能和跳过列表大小具有明确的反向关系。
如上所述,跳过列表是我们ES性能的相当大的瓶颈。我们还表现测试了遗漏了其他查询组件,但跳过列表是迄今为止延迟的最大贡献者。虽然可以降低其尺寸以提高性能,但这可能导致否定用户体验,因为用户可能会看到重复的建议。我们的目标是删除瓶颈,但留下业务逻辑完整。
解决方案
从根本上,解决方案是找到使用术语查询的替代方案。我们的想法是使用压缩数据结构发送序列化跳过列表,然后可以在ES服务器上进行反序列化并使用。假设序列化和反序列化开销是可以接受的,而是通过避免大术语查询来降低延迟,但它也可能大大减少查询请求的大小。
现在我们熟悉ES插件的使用,我们考虑了我们如何利用它来优化跳过列表。除了添加了我们的LoaderPlugin可以使用的新脚本,另一种可能性是使用ActionPlugin添加新的自定义API(类似于我们在第1部分中的可观察性所做的内容)。我们将涵盖以下实施细节和权衡。
插件类型
ActionPlugin.
要使用序列化跳过列表通过自定义API,我们将调用“_newsearch”,必须进行以下步骤。
- eS客户端序列化跳过列表。
- 使用_newsearch API向ES发送查询并在序列化列表中传递。
- 在ES集群中,查询节点在没有跳过列表的情况下向数据节点发送搜索查询。请求的文档计数等于客户端发送的请求的文档计数加上跳过列表的大小,因为跳过列表将应用于查询节点。
- 在查询节点中接收排名文档。将跳过列表进行反序列化。包括不在跳过列表中的文档,最多可在客户端发送的请求的大小并返回客户端。
优点:
- 易于实现
缺点:
- 不必要的处理:在排序阶段之后发生跳过逻辑,因此为跳过列表中的文档计算相关性因子
- 对数据节点加载的影响是查询依赖的
- 增加了负载:可能需要对额外的文件进行排名,这可能是大量跳过列表的疑问
- 减少负载:跳过列表处理被移动到查询节点
- 增加查询节点的负载,因为它需要反序列化并应用跳过列表
- 更新需要群集重启,因为它不利用loaderplugin
- 增加查询和数据节点之间的网络流量
loaderplugin.
要使用序列化跳过列表通过从第1部分利用LoaderPlugin来使用序列化跳过列表,我们需要添加一个新脚本来反级化并应用跳过列表。此新脚本将使用以下工作流。
- eS客户端序列化跳过列表。
- 通过标准_Search API向ES发送查询。通过请求中的“params”将序列化跳过列表发送。指定在“源”字段中使用跳过列表Deserializer的脚本。将“min_score”(从es)参数添加到查询(在下一步骤中使用)。这是一个例子:
{
"min_score": -100000,
"query": {
"filter": {
...
},
"functions": [
{
"script_score": {
"script": {
"params": {
"key1": value1,
...
},
"source": "my_bitmap_script",
"lang": "tinder_scripts"
}
}
}
]
}
}
3.在数据节点上,将跳过跳过列表。对于应跳过的文档,脚本将返回低于Min_score的相关因子,因此它们将被省略。
4.剩余的文件返回给客户。
优点:
- 更新脚本很容易,因为它使用loaderplugin
- 在数据节点上没有完成不必要的相关性计算
- 减少数据节点上的负载,因为反向跳过列表比具有大术语过滤器的速度快得多
- 查询节点上没有增加负载
缺点:
- 使用Min_Score进行跳过可能有局限性 - 可能难以确定正确的阈值
我们进行了一个使用位图序列化的两种实现的性能测试,以及大小50k的跳过列表。
虽然延迟在QP较低的QPS中相似,但差异在125 QPS时非常明显。正如我们最初的预期一样,Loaderplugin产生了更好的性能。
数据结构
现在我们已经确定了哪个插件实现使用,我们仍然必须决定用于序列化跳过列表的数据结构。为了帮助我们做出决定,我们进行了更多的性能测试进行比较。我们测试了以下数据结构。
- 基本情况:术语查询
- 哈希表
- 布隆过滤器
- Roaring位图
序列化跳过列表的大小对ES网络延迟具有潜在影响,因为它将包含在请求中。使用大小1000万的跳过列表,每个实现的序列化跳过列表大小如下所示。
由于标准哈希集不适合压缩,因此预计它将大于原始值。相反,布隆过滤器和Roaring位图生成的序列化跳过列表要小得多。虽然较小的尺寸将导致网络带宽使用减少,但它可能与减少的延迟或群集负载可能没有任何相关性。因此,我们使用LoaderPlugin脚本实现了每个数据结构,并使用各种跳过列表大小测试延迟。
以下是使用略量10K的跳过列表比较数据结构时的结果。
很明显,布隆过滤器和位图比其余的包装好多了。我们做了更多的性能测试,比较了那两个具有较大跳过列表的两种。以下是使用略量40K的跳过列表时的结果。
即使布隆过滤器略快地比位图略快,它也有误报的问题。由于性能差异很小,我们决定使用位图,因为它不会影响我们的业务逻辑。
最终决定
最后,我们决定采用咆哮Roaring性界线作为数据结构并将其作为LoaderPlugin实施。
影响
通过通过我们的ES插件开发周期快速迭代,我们能够通过将命中尺寸完好无损,并将其透明地向我们的价值用户透明地验证此插件在生产中的功能。
更重要的是,我们在生产方面看到了比我们的性能测试结果更大的性能提升。以下是数字:
- 所有延迟百分比的80%左右减少(绿线是原始的“术语”查询,蓝线是roaringbitmap + loaderplugin): > P50: 65ms -> 13ms
- 分别为查询节点和数据节点的35% - 50%CPU利用率下降。 > Query Nodes
- 查询和数据节点的网络IO约为50%
我们非常兴奋地宣布通过释放此插件,我们不仅能够提供更好的用户体验,同时保持业务逻辑完好无损,还可以获得我们的集群能力以备将来增长的重要资料室。
概括
在构建插件动态加载和迭代的框架之后,我们通过积极识别我们当前的瓶颈,调查和测试不同的选择,并最终向我们的最终用户提供福利,将群集的性能推向下一个级别。我们在此过程中得到了一些关键结果:
- 大的术语滤波器会导致性能不佳。Lucene / ES尚未设计用于有效处理此类案例
- 在我们探索的所有解决方案中,RoaringbitMap提供了最佳的压缩率,相对快速的序列化/反序列化性能,避免了误报
- 性能测试为设计决策提供了关键洞察
- 通过利用评分函数来处理跳过,我们能够支持更复杂的逻辑前进,只要通过查询参数传递信息/逻辑即可
这一点是我们对我们如何运营ES集群的最新创新,并使IT过度缩放,这只是我们在火种上处理的许多工程挑战之一。如果您有兴趣挑战自己并希望与才华横溢的队友一起工作,请查看我们的工作网站以供开口。
(本文由闻数起舞翻译自Xiaohu Li的文章《How We Improved Our Performance Using ElasticSearch Plugins: Part 1/Part 2》,转载请注明出处,原文链接:https://medium.com/tinder-engineering/how-we-improved-our-performance-using-elasticsearch-plugins-part-1-b0850a7e5224,https://medium.com/tinder-engineering/how-we-improved-our-performance-using-elasticsearch-plugins-part-2-b051da2ee85b)