简介
在 es 6.0 中,引入了一项名为索引排序的新功能。用户现在可以优化 es 索引,以按特定顺序将文档存储在磁盘上。它是优化 es 性能的另一个有用工具。
通过本文,我们将深入探讨多个领域:
- Lucene 的索引排序功能
- 索引排序将提高查询性能的示例
- 对时间序列数据使用索引排序时要考虑的注意事项
- 性能注意事项
Lucene 中的索引排序
多年前,lucene 引入了一种称为 IndexSorter 的离线工具。IndexSorter 将源索引复制到新的目标索引,并根据用户指定的顺序对磁盘上的文档进行排序,注意,这里是磁盘排序,而且是用户指定的方式对磁盘排序。当时,由于无法直接更新目标索引,因此每次将新文档添加到源索引时,此功能的用户都必须重新构建排序视图(重新构建一遍)。IndexSorter 是第一次尝试提供一种对磁盘上的文档进行排序的方法,不是在搜索时,而是在索引时。
通过索引排序,引入了一个称为 early termination称之为提前终止查询 的新概念。例如,假设你要检索按日期排序的 N 个文档(日期是索引中的一个字段)。如果索引在磁盘上按此日期字段排序,则可以在访问与查询匹配的前 N 个文档后“停止”请求(因为它们已经按照用户指定的顺序,可以借助mysql的索引来理解)。这就是我们所说的“提前终止”。提前终止查询可以显著缩短搜索响应时间,尤其是对于基于排序的查询,并导致 IndexSorter 工具在 Lucene 用户中越来越受欢迎。该工具的静态特性阻止了它用于具有大量更新的索引,这就是为什么它最终被允许增量更新的解决方案所取代。提出了一种新的解决方案,在合并时对文档进行排序,而不是对静态索引进行一次性排序。换言之他可以支持你在写入文档的时候进行排序,而不是写入一次就完了,下次再写入得重新全排序。
Lucene 改进
在没有索引排序之前,最初的时候,Lucene 按接收文档的顺序对文档进行索引,并为每个文档分配一个增量(和内部)文档 ID(按区段分配)。在区段中索引的第一个文档的文档 ID 为 0,依此类推。在搜索时,将按文档 ID 顺序访问每个区段,以检索与用户查询匹配的文档。为了检索查询的最佳 N 个文档,Lucene 需要访问所有区段中与查询匹配的每个文档。如果查询与数百万个文档匹配,则仅检索最佳 N 仍需要访问数百万个文档。就是说最初排序是按照文档写入的顺序排序的,无法按照指定字段排序。这种按照顺序排序使得当你要按照某个字段有序返回的时候,你要n个文档,就需要索引全部的内容才能知道完整的顺序。
每当触发刷新(flush)时,Lucene 索引都会创建一个新分段。此新区段包含在上次刷新后添加的所有文档。当区段被刷新时,搜索者可以看到它,并且新文档可以显示在搜索结果中。由于刷新不断发生,因此索引中的分段数量很容易激增。区段合并在后台进行,以限制区段数增长过大。合并是根据选择符合合并条件的区段的策略触发的,然后,所选区段将合并到替换旧区段的新区段中。默认情况下,区段合并过程会根据文档的内部文档 ID 将不同区段中的文档复制到新区段。为了替换静态工具(上面提到的 IndexSorter), 引入了一个新的合并策略,以允许对动态索引进行索引排序,该索引在合并过程中根据可配置的顺序(例如字段的值)对文档进行重新排序。这种新设计是朝着正确方向迈出的一大步,它允许对索引进行动态排序,并按段使用这些信息。某些区段已排序(通过合并创建的区段),而有些区段未排序(新刷新的区段)。在合并时,未排序的区段首先被“排序”,然后与其他已排序的区段合并。
然后,这个位于模块中的合并策略被移动到 IndexWriterConfig 上的顶级选项中,以使索引排序成为 Lucene 中的一等公民 。
尽管一些基准测试表明,合并时排序的成本可以将索引的总吞吐量除以 2 倍:
这也无可厚非,在索引时的开销减轻了在搜索时的开销。具体的测试报告可以查看测试报告
索引性能降低的原因很简单:重新排序 Segments 会产生成本,导致这些索引的合并时间和内存消耗大幅增加。
由于一次对多个 segment 重新排序的成本很高,因此决定在索引过程的早期对文档进行排序。我们不再等待合并时间对多个区段进行排序,而是将排序移至刷新时间(首次创建区段时):LUCENE-7579。 如果所有区段都已排序,则可以使用简单的合并排序策略进行合并,该策略要快得多。这种新策略首次在 Lucene 6.5 中引入,并将吞吐量基准提高了近 65%。
正如你在这个故事中看到的,索引排序在 Lucene 中有很多历史,但直到现在,从es 6.0开始便解锁了此功能。
索引排序的实际应用
提前终止搜索查询
在应用程序中,查询前 X 个结果(按值 Y 排序)是很常见的(顶级玩家分数、新用户、最新事件等)。在大多数情况下,在检查整个数据集之前,Elasticsearch 没有足够的信息来快速收集前 X 个结果并对其进行排序。文档值使此过程更加高效,但是,在数据集非常大的情况下,将检查和比较比用户需要的值多得多的值。
随着 es 6.0 中索引排序的引入,我们现在可以指定磁盘上文档的顺序,从而允许 es 更有效地短路并返回查询。例如,如果我们要为一家视频游戏公司创建一个排行榜来跟踪前 3 名玩家分数(而且我们有非常多的玩家),我们可以指示 es 按照他们的玩家分数顺序存储文档,从而使我们能够更高效地计算排行榜。
// 获取分数前三的玩家,分数基于points字段
GET scores/score/_search
{
"size": 3,
"sort": [
{ "points": "desc" }
]
}
根据 es 的版本和索引排序的使用情况,我们可以非常有效地将文档存储在磁盘上,以便进行上述查询:
我们看到在有了索引排序之后,因为文档按照字段在磁盘存放,我们只需要获取对应的top n即可,因为他是严格有序的。不过上面的查询仍然需要返回结果数量的计数(并且需要一些额外的工作)。我们可以通过将新选项 “track_total_hits” 设置为 false 来删除此要求:
// 获取分数前三的玩家,分数基于points字段
GET scores/score/_search
{
"size": 3,
"track_total_hits" : false,
"sort": [
{ "points": "desc" }
]
}
现在,我们使用排序索引对顶级玩家分数进行了非常高效的排行榜查询。
在 Elasticsearch 6.0 中指定索引排序顺序
要继续上面的示例(创建顶级玩家分数的排行榜),我们需要告诉 es 如何对磁盘上的文档进行排序。我们可以通过在索引的设置中提供定义来做到这一点:我们在索引的settings中配置了排序字段为分数points,并且排序的方式是倒序排列。此时当我们往这个索引写入文档的时候,文档在磁盘中的排序就是按照points字段倒序排列的,这对于上面的简单查询(前 3 名玩家得分)很有帮助。因为我们知道严格有序了,所以他只需要在磁盘上扫描三条数据即可。放佛把mysql的索引引入了es中。
PUT scores
{
"settings" : {
"index" : {
"sort.field" : "points",
"sort.order" : "desc"
}
},
"mappings": {
"score": {
"properties": {
"points": {
"type": "long"
},
"playerid": {
"type": "keyword"
},
"game" : {
"type" : "keyword"
}
}
}
}
}
按类似结构对索引中的文档进行分组
存储按相似类型排序的文档有很多优点。例如,如果有一个名为 “scores” 的索引,则某些分数可能来自游戏 “Joust”,并包含特定字段,例如 “top-speed” 和 “farthest-jump”,其他游戏的分数,例如 “Dragon’s Lair” 可能包含 “sword-fight-score” 和 “goblins-killed” 字段:
// Score for the game "Joust"
{
"game" : "joust",
"playerid" : "1234",
"top-speed" : 212,
"farthest-jump" : 49
}
// Score for the game "Dragon’s Lair"
{
"game" : "dragons-lair",
"playerid" : "5678",
"sword-fight-score" : 89,
"goblins-killed" : 3
}
将按游戏排序的文档存储在磁盘上将有助于将相似的文档(具有相似的字段名称)放在一起。这样做的优点是查询速度(尽管重要的是要记住这实际上取决于查询)和压缩。将相似的字段存储得更紧密可能会带来更好的压缩效果,并且 es(反过来是 Lucene)能够更有效地存储增量数据,因为把相似类型的字段排序放在一起总是方便压缩存储的:
PUT scores
{
"settings" : {
"index" : {
"sort.field" : "game",
"sort.order" : "desc"
}
}
}
更高效的 AND 连词
使用索引排序以特定顺序在磁盘上查找文档还可以改进 AND 连词 ,即具有许多条件的复杂查询。
让我们继续我们的视频游戏示例,当玩家加入游戏时,他们必须与同一地区、技能水平和课程中的其他玩家配对。用于查找类似玩家以开始新比赛的示例查询可能类似于以下内容(在“EU”区域内获取 10 名玩家,玩“Dragon’s Lair”,技能等级为 9,在“Castle”地图上):
GET players/player/_search
{
"size": 3,
"track_total_hits" : false,
"query" : {
"bool" : {
"filter" : [
{ "term" : { "region" : "eu" } },
{ "term" : { "game" : "dragons-lair" } },
{ "term" : { "skill-rating" : 9 } },
{ "term" : { "map" : "castle" } }
]
}
}
}
我们来看下es如何收集检索结果:
下图为没有索引排序的情况下:
然后我们使用索引排序再来看下,我们这里指定多个排序。
PUT players
{
"settings" : {
"index" : {
"sort.field" : ["region", "game", "skill-rating", "map"],
"sort.order" : ["asc", "asc", "asc", "asc"]
}
},
"mappings": {
"player": {
"properties": {
"playerid": {
"type": "keyword"
},
"region": {
"type": "keyword"
},
"skill-rating" : {
"type" : "integer"
},
"game" : {
"type" : "keyword"
},
"map" : {
"type" : "keyword"
}
}
}
}
}
现在我们可以看到文档放置得更近:
通过使用排序索引,我们可以将具有相似字段值的文档更紧密地定位在一起,从而使我们查找给定对战游戏的玩家的查询更加高效。
当索引排序不适合时
与存储未排序的值相比,在索引时将排序的值存储在磁盘上需要 es 进行更多的工作。在某些情况下,索引排序的性能开销可能会使写入性能降低多达 40-50%。因此,确定是否应该针对查询性能或写入性能优化应用程序非常重要。优化应用程序的写入性能(并影响查询性能)很可能意味着索引排序不是一个好的选择。
这个自然,当你需要在写入时排序的时候,自然需要在磁盘上找到合适的位置写入,这个开销不可谓不大,尤其是写入比较高频的时候,这时候我们一般需要一个mq作为缓冲。
你可以检查索引的吞吐量(带索引排序和不带索引排序)。如上所述,性能影响会有很大差异,具体取决于您的使用案例。例如,geonames Elasticsearch 基准测试显示索引排序的性能影响非常小(标记为“Append Sorted”的蓝线):详情可以查看测试报告
或者,“NYC Taxis” 基准测试显示,使用索引排序时,索引性能会大幅下降:测试报告
在系统设计中,几乎每个级别都有权衡,对于索引排序,我们考虑的权衡是更快的查询(在特定情况下)的写入效率较低(因为必须对文档进行排序)与更高效的写入和较慢的查询(因为结果必须在查询时排序)。与任何新功能类似,使用特定用例和数据集测试索引排序非常重要。
详情操作可以查看es的索引排序的官方文档