ElasticSearch的调优

跟Mysql类似的,ES的调优也可以主要分为几个方面的优化,首先是数据结构的声明方面,其次是ES操作方面的优化,最后是操作系统方面的调优。

在结构方面

在声明mapping的时候,我们可以尽量避免以下的操作来尽量避免影响性能问题出现。

  1. 少声明字段:如果我们声明字段过多,在写入存储的时候Index Buffer就只能存入更少的文档进行插入;而在查询读取的方面,由于ES本身通过倒排索引等方式进行的优化,性能其实不会被太大影响,但过多的字段仍然会导致网络方面的压力。
    推荐的做法是:ES之中只记录一些比较关键的的信息,构成所谓的 search + fetch ,search从ES之中将目标数据的ID等可用于查询的信息获得,然后再到Mysql / hbase之中获得目标文档的数据。

  2. 数据尽量维持扁平化 :同样的在介绍es的数据结构的时候,有提到里有提到三种比较特殊的数据结构,一种是 nested 也就是对象类型、父子级别的对象类型、还有一种是兄弟级别的对象类型。在某些场景下使用当然非常方便,但是需要注意的是,nested 这种数据类型在索引和查询处理的时候对es来说是非常麻烦的,这也就导致了使用这种类型会 导致性能非常差

  3. 不查询字段关闭Index:这个其实在elasticsearch文档之中介绍过,也就是在ES之中,如果我们索引之中的某个字段,本身是不会针对其做查询的,最好的做法是在mapping声明的时候,直接指定它的index是false

      "mappings": {
        "properties": {
            "content": { "type": "text" },
            "name": {
              "type": "text",
              "index": false
            }
          }
      }
    
  4. 禁用 Dynamic Mapping:我们知道es在索引声明中,支持通过映射关系动态的增加字段,这个功能看起来就灵活好用,但他的问题在于,Dynamic Mapping新增的字段我们无法对其做设置,也就是即使我们不需要构建某个 field 索引,es也会自动创建。(也就是说这一点跟 不需要搜索的字段不要构建索引 是一样的原因)

  5. 不评分字段关闭Norms:跟index类似的,es针对字段查询的时候,往往会对查询的字段做评分处理,这个工作当然也是需要消耗性能来实现的,如果我们不需要使用

  6. 尽量不使用text的fileddata:同样在介绍es的数据结构的时候,介绍过text在由于自身的索引结构是倒排索引结构,这也就导致了text本身无法支持聚合。如果需要针对text做倒排索引就需要使用doc_values明确指定是true。但这会导致性能严重下降。

  7. 不聚合、排序字段关闭doc_values:跟上文text类似的,在其他的类型之中使用doc_values构建正排索引虽然消耗没有text构建fielddata那么夸张,但实际上也会产生一定的性能影响,因此实际上针对不需要排序聚合的字段,推荐关闭 doc_values。

    需要注意的是,如果后续想要根据该字段开启doc_values只能重建索引。

  8. 尽量使用更小的数据类型 & 更准确的数据类型

    • 更小的数据类型:我们在 ES 的倒排索引和存储介绍的内容里面知道,ES会使用整形压缩等手段做数据的压缩尽量提高存储效率和查询性能。但在使用索引的时候还是推荐直接根据需要来使用更小的数据结构;而对于准确使用类型方面

    • 更准确的数据类型:针对ID 和 枚举类型的时候,应当使用 keyword,而不是使用text。针对标题时,直接使用text。

      (使用Keyword在针对聚合和排序、做term、prefix 查询的时候,性能明显会比text好,存储效率上keyword也会比text好很多;并且keyword不会被分词处理)

  9. text字段使用合适分词器:在经过es的学习之后,我们当然知道实际上text的保存和查询其实都需要分词器做分词处理,如果我们使用的分词器不合适,这当然也会对我们的查询和插入的性能上造成一定的影响

批量插入的优化

