Published on

Elasticsearch 使用规范与索引管理最佳实践

Authors
  • avatar
    Name
    Liant
    Twitter

Elasticsearch 使用规范

基于现有资源制订的使用规范,假设生产项目集群有5个节点。

构架索引要求

  1. 索引对外提供服务使用别名,命名需要加上版本号。

别名search_online_a_index,实名search_online_a_index_v2

POST /_aliases
{
    "actions": [
        {
            "add": {
                "index": "search_online_a_index_v2"                "alias": "search_online_a_index"
            }
        }
    ]
}

规则: search 为搜索引擎项目提供更新服务, online_a 具体品牌业务, index_v2 当前版本号

使用别名好处较多: 如要修改字段类型时,先构建一个索引,然后索引数据,数据索引完成之后,可以几乎不影响正常业务情况下无缝切换。

  1. 创建索引时,在mapping中指定 dynamic=false

    避免新增字段时,自动解析字段类型导致类型不准确.比如第一次解析的值是1,被解析为integer,第二次值是2.1,这种情况下自动解析为是double。那么造成的后果就是类型不匹配。最后的结果只能修改mapping重新索引数据。

  2. 如果索引的字段的值是数字类的,且确定不使用范围查询,需要全部设置为keyword类型。

  3. 避免mapping字段数过多,一般不超过100个。

  4. 创建索引时,设置慢日志。

        "search.slowlog.threshold.query.warn": "5s", # 搜索慢日志 warn级别
        "search.slowlog.threshold.query.info": "1s", # 搜索慢日志 info级别
        "search.slowlog.threshold.fetch.warn": "1s", # fetch慢日志 warn级别
        "search.slowlog.threshold.fetch.info": "800ms", # fetch慢日志 info级别
    
  5. Elasticsearch索引不支持连表,但支持join类型,即nested和parent-child,尽量避免使用。

    Elasticsearch的索引默认支持mapping嵌套,正常情况下慎用嵌套类型,如果使用嵌套类型,建议深度不要超过2,嵌套类型文档内部更新无法原子更新。

    通常情况下,nested比parent-child查询快5-10倍,但parent-child更新文档比较方便。

  6. 需要批量更新时,走批量更新队列服务,这样不影响单个数据更新。

  7. 需要批量更新时,尽量在业务服务不繁忙时操作。

  8. 不直连Elasticsearch集群,使用搜索引擎项目进行查询,避免排查问题时增加难度。

  9. 字段名称禁止使用以_开始的索引名称,Elasticsearch内部使用的字段名称使用_开始。

  10. 索引命名全为小写。建议全部由字母、数字组成,字母开头。不是用特殊字符。

    使用搜索引擎搭建的同步服务,示例:别名search_index,实际名字search_index_v2

  11. 索引名称字符长度不超过255位,字段名称字符长度不要超过32位。

  12. 日志类型建议以月分片(根据日志体量来,可以以天/周/月为单位)。

查询要求

  1. 设置查询超时,使用request_cache。

    curl -XPOST http://localhost:9200/search_index/_search?request_cache=true -d '
    {
        "size" : 0,
        "timeout" : "60s",
        "terminate_after" : 1000000,
        "query" : { "match_all" : {} },
        "aggs" : { "terms" : { "terms" : { "field" : "keyname" } } }
    }'
    
  2. 必须指定_source中的返回字段。

    避免占用不必要的资源,比如网络传输,程序内存等。

  3. 避免深度分页,超过 from+size>10000 时,考虑其他搜索接口 search_afterscroll

    另外,调整 max_result_window 过大容易出现 OOM

  4. 查询时,谨慎使用 scriptwildcard模糊查询,cardinality基数查询。

    这几个查询类型都是消耗资源的查询

  5. 查询时,注意字段类型匹配。

    mapping中integer的字段,在查询时不要输入汉字等明显不能转为整数的字符。

  6. 如果需要使用聚合等,建议异步计算。

    如果聚合耗时,建议凌晨时分做聚合计算。保存结果数据。

  7. 数据量大的时候,查询频繁,建立考虑独立的集群。

其他应该考虑

关于集群

规模较大的集群配置专有主节点,避免脑裂问题

在集群规模较大时,建议配置专有主节点只负责集群管理,不存储数据,不承担数据读写压力。

JVM内存

给JVM配置机器一半的内存,但是不建议超过32G。

JVM 堆内存较大时,内存垃圾回收暂停时间比较长,建议配置 ZGCG1 垃圾回收算法。

尽可能使用SSD

