Loading

[14] EsAPI(下)

1. 数据写入原理

1.1 写入流程

在 ES 中,NRT(Near Real-Time)指的是其搜索和索引的实时性特性。尽管 ES 可以快速地将数据写入并使其可搜索,但它并不是严格的实时系统,而是接近实时(Near Real-Time),通常延迟在 1s 左右。这种延迟主要与其内部架构和数据处理机制有关。

当文档被索引时,首先会被写入到内存中的缓冲区 Buffer。这个缓冲区是一个暂时的存储区域,用于快速接受写入请求。只要 buffer 中的数据被 refresh 操作刷入 os cache 中,这个数据就可以被搜索到了。

同时,文档也会被写入到 translog 中。translog 的作用是确保数据持久性,防止数据丢失。translog 记录了所有还没有被刷新到磁盘上的操作,提供了一种恢复机制。如果节点崩溃,可以使用 translog 重做这些操作。

重复上面的步骤,新的数据不断进入 buffer 和 translog,不断将 buffer 数据写入一个又一个新的 segment file 中去,每次 refresh 完 buffer 清空,translog 保留。随着这个过程推进,translog 会变得越来越大。当 translog 达到一定长度的时候,就会触发 commit 操作。

commit 操作发生第一步,就是将 buffer 中现有数据 refresh 到 os cache 中去,清空 buffer。然后,将一个 commit point 写入磁盘文件,里面标识着这个 commit point 对应的所有 segment file,同时强行将 os cache 中目前所有的数据都 fsync 到磁盘文件中去。最后清空 现有 translog 日志文件,重启一个 translog,此时 commit 操作完成。

这个 commit 操作叫做 flush。默认 30 分钟自动执行一次 flush,但如果 translog 过大,也会触发 flush。flush 操作就对应着 commit 的全过程,我们可以通过 es api,手动执行 flush 操作,手动将 os cache 中的数据 fsync 强刷到磁盘上去。

translog 日志文件的作用是什么?你执行 commit 操作之前,数据要么是停留在 buffer 中,要么是停留在 os cache 中,无论是 buffer 还是 os cache 都是内存,一旦这台机器死了,内存中的数据就全丢了。所以需要将数据对应的操作写入一个专门的日志文件 translog 中,一旦此时机器宕机,再次重启的时候,es 会自动读取 translog 日志文件中的数据,恢复到内存 buffer 和 os cache 中去。

translog 其实也是先写入 os cache 的,默认每隔 5 秒刷一次到磁盘中去,所以默认情况下,可能有 5 秒的数据会仅仅停留在 buffer 或者 translog 文件的 os cache 中,如果此时机器挂了,会丢失 5 秒钟的数据。但是这样性能比较好,最多丢 5 秒的数据。也可以将 translog 设置成每次写操作必须是直接 fsync 到磁盘,但是性能会差很多。

数据先写入内存 buffer,然后每隔 1s,将数据 refresh 到 os cache,到了 os cache 数据就能被搜索到(所以我们才说 es 从写入到能被搜索到,中间有 1s 的延迟)。每隔 5s,将数据写入 translog 文件(这样如果机器宕机,内存数据全没,最多会有 5s 的数据丢失),translog 大到一定程度,或者默认每隔 30mins,会触发 commit 操作,将缓冲区的数据都 flush 到 segment file 磁盘文件中。

1.2 Segment 介绍

Segment 是 Lucene 全局倒排索引中的一个最小存储单位。每个 Segment 都是一个独立的倒排索引,包含数据的一个子集。ES 在底层使用 Lucene,因此 Segment 也是 ES 中数据存储和检索的基础单元。

Segment 的生命周期

(1)创建

当新的文档被索引时,被写入内存缓冲区。定期的 refresh 操作会将这些新文档刷新到磁盘中,形成新的 Segment。

(2)合并

随着时间的推移,会创建许多小的 Segment。为了提高查询效率,ES 会定期地将小的 Segment 合并成更大的 Segment。合并过程是自动进行的,目的是减少 Segment 的数量,从而提高搜索性能和减少资源消耗。

(3)删除标记

