前言

我瓣用户产品中有多个核心场景都在使用 Elasticsearch (以下简称 ES),主要用在全文搜索、聚合计算等方面。ES 相关的功能开发者主要是我,我最早是 2016 年在 选影视 的功能上开始使用 ES 做结构化搜索,这个工具给运营同学和资深用户提供了按各种复杂条件找电影的,可以说是最好的途径。

这个项目为多个核心产品功能提供原始数据,这几年它历经多次迭代和功能完善,不过最近它出了一点问题,我觉得解决问题的过程很值得写篇文章分享一下,所以就有了此文。

为了让大家能更理解下面的内容,先介绍下「选影视」存储在 ES 里面的文档部分字段:

class Subject(DocType):
    title = Text()
    genres = Text()
    countries = Text()
    standard_tags = Text()
    ...

每个文档的_id是条目 ID,也包含很多条目属性字段 (如上述列出的标题、类型、地区、标签,以及未列出的一些字段),其中标签 (standard_tags) 字段是非常重要的,正是由于这个字段的数据,可以基于 ES 搜索包含某个 (些) 标签条目 ID 列表。

问题

8 月 28 日 (下图中的最高峰那天) 平台同事 @我,说发现这个项目最近服务调用慢且有大量超时,由于项目是一个微服务,所以经常触发服务调用的熔断,希望解决。

这个项目由于最近改动并不大,我已经有一段时间没有特别关注了,赶紧打开对应的 Sentry 页面,通过一个 Issue 页面找到问题原因:

某个 API 请求 ES 很容易造成请求超时 (抛 ConnectionTimeout)

下面是按天统计的事件总数的趋势图:

图一

设置的超时间隔是 2 秒:这已经是 API 请求里面非常宽容的阈值了,事实上在之前的使用中大部分对 ES 的 API 请求在几毫秒到几十毫秒之间,鲜有超时问题。

PS: 这个图下面还会出现多次,会分别展示对应时间点下每天事件的总数范围。

很快和对应开发同事确认,造成问题的接口是给新版本 APP 里面的新功能提供的,我们发现问题时这个超时事件已经达到每天 4-6w+,虽然看起来量倒不算大,但是我依然觉得需要快速解决它:

  • 已经影响了服务质量
  • 功能比较隐蔽,如果不是主动用这个功能是不会触发 API 请求的,我体验了下对应功能确实很容易出现点开页面还没加载出来或者干脆窗口空白的情况,这太影响用户体验了
  • 这个超时量会随着新版本 APP 的装机量不断提高
  • 这些慢查询给 ES 带来压力,也影响了其他正常查询请求

回到问题,这个特别慢的请求是做一个聚合计算,看一下请求的 body:

{
  'aggs': {
    'by_standard_tags': {
      'terms': {
        'field': 'standard_tags.keyword',
        'size': 100
      }
    }
  },
  'query': {
    'bool': {
      'must': [{'ids': {'values': IDS}}]
    }
  }
}

其中 IDS 是条目 ID 列表,这个请求是让 ES 聚合这些条目包含的标签总数。举个例子,ID 为 1 的条目有「剧情 / 犯罪 / 动作」三个标签;ID 为 2 的条目有「喜剧 / 剧情 / 爱情」三个标签,那么 IDS 为[1, 2] 时,返回的内容中「剧情」为 2 (2 个条目都有这个标签),其他的标签都是 1 (只有一个条目包含)

OK,现在事情很明朗了,我们开始解决超时的问题吧。

这个接口的代码我当时一眼看去并没有发现问题,那马上想到的就是缓存和减少调用这 2 条路,但是很遗憾行不通:

  • 缓存。IDS 是用户看过 / 想看电影的条目列表,每个人都不同。用户访问和用户兴趣相关,且频率不高,由于用户量庞大不值得为了这么个小功能就主动缓存并添加一个好的更新缓存的机制,成本太高甚至效果会更差
  • 减少调用。目前的调用已经是按需请求,没有找到明显的可以优化调用量的地方

OK,既然「绕」不了了,就直面吧,我继续尝试其他解决超时问题的方法

优化聚合计算请求

这算是我的个人性格,接下来我第一个想法的思路就是优化这个聚合请求的写法。其实在 5 年前我的那个 《Python 高级编程》 的 PPT 里面就说过:

  1. 在合适的地方用合适的技巧
  2. 不是它不好,而是你没有用好