对于文档检索类查询性能要求较高的场景,建议考虑 SSD 作为存储,同时按照 1:10 的比例配置内存和硬盘。

对于日志分析类查询并发要求较低的场景,可以考虑采用机械硬盘作为存储,同时按照 1:50 的比例配置内存和硬盘。

配置查询聚合节点

查询聚合节点可以发送粒子查询请求到其他节点,收集和合并结果,以及响应发出查询的客户端。通过给查询聚合节点配置更高规格的 CPU 和内存,可以加快查询运算速度、提升缓存命中率。

某客户使用 25 台 8 核 CPU32G 内存节点 Elasticsearch 集群,查询 QPS 在 4000 左右。增加 6 台 16 核 CPU32G 内存节点作为查询聚合节点,观察服务器 CPU、JVM 堆内存使用情况,并调整缓存、分片、副本参数,查询 QPS 达到 12000。

创建索引

创建索引的mapping文件

PUT /search_index_v1
{
  "aliases": { # 别名一般在建立索引之后手动添加
    "search_index": {} # 别名1,可以有多个别名.有多个别名时,写入只能使用实名.否则es会有歧义错误
  },
  "settings": {
    "index": {
      "refresh_interval": "1s", # 数据写入之后,需要等待刷新,刷新之后才可以被搜索到
      "number_of_shards": "5", # 分片
      "number_of_replicas": "1", # 副本
      "search.slowlog.threshold.query.warn": "5s", # 搜索慢日志 warn级别
      "search.slowlog.threshold.query.info": "1s", # 搜索慢日志 info级别
      "search.slowlog.threshold.fetch.warn": "1s", # fetch慢日志 warn级别
      "search.slowlog.threshold.fetch.info": "800ms", # fetch慢日志 info级别
      "indexing.slowlog.threshold.index.warn": "12s", # index慢日志 warn级别
      "indexing.slowlog.threshold.index.info": "5s" # index慢日志 info级别
    }
  },
  "mappings": {
    "_source": { # 开启_source,可以避免二次查询,相对的占用存储空间.
      "enabled": true
    },
    "properties": {
      "xxx_id": {
        "type": "keyword"
      },
      "timestamp": {
        "type": "long"
      },
      "xxx_status": {
        "type": "integer"
      },
      "xxx_content": {
        "type": "text"
      }
    }
  }
}

主要介绍两个重要设置 settingsmappings

settings 设置

{
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 0
    }
  }
}
  • 分片(Shard)数量

    生产环境目前是5台机器,按照目前数据量来说5个分片比较合理。

  • 分片(Shard)存储大小

    一般来说,单个 Shard 的建议合适的大小是 20G 到 50G,对于普通搜索类数据,最好控制在 20G,而对于时间序列类数据(如日志)最好控制在 50G。

  • 副本(replica)数不大于3

    Elasticsearch默认副本数是1,即存在1个primary和1个replica共两份数据,主备形式同步,为了提升数据的安全性,避免数据丢失,可以设置副本数为2,即存在1个primary和两个replica共三份数据。但是没有必要设置过大的副本数,Elasticsearch的复制会对集群性能产生影响,特别是refresh_interval设置比较小的集群。

需要重建索引时,设置number_of_replicas=0(即副本数为0)可以提升写入文档速度,数据索引完成之后,可以调整副本数量,主分片是不可以的,需要一开始就确定好。

mappings 设置

Elasticsearch的索引最多默认支持1000个字段,Elasticsearch的字段数越多,对于集群缓存的消耗越严重,通常建议不要超过 100 个字段。

不使用多type索引

Elasticsearch7.x 之后将不再支持多type索引。多type索引共用_id,其version往往容易产生冲突。

几个重要的设置项

  • dynamic 设置字段自动解析

        {
            "settings": {...},
            "mappings": {
                "dynamic": false,
                "properties": {...}
            }
        }
    
  • _source 是否保留源文档,通常为true.避免二次查询。

        {
            "settings": {...},
            "mappings": {
                "_source": {
                    "enabled": true
                },
                "properties": {...}
            }
        }
    
  • _all 将多个字段合并为一个字段提供检索,7.x 被遗弃了

        {
            "settings": {...},
            "mappings": {
                "my_type":{
                    "_all": {
                        "enabled": false
                    },
                    "properties": {...}
                }
            }
        }
    
  • null_value 查询null是必要,另外可以和exist结合起来使用

        {
            "settings": {...},
            "mappings": {
                "properties": {
                    "status_code": {
                        "type":       "keyword",
                        "null_value": "NULL"
                    }
                }
            }
        }
    
  • ignore_malformed 默认false,添加文档时忽略该字段的异常数据

        {
            "mappings": {
                "my_type": {
                    "properties": {
                        "number_one": {
                            "type": "integer",
                            "ignore_malformed": true
                        },
                        "number_two": {
                            "type": "integer"
                        }
                    }
                }
            }
        }
    
  • keyword 表示最大的字段值长度,超出这个长度的字段将不会被索引,但是会存储。

        {
            "settings": {...},
            "mappings": {
                "properties": {
                    "keyword": {
                        "type": "keyword",
                        "ignore_above": 256
                    }
                }
            }
        }
    