当文档被删除或更新时,实际上并不会立即从 Segment 中移除,而是打上删除标记。这些被标记为删除的文档在后续的 Segment 合并过程中会被实际移除。

Segment 的状态和属性

(1)活跃和非活跃

  • 活跃的 Segment 是在当前被查询的段,数据是最新的。
  • 非活跃的 Segment 则是那些被合并或即将被删除的段。

(2)Committed 和 Uncommitted

  • Committed Segment 是已经被写入磁盘并且在 commit 操作中被确认的段,这些段在系统崩溃后仍然存在。
  • Uncommitted Segment 是那些还在内存缓冲区中的数据,尚未被正式写入磁盘。

写入 Segment

(1)写入内存缓冲区(Uncommitted segment)

  • 当新的文档被索引时,它们首先会被写入到内存缓冲区中的 Uncommitted Segment。这些 Uncommitted Segments 只存在于内存中,并且数据在此期间可以进行更新、合并和删除。
  • 写入内存缓冲区是一个高效的操作,因为它不需要频繁地访问磁盘。

(2)提交(Commit)

  • 定期或手动执行提交操作时,ES 会将内存缓冲区中的 Uncommitted segments 刷新到磁盘上的 Committed segments。
  • 提交操作会将内存缓冲区中的变化持久化到磁盘,并创建一个新的 Committed segment。
  • Committed segments 是稳定的、不可修改的,并且在系统崩溃后仍然存在。
  • 通过这种机制,ES 能够在保持较高的写入性能的同时,保证数据的持久化和可靠性。同时,由于 Committed segments 是不可修改的,ES 还可以进行各种优化操作,如合并小的 segments 以提高搜索性能。

需要注意的是,Uncommitted segments 中的数据在提交之前是暂时的,如果系统在提交之前崩溃,那些未提交的数据可能会丢失。因此,定期的提交操作是确保数据持久性的重要步骤。

2. 索引、更新、删除数据

2.1 索引文档

a. 字段类型

关于数据的存储:简单来说就是压扁了存~

(1)动态映射

PUT /company/_doc/1
{
  "address": {
    "country": "china",
    "province": "guangdong",
    "city": "guangzhou"
  },
  "name": "jack",
  "age": 27,
  "join_date": "2019-01-01"
}

(2)查询映射

GET /company/_mapping
{
  "company" : {
    "mappings" : {
      "properties" : {
        "address" : {
          "properties" : {
            "city" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            },
            "country" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            },
            "province" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            }
          }
        },
        "age" : {
          "type" : "long"
        },
        "join_date" : {
          "type" : "date"
        },
        "name" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
      }
    }
  }
}

(3)docID=1 的底层存储格式

{
    "name":             [jack],
    "age":              [27],
    "join_date":        [2021-07-13],
    "address.country":  [china],
    "address.province": [jiangsu],
    "address.city":     [nanjing]
}

(4)扩展:对象数组

{
    "authors": [
        { "age": 26, "name": "Jack White"},
        { "age": 55, "name": "Tom Jones"},
        { "age": 39, "name": "Kitty Smith"}
    ]
}
·························· 存储格式 ··························
{
    "authors.age":    [26, 55, 39],
    "authors.name":   [jack, white, tom, jones, kitty, smith]
}

b. Mapping&Template

类似数据库的字段类型定义,你定义了一个字段,就要指定一个类型;如果不指定字段类型,Es 会在插入第一条数据时,自动创建一个 index,同时帮你创建 mapping 或者是索引了新字段,字段 mapping 也将会被动态设置。

字段如果和定义的类型不匹配,会插入失败。因此同一个字段不能有时候是字符串,有时候是数组。最好提前定义映射而不是依赖自动。

字段的 Mapping 配置中,处理指定类型,还可以为字段指定参数:① analyzer,指定分析器;② boost,指定字段的分值加成。

通过 模板(Template) 创建索引:除了每次创建 index 前,手动指定 index 的 mapping 和配置外,还可以自动从 Template 中获取 index 的 mapping 以及其他的设置(分片、副本数等),这是一个非常常用的操作。

  • dynamic field mapping:Es 会根据 Json 字段自动判断类型,甚至能够发现 string 里面填的是日期、数值还是文本来设置字段类型;
  • dynamic template:可以配置映射模板,自定义类型识别;
  • index template:索引模板,通过索引名命中模板,模板中设置字段类型;

