京东自营 618 + 国补 iPhone 历史最低价

Elasticsearch 最佳生产实践

前言

Elasticsearch是一个底层基于Lucene的全文搜索和分析引擎,支持近乎实时地存储、搜索和分析大量数据的能力,最常用于网站搜索、日志搜索、数据分析等场景。

本文主要针对日常工作中Elasticsearch使用的一些基础概念、使用规范、注意事项、常见优化以及工具使用进行总结,如有不当的地方,欢迎指正。

Elasticsearch建索引规范

索引建立时需搞清楚每个字段存在的用途(这里用途不仅仅是业务上的定义,还需关心该字段是会做索引,还是会聚合计算,还是会有排序,或者仅仅只是文档),在建立mapping时应当根据字段的不同用途,不同数据类型来匹配合适的Elasticsearch中的数据类型。

搞清楚中Elasticsearch中的数据类型

string类型Keyword和Text都属于string类的基本数据类型,但使用场景完全不同。

Keyword与Text差异对比

  • Keyword(不分词): 如果文本上有精确搜索、排序、聚合查询的需求时,可以使用,大文本用不要用keyword类型。

  • Text(分词): 按分词器进行分词,用于全文检索,不能排序,也禁止用来聚合。

以下测试案例,具体说明了两种类型分别在match和term查询时反映出来的差异性。

# 建立名为emp的索引
PUT /emp/
{
"mappings": {
    "_doc": {
      "properties": {
        "nickname": {
          "type": "text"
        },
        "name": {
          "type": "keyword"
        },
        "age": {
          "type": "integer"
        }
      }
    }
  }
}

# 插入测试数据
POST /emp/_doc/_bulk
{"index":{"_id":"1"}}
{"nickname":"zhang san","name":"zhang san","age":"28"}
{"index":{"_id":"2"}}
{"nickname":"zhang san feng","name":"zhang san feng","age":"88"}
{"index":{"_id":"3"}}
{"nickname":"zhang san bu feng","name":"zhang san bu feng","age":"8"}

# 3条全部能够查到
GET /emp/_search
{"query":{"match":{"nickname":"zhang san"}}}
# 3条全都查不到
GET /emp/_search
{"query":{"term":{"nickname":"zhang san"}}}

# 换成查name,match和term都只能查到id为1的这条数据
GET /emp/_search
{"query":{"match":{"name":"zhang san"}}}
GET /emp/_search
{"query":{"term":{"name":"zhang san"}}}

# match和term都是3条全部能够查到
GET /emp/_search
{"query":{"match":{"nickname":"zhang"}}}
GET /emp/_search
{"query":{"term":{"nickname":"zhang"}}}

# match和term都是一条也查不到
GET /emp/_search
{"query":{"match":{"name":"zhang"}}}
GET /emp/_search
{"query":{"term":{"name":"zhang"}}}

# 可以排序
GET /emp/_search
{"sort":[{"name":{"order":"desc"}}]}

# 报错
GET /emp/_search
{"sort":[{"nickname":{"order":"desc"}}]}

数值类型

数组类型包括:long, integer, short, byte, double, float, half_float, scaled_float

请按实际需求选择,因为这不仅仅是能够节省空间,同时索引和搜索都将变的更有效率,不过Elasticsearch会根据存储的实际情况进行优化。

以下案例具体说明了,当实际存储类型与数据类型精度不一致时,所导致的“怪异”现象,主要原因就是因为Elasticsearch会根据存储的实际情况进行优化。

# 注意age字段指定的是integer类型
PUT /emp/
{
"mappings": {
    "_doc": {
      "properties": {
        "nickname": {
          "type": "text"
        },
        "name": {
          "type": "keyword"
        },
        "age": {
          "type": "integer"
        }
      }
    }
  }
}

# 插入的测试数据,age字段实际上保存的都是字符串,但完全不影响插入
POST /emp/_doc/_bulk
{"index":{"_id":"1"}}
{"nickname":"zhang san","name":"zhang san","age":"28"}
{"index":{"_id":"2"}}
{"nickname":"zhang san feng","name":"zhang san feng","age":"88"}
{"index":{"_id":"3"}}
{"nickname":"zhang san bu feng","name":"zhang san bu feng","age":"8"}

# 同样,保存小数也没问题
POST /emp/_doc/_bulk
{"index":{"_id":"6"}}
{"nickname":"li gang","name":"li gang","age":16.9}

不过问题会出在查询时,直接查16.9是查不到的。

# 查不出结果
GET /emp/_search
{"query":{"match":{"age":16.9}}}

而查16却可以查到结果。

# 能查出结果
GET /emp/_search
{"query":{"match":{"age":16}}}