当然我们的优化肯定不仅仅只能在结构的层面进行优化,还可以通过修改和配置ES查询和es的一些配置项的方式来进行优化。(需要注意的是,下面的所有的优化方式,其实都是针对大量数据导入的场景下提出的)

  1. bulk : 在使用kibana或者java api对es做操作或者是针对其他数据库进行操作的时候,不要忘记我们实际上的操作本身都是通过请求的方式来发起的,而在ES之中请求协议使用的是TCP,在大量文档需要导入的场景下,我们通过TCP逐条导入的话,势必是低效的。组合多个任务执行,通过组合执行构建task之后再获取执行效果的方式来做批量处理的方式 & 效果,从而提高性能。
    需要注意:这个批次的处理并非是堆越多越好的,当数量的大小超过 10M 之后就会反而无法继续提高性能。一般来说,假设一条数据的量是 1k,而ES推荐批量处理数据是 5M - 10M,因此如果想要通过使用bulk进行优化,**推荐最先设置批量处理的数据大小是 5000 ,然后逐条数据量提高,到8000,到12000。**直到感觉不到性能提升为止。

  2.  减少index refresh :其实就是通过降低触发 index buffer 调用 refersh 生成新的 Lucene Segment的频率的方式,从而减少实际上生成 Segment 的数量,进而提高实际上的存储效率。

    • indices.memory.index_buffer_size

      indices.memory.index_buffer_size 其实是一个静态的配置项,需要在 elasticsearch.yml 之中进行配置,需要注意的是,这个配置需要重启es集群的服务才能实际生效。index_buffer_size 本身是一个配置项目,允许我们针对 index_buffer 大小做修改,需要注意的是,这里配置的 index_buffer_size 其实是针对空间内的所有的节点做分片共享的。 (index_buffer_size的默认大小是堆大小的 10% )

      elasticsearch.yml (需要重启)

      需要注意的是这里的index_buffer是每个分片都会具有的大小,所以如果有三个节点,那么实际上是 3 * index_buffer_size 的大小缓冲共享的

      indices.memory.index_buffer_size: mb / gb   # 静态配置 默认 10%,可选[10-20%]
      indices.memory.min_index_buffer_size: mb / gb # 配置最少index_buffer大小(可以默认值)
      indices.memory.max_index_buffer_size: mb / gb  # 配置最大index_buffer大小(可以默认值)
      
    • index.refresh_interval

      index_refresh_interval 实际上改值就是修改index_buffer刷新写入到 index_refresh_interval 的值来调整 refresh 的时间间隔,该值如果更大,那么在合适的情况下,会提高Segement的利用率。

      值得一提的是,如果将改值的配置为 -1 ,那么就会完全禁止自动的 refersh 行为,而是更改为只有当 index_buffer 满了之后,才会自动进行 refresh,在大量批量导入的时候,可以使用该配置。

      通过请求来修改

      // 将 refresh 间隔修改为 -1 不自动触发  或者在 创建索引的时候设置
      PUT /${index}/_settings
      {
        "refresh_interval": -1    
      }
      // 修改完毕之后,通过命令查看当前索引的 refresh_interval 是多少
      GET ${index}/_settings
      // 批量插入完毕之后,可以通过以下配置恢复正常
      PUT /${index}/_settings
      {
        "index" : {
          "refresh_interval" : null
        }
      }
      
  3. 加大 transaction log flush 间隔 :在ES介绍存储的时候,介绍过 写入到 index_buffer -> refersh -> systemfile cache (transaction log) -> flush -> 持久化磁盘 (commit log)。也在上次的文档之中提到过,进行 transaction log flush 进行持久化处理之前文档其实都在缓存之中,es为了避免出现意外情况系统宕机等情况出现,往往会尽快的做 flush 动作将文档刷盘到磁盘之中。
    但如果我们的操作是大批量的文档导入,这个时候,频繁的flush反而会成为我们批量插入性能影响的来源,因此 加大 flush 的时间间隔也成为优化的一种方式。

    模板

    PUT ${index}/_settings
    {
        "index.translog.durability":"async",    // 关闭transtion log (改为异步处理)
        "index.translog.sync_interval": "240s",     // 加大持久化间隔 -- 使得触发更少
        "index.translog.flush_threshold_size": "512m"   //加大translog触发大小
        										    //让触发频率更低
    }
    

    批量导入之后,如果想要恢复默认配置

    PUT /${index}/_settings
    {
      "index.translog.durability": null,
      "index.translog.sync_interval": null,
      "index.translog.flush_threshold_size": null
    }
    

    恢复完毕之后,如果有需要可以手动触发flush

    POST /${index}/_flush
    
  4. 负载均衡让请求落到不同的节点处理:其实就是直接不指定文档的ID,而是使用默认情况下es提供的ID生成,自动ID生成的值,可以让数据更加均匀的分布在各个节点上,从而提高写入的效率(不需要检查ID是否已经存在),并且避免节点查询时,由于数据分配不均匀导致可能会出现的部分节点压力很大的问题。

  5. 减少副本节点的数量:显然的,我们设置副本的目的是为了保证主分片挂掉的情况下,es能快速的使用副本来恢复集群的服务,但是从写入的角度来看,大量的副本自然而然的也会导致同步压力的增大,越多的副本,最终实现数据一致性的压力、消耗的时间、资源也久越大,所以并不推荐设置过多的副本尤其在大量数据导入的时候。
    因此推荐的做法是,在大批量导入的时候,我们可以先把副本的数量减少,最好减少的0,先完成导入操作,然后再恢复副本。

    // 使用_settings重新设定副本数
    PUT /${index}/_settings
    {
    	"number_of_replicas": n          // 先设置为0,然后再重新恢复
    }
    