【注】

  • 字段是否分析:不分析就不能全文检索这个字段,省性能;
  • 字段是否索引:不索引就不能搜这个字段,省性能;

c. 分析字段

  • 定义:解析、转变、分解文本,使得搜索变得更加相关;
  • 步骤:
    • 字符过滤(过滤器):使用字符过滤器转变字符(比如:大写变小写);
    • 文本切分为分词(分词器):将文本切分为单个或者多个分词(比如:英文文本用空格切位一堆单词);
    • 分词过滤(分词过滤器):转变每个分词(比如把 'a', 'an', 'of' 这类词干掉,或者复数单词转为单数单词);

2.2 更新文档

  • 前面提过 segment 创建之后不能修改,因此文档更新实际上是创建了一条新的然后删掉旧的。但其实删除也不是真删,而是加上一条删除标记。
    • 分段合并的时候会真删
    • 删除会消耗性能
    • 整个 index 删除是最快的
  • 文档更新步骤:检索文档(按 id)→ 处理文档 → 重新索引文档
  • 更新有 3 种方式:整条更新、字段合并、若存在则放弃更新
  • 可以使用自动生成 id 来插入新文档,这样可以节省检索文档所耗费的资源(如果手动指定,Es 会先去检索该 id 的文档是否存在,如果存在,则会新增 → 更新),加快索引速度。

Es 显然是分布式的,那么就会有并发问题:再一个更新获取原文档进行修改期间,可能会有另一个更新也在修改这篇文档,那么第一个更新就丢了 → Es 通过「文档版本」实现并发控制(类似‘乐观锁’)

  1. 为每个文档设置一个版本号;
  2. 文档创建时版本号是 1;
  3. 当更新后,版本号++变成 2;
  4. 如果此时有另一个更新,版本号也将是 2,此更新结束时发现已经有一个 2,那么将产生冲突;
  5. 发生冲突后,重试这个更新操作,如果不再冲突,那么完成更新,版本号设置为 3;

  • 可以更精确地控制冲突,默认情况下遇到冲突会更新失败,通过参数 retry_on_conflict 控制重试次数,默认为 0(不重试);
  • 可以显式指定版本号(插入和更新都可以),而不是默认取最新版本;
  • 可以使用外部版本号,比如时间戳;

2.3 删除文档

a. 删除文档

可以通过 id 删除单个,也可以通过条件批量删除,类似于 DELETE ... FROM ... WHERE ...

删除文档会拖慢查询和进一步的索引:因为删除只是标记为删除,搜索的时候还要检查一遍命中的文档是否已经被删除;只有分段合并的时候才彻底删除。

不推荐使用。

b. 删除索引

  • 删除索引是很快的,因为是直接移除索引相关的分片文件;
  • 删除是不可恢复的,在生产环境上也没有权限控制,一定要小心操作(7.0+可控制权限);
  • 小心!DELETE_ALL 会直接瞬间清空所有索引!

c. 关闭索引

  • 除了删除,还有一个更安全的操作,就是关闭索引;
  • 索引关闭之后,不能读取和写入,直到再次打开;
  • 关闭后的索引只占磁盘,非常 cheap,因此我们通常会关闭而不是删除索引;
[POST] /my_index/_close
[POST] /my_index/_open

d. 冻结索引

  • 扩展包功能
  • 介于打开和关闭之间:不能写入;分片开销很小;
    [POST] /my_index/_freeze
    [POST] /my_index/_unfreeze
    
  • 腾讯云版 Es 做了限制,需要特殊参数才能搜;
    [GET] /my_index/_search?ignore_throttled=false
    

2.4 reindex

  • 复制一个索引
    • 可用于重建索引
    • 可用于提取字段
    • 可以跨集群复制
  • 改变索引配置
    • 分段一旦生成就不能修改,因此索引一旦创建就无法改变;
    • 有些索引的配置也是不可改变的,比如分片数量、Mapping 映射等;
    • 只能通过重建索引修改
  • 提取字段
    • 有时字段休要通过脚本处理后才能满足新的使用需求;
    • 比如只存了航班 AA571,没有单独存航司,需要按航司聚类;
    • 可以用脚本字段聚类,但不建议使用;
    • 可以通过 reindex,写脚本来索引新字段;
  • 注意,reindex 操作不会复制索引的配置,所以需要体检设置或者配置 Template;
  • reindex 之前最好先把目标配置的副本数减为 0,并关闭刷新,加快写入;