索引管理

索引mapping新增字段

处理步骤

  1. 更新mapping
  2. 通过全量数据索引数据服务索引数据

这种方式不调整索引名称,不影响业务。

尽量在业务使用不频繁的情况下操作

索引mapping修改字段类型

Elasticsearch是不支持mapping字段的类型修改的,只能重建索引才可以。

方案一 reindex

适用于任何调整: 删除字段,修改字段类型,新增字段别名(fields)

  1. 使用调整过的mapping创建新版本的索引

  2. 使用reindex的方式重新索引数据

  3. 索引数据之后,切换别名即可提供服务

__reindex_介绍

该方法要在mapping中启用 _source

POST _reindex?slices=5&refresh&wait_for_completion=false
{
  "conflicts": "proceed",
  "source": {
    "index": "search_index_v1", // 源索引
    "size": 5000, // 每批次大小
    "query": { // 查询条件,可做条件过滤
      "term": {
        "user": "kimchy"
      }
    }
  },
  "dest": {
    "index": "search_index_v2", // 目标索引
    "pipeline": "mytx_pipeline_20220530" // 使用pipeline处理数据
  },
  "script": {
    "source": "ctx._source.tag = ctx._source.remove(\"flag\")"
  }
}

reindex数据比bulk要快5~10倍

方案2 _update_by_query

适用于任何调整: 新增字段别名(fields),删除字段

  1. 在原索引中更新mapping

  2. 使用_update_by_query的方式重新索引数据

_update_by_query 介绍

POST /search_index_v1/_update_by_query?conflicts=proceed&wait_for_completion=false
{
  "script": {
    "inline": "ctx._source.commission2 = []", // 做一些字段处理,如清空数据等
    "lang": "painless"
  },
  "query": { // 搜索条件
    "term": {
      "id": {
        "value": "6256"
      }
    }
  }
}

_update_by_query 用于重新索引数据,新增字段别名,删除数据等。

方案3 使用全量数据索引数据服务

适用于任何调整: 调整字段类型,新增字段别名(fields),删除字段

  1. 使用新的mapping新增新索引

  2. 将数据id投递到全量数据索引数据服务消息队列,消费者消费消息,bulk写入Elasticsearch

  3. 索引数据之后,切换别名即可提供服务

索引数据清理

  • _delete_by_query

在kibana中执行

POST /search_index_v1/_delete_by_query
{
  "query": {
    "term": {
      "project": 5
    }
  }
}

谨慎查询条件,在删除之前使用查询确认下要删除的数据是否正确。

索引数据

索引方案:

  1. Elasticsearch建模,确定mapping中的字段类型,在Elasticsearch中手动创建索引。
  2. 具体项目中,将要更新的数据id投递到消息队列中,消费者从队列中读取数据id,查询数据库,最后索引到Elasticsearch中。
  3. 搜索引擎项目中新建一个控制器,提供具体索引http访问接口。