总结

总结以上所有的ES层面批量导入的优化,我们可以在导入之前事先使用以下配置最大程度上优化,并最后将配置还原为默认配置。(当然如果原先索引本身不是默认配置,可以先使用请求查看配置方便后续恢复)

查看原先配置

GET ${index}/_settings
// 查询结果
{
  "articles" : {
    "settings" : {
      "index" : {
        "routing" : {
          "allocation" : {
            "include" : {
              "_tier_preference" : "data_content"
            }
          }
        },
        "refresh_interval" : "-1",
        "number_of_shards" : "3",
        "translog" : {
          "flush_threshold_size" : "512m",
          "sync_interval" : "240s",
          "durability" : "async"
        },
        "provided_name" : "articles",
        "creation_date" : "1728712534571",
        "number_of_replicas" : "1",
        "uuid" : "Fux4F9-FQKev8wVjBVxwpA",
        "version" : {
          "created" : "7130099"
        }
      }
    }
  }
}

修改配置

如果我们可以重启 es 集群,可以先修改 elasticsearch.yml 配置文件,先修改单个分片允许使用的 index_buffer 的大小

indices.memory.index_buffer_size:${elasticsearch jvm  10 ~ 20%}
indices.memory.min_index_buffer_size:${48mb}  // 默认 48mb,可以更小,但是一般不会改
indices.memory.max_index_buffer_size: ${512mb}  // 默认无限制,可以加一个大小限制,免得单个分片消耗过多

修改完静态配置之后,我们可以通过使用请求做动态配置的修改

PUT ${index}/_settings
{
  "settings": {
    "number_of_replicas": 0,
    "index.refresh_interval": "-1",
    "index.translog.durability":"async",
    "index.translog.sync_interval": "240s",
    "index.translog.flush_threshold_size": "512m"
  }
}

使用 bluk 做批量的数据导入

通过动态配置将配置重新修改回默认情况

PUT ${index}/_settings
{
  "settings": {
    "number_of_replicas": n,
    "index.refresh_interval": null,
    "index.translog.durability": $["request" / null],
    "index.translog.sync_interval": null,
    "index.translog.flush_threshold_size": null
  }
}

恢复默认的静态配置 (如果原先没有,这里直接删掉就行)

使用层面上的优化

除了上文的在批量导入的场景下,我们可以通过一些参数的修改来提供使用的性能,而在平时时候的时候,我们也有一些办法可以让我们的性能得到一定程度上的提升。

  1. 深分页或者返回的结构集不易过大

  2. 查询返回的字段应该尽量的少

  3. 聚合的字段不可以有存在大量不重复值,这会导致聚合性能很差

  4. 模糊查询有可能会导致内存溢出

  5. 不要对text做排序

  6. 插入文档不要自己指定文档 ID

  7. 大量的段合并会导致 I/O 资源爆炸

  8. 大量插入必须考虑上文的优化

  9. 不要将ES的堆空间 -Xms -Xmx设置过大,Lucene本身还会占用一些内存空间,建议是不要超过机器本身一半内存

  10. GC回收算法应当尽量选择更好的更适合的,否则内存不够