[POST] _reindex
{
  "source": {
    "index": "source_index"
  },
  "dest": {
    "index": "dest_index"
  }
}

2.5 ILM

ILM(Index Lifecycle Management)索引生命周期管理

  • rollover 滚动存储:可让分片大小均匀在 30~40 G;
  • shrink 缩减分片:写的时候分片多可加速;读的时候收缩分片,减小内存消耗;
  • allocate 分片节点感知:冷热分离;远期日志放到冷节点;省钱+延长日志存放时间;
  • forcemerge 压缩分段:加速查询,节省开销;
  • freeze 把索引关闭:不占内存只占存储;进一步延长日志存放时间;
  • delete 彻底删除:自动删除,当前是用云脚本,不方便统一管理;

3. 搜索数据

Es 的搜索上下文分为「查询上下文(query context)」和「过滤上下文(filter context)」。区别在于过滤器不计算相关性,只关心是否命中条件。计算相关性需要计算匹配度分值,耗费性能(匹配度分值都是实时计算,无法缓存),所以尽量使用过滤查询以减少性能消耗加快查询速度。

3.1 Full-Text-Queries

全文检索,被查询的字段需要被分析;查询条件也会被分析。

a. match

最基本的全文检索查询,支持单词查询、模糊匹配、短语查询、近义词查询。

b. match_phrase

类似 match,专门查询短语,可以指定短语的间隔 slop(默认是 0)。例如希望含有 "quick brown fox" 的文档也能够匹配对 "quick fox" 的查询。

slop 参数告诉 match_phrase 查询词条能够相隔多远时仍然将文档视为匹配。相隔多远的意思是,你需要移动一个词条多少次来让查询和文档匹配?

尽管在使用了 slop 的短语匹配中,所有的单词都需要出现,但是单词的出现顺序可以不同。如果 slop 的值足够大,那么单词的顺序可以是任意的。

再比如说,想查一个词出现多次的:

{
    "query": {
        "match_phrase": {
            "products.product_name": {
                "query": "blue blue blue",
                "slop": 1000
            }
        }
    }
}

c. match_phrase_prefix

类似短语查询,但最后一个单词是前缀查询,用于最后一个单词想不起来的情况。比如“is t”可以命中“this is a test”。

d. multi_match

把 match 查询用在多个字段上。

e. common_terms

给非普通单词更大的权重。比如“eat”是普通单词,“tree6x7”是特殊单词。

f. query_string

使用 Lucene 查询语法的查询,可以指定各种 AND|OR|NOT 查询条件,而且支持在一条语句里对多字段查询。

g. simple_query_string

傻瓜版的 query_string,可以兼容错误的语法,不会搞挂查询,适合当作搜索框直接暴露给用户。

3.2 Term-Level-Queries

精确匹配查询,查询条件不会被分析。通常用于结构化的数据。比如数字、日期、枚举、keyword(当然,对于被分析过的字段也可以用)。

a. Normalizer

【场景】在 Es 中处理字符串类型的数据时,如果我们想把整个字符串作为一个完整的 term 存储,我们通常会将其类型 type 设定为 keyword。但有时这种设定又会给我们带来麻烦,比如同一个数据再写入时由于没有做好清洗,导致大小写不一致,比如 apple、Apple 两个实际都是 apple,但当我们去搜索 apple 时却无法返回 Apple 的文档。要解决这个问题,就需要 Normalizer 出场了。

PUT test_normalizer
{
  "mappings": {
    "doc":{
      "properties": {
        "type":{
          "type":"keyword"
        }
      }
    }
  }
}

PUT test_normalizer/doc/1
{
  "type":"apple"
}

PUT test_normalizer/doc/2
{
  "type":"Apple"
}

# 查询一 
GET test_normalizer/_search
{
  "query": {
    "match":{
      "type":"apple"
    }
  }
}