索引服务规则

  • 规则 1 : 单一项目更新数据到同一个索引(通过MQ同步ES数据)。

    1. 至少两个队列sync,通过 routing_key 投递数据
    2. 定义服务:
      1. 新增数据服务(insert)

        • 队列名称: 索引别名.项目.具体业务.操作.que后缀(如 search_index.insert.que )
        • routing_key名称: 索引别名.项目.具体业务.操作.rk后缀(如 search_index.insert.rk )
      2. 更新数据服务(update)

        • 队列名称: 索引别名.项目.具体业务.操作.que后缀(如 search_index.update.que )
        • routing_key名称: 索引别名.项目.具体业务.操作.rk后缀(如 search_index.update.rk )
      3. 索引数据服务(index,不区分update与insert)

        • 队列名称: 索引别名.项目.具体业务.操作.que后缀(如 search_index.index.que )
        • routing_key名称: 索引别名.项目.具体业务.操作.rk后缀(如 search_index.index.rk )
      4. 全量数据索引数据服务(whole,不区分update与insert) 尽量使用批量投递.但也不可过长,建议20个数据id

        • 队列名称: 索引别名.项目.具体业务.操作.que后缀(如 search_index.whole.que )
        • routing_key名称: 索引别名.项目.具体业务.操作.rk后缀(如 search_index.whole.rk )

    其中 全量数据索引数据服务必需添加。

    另外如果不需要区分新增数据服务更新数据服务,则可以选择索引数据服务

  • 规则 2 : 多个项目都需要更新数据到同一个索引。

    对每一个项目都使用 规则1

    多个队列同时写入一个索引中,应该尽量避免同时批量更新数据

    坑点:先创建了数据,在更新mapping,再次更新数据(如果文档数据没有变化,Elasticsearch会认为是空操作,不做任何处理),会导致mapping不起作用.

    解决办法:使用Elasticsearch的新建接口创建文档;如果是新增了字段,将改字段清空,在修改文档。

查询数据

对外提供服务一缕走搜索引擎项目,搜索引擎项目查询服务需要配置索引的别名。

检索分类说明

检索分类

检索分类说明

查询建议

  • match操作不走缓存

  • exists 的 Elasticsearch提供了exists查询,用以返回字段存在值的记录,默认情况下只有字段的值为null或者[]的时候,Elasticsearch才会认为字段不存在

    1. 对于字符串类型字段,当字段没有出现、字段值为null的情况下,则该字段不存在;字段值为空则计算为字段存在;
    2. 对于数字类型字段,当字段没有出现、字段值为null的情况下,则该字段不存在;
    3. 对于布尔类型字段,当字段没有出现、字段值为null的情况下,则该字段不存在;
    4. 对于数组类型字段,只要字段没有出现、字段值为null、字段值为空数组、字段值数组的所有元素都为null,则该字段不存在;
    5. 对于对象字段,只要字段没有出现、字段值是空对象、字段值为null,则该字段不存在;
  • 不需要评分时,使用boolfilter可以极大的提高检索速率

  • 避免使用脚本查询script、通配符查询wildcard以及正则查询regexp

    如果查询较为复杂,会有性能问题,结果会导致单个查询占用整个集群资源,导致服务不可用

  • 避免使用深度分页,越是往后越是消耗资源。

    1. 使用search_after时 from设置为0,-1或者直接不加from
    2. 使用search_after进行分页相比from+size的方式要更加高效,而且在不断有新数据入库的时候仅仅使用1. from和size分页会有重复的情况
    3. 相比使用scroll分页,search_after可以进行实时的查询
    4. search_after不能跳跃分页,只能顺序一页一页往下查询

易出现的问题

1. mapping字段不支持修改,需要谨慎

需要修改,只能重建索引

2. 分页

    # 默认情况下,结果最多是10000,当返回结果不够的时候设置参数 `max_result_window`

    curl -XPUT 127.0.0.1:9200/search_index/_settings -d '{
        "index.max_result_window":1000000
    }'

注意:深度分页会占用大量的资源,配合search_afterscroll使用

另外 max_result_window 调整过大容易出现 OOM

2. 查询类型不匹配

当mapping中的类型为integer,long,byte等数字类型时,如果输入的不是数字类型,查询时会有类型转换错误。

3. 使用script查询

script查询时会占用大量的资源。如果要使用的话,位置最好放到DSL最后,因为DSL是从前往后的执行过滤。

4. 查询 null

如果要查询 null值 需要在设计mapping的时候指定字段的 null_value。

5. Elasticsearch的搜索引擎结果并不一定准确,这个需要知晓

这个是数据分片之后排序的通病。

案例一 新建索引