这一直算是我的技术格言吧,无论是不熟悉的还是熟知的内容,我都会保持敬畏。出了问题会首先考虑是不是我没有用对用好。就拿上面的这个 body 来说,其实做 2 件事:

  1. 限定要查询的 _id 范围 (in IDS)
  2. 聚合查找 standard_tags 字段中的标签数据,返回匹配标签数量最多的 100 个标签和包含的条目数量

那怎么优化呢?首先我试了一下改用Term Query代替IDs QueryIDs Query的写法是我开始用的,之后其他同学有这样的需求就按着我这种写法来了,我怀疑是这种查询语句的问题,改成下面这样的效果:

{
  'aggs': {
    'by_standard_tags': {
      'terms': {                                                                                                 'field': 'standard_tags.keyword',
        'size': 100
      }
    }
  },
  'query': {
    'bool': {
      'must': [{'terms': {'_id': IDS}}]
    }
  }
}

上线后发现修改对超时没有帮助 😢,说明IDs Query的写法没有问题。

优化点 1

这让我一时间没有了头绪,我开始搜索一些「优化 Elasticsearch」相关的技术博文和开源书籍,希望从中找找灵感。然后就在 Elastic 社区找到了一个 query+aggs 查询性能问题 的帖子,其作者也遇到了使用聚合后查询非常慢的问题,@kennywu76 给出了一个方案: 「在每一层 terms aggregation 内部加一个 {"execution_hint":"map"}」。在评论区 @kennywu76 也给了详细的解释,我认为算是全网最好的解释了,转发一下:

Terms aggregation 默认的计算方式并非直观感觉上的先查询,然后在查询结果上直接做聚合。

ES 假定用户需要聚合的数据集是海量的,如果将查询结果全部读取回来放到内存里计算,内存消耗会非常大。因此 ES 利用了一种叫做 global ordinals 的数据结构来对聚合的字段来做 bucket 分配,这个 ordinals 用有序的数值来代表字段里唯一的一个字符串,因此为每个 ordinals 值分配一个 bucket 就等同于为每个唯一的 term 分配了 bucket。 之后遍历查询结果的时候,可以将结果映射到各个 bucket 里,就可以很快的统计出每个 bucket 里的文档数了。

这种计算方式主要开销在构建 global ordinals 和分配 bucket 上,如果索引包含的原始文档非常多,查询结果包含的文档也很多,那么默认的这种计算方式是内存消耗最小,速度最快的。

如果指定 execution_hint:map 则会更改聚合执行的方式,这种方式不需要构造 global ordinals,而是直接将查询结果拿回来在内存里构造一个 map 来计算,因此在查询结果集很小的情况下会显著的比 global ordinals 快。

要注意的是这中间有一个平衡点,当结果集大到一定程度的时候,map 的内存开销带来的代价可能就抵消了构造 global ordinals 的开销,从而比 global ordinals 更慢,所以需要根据实际情况测试对比一下才能找好平衡点。

对于我们这个场景,IDS 量级比较小所以查询结果集很小,可以改用execution_hint:map这种方式:

{
  'aggs': {
    'by_standard_tags': {
      'terms': {
        'execution_hint': 'map',
        'field': 'standard_tags.keyword',
        'size': 100
      }
    }
  }
}

优化点 2

优化过程里我突然想起了官网对于Filter or Query的优化意见 (详见延伸阅读链接 1),文中说:

过滤查询(Filtering queries)只是简单的检查包含或者排除,这就使得计算起来非常快。考虑到至少有一个过滤查询(filtering query)的结果是 “稀少的”(很少匹配的文档),并且经常使用不评分查询(non-scoring queries),结果会被缓存到内存中以便快速读取,所以有各种各样的手段来优化查询结果。

相反,评分查询(scoring queries)不仅仅要找出 匹配的文档,还要计算每个匹配文档的相关性,计算相关性使得它们比不评分查询费力的多。同时,查询结果并不缓存。

多亏倒排索引(inverted index),一个简单的评分查询在匹配少量文档时可能与一个涵盖百万文档的 filter 表现的一样好,甚至会更好。但是在一般情况下,一个 filter 会比一个评分的 query 性能更优异,并且每次都表现的很稳定。

过滤(filtering)的目标是减少那些需要通过评分查询(scoring queries)进行检查的文档。

这段话之前我也看过,但是这次再看发现了一个可优化的点,就是用Filter替代Query,因为我们想要的只是聚合结果,完全不需要做评分查询。请求 body 就改成了这样:

{
  'aggs': {
    'by_standard_tags': {
      'terms': {
        'execution_hint': 'map',
        'field': 'standard_tags.keyword',
        'size': 100
      }
    }
  },
 'query': {
   'bool': {
     'filter': [{'ids': {'values': IDS}}]
    }
  }
}

优化点 3

正是上面的这段对于Filter or Query的优化意见,让我也注意到一个词:「不评分查询」,前面的查询结果其实会返回传入的 IDS 列表的对应文档结果,但是我们根本不需要,所以可以不返回 source:

{
  'aggs': {
    'by_standard_tags': {
      'terms': {
        'execution_hint': 'map',
        'field': 'standard_tags.keyword',
        'size': 100
      }
    }
  },
 'query': {
   'bool': {
     'filter': [{'ids': {'value': IDS}}]
    }
  },
  '_source': False
}

优化结果

基于上面三点优化。上线后超时问题得到了很大缓解:

图二

优化效果比较显著:

  1. 超时数降到了之前的约 1/4
  2. 项目总超时 (还有其他查询引起的超时) 降到了约之前的 1/5
  3. 找了一些 BadCase 对比效果,优化后请求耗时最差降到之前的一半,约 1/3 的请求的时间重回小于 100ms 级别

虽然已经不会触发平台的熔断了,但是超时事件总量依然很大,需要进一步优化。

优化分片数

我继续保持怀疑的态度寻找优化方案,在我的认知里面,像 Elasticsearch 这样成熟的、广受关注和欢迎的项目经过多年的发展,我不相信一个很常见的聚合查询就能引起集群负载的波动,也不相信它能引起这么大量的请求超时,所以我还在怀疑问题出在使用姿势上。由于我不熟悉 Java 语言,在短时间不具备阅读 ES 源码的能力,所以开始希望通过别人的文章中获得灵感。

