Elasticsearch 搜索性能优化实践,单机 QPS 提升 120%

作者: 韦子扬 录信数软

随着互联网的快速发展,网络上的数据也在不断增多,各类文章、图片、视频都充斥于各类网站和应用程序之中,用户如果想要在这些海量的信息中寻找和获取自身所喜爱的内容,就会需要使用搜索的功能。而面对这样海量复杂的数据,传统数据库搜索无法实现 快速的响应和模糊搜索,一般针对这种情况都会采用全文检索技术,而 Elasticsearch(以下简写为 ES)和 Solr 就是目前最常用的全文检索引擎。

而对于搜索引擎而言,查询的响应速度是最为重要,也是最直观会被用户所感知的。本次针对于新闻搜索场景的 ES 优化,实现了将单机 QPS 是从 8/s 升到 108/s,提升了 120%,并且通过优化节约了 30% 服务器成本。

下面,enjoy:

项目优化背景

该项目为一个企业舆情监测系统,通过监测企业的各类公开信息和新闻来帮助用户快速了解企业动态,掌握企业舆情走向。而用户所操作的最多的一个核心功能就是搜索,囊括了新闻搜索、事件搜索、公司搜索。其中新闻搜索由于更新频率快、时效性强,因此数据量最大,也是最容易被用户感知到“速度慢”、“卡”、“转不动”的一个模块。

因此,本次优化的核心背景就是舆情系统的新闻搜索速度慢,QPS 过低,具体可以参考下图:

图片

本次测试是在单机情况下进行的,采用的测试工具是 k6,测试方法是通过发起 10 个线程模拟 10 个用户的并发请求,观察 10 分钟内的处理请求数。

通过测试显示,新闻搜索的单机服务器 QPS 为 8.9/s。由于是单机测试,正式上线后会采用集群,因此正式环境中的 QPS 肯定会远远大于这个数字。并且由于搜索业务调用比较频繁,QPS 小于 10 会让客户感到明显的卡顿,因此该问题亟需优化。另外公司大量的业务依赖于 ES,因此研究如何提升新闻搜索也可以对优化其他类似的业务有启发,例如实体链接、知识图谱包括基础架构部的日志收集 ELK 等等。

图片

ES 优化过程

1.段合并

也就是对 ES 内部的新闻索引进行 force segment,这是一种非常常见的加速 ES 查询速度的手段。

(1)原理

图片

一个 index 的所有数据在一个集群中是分布在不同的 nodes 上的,而一个 node 又是由若干 shards 来组成的,一个 shard 就是一个 Lucene 实例,是一个基本的搜索单元,发送给 ES 的查询请求,最终会打到所有的 Lucene 实例中去进行搜索(1 个 ES 的索引有 1 个或者多个分片 ,分片对应实际存储数据的 Lucene 的索引),并进行聚合。而对于一个 Lucene 实例来说,索引文件又是由大量的 segments 来组成的,每次查询,Lucene 实例都会打开所有的 segments 分段来进行搜索,而打开 segments 又需要维持响应数量的文件句柄、上下文环境等,所以底层的 segments 的数量会影响到搜索效率,大量的小 segment 会严重拖慢搜索速度。

这里留一个坑有空来填,就是段合并的原理,为什么每次 flush 后触发的段合并仍然会有大量的小分段?合并分段会给线上索引带来怎样的影响?其中采用的怎样的算法我会在后面描述。

官方文档参考: Force merge

总结来说,对冷索引执行 force merge 有如下好处:

  • 单一的大分段比众多小分段占用磁盘空间要小
  • 减少打开的文件句柄
  • 加快搜索速度,因为 lucene 搜索需要检索全部分段
  • 单个分段加载到内存时,占用的 Memory 更小
(2)操作
POST <host>/<index>/_forcemerge?max_num_segments=
(3)效果

图片

通过段合并,分段数量从 269 到 10 ,QPS 数值提升了大约 3 倍。

2.使用 highlight 取代全文

由于在优化时错将 pprof 的 CPU 时间当做了实际运行时间,而字符串操作又占据了 CPU 时间的大头,因此当时就想办法先降低字符串操作的次数。先前采用的是召回阶段直接将整篇新闻内容全部召回给精排算特征用,该方法算的准,但是总共会带来两个问题,一个是 IO 带来的传输速度慢,粗排阶段总共召回 100 篇,平均一篇文章 5KB 左右,总共大约 500KB,但如果只召回 highliaht 部分,可以减少到 10KB 以下;另外就是文章篇幅长,在算特征的时候,处理时间也会相应地提升。

用 highlight 取代全文也并不会对搜索结果造成什么影响,因为新闻搜索对新闻的时间特征特别敏感,所以其他内容特征在最后的打分中不起决定作用。

最后 QPS 的实际提升效果较为一般,服务器 QPS 大致在 32 -> 35,算是有了小幅提升。

3.优化精排

通过如下的 PPROF 图,我们可以发现,在 NewSentence 这个函数里面 strings.Split 和 strings.ToLower 占据了很大的比重:

图片

下面分析一下这一步的代码究竟在执行什么:


func NewSentence(indexCategory string, field string, text string, k1 float64, b float64, weight float64, id string) *SentenceType {
  textBlank := strings.ToLower(strings.Replace(text, "
", " 
 ", -1))
  textBlankVec := strings.Split(textBlank, " ")
  NonBlankVec := make([]string, 0, len(textBlankVec))
  for _, term := range textBlankVec {
    if len(term) > 0 {
      NonBlankVec = append(NonBlankVec, term)
    }
  }
  length := len(NonBlankVec)
  // 涉及公司敏感代码,以下省略
}

这个函数的目的是根据 ES 召回的粗排内容生成为精排准备的数据

函数先把换行符后面加个空格方便分句,然后把字符全部小写,最后按照空格分词。

那么我们有从两个方面可以优化:

第一,ToLower 中为什么有 map 操作?这步在干什么?标准库里的 ToLower 方法是否干了一些无关操作?

第二,为什么不考虑把上面这些操作结合起来只遍历一遍所有字符呢?

针对第一个问题,我们可以参考 golang 的 ToLower 官方实现:

// ToLower returns s with all Unicode letters mapped to their lower case.
func ToLower(s string) string {
  isASCII, hasUpper := true, false
  for i := 0; i < len(s); i++ {
    c := s[i]
    if c >= utf8.RuneSelf {
      isASCII = false
      break
    }
    hasUpper = hasUpper || ('A' <= c && c <= 'Z')
  }

  if isASCII { // optimize for ASCII-only strings.
    if !hasUpper {
      return s
    }
    var b Builder
    b.Grow(len(s))
    for i := 0; i < len(s); i++ {
      c := s[i]
      if 'A' <= c && c <= 'Z' {
        c += 'a' - 'A'
      }
      b.WriteByte(c)
    }
    return b.String()
  }
  return Map(unicode.ToLower, s)
}

简单来说,golang 为了满足全世界字母的大小写转换,所以当它判定字符串中有非 ascii 码的时候,就会走 Map 函数去进行其他语言的大小写转换,比如德语的ä会被转写为Ä,全世界这么多语言有非常多的大小写映射,所以要在内存里面装一个大 table 来表示各种语言的大小写映射,而查找字符,则是用的二分法来查找。

图片

而我们恰恰是中文字符,所以每次都会走这个 Map,但中文没有大小写区分,所以做了很多无用功。为了规避这一点,就需要自己写 Lowercase 方法。



对于第二个问题而言,我们只需要把这些操作放在一个方法里面走一次遍历完成 Tolower, split, replace 操作即可。

// PreprocessText 这步的目的是要把lower replace split 混在一起做加速, split是用空格来做分割, 另外只提取非空字符
func PreprocessText(s string) []string{
  result := make([]string, 0, CountSpace(s))
  builder := strings.Builder{}
  for i:=0;i<len(s);i++{
    c := s[i]
    if c == ' ' || c == '
'{
      if builder.Len() > 0{
        result = append(result, builder.String())
      }
      if c == '
'{
        result = append(result, "
")
      }
      builder = strings.Builder{}
      continue
    }
    if 'A' <= c && c <='Z'{
      c += 'a' - 'A'
    }
    builder.WriteByte(c)
  }
  if builder.Len() > 0{
    result = append(result, builder.String())
  }
  return result
}

最终的优化效果就是,从 CPU 耗时上,精排比以前缩减了大约 50%。

图片

实际 QPS 的提升却并不明显,主要原因是引发性能瓶颈的桎梏并不在精排上。但是优化还是有意义的,当需要用 golang 处理大量文本来实现一些底层函数时可以得到加速,用 go bench 来评估一下速度:


BenchmarkOfStandardMethod-8     1000000000           0.000166 ns/op
BenchmarkOfNewMethod-8          1000000000           0.000098 ns/op

第一行是用的标准库实现,第二行是我们自己重写的。理论上性能提升了 60%。

4.优化 ES query 语句

在保证召回相关度的情况下,用户更希望看到的是近期的数据,总共有两种方法来实现目的:

第一,采用多个 date filter 来召回数据,并维护一个 set 保证召回的数据不重复。

具体来说,每个 filter 都是一个时间段,例如”近 3 天” ”近 7 天" “近 30 天” 这三个 filter, 在每个时间段里去取 TOP100 的数据,最终召回 300 条数据送到精排。问题在于:即便采用 go routine 进行并发请求,但根据木桶效应,其速度依然取决于最慢的那个 query,如下图显示,其实际处理时间是 1.67s,另外还很容易对 ES 集群造成很大的压力。

图片

第二,采用 gaussian decay function。

它其实是在 ES 打分时,加入了一个 function score,考虑其时间因素来进行召回, 但是因为它本质上是个 function score,而 function score 又是个性能杀手,所以当时我们没考虑使用这个。

最终,我们决定通过测试来决定最终采取哪种方式来实现近期数据的召回,我们采用 vue 为 10,并发请求 1min 来进行测试,下图分别为多 date filter qps 和 Gaussian decay function 的测试结果:

图片

图片

最终测试显示,相比于多 date filter qps 的方式,Gaussian decay function 的 QPS 要明显高出很多,因此选择 Gaussian decay function 的方式。

5.自建 ES Cluster, 扩大 Nodes 数量

公司的系统是部署于亚马逊 AWS 之上,原来使用的 AWS 提供一个包含 kibana 在内的完整开箱即用的 ES 集群服务,管理监控起来十分方便。

但 AWS 的 ES Cluster 为了安全性等考虑,加入了太多限制,使得机器利用率不高,而且大量 API 无法使用,包括动态同义词方案,close index, 查看 segment 具体情况等,从而对优化形成了很大的掣肘。

因此想要更深入地控制 ES 集群,就必须采取自建 ES 集群的方式。本来这里是有一个指向公司内网 wiki 的外链的,由于涉及公司保密问题,因此自建的详细步骤就不在这里说了。

简单来说,在费用降低的情况下提高了机器的配置以及 data node 的数量。并且采用了高版本的 ES 以及 Kibana,在成本降低 30% 的情况下,将 QPS 提升到 96/s。

图片

6.优化 shards 数量

关于这个主题,我也是在公司 wiki 单独有个栏目介绍,所以这里就不放链接了,简而言之,我做了一个对比试验,一个 index 有 10 个 shards,另一个只有 3 个 shards,数据量完全相同,都是 100 万的近期新闻数据,大约 20G,3 分片测试结果如下,QPS 已经从 96/s 提升到 108/s。

图片

这里面涉及的原理就如同本文开头所提到的,在 ES 中一个 shard 就是一个 Lucene 实例,而每个 shard 的底层又是由 segments 组成的,每个 segments 都有自身需要维护在内存的元数据,这些元数据包括字段列表、文档数量, terms dictionaries 随着 shard 的增大,它的 segments 的 size 也会随着增大,这很大地减少了 shard 下分段的元数据所需要的堆内存,单个 shard 至少大于 1G 才会充分利用内存。

但是,即使 shard 在 1GB 大小时能够获得更多的内存性能,一个拥有大量 1GB 的小 shard 仍然表现的很差,这是因为有太多的小 shards 对于搜索和入索引会造成一个很差的影响,每个查询或者 index 操作对于单个 shard 来说,都是要开辟一个线程的,一个 Node 从一个客户端接收到一个请求后,就会将请求分布给每个 shards 去处理,最终将结果汇总到单个响应中。即使一个集群有充分的线程池去立刻处理这些请求,向每个 shard 发送这些请求以及合并这些结果所带来的额外开销仍然会产生延迟,这些操作轮流会导致线程池的耗尽,从而降低吞吐量。

总而言之,每个分片都是一个 Lucene 实例,当查询请求打到 ES 后,ES 会把请求转发到每个 shard 上分别进行查询,最终进行汇总。这时候,shard 越少,产生的额外开销越少(官方推荐,每个 shard 的数据量应该在 20GB - 50GB)。

关于这部分的详细原理可以参考这篇官方文档:

https://www.elastic.co/guide/en/elasticsearch/reference/current/avoid-oversharding.html.

图片

图片

总结

本次通过以上 7 个步骤的优化,单机 QPS 从 8/s 提升到额 108/s,整体 QPS 提升了 120%,连带着使得系统的整体硬件成本下降了 30%。

整个优化过程大约持续了 3 周,其中看了无数的文档做了无数次实验,也踩了大量的坑,当初甚至对着 PPROF 图甚至想要优化 Golang 的垃圾回收…整个优化流程走下来,对 Golang 底层、ES 底层、精排各种细节的特征实现又有了新的认识。

如果问我这次优化我对什么影响最深,那就是优化性能一定需要分析对性能瓶颈,否则是隔靴搔痒,盲人摸象。在找到性能瓶颈以后,对症下药,去研究其底层内部实现,比如这次的核心瓶颈出现在 ES 上,那就一定要弄清楚,ES 从一个请求过来到返回结果,其中的底层原理究竟是怎样的?Lucene 底层数据结构是怎样的?都说搜索引擎的原理是倒排表,那在数据量大到内存装不下的时候怎么存放对应的数据结构?数据量再大到单机装不下的时候 ES 怎么管理集群的?怎么保证它的一致性的?里面每个问题都可以拿一门学科来讲,真的太丰富了,计算机的魅力就在于此吧。希望以后能够继续有幸这些领域深入研究,甚至做出点小突破。

本文来自 Wei Ziyang 博客,录信数软对于部分内容进行了修改,原文链接:https://www.wzy-codify.com/article/ji-yi-ci-sou-suo-fu-wu-qpsyou-8-sdao-108-sde-diao-you-guo-cheng.


本文地址:https://www.6aiq.com/article/1622419462346
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出