数据搜索优化示例

在针对类似于消费记录、短信推送记录、登录记录、签到记录一类的数据做统计消费人员类型级别的统计处理的时候,往往有一些场景下需要针对记录数量多、价格统计总和多、时间排序前的人做聚合处理。这个时候往往会涉及到聚合功能方面上的查询及优化问题。

在集群节点、分片分配都完全合理的情况下,如果我们的记录本身有大量的数据的话,往往还是需要大量的时间做统计计算。几乎百分百会请求超时。例如下面的例子:

GET sms/_search
{
  "aggs": {
    "sms_count": {
      "terms": {
        "field": "phone",
        "size": 1000
      }
    }
  },
  "size": 0
}
// 哪怕我们直接加上时间范围的过滤,还是一样的慢
GET sms/_search
{
  "query": {
    "range": {
      "sent_time": {
        "gte": "2022-08-03T00:00:00Z",
        "lt": "2022-08-04T00:00:00Z"
      }
    }
  },
  "aggs": {
    "sms_count": {
      "terms": {
        "field": "phone",
        "size": 100
      }
    }
  },
  "size": 0
}

那为什么会这么慢呢?其实原因就跟 Cardinality 有关,实际上es在发现我们查询的字段本身是keyword并且使用到了 Global Ordinals的时候,就会默认的在内部先构建 Global Ordinals,甚至这个优先的操作,会高于我们的 query 行为查询,而实际上构建的这个行为其实相当消耗性能。这就导致了我们查询会这么慢的原因。(关于Ordinals的介绍在另外一个文档,ES存储 & 搜索原理之中有所介绍,可以看那个文档)

如何解决这个问题呢?

(High Cardinality 问题,也就是聚合结果数量没多少,但是缺需要在聚合之前做Global Ordinal Mapping消耗大量时间)

在Ordinals介绍的时候,我们就已经知道实际上Ordinals不开启Global Ordinals的情况下也会在各个分片下的 Segment 内部就已经存在,而如果我们开启 Global Ordinals 就需要将Segment的Ordinals统一规划到Shard级别,也就是分片级别上,显然的在数据量一样的情况下,我们分片Shard的数量越多,单个Shard需要处理出来的Global Ordinals Mapping就会越少,因此我们可以通过增加分片的方式来优化。(也显然,增加分片进行优化的话,太过理想)
其次,同样的在针对 Ordinals 进行介绍的时候,其实就已经提到过,我们可以通过使用 egaer_global_ordinals 让构造从使用聚合查询的时候,提前到数据写入的时候提前构造。这样可以有效的提高我们在做聚合查询时候的性能。
还有一种办法,也可以提高一部分情况下的聚合性能,通过修改 terms 聚合下的 execution_hint 的值直接逃避创建 global_ordinals_mapping 而是直接通过字段的值进行聚合,当然这种方式不适合聚合的组很多的情况。

总的来说,就是可以采取以下三种方式来尝试优化上面的聚合

  • 增加分片的数量,可以降低单个分片的聚合计算压力
  • 配置eager_global_ordinals 并且将 index_refersh 调整参数来true
  • 修改 terms 聚合 exection_hint 值来跳过构造 Global Ordinals Mapping 来构造步骤,在terms聚合重 execution_hit 可选用 map 或者 global_ordinals
    • map:直接使用字段的值,来分桶聚合统计,适合在聚合少量数据的使用,也就是不使用 Global Ordinals Mapping
    • global_ordinals,使用 Global Ordinals Mapping 来辅助聚合数据
GET sms/_search
{
  "query": {
    "range": {
      "sent_time": {
        "gte": "2022-08-03T00:00:00Z",
        "lt": "2022-08-04T00:00:00Z"
      }
    }
  },
  "aggs": {
    "sms_count": {
      "terms": {
        "field": "phone",
        "execution_hint": "map",
        "size": 100
      }
    }
  },
  "size": 0
}