本文翻译自, 侵删
Elasticsearch 从 6.0 版本开始, 引入了一个索引预排序 (index sorting) 的功能. 使用这个功能, 用户可以在文档写入的阶段, 按指定的字段规则对文档进行排序. 这是一个令人激动的新功能, 它将极大的提高 Elasticsearch 在某些场景下的性能!
本文内容涉及如下几个方面:
Lucene 索引预排序功能的实现
几个索引预排序功能提升查询性能的例子
在时序数据中开启索引预排序的注意事项
性能考量
索引预排序在 Lucene 中的实现
Lucene 离线排序工具 IndexSorter
初期, Lucene 曾引入了一个离线的排序工具 --IndexSorter.IndexSorter 把需要排序的索引完全复制了一份, 将新的复制索引中的文档按用户指定的顺序重新排序. 因为排序后的索引是一个新的索引, 每次源索引中有新的数据更新, 不得不重新执行一遍这个工具. IndexSorter 工具是第一次在索引写入阶段而不是查询阶段对文档进行排序的尝试.
针对索引预排序, 社区提出了一个新的概念 "early termination". 假设你要遍历出前 N 个文档, 并且文档是按 date 字段排序的. 如果索引存储在磁盘上时已经是有序的了, 那么我们遍历出前 N 个文档就可以直接返回, 而不需要遍历所有的文档. 这就是我们所说的 "early termination". 提早的返回查询结果, 可以明显的缩短查询响应时间, 特别是含有排序的查询. 刚才介绍的离线排序的方案不能满足有大量文档更新的场景, 这也是为什么最终离线排序方案会被其他方案取代. 为了替换离线排序的方案, 我们提出了一个新的解决方案, 在文档的 merge 阶段进行排序.
Lucene 所做的改进
正常情况下, Lucene 按文档的接收顺序写入, 并且分配一个自增的文档 id. 在 segment 中的第一个文档的文档 ID 为 0, 依次递增. 在查询阶段, segment 中的文档是按文档 id 的顺序遍历的. 如果某个查询需要遍历符合条件文档的 TOP N,Lucene 需要访问所有符合条件的文档, 并建立最大 (小) 堆进行过滤. 在文档数量为百万级别的场景中, 这样的排序取前 N 的场景是非常耗时间的.
每当刷新 (refresh) 操作被触发, Lucene 会为索引创建一个新的段. 新的段包含上一次刷新后的所有新加入的文档. 刷新操作之后, 新加入的文档才能被搜索. 因为刷新操作发生的频率是恒定的, 所以 segment 的数量会爆炸式的增长. segment 合并操作会在后台触发以限制 segment 的数量. merge 操作基于某种合适的策略被触发, 几个小的 segment 会合并为一个更大的 segment.segment 合并的时候默认还是以文档 id 为序的. 为了取代静态的离线合并工具(如上面提到的 IndexSorter ), 引入了一种新的 segment 合并策略, 允许在 segment 合并的时候, 按用户指定的字段对文档重新排序. 这个新的设计方案, 在正确的方向上前进了一大步, 允许索引在写入的过程中排序并且只用了 segment 的一些基本信息. 如果一些 segment 已经被排序, 另外一些新创建的 segment 还没有被排序. 所以在合并的阶段, 未排序的 segment 会首先进行排序, 然后再与其它已经排序的 segment 进行合并.
这个新的 segment 合并策略已经出现在了 IndexWriterConfig 这个模块配置中的最外层的位置, 成为了最重要的合并策略.
然而, 一些 benchmark 测试显示, 在合并阶段进行排序的性能会以指数递减:
es1.PNG
造成索引写入性能衰减的原因很简单: 重新排序 segment 中的文档, 将导致合并操作时间和 jvm 占用大幅增加.
如上所述, 重新排序多个 segment 的耗时很长, 我们决定将排序提前到生成索引的阶段. 我们把排序的操作提前到新 segment 刷盘的阶段, 而不是等到 merge 阶段才排序多个 segment : https://issues.apache.org/jira/browse/LUCENE-7579 . 显然, 如果所有 segment 已经是排好序的了, 那么 merge 阶段只需要执行一次快速的 merge sort 排序. 这个新的算法首次在 Lucene 6.5 被引入, 将压测的吞吐指标提升了 65% 左右.
索引预排序在 Lucene 中有那么长的历史, 然后直到最近才被引入到 Elasticsearch 中. 感谢开源社区在这个功能上做的大量的优化和努力, 我们终于在 Elasticsearch 6.x 开始解锁了这个功能, 并且期待这个新功能的发布能极大的优化你的使用!
索引预排序实践
尽早返回查询语句的结果
在日常应用中, 返回按某个字段排序的 TOP N 是非常常见的. 大多数的情况下, 除非对整个数据集遍历并排序, 否则 Elasticsearch 不能快速的获得 TOP N 的值. 尽管 Doc values 的列式存储可以加快遍历的速度, 但是在数据集量级非常大的场景下, 效果就不是特别的好了.
有了索引预排序的功能之后, 我们现在能指定磁盘上存储文档的顺序, 允许 Elasticsearch 尽快的返回查询结果. 这里举一个例子, 如果我们创建了一个电脑游戏的排行榜, 返回成绩最好的前三个玩家. 我们可以使用 Elasticsearch 来存储玩家的分数, 并且保证数据以分数的维度排序.
- es2.jpg
- // Get the top 3 player scores (based on the number of points)
- GET scores/score/_search
- {
- "size": 3,
- "sort": [
- { "points": "desc" }
- ]
- }
使用 Elasticsearch 6.x 版本中的索引预排序, 我们能更高效的存储上面场景中的数据:
es3.PNG
上面的查询依旧需要返回所有符合条件的文档个数, 这会多做很多操作. 我们可以让 track_total_hits 这个参数的值为 false 来去掉这个操作:
- // Get the top 3 player scores (based on the number of points)
- GET scores/score/_search
- {
- "size": 3,
- "track_total_hits" : false,
- "sort": [
- { "points": "desc" }
- ]
- }
现在, 我们应用索引预排序构造了一个非常高效的玩家分数积分榜的查询.
指定索引与排序的字段顺序
继续我们上面玩家积分榜的例子, 我们需要在索引写入的时候告诉 Elasticsearch 如果对文档进行排序. 我们可以在索引的 settings 里面进行设置:
- PUT scores
- {
- "settings" : {
- "index" : {
- "sort.field" : "points",
- "sort.order" : "desc"
- }
- },
- "mappings": {
- "score": {
- "properties": {
- "points": {
- "type": "long"
- },
- "playerid": {
- "type": "keyword"
- },
- "game" : {
- "type" : "keyword"
- }
- }
- }
- }
- }
如上面的例子, 文档在写入磁盘时会按照 points 字段的递减序进行排序.
聚合相似结构的文档存储
对相似类型的文档进行排序有很多好处. 举例来说, 一个名字为 "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 "Joust"
- {
- "game" : "joust",
- "playerid" : "1234",
- "top-speed" : 212,
- "farthest-jump" : 49
- }
将文档按 game 字段排序可以使相似的文档存在一个 segment . 这样做的好处可以加速查询和压缩的比率. 将相似结构的文档存储在一起确实有助于提高压缩的比例, 并且 Lucene 可以更高效的存储偏移量信息:
- PUT scores
- {
- "settings" : {
- "index" : {
- "sort.field" : "game",
- "sort.order" : "desc"
- }
- }
- }
更高效的 AND 连接查询
使用索引预排序可以提高 AND 连接查询的效率.
还是上面游戏的例子, 当一个新玩家加入了游戏后, 他应该能够和相同地区, 相似等级的其他玩家配对, 以便可以开始一局新的游戏. 这里有一个简单的查询例子, 可以帮助查找相似的玩家, 然后让他们开始一局新的游戏:
- 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" } }
- ]
- }
- }
- }
让我们来展示下 Elasticsearch 是如何获取结果的.
es4.PNG
然后我们配置一下这个索引的排序策略, 看能否提高查询效率:
- 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"
- }
- }
- }
- }
- }
现在我们可以看到, 所有相似条件的文档都被被存储到了一起:
es5.PNG
通过使用索引预排序的功能, 我们能快速的定位到相似字段条件的文档, 是我们的玩家配对查询能更快的得到结果.
索引预排序不适用的场景
开启索引预排序功能后, 会比不开启这个功能耗费更多的索引生成时间. 在某些用户适用场景下, 开启索引预排序会有大约 40%-50% 的性能下降. 基于这个问题, 我们需要考虑好我们的业务更关注查询的性能还是写入的性能, 这点是非常重要的. 如果更关注写性能的业务, 开启索引预排序不是一个很好的选择.
下图是一个是否开启索引预排序时写入吞吐的一个对比图. 这个压测结果完全基于你的用户使用场景. 比如,"geonames" 的压测显示索引预排序对写入性能的影响是比较低的(深蓝色的线):
es6.PNG
另外一个场景,"NYC Taxis" 的压测结果显示写入性能有大幅度的下降:
es7.PNG
在系统设计层面, 我们必须仔细的考虑业务使用场景的方方面面, 对是否开启索引预排序这个功能进行慎重的权衡.
来源: https://www.qcloud.com/developer/article/1365893