当然如果改为使用double类型存储小数后,则可正常查询,以下是测试案例:

# 新建一个double类型的字段
PUT /emp_double/
{"mappings":{"_doc":{"properties":{"salary":{"type":"double"}}}}}

# 构建数据
POST /emp_double/_doc/_bulk
{"index":{"_id":"1"}}
{"salary":1000.5}
{"index":{"_id":"2"}}
{"salary":2000.25}
{"index":{"_id":"3"}}
{"salary":3000}
{"index":{"_id":"4"}}
{"salary":4000.1234}

# 4条数据查1000.52000.2530004000.1234都没问题
GET /emp_double/_search
{"query":{"match":{"salary":4000.1234}}}

所以,实际生产环境不要存储比字段类型精度更高的数据。

当然数值类型也是支持聚合和排序的。同样,注意字段类型与实际存储类型产生的差异即可。

并没有按照实际存储的数值进行排序,因为实际精度并没有保留到小数。

这里还需要注意的是,在Elasticsearch中存储时并不是所有数值数据都一定要被映射为数值类型的,通常情况是当数值需要进行范围查询时,则建议使用数值类型,而术语级别的查询,则使用keyword更合适。

例如像ID、编号等这样的数据通常不会被用来做范围查询,而是经常用来做术语查询,因此应当建立为keyword类型,尽管它本身是一串数字。

日期类型

Elasticsearch中日期类型既可以是格式化后的字符串,也可以是时间戳,Elasticsearch内部统一会将其转换为UTC,并按照long number类型进行存储。

多种时间格式的测试案例:

POST /my_date/_doc/_bulk
{"index":{"_id":"1"}}
{"date":"2015-01-01"}
{"index":{"_id":"2"}}
{"date":"2015-01-01T12:10:30Z"}
{"index":{"_id":"3"}}
{"date":1420070400001}

GET my_date/_search
{"sort":{"date":"desc"}}

也支持指定的日期格式:

PUT my_date
{
"mappings": {
    "_doc": {
      "properties": {
        "date": {
          "type": "date",
          "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd"
        }
      }
    }
  }
}

# 此时插入第3条时间戳的格式将会报错
POST /my_date/_doc/_bulk
{"index":{"_id":"1"}}
{"date":"2015-01-01"}
{"index":{"_id":"2"}}
{"date":"2015-01-01 12:10:30"}
{"index":{"_id":"3"}}
{"date":1420070400001}

boolean类型

boolean类型只能是true和false这两种,但不区分字符串,意思就是传入true和"true"都可以,没有区别。

POST /my_boolean/_doc/_bulk
{"index":{"_id":"1"}}
{"is_published":"true"}
{"index":{"_id":"2"}}
{"is_published":true}
{"index":{"_id":"3"}}
{"is_published":false}

# 查询出两条记录
GET my_boolean/_search
{"query":{"term":{"is_published":"true"}}}

# 聚合true两条,false一条
GET my_boolean/_search
{"aggs":{"published_count":{"terms":{"field":"is_published"}}}}

规范建议

fielddata尽量不使用

PUT /emp/
{
"mappings": {
    "_doc": {
      "properties": {
        "nickname": {
          "type": "text"
        },
        "name": {
          "type": "keyword"
        },
        "age": {
          "type": "integer"
        }
      }
    }
  }
}

如果对text类型进行排序,得到的报错信息如下,注意有提到Fielddata默认是关闭的,可以为nickname设置fielddata=true这样的属性。

GET /emp/_search
{"sort":[{"nickname":{"order":"desc"}}]}