# 查询二
GET test_normalizer/_search
{
  "query": {
    "match":{
      "type":"aPple"
    }
  }
}

大家执行后会发现 查询一返回了文档 1,而 查询二没有文档返回,原因如下图所示:

  1. Docs 写入 Es 时由于 type 是 keyword,分词结果为原始字符串;
  2. 查询 Query 时分词默认是采用和字段写时相同的配置,因此这里也是 keyword,因此分词结果也是原始字符;
  3. 两边的分词进行匹对,便得出了我们上面的结果。

Normalizer 是 keyword 的一个属性,可以对 keyword 生成的单一 Term 再做进一步的处理,比如 lowercase,即做小写变换。使用方法和自定义分词器有些类似,需要自定义,如下所示:

DELETE test_normalizer

# 自定义 normalizer
PUT test_normalizer
{
  "settings": {
    "analysis": {
      "normalizer": {
        "lowercase": {
          "type": "custom",
          "filter": [
            "lowercase"
          ]
        }
      }
    }
  },
  "mappings": {
    "doc": {
      "properties": {
        "type": {
          "type": "keyword"
        },
        "type_normalizer": {
          "type": "keyword",
          "normalizer": "lowercase"
        }
      }
    }
  }
}

PUT test_normalizer/doc/1
{
  "type": "apple",
  "type_normalizer": "apple"
}

PUT test_normalizer/doc/2
{
  "type": "Apple",
  "type_normalizer": "Apple"
}

# 查询三
GET test_normalizer/_search
{
  "query": {
    "term":{
      "type":"aPple"
    }
  }
}

# 查询四
GET test_normalizer/_search
{
  "query": {
    "term":{
      "type_normalizer":"aPple"
    }
  }
}

上述是自定义了名为“lowercase”的 Normalizer,其中 filter 类似自定义分词器中的 filter ,但是可用的种类很少,详情大家可以查看官方文档。然后通过 Normalizer 属性设定到字段 type_normalizer 中,然后插入相同的 2 条文档。执行发现,查询 3 无结果返回,查询 4 返回 2 条文档。

  1. 文档写入时由于加入了 Normalizer,所有的 term 都会被做小写处理;
  2. 查询时搜索词同样采用有 Normalizer 的配置,因此处理后的 term 也是小写的;
  3. 两边分词匹对,就得到了我们上面的结果。

b. term

精确匹配整个查询语句。

c. terms

类似 terms,可以传入一个数组,匹配到其中一个即可。

d. terms_set

类似 terms,可以指定匹配条件数。支持脚本通过计算指定。

e. range

范围查询;可以按区间查日期、数字,甚至字符串。

f. exists

非空查询;

g. prefix

前缀匹配。

h. wildcard

通配符查询,支持单个 ? 和多个 *,通配符放在越前面,查询效率越低。

i. regexp

正则表达式查询;使用不当会造成效率低下的查询。不要出现过度通配。

查询结果不被缓存。

j. fuzzy

模糊查询,比如 ab 可以命中 ba

k. type

类型查询,指定被查询字段的 mapping。

l. ids

id 查询,可指定多个 id。

3.3 Compound-Queries

a. constant_score

包裹住的查询,会使用 filter 上下文,不计算相关性得分(可以指定常量分值)。

b. bool

最常用的组合查询,AND / OR / NOT / filter 可嵌套。

c. dis_max

对多个子查询的得分取最高,如果有子查询得分相近,还有加成选项。

d. function_score

可以对子查询的得分进行复杂计算,比如最大、最小、平均、随机、各种复杂的数学运算。

e. boosting

可以对子查询进行加分 positive 或者减分 negative,区别在于 bool 查询里的 NOT,不是去掉,而是降低命中者的权重。

3.4 Other-Queries

  • Join-Queries:类似关系型数据库那样的关联查询;
  • Geo-Queries:地理信息查询;
  • Specialized-Queries:特殊查询,比如脚本;
  • Span-Queries:跨度查询,将分词之间的距离纳入查询;被查询的字段需要被分析
posted @ 2022-01-13 23:30  tree6x7  阅读(117)  评论(0编辑  收藏  举报