在看到《Mastering Elasticsearch》中的「选择恰当的分片数量和分片副本数量」(延伸阅读链接 4) 这一节后,我赶紧看了下项目用的这个索引的分片情况 (通过http http://ES_URL/_cat/shards/INDEX_NAME),并和萨 (sa) 确认了一下。我瓣一开始用的 ES 版本比较低 - 2.3,之后升级到当时最新的 6.4,ES 在 6.X 及之前的版本默认索引分片数为 5 (Primary Shards)、副本数为 1 (Replica,当一个节点的主分片丢失,ES 可以把任意一个可用的分片副本推举为主分片),从 ES7.0 开始调整为默认索引分片数为 1、副本数为 1 (详见延伸阅读链接 2 和链接 3)。而我瓣为了数据安全,默认每个索引分片数为 5、副本数为 2,也就是这个索引一共有 15 个分片(总分片数 = 主分片数*(副分片数 + 1))。

ES 默认的分片配置并不适用于所有业务场景,那么分片数应该怎么安排呢?延伸阅读链接 5 是 ES 官方博客中的一篇叫做「Elasticsearch 究竟要设置多少分片数?」的文章,其中有这么 2 段内容:

Elasticsearch 中的数据组织成索引。每一个索引由一个或多个分片组成。每个分片是 Luncene 索引的一个实例,你可以把实例理解成自管理的搜索引擎,用于在 Elasticsearch 集群中对一部分数据进行索引和处理查询。

构建 Elasticsearch 集群的初期如果集群分片设置不合理,可能在项目的中后期就会出现性能问题。

通过分片,ES 把数据放在不同节点上,这样可以存储超过单节点容量的数据。而副本分片数的增加可以提高搜索的吞吐量 (主分片与副本都能处理查询请求,ES 会自动对搜索请求进行负载均衡)。在《Mastering Elasticsearch》里面还提到了路由 (routing) 功能 (延伸阅读链接 6),ES 在写入文档时,文档会通过一个公式路由到一个索引中的一个分片上。默认的选择分片公式如下:

shard_num = hash(_routing) % num_primary_shards

_routing字段的取值默_id字段。如果不指定路由,在查询 / 聚合时就需要由 ES 协调节点搜集到每个分片 (每个主分片或者其副本上查询结果,再将查询的结果进行排序然后返回:在我们这个场景,每次要从 5 个分片上聚合。

如果能够确定文档会被映射到哪个 (些) 分片,可以只在对应的一 (多) 个分片上执行查询命令,不用全局的搜索。我实际的试了下,用 routing 确实快了很多。所以看到这里我第一个感觉是分片多了会带来路由问题,前面说了,「每个分片是自管理的,对一部分数据进行索引和处理查询」,查询和聚合都需要在最后把结果搜集起来。那可不可以就把数据放在一个分片上,这样就没有路由的问题了?另外 ES7.0 默认的分片方案也是朝着这个方面走的,所以我觉得方案调整应该是经过一段时间的实践,考虑到大部分场景下1 Shard + 1 Replica这样的方案是更高的选择。

Ok, 现在优化的目的就是选择合适的主分片数 + 合适的副本分片数

要改善现在面临的问题,考虑本业务的数据量,分片坏掉的概率和数据安全等因素,我直观的感受就是我瓣的索引分片数太多了。但由于索引创建好后,主分片数量不可修改,只可以修改副本分片数量。而要修改主分片数量,只能重建或者建新的索引,代价比较大。

无论是官方还是一些大厂相关文章都没有对于分片数做出完美的公式,默认的不一定是最好的,但什么样的组合还是需要实际测试,所以我尝试了多个分片方案,如下:

Shard(s) Replica(s)
1 1
1 2
2 1
2 2
3 1
3 2
4 1
4 2
5 2
6 1
6 2
9 1
9 2

Note: 这里副本分片数没有大于 2 的方案是所用到的 ES 集群节点规模所限。

为了不影响现有业务,用的都是新索引,这么创建:

curl -XPUT "http://ES_URL/INDEXNAME/" -H 'Content-Type: application/json' -d '{
    "settings" : {
        "index" : {
            "number_of_shards" : 1,
            "number_of_replicas" : 1
        }
    }
}'

在数据上我使用 elasticsearch-dump 把旧索引的中数据灌到新索引,另外一方面在新索引的因业务逻辑上订阅 Kafka 消息同步对索引数据的修改。为了让写入更快,我还在灌数据过程中关闭了副本和索引刷新:

curl -XPUT "http://ES_URL/INDEXNAME/_settings" -H 'Content-Type: application/json' -d '{ "index" : { "refresh_interval" : "-1", "number_of_replicas": 0 } }'

优化结果

上述分片方案的尝试并不是按表格顺序来做的,而且一开始我的理解是分片太多,所以最初的主要目的是要减少分片数。我一开始是在当前的主分片方案上尝试5 Shards + 1 Replica,也就是减少副本分片数:

图三

就是图中 9 月 2 日这天,超时降得很明显,这给我带来了非常大的信心,说明我的方向是对的。当时由于数据的问题,后来迁回了原来的索引一直到 9 月 4 日。

刚才提到,官方从 7.0 开始改为默认1 Shard + 1 Replica这样的方案,所以接下来我新建了一个1 Shard + 2 Replicas的新索引,9 月 4 日上线,当时效果也非常好。

图四

但是通过上图可以看到当天超时量又涨起来了,其实这是我犯的一个错误,早上测试1 Shard + 2 Replica观察了一段时间效果非常好,我认为还可以继续降分片数以提高「路由效率」,所以调整成了1 Shard + 1 Replica,我当时觉得毫无疑问效果会更好,然后下午就请假了... 结果过了 2 个小时发现超时涨的非常厉害,就回滚了。

不过到这里,可以感受到官方默认的方案对于我们这个例子是不可取的:如果当时选择1 Shard + 1 Replica运行满一天,我相信超时量将远高于之前 8 月 28 日最高峰的超时量。我觉得造成这个问题是由于分片数太少了,2 个分片扛不住这样的吞吐量。

在接下来的一段时间里面在准备好数据后。我分别尝试了上述提到的主分片小于 5 的各种组合,结果非常反直觉:

超时情况在 1 Shard + 2 Replicas5 Shard + 1 Replica 这 2 个方案下表现是最好的,其他的方案的效果都很差。

为什么说反直觉呢?我本来认为:

  • 考虑请求 ES 集群带来的压力,在一定分片数范围内增加主分片能提高吞吐量,由于路由效率超过一定阈值应该会起反作用
  • 副本分片数多的副作用只是硬盘空间的「浪费」,但是能对查询效率有帮助,所以可以在一定范围内增加副本

在《eBay 的 Elasticsearch 性能调优实践》(延伸阅读链接 7) 中有「搜索性能和副本数之间的关系」和「搜索性能和分片数量之间的关系」的 2 张图表,支持了我的自觉:

  • 分片数增加的过程中,开始时搜索吞吐量增大 (响应时间减少),但随着分片数量的增加,搜索吞吐量减小 (响应时间增加)
  • 搜索吞吐量几乎与副本数量成线性关系

现在的测试结果和预想对不上,尤其是5 Shard + 1 Replica5 Shard + 2 Replicas(最初的方案) 怎么效果差这么多?

我继续搜索,找到官方对副本数的建议和问题解释 (延伸阅读链接 7):

Which setup is going to perform best in terms of search performance? Usually, the setup that has fewer shards per node in total will perform better. The reason for that is that it gives a greater share of the available filesystem cache to each shard, and the filesystem cache is probably Elasticsearch’s number 1 performance factor.

So what is the right number of replicas? If you have a cluster that has num_nodes nodes, num_primaries primary shards in total and if you want to be able to cope with max_failures node failures at once at most, then the right number of replicas for you is max(max_failures, ceil(num_nodes / num_primaries) - 1).

也就是说,当我能接受的max_failures为 1、num_nodes为 3:

In : from math import ceil

In : max(1, ceil(3 / 5) - 1)  # num_primaries = 5
Out: 1 # 5 Shard + 1 Replica

In : max(1, ceil(3 / 1) - 1)
Out: 2 # 1 Shard + 2 Replicas  # num_primaries = 1

In : max(1, ceil(3 / 6) - 1)  # num_primaries = 6
Out: 1

In : max(1, ceil(3 / 9) - 1)  # num_primaries = 9
Out: 1

大家可以看图示,从 9 月 4 日到 9 月 19 日每日的超时量大部分在 100 - 300,也有几天达到了 4000+,较之前的超时量也可说降到了之前的 1%。每天几百的量级已经很少了:

图五

那么是否可以继续优化呢?我找萨跑了下Slow Log想分析这些慢的请求 body 的特点,结果发现这些请求并没有什 么特殊性。我又想如果选「大于 5 个主分片」这种反直觉的方案,也就是让分片数变的更多会怎么样呢?所以,我试了6 Shard9 Shards,可以看最近几天:

图六

结论是大于 5 分片的全部方案效果都不错。最好的是9 Shards + 1 Replica,每天超时数小于 30 之间:

图七

这些超时都发生下凌晨各服务定期任务对这个服务产生大量大量引起的,不会影响用户体验。找调用方确认了下,有重试机制,所以到现在,我们的优化任务告一个段落了。

后记

还是那句话:

不是它不好,而是你没有用好

通过这个带着问题做优化的案例,让我对 ES 有了更深入的了解。我获得的经验是:

  • 默认的分片方案不一定合适,具体的分片方案应该根据业务场景具体实验
  • 如官网所说「副本可能有助于提高吞吐量,但并不总是如此」,主要是由于节点数少儿带来的文件系统缓存性能问题,所以副本数不是越多越好,还是尽量按照官方推荐的来
  • 主分片数在一定范围内越多越好。我之前只觉得路由效率的问题,但是另外一个角度,分片多那么每个分片上的数据就变少了,查询和聚合要更快。

这次优化是从开发者有权限的地方去找优化思路的,没有考虑服务器资源和 ES 配置方面的优化方向,相信也会有收获。

另外本来还准备了「 使用 preference 优化缓存利用率 」、「 6.0 新增的 Index Sorting 」、「 直接路由 」等多个思路来优化。之后有时间我准备调低现在超时的阈值,相信到时候都能用上。

延伸阅读

  1. https://www.elastic.co/guide/cn/elasticsearch/guide/current/_queries_and_filters.html
  2. https://www.elastic.co/guide/en/elasticsearch/reference/6.2/_basic_concepts.html
  3. https://www.elastic.co/guide/en/elasticsearch/reference/7.3/indices-create-index.html
  4. https://doc.yonyoucloud.com/doc/mastering-elasticsearch/chapter-4/41_README.html
  5. https://www.elastic.co/blog/how-many-shards-should-i-have-in-my-elasticsearch-cluster
  6. https://doc.yonyoucloud.com/doc/mastering-elasticsearch/chapter-4/42_README.html
  7. https://www.infoq.cn/article/elasticsearch-performance-tuning-practice-at-ebay
  8. https://www.elastic.co/guide/en/elasticsearch/reference/master/tune-for-search-speed.html#_replicas_might_help_with_throughput_but_not_always