# 报错信息如下:
{
"error": {
    "root_cause": [
      {
        "type": "illegal_argument_exception",
        "reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [nickname] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
      }
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
    "grouped": true,
    "failed_shards": [
      {
        "shard": 0,
        "index": "emp",
        "node": "CKN5Zo86QTmwjnK7NHHNQQ",
        "reason": {
          "type": "illegal_argument_exception",
          "reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [nickname] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
        }
      }
    ],
    "caused_by": {
      "type": "illegal_argument_exception",
      "reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [nickname] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead.",
      "caused_by": {
        "type": "illegal_argument_exception",
        "reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [nickname] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
      }
    }
  },
"status": 400
}

按照如下方式在构建text类型时,设置fielddata=true即可进行排序。

PUT /emp_fielddata/
{
"mappings": {
    "_doc": {
      "properties": {
        "nickname": {
          "type": "text",
          "fielddata": true
        }
      }
    }
  }
}

POST /emp_fielddata/_doc/_bulk
{"index":{"_id":"1"}}
{"nickname":"zhang san"}
{"index":{"_id":"2"}}
{"nickname":"zhang san feng"}
{"index":{"_id":"3"}}
{"nickname":"zhang san bu feng"}


GET /emp_fielddata/_search
{"sort":[{"nickname":{"order":"desc"}}]}

关于fielddata的使用,如官方介绍,它会大量消耗堆内存,并且驻留在内存中的生命周期是跟随segment的,所以生产上应当尽量避免使用。

官方介绍

替代方案

解决方案也很简单了,在一开始的报错信息中已经说的很清楚了,可以用keyword来代替。

Fielddata is disabled on text fields by default. Set fielddata=true on [nickname] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead.

当然keyword不会分词,不过为什么一个字段既要分词又要排序或聚合?当你仔细思考使用场景后你会发现这样做通常是没有意义的。

索引与评分

  • 不需要索引的字段,index属性设置为false。
  • 如果不需要计算文档评分,建议将norms设置为false。

根据字段的实际使用情况,确定是否需要索引、文档评分,这样将会减少磁盘存储(每条文档中每个开启了norms的字段需占用1个字节),大多数字段类型默认是开启的。

PUT my_index/_mapping/_doc
{
  "properties": {
    "title": {
      "type": "text",
      "norms": false
    }
  }
}

source存储

source是用来存储原始数据的,默认情况下都是存储的,但如果文本本身比较大,确实会消耗一定的存储资源,如果该文本字段本身不常被用来展示,可以考虑不进行存储,偶尔需要展示时,可以通过id再去mysql或其他数据库查出来。

注意,虽然不存储source了,但索引信息还是正常存储的,因此不影响检索,请参考以下的示例:
# remark信息不存储到_source中
PUT my_source
{"mappings":{"_doc":{"_source":{"excludes":["remark"]},"properties":{"remark":{"type":"text"}}}}}

POST /my_source/_doc/_bulk
{"index":{"_id":"1"}}
{"remark":"这是一段用于测试source作用的文本"}

# 可以搜索到结果,但没有_source信息
GET my_source/_search
{"query":{"match":{"remark":"本"}}}

# 搜索结果信息
{
"took" : 0,
"timed_out" : false,
"_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
"hits" : {
    "total" : 1,
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "my_source",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.2876821,
        "_source" : { }
      }
    ]
  }
}

doc_values

doc_values与fielddata作用是类似的,都是用来做正排索引的,只不过doc_values是按列的方式构建与存储,且都是在磁盘上完成的,而fielddata是从磁盘中构建之后再存储到JVM堆内存中。

关于倒排索引和正排索引

  • 倒排索引:解决的是找到那些包含某个特定术语的文档?
  • 正排索引:解决的是该字段所在的文档具体值是什么?

因此一旦涉及到类似聚合、排序这样的需求时,再使用倒排索引就不太合适了,比如排序场景,如果是用倒排索引就需要遍历所有索引,然后提取出对应的文档,再进行去重、排序。

了解了这个背景以后,我们就知道了为什么Elasticsearch默认会对除text以外的所有数据类型默认都开启了doc_values(text因为本身会被分词,所以没法按列的方式构建)。

以下案例说明了当doc_values关闭时,无法被排序的情况。

# age字段关闭了doc_values
PUT /emp_doc_values/
{"mappings":{"_doc":{"properties":{"name":{"type":"keyword"},"age":{"type":"integer","doc_values":false},"nick_name":{"type":"text"}}}}}

POST /emp_doc_values/_doc/_bulk
{"index":{"_id":"1"}}
{"nickname":"zhang san","name":"zhang san","age":28}
{"index":{"_id":"2"}}
{"nickname":"zhang san feng","name":"zhang san feng","age":88}
{"index":{"_id":"3"}}
{"nickname":"zhang san bu feng","name":"zhang san bu feng","age":8}

# name可排序
GET /emp_doc_values/_search
{"sort":[{"name":{"order":"desc"}}]}

# age字段由于关闭了doc_values,因此无法排序
GET /emp_doc_values/_search
{"sort":[{"age":{"order":"desc"}}]}

# nickname本身就是text字段,因此也无法排序
GET /emp_doc_values/_search
{"sort":[{"nickname":{"order":"desc"}}]}

基于上述结果可得,如果当前字段没有排序、聚合、脚本操作的需求,可以考虑关闭doc_values,节省磁盘存储空间。

优化建议

index.refresh_interval 此参数用于控制文档从被写入到可被搜索的处理时间,默认频率为每秒执行一次(仅针对在过去index.search.idle.after秒内至少接收到一次搜索请求的索引),也就是说数据至少要在写入1s以后才能被搜索到,如果索引搜索请求不高,可以不调整,如果索引请求频率较高,则建议适当延长这个时间以减少集群压力(建议调到30s)。

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java