作者:lynneyli,腾讯 IEG 运营开发工程师
Elasticsearch(简称:ES)功能强大,其背后有很多默认值,或者默认操作。这些操作优劣并存,优势在于我们可以迅速上手使用 ES,劣势在于,其实这些默认值的背后涉及到很多底层原理,怎么做更合适,只有数据使用者知道。用 ES 的话来说,你比 ES 更懂你的数据,但一些配置信息、限制信息,还是需要在了解了 ES 的功能之后进行人工限制。
你是否遇到:在使用了一段时间 ES 之后,期望使用 ES 的其他功能,例如聚合、排序,但因为字段类型受限,无奈只能进行reindex等一系列问题?
题主在遇到一些问题后,发现用 ES 很简单,但是会用 ES 很难。这让我下定决心一定好好了解 ES,也就出现了本文。
ES(全称 Elastic Search)是一款开源、近实时、高性能的分布式搜索引擎。在近 3 年的热门搜索引擎类数据统计中,ES 都霸居榜首(数据来源:DBRaking),可见的其深受大家的喜爱。
随着 ES 的功能越来越强大,其和数据库的边界也越来越小,除了进行全文检索,ES 也支持聚合/排序。ES 底层基于Lucene开发,针对Lucene的局限性,ES 提供了 RESTful API 风格的接口、支持分布式、可水平扩展,同时它可以被多种编程语言调用。
ES 很多基础概念以及底层实现其本质是 Lucene 的概念。
ps:本文所有的 dsl 查询、结果展示均基于 ES v7.7
下图这个人叫Doug Cutting,他是 Hadoop 语言和 Lucene 工具包的创始人。Doug Cutting 毕业于斯坦福大学,在 Xerox 积累了一定的工作经验后,从 1997 年开始,利用业余时间开发出了 Lucene。Lucene 面世于 1999 年,并于 2005 年成为 Apache 顶级开源项目。
Lucene的特点:
Lucene的局限性:
ES 多个版本可能出现破坏性变更,例如,在 6.x,ES 不允许一个 Index 中出现多个Type。在 ES 的官网,每个版本都对应着一个使用文档。
在使用 ES 之前,最好先了解 ES 的版本历史。下面列出一些比较重大的更新版本,可以在了解了基本概念之后再看。
初始版本 0.7.0 2010 年 5 月 14 日
1.0.0 2014 年 2 月 14 日
2.0.0 2015 年 10 月 28 日
5.0.0 2016 年 10 月 26 日
6.0.0 2017 年 8 月 31 日
7.0.0 2019 年 4 月 10 日
下图简单概述了 index、type、document 之间的关系,type 在新版本中废弃,所以画图时特殊标识了一下。
Index 翻译过来是索引的意思。在 ES 里,索引有两个含义:
index
可以被认为是一个数据库)document
保存在一个 index
里,这个过程也可以称为索引。在 6.x 之前, index
可以被理解为关系型数据库中的【数据库】,而 type
则可以被认为是【数据库中的表】。使用 type
允许我们在一个 index
里存储多种类型的数据,数据筛选时可以指定 type
。type
的存在从某种程度上可以减少 index
的数量,但是 type
存在以下限制:
index
下的不同 type
里有两个名字相同的字段,他们的类型(string, date 等等)和配置也必须相同。type
里存在的字段,在其他没有该字段的 type 中也会消耗资源。index
内的统计数据来决定的。也就是说,一个 type 中的文档会影响另一个 type 中的文档的得分。以上限制要求我们,只有同一个 index
的中的 type 都有类似的映射 (mapping) 时,才勉强适用 type
。否则,使用多个 type
可能比使用多个 index
消耗的资源更多。
这大概也是为什么 ES 决定废弃 type 这个概念,个人感觉 type 的存在,就像是一个语法糖,但是并未带来太大的收益,反而增加了复杂度。
index 中的单条记录称为 document
(文档),可以理解为表中的一行数据。多条 document
组成了一个 index
。
"hits" : {
"total" : {
"value" : 3563,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "test",
"_type" : "_doc",
"_id" : "3073",
"_score" : 1.0,
"_source" : {
...
}
}
]
上图为 ES 一条文档数据,其中:
_index
:文档所属索引名称。_type
:文档所属类型名(此处已默认为_doc)。_id
:Doc 的主键。在写入的时候,可以指定该 Doc 的 ID 值,如果不指定,则系统自动生成一个唯一的 UUID 值。_score
:顾名思义,得分,也可称之为相关性,在查询是 ES 会 根据一些规则计算得分,并根据得分进行倒排。除此之外,ES 支持通过 Function score query
在查询时自定义 score 的计算规则。_source
:文档的原始 JSON 数据。一个 document
会由一个或多个 field 组成,field 是 ES 中数据索引的最小定义单位,下面仅列举部分常用的类型。
⚠️ 在 ES 中,没有数组类型,任何字段都可以变成数组。
keyword
字段。string
字段映射为用于全文搜索的 text
字段,并映射为用于排序或聚合的 keyword
字段:PUT my_index
{
"mappings": {
"properties": {
"city": {
"type": "text",
"fields": {
"raw": {
"type": "keyword"
}
}
}
}
}
}
text
字段默认无法进行排序或聚合text
字段一定要使用合理的分词器。text
字段。keyword
字段只能精确匹配。long, integer, short, byte, double, float, half_float, scaled_float...
byte
、 short
、 integer
和 long
)而言,应该选择足以满足用例的最小类型。scaled_float
类型的实现。scaling_factor
缩放因子设置为 100,对于所有的 API 来说, price 看起来都像是一个双精度浮点数。但是对于 ES 内部,他其实是一个整数 long
。"price": {
"type": "scaled_float",
"scaling_factor": 100
}
scaled_float
无法满足精度要求,可以使用 double
、 float
、 half_float
。numberic
,numberic
类型更擅长 range
类查询,精确查询可以尝试使用 keyword
。mapping
是一个定义 document
结构的过程, mapping
中定义了一个文档所包含的所有 field 信息。
定义字段索引过多会导致爆炸的映射,这可能会导致内存不足错误和难以恢复的情况, mapping
提供了一些配置对 field
进行限制,下面列举几个可能会比较常见的:
在索引 document 时,ES 的动态 mapping
会将新增内容中不存在的字段,自动的加入到映射关系中。ES 会自动检测新增字段的逻辑,并赋予其默认值。
截取了部分 ES 官方文档中的话术,ES 认为一些自动化的操作会让新手上手更容易。但是同时,又提出,你肯定比 ES 更了解你的数据,可能刚开始使用起来觉得比较方便,但是最好还是自己明确定义映射关系。
(🙄️ 个人认为,这些自动操作是在用户对 ES 没有太多了解的情况下进行的,如果刚开始依赖了这些默认的操作,例如:新增字段使用了 ES 赋予的默认值,如果后续有分析、排序、聚合等操作可能会有一定限制)。
⚠️ 在 ES 中,删除/变更 field 定义,需要进行 reindex
,所以在构建 mapping
结构时记得评估好字段的用途,以使用最合适的字段类型。
match
:用于执行全文查询的标准查询,包括模糊匹配和短语或接近查询。重要参数:控制 Token 之间的布尔关系:operator:or/andmatch_phrase
:与 match 查询类似但用于匹配确切的短语或单词接近匹配。重要参数:Token 之间的位置距离:slop 参数,默认为 0GET /_analyze
{
"text": ["这是测试"],
"analyzer": "ik_smart"
}
//Result
{
"tokens" : [
{
"token" : "这是",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "测试",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
}
]
}//match+analyzer:ik_smart
//可以查询到所有describe中包含【这是测试】、【这是】、【测试】的doc
GET /doraon_recommend_tab_test/_search
{
"query": {
"match": {
"describe":{
"query": "这是测试",
"analyzer": "ik_smart"
}
}
}
}
//match_phrase + analyzer:ik_smart + slop=0(默认)
//可以查询到所有describe中包含【这是】+【测试】token间隔为0的doc(说人话就是:模糊匹配【这是测试】)
GET /doraon_recommend_tab_test/_search
{
"_source": "describe",
"query": {
"match_phrase": {
"describe": "这是测试"
}
}
}
//match_phrase + analyzer:ik_smart + slop=1
//可以查询到所有describe中包含【这是】+【测试】token间隔为1的doc
//例如某个doc中describe为【这是一个测试】,【这是一个测试】分词后的token分别为【这是】【一个】【测试】
//【这是】和【测试】之间间隔了1个token【一个】,所以可以被查询到;同理【这是一个我的测试】查询不到
GET /test/_search
{
"query": {
"match_phrase": {
"describe":{
"query": "这是测试",
"analyzer": "ik_smart",
"slop": 1
}
}
}
}
term
是进行精确查找的关键;在 Lucene 中,term 是中索引和搜索的最小单位。一个 field 会由一个或多个 term
组成, term
是由 field 经过 Analyzer(分词)产生。Term Dictionary
即 term
词典,是根据条件查找 term
的基本索引。
text
字段使用术语查询。默认情况下,ES 会在分析过程中更改文本字段的值。这会使查找 text
字段值的精确匹配变得困难。要搜索 text
字段值,强烈建议改用 match
查询。term
还是 match
,都无法判断text
类型字段是否为空字符串以上两点均是因为 text
字段存储的是分词结果,如果字段值为空,分词结果将不会存储 term
信息, keyword
字段存储的是原始内容。
GET /test/_termvectors/123?fields=content
{
"_index" : "[your index]",
"_type" : "_doc",
"_id" : "123",
"_version" : 2,
"found" : true,
"took" : 0,
"term_vectors" : { }
}GET /test/_termvectors/234?fields=card_pic
{
"_index" : "[your index]",
"_type" : "_doc",
"_id" : "234",
"_version" : 1,
"found" : true,
"took" : 0,
"term_vectors" : {
"card_pic" : {
"field_statistics" : {
"sum_doc_freq" : 183252,
"doc_count" : 183252,
"sum_ttf" : 183252
},
"terms" : {
"" : {
"term_freq" : 1,
"tokens" : [
{
"position" : 0,
"start_offset" : 0,
"end_offset" : 0
}
]
}
}
}
}
}
在上一篇文章中提到了,针对全文索引类型,一定要选择合适的分析器,现在我们就来了解一下分析器~
Analyzer 主要是对输入的文本类内容进行分析(通常是分词),将分析结果以 term
的形式进行存储。
Analyzer 由三个部分组成:Character Filters、Tokenizer、Token Filters
ES 内置的分析器有Standard Analyzer、Simple Analyzer、Whitespace Analyzer、Stop Analyzer、Keyword Analyzer、Pattern Analyzer、Language Analyzers、Fingerprint Analyzer,并且支持定制化。
这里的内置分词器看起来都比较简单,这里简单介绍一下 Standard Analyzer、Keyword Analyzer,其他的分词器大家感兴趣可以自行查阅。
Standard Analyzer 的组成部分:
max_token_length
参数指定 token 长度,默认为 255。stopwords
或 stopwords_path
进行指定。如果 text 类型没有指定 Analyzer,Standard Analyzer,前面我们已经了解了 ES 分析器的结构,理解它的分析器应该不在话下。Unicode 文本分割算法依据的标准,给出了文本中词组、单词、句子的默认分割边界。该附件在 notes 中提到,像类似中文这种复杂的语言,并没有明确的分割边界,简而言之就是说,中文并不适用于这个标准。
通常我们的全文检索使用场景都是针对中文的,所以我们在创建我们的映射关系时,一定要指定合适的分析器。
Keyword Analyzer 本质上就是一个"noop" Analyzer,直接将输入的内容作为一整个 token。
github 地址:https://github.com/medcl/elasticsearch-analysis-ik
IK Analyzer 是一个开源的,基于 java 语言开发的轻量级的中文分词工具包。从 2006 年 12 月推出 1.0 版开始, IKAnalyzer 已经推出了 4 个大版本。最初,它是以开源项目 Luence 为应用主体的,结合词典分词和文法分析算法的中文分词组件。从 3.0 版本开始,IK 发展为面向 Java 的公用分词组件,独立于 Lucene 项目,同时提供了对 Lucene 的默认优化实现。在 2012 版本中,IK 实现了简单的分词歧义排除算法,标志着 IK 分词器从单纯的词典分词向模拟语义分词衍化。
使用方式:
// mapping创建
PUT /[your index]
{
"mappings": {
"properties": {
"text_test":{
"type": "text",
"analyzer": "ik_smart"
}
}
}
}// 新建document
POST /[your index]/_doc
{
"text_test":"我爱中国"
}
//查看term vector
GET /[your index]/_termvectors/ste3HYABZRKvoZUCe2oH?fields=text_test
//结果包含了 “我”“爱”“中国”
TF/IDF 介绍文章:https://zhuanlan.zhihu.com/p/31197209
TF/IDF 使用逆文档频率作为权重,降低常见词汇带来的相似性得分。从公式中可以看出,这个相似性算法仅与文档词频相关,覆盖不够全面。例如:缺少文档长度带来的权重,当其他条件相同,“王者荣耀”这个查询关键字同时出现在短篇文档和长篇文档中时,短篇文档的相似性其实更高。
在 ESV5 之前,ES 使用的是 Lucene 基于 TF/IDF 自实现的一套相关性得分算法,如下所示:
score(q,d) =
queryNorm(q)
· coord(q,d)
· ∑ (
tf(t in d)
· idf(t)²
· t.getBoost()
· norm(t,d)
) (t in q)
Lucene 已经针对 TF/IDF 做了尽可能的优化,但是有一个问题仍然无法避免:
另一条曲线是 BM25 算法相似性得分随词频的关系,它的结果随词频上升而趋于一个稳定值。
BM25 介绍文章:https://en.wikipedia.org/wiki/Okapi_BM25 ,对 BM25 的实现细节我们在这里不做过多阐述,主要了解一下 BM25 算法相较于之前的算法有哪些优点:
我们在查询过程可以通过设置 "explain":true
查看相似性得分的具体情况
GET /[your index]/_search
{
"explain": true,
"query": {
"match": {
"describe": "测试"
}
}
}
//简化版查询结果
{
"_explanation": {
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:",
"details": [
{
"value": 0.21110919,
"description": "score(freq=1.0), computed as boost * idf * tf from:",
"details": [
{
"value": 2.2,
"description": "boost",
"details": []
},
{
"value": 0.18232156,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
"details": [...]
},
{
"value": 0.5263158,
"description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
"details": [...]
}
]
}
]
}
}
boolean 相似性非常好理解,只能根据查询条件是否匹配,其最终值其实就是 query boost 值。
他们本质上的区别是是否参与相关性得分。在查询过程中,官方建议可以根据实际使用情况配合使用 filter
和 query
。但是如果你的查询并不关心相关性得分,仅关心查询到的结果,其实两者差别不大。
题主本来以为使用 filter 可以节省计算相似性得分的耗时,但是使用 filter 同样会进行相似性得分,只是通过特殊的方式将其 value 置为了 0。
//only query
GET /[your index]/_search
{
"explain": true,
"query": {
"bool": {
"must": [
{"match": {"describe": "测试"}},
{"term": {"tab_id": 5}}
]
}
}
}
//简化_explanation结果
{
"_explanation": {
"value": 1.2111092,
"description": "sum of:",
"details": [
{
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:"
},
{
"value": 1,
"description": "tab_id:[5 TO 5]",
"details": []
}
]
}
}//query+filter
GET /[your index]/_search
{
"explain": true,
"query": {
"bool": {
"filter": [
{"term": {"tab_id": "5"}}
],
"must": [
{"match": {"describe": "测试"}}
]
}
}
}
//简化_explanation结果
{
"_explanation": {
"value": 0.21110919,
"description": "sum of:",
"details": [
{
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:"
},
{
"value": 0,
"description": "match on required clause, product of:",
"details": [
{
"value": 0,
"description": "# clause",
"details": []
},
{
"value": 1,
"description": "tab_id:[5 TO 5]",
"details": []
}
]
}
]
}
}
在执行 ES 查询时,默认的排序规则是根据相关性得分倒排,针对非全文索引字段,可以指定排序方式,使用也非常简单。
//查询时先根据tab_id降序排列,若tab_id相同,则根究status升序排列
GET /[your index]/_search
{
"sort": [
{"tab_id": {"order": "desc"}},
{"status": {"order": "asc"}}
]
}
事情的背景
题主使用的编程语言是 golang,通常使用 pb 定义结构体,生成对应的 go 代码,默认情况下,结构体字段的 json tag 都会包含 omitempty
属性,也就是忽略空值,如果数字类型的 value 为 0,进行 json marshall 时,不会生成对应字段。
事情的经过
刚好题主通过以上方式进行文档变更,所以实际上如果某个数值字段为 0,它并没有被存储。
在题主的功能逻辑里,刚好需要对某个数值字段做升序排列,惊奇地发现我认为的字段值为 0 的文档,出现在了列表最末。
事情的调查结果
针对缺失数值类字段的默认值并不是 0,ES 默认会保证排序字段没有 value 的文档被放在最后,默认情况下:
好消息是,ES 为我们提供了 missing
参数,我们可以指定缺失值填充,但是它太隐蔽了 😭,其默认值为 _last
。
GET /[your index]/_search
{
"sort": [
{"num": {"order": "asc"}}
]
}
//简化结果
{
"hits": [
{"sort": [1]},
{"sort": [9223372036854775807]},
{"sort": [9223372036854775807]}
]
}GET /your_index/_search
{
"sort": [
{"num": {"order": "desc"}}
]
}
//简化结果
{
"hits": [
{"sort": [1]},
{"sort": [-9223372036854775808]},
{"sort": [-9223372036854775808]}
]
}
// with missing
GET /[your index]/_search
{
"sort": [
{
"num": {
"order": "asc",
"missing": "0"
}
}
]
}
//简化结果
{
"hits": [
{"sort": [0]},
{"sort": [0]},
{"sort": [1]}
]
}
不知道大家是否遇到过类似的场景:期望查询结果按照某个类型进行排序,或者查询结果顺序由多个字段的权重组合决定。
具体解决方案需要根据业务具体情况而定,这里给出一种基于 ES 查询的解决方案。ES 为我们提供了 function score
,支持自定义相关性得分 score 的生成方式,部分参数介绍:
举点实际的栗子,假设咱们有一个存放水果的 Index:
GET /fruit_test/_search
{
"explain": true,
"query": {
"function_score": {
"functions": [
{
"filter": {"term": {"type": "pear"}},
"weight": 1
},
{
"filter": {"term": {"type": "apple"}},
"weight": 2
}
],
"boost": 1,
"score_mode": "sum"
}
}
}
sort
中指定先根据 _score
降序排列,再根据价格升序排列。GET /fruit_test/_search
{
"query": {
"function_score": {
"query": {"range": {"stock": {"gt": 0}}
},
"functions": [
{
"filter": {"term": {"color": "green"}},
"weight": 1
},
{
"filter": {"term": {"color": "red"}},
"weight": 2
},
{
"filter": {"term": {"type": "pear"}},
"weight": 3
},
{
"filter": {"term": {"type": "apple"}},
"weight": 4
},
{
"filter": {"term": {"pre_sale": false}},
"weight": 7
}
],
"boost": 1,
"boost_mode": "sum",
"score_mode": "sum"
}
},
"sort": [
{"_score": {"order": "desc"}},
{"price_per_kg": {"order": "asc"}
}
]
}
聚合操作可以帮助我们将查询数据按照指定的方式进行归类。常见的聚合方式,诸如:max、min、avg、range、根据 term 聚合等等,这些都比较好理解,功能使用上也没有太多疑惑,下面主要介绍题主在使用过程中遇到的坑点以及指标聚合嵌套查询。
ES 还支持pipline aggs,主要针对的对象不是文档集,而是其他聚合的结果,感兴趣的同学可以自行了解。
如果你有诉求,需要针对秒级时间戳进行时间聚合,例如:某销售场景下,我们期望按小时/天/月/进行销售单数统计。
那么有以下两种常见错误使用方式需要规避:
date
类型字段,但是没有指定时间 format 格式,并且以秒级时间戳赋值(直接以年月日赋值没有问题)
根据时间聚合将无法解析出正确的数据,时间会被解析为 1970 年numberic
类型,例如 integer
存储时间戳
不管是秒级还是毫秒级,都无法被正确识别正确的做法:创建 mapping,明确指定时间的格式为秒级时间戳。
PUT /date_test/_mapping
{
"properties":{
"create_time":{
"type":"date",
"format" : "epoch_second"
}
}
}//以年为时间间隔 进行统计
GET /date_test/_search
{
"size": 0,
"aggs": {
"test": {
"date_histogram": {
"field": "create_time",
"format": "yyyy",
"interval": "year"
}
}
}
}
//从查询结果可以看出来,实际计算时ES会帮我们把秒级时间戳转成毫秒级时间戳
{
"aggregations" : {
"test" : {
"buckets" : [
{
"key_as_string" : "2018",
"key" : 1514764800000,
"doc_count" : 2
},
{
"key_as_string" : "2019",
"key" : 1546300800000,
"doc_count" : 0
},
{
"key_as_string" : "2020",
"key" : 1577836800000,
"doc_count" : 3
}
]
}
}
}
上面介绍了根据时间聚合,还是以刚刚的例子来说,某销售场景下,我们期望在根据时间统计销售单数的同时,统计出时间区间内的销售总金额。
GET /date_test/_search
{
"size": 0,
"aggs": {
"test": {
"date_histogram": {
"field": "create_time",
"format": "yyyy",
"interval": "year"
},
"aggs": {
"sum_profit": {
"sum": {
"field": "profit"
}
}
}
}
}
}{
"aggregations" : {
"test" : {
"buckets" : [
{
"key_as_string" : "2018",
"key" : 1514764800000,
"doc_count" : 2,
"sum_profit" : {
"value" : 200.0
}
},
{
"key_as_string" : "2019",
"key" : 1546300800000,
"doc_count" : 0,
"sum_profit" : {
"value" : 0.0
}
},
{
"key_as_string" : "2020",
"key" : 1577836800000,
"doc_count" : 3,
"sum_profit" : {
"value" : 3000.0
}
}
]
}
}
}
ES 默认并不支持 distinct,可以尝试使用 terms
聚合,解析结果中的 key
{
"aggregations" : {
"test" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{"key" : "1","doc_count" : 2},
{"key" : "10","doc_count" : 2},
{"key" : "16","doc_count" : 2}
]
}
}
}
PUT /[your index]/_alias/[your alias name]
index_patterns
参数设置索引名正则匹配规则,向一个不存在的索引 POST 数据,命中索引名规则后即会根据索引模版创建索引,不会进行动态映射。ES 的一个比较常见的应用场景是存储日志流,自实现一套这样的系统就可以结合上述 3 个功能。
参考