模拟一个场景:商品需要检索服务。

  1. 索引建模,确定商品索引需要那些字段。 在kibana执行

    PUT /shop_index_v1
    {
      "aliases": {
        "shop_index": {}
      },
      "settings": {
        "refresh_interval": "1s",
        "number_of_shards": 5,
        "number_of_replicas": 0, // 新建时不使用,数据索引完成之后开启
        "search.slowlog.threshold.query.warn": "5s",
        "search.slowlog.threshold.query.info": "1s",
        "search.slowlog.threshold.fetch.warn": "1s",
        "search.slowlog.threshold.fetch.info": "800ms",
        "indexing.slowlog.threshold.index.warn": "12s",
        "indexing.slowlog.threshold.index.info": "5s"
      },
      "mappings": {
        "_source": {
          "enabled": true
        },
        "dynamic": "false",
        "properties": {
          "accordexpand": {
            "type": "text",
            "analyzer": "ik_max_word"
          },
          "activity": {
            "type": "keyword"
          },
          "additional_money": {
            "type": "float"
          },
          "apply_year": {
            "type": "keyword"
          },
          "assess_time": {
            "type": "integer"
          },
          "bank": {
            "type": "short"
          },
          "brand_text": {
            "type": "keyword",
            "fields": {
              "standard": {
                "type": "text",
                "analyzer": "standard"
              }
            }
          }
        }
      }
    }
    

    搜索引擎项目中创建索引 (结合自己的后端项目,以下是PHP项目举例)

    目前是在搜索引擎项目创建mapping文件: app/Common/Mapping/ShopMapping.php

    然后在搜索引擎项目(Swoft框架)中使用command功能新建命令脚本: app/Console/Command/ShopMapping.php

        # 创建index
        # 应该读取别名,检查Elasticsearch中是否已有使用,如果有的话需要在版本号后+1,否则就是_v1
        php bin/swoft shop:createIndex
    
        # 更新index
        # 应该读取别名,更新mapping。当有多个别名时,需要手动指定真实名称
        php bin/swoft shop:updateIndex
    

    另外在配置文件中配置索引别名

    在生产环境执行命令,创建索引。真实名字应该为:search_index_v1

  2. 索引数据

    同样在ShopCommand.php文件中定义消费者服务,因为只需要更新数据即可。

        # 索引数据服务(index,不区分update与insert)
        # 实时更新数据,新增数据等。默认是写入索引别名中
        php bin/swoft shop:indexData
    
        # 全量数据索引数据服务
        # 批量更新数据,新增字段等场景使用到
        # 索引需要指向最新的索引版本,默认向最新版本的索引(应该在命令行中预留参数,便于更新到其他索引中)中更新数据数据。
        php bin/swoft shop:wholeData
    
        # 将数据投递到消息队列中,可带常用的查询参数,导入指定的数据到全量队列
        php bin/swoft shop:publishData
    
  3. 提供查询服务

    在搜索引擎项目中新增接口文件: app/Http/Controller/ShopController.php

        /**
         * 商品索引
         * @RequestMapping("/shop-index/_search")
         * @return Response
         * @throws Exception
         */
        public function _search()
        {
            $request = context()->getRequest();
            $response = context()->getResponse();
            $authorization = $request->getHeaderLine('authorization');
            $body = $request->getRawBody();
            $url = ElasticsearchService::build_search_proxy_url(env('SHOP_INDEX'));
            $params = $request->getQueryParams();
            if (count($params) > 0) {
                $url .= '?' . http_build_query($params);
            }
            list($content, $info) = ESHelper::proxy($url, $authorization, $body);
            return ESHelper::mergeResponse($response, $content, $info);
        }
    

    重启搜索引擎项目后,可以提供商品的搜索服务。

    另外,需要单独配置读取权限Elasticsearch账户给具体的品牌业务项目。http的base认证。

案例二 重建索引

模拟一个场景进行讲解: 商品需要修改字段类型,没有新增字段

需要修改类型,则需要新建索引,在索引数据。

  1. 在案例一的前提下,更新mapping文件之后。

    执行 php bin/swoft shop:createIndex,创建新索引,真实名字应该为: search_index_v2

  2. 使用reindex功能更新数据(在业务使用频率低时处理)。

    在kibana执行

    POST _reindex?slices=5&refresh&wait_for_completion=false
    {
      "conflicts": "proceed",
      "source": {
        "index": "search_index_v1", // 源索引
        "size": 5000, // 每批次大小
        "query": { // 所有数据
          "match_all": {
          }
        }
      },
      "dest": {
        "index": "search_index_v2", // 目标索引
      }
    }
    

    生产任务,后台执行,需要记住任务id.

  3. 任务已经执行完成之后切换别名

    在kibana中执行

    POST /_aliases
    {
      "actions": [
        {
          "remove": {
            "index": "search_index_v1",
            "alias": "search_index"
          }
        },
        {
          "add": {
            "index": "search_index_v2",
            "alias": "search_index"
          }
        }
      ]
    }
    

    需要移除别名,不然更新服务会有索引歧义错误。

    之后看业务具体情况关闭索引,最后删除数据。

参考

  1. 铭毅天下---Elasticsearch相关博客
  2. 浅述Elasticsearch开发规范指南
  3. 使用 ElasticSearch 的 44 条建议
  4. thread pool
  5. 两小时 Elasticsearch 性能优化