Elasticsearch从入门到精通-07ES底层原理和高级功能
👏作者简介:大家好,我是程序员行走的鱼
📖 本篇主要介绍和大家一块学习一下ES底层原理包括集群原理、路由原理、分配控制、分配原理、文档分析原理、文档并发安全原理以及一些高级ES语法的学习包括SQL使用、模版搜索、建议搜索、嵌套文档、父子文档、地理位置索引等
一.分布式集群
1.1单节点集群
我们在包含一个空节点的集群内创建名为 users 的索引,为了演示目的,我们将分配 3个主分片和一份副本(每个主分片拥有一个副本分片)
{
"settings" : {
"number_of_shards" : 3,
"number_of_replicas" : 1
}
}
此时我们的集群现在是拥有一个索引的单节点集群,所有 3 个主分片都被分配在 node-1 。
这里我们可以通过ES的插进elasticsearch-head查看集群情况
集群健康值:yellow( 7 of 14 ) : 表示当前集群的全部主分片都正常运行,但是副本分片没有全部处在正常状态,3个副本分片都是 Unassigned----它们都没有被分配到任何节点。 在同一个节点上既保存原始数据又保存副本是没有意义的,因为一旦失去了那个节点,我们也将丢失该节点上的所有副本数据。此时我们的集群是正常运行的,但是在硬件故障时有丢失数据的风险.
1.2故障转移
当集群中只有一个节点在运行时,意味着会有一个单点故障问题——没有冗余。 幸运的是,我们只需再启动一个节点即可防止数据丢失。当你在同一台机器上启动了第二个节点时,只要它和第一个节点有同样的 cluster.name 配置,它就会自动发现集群并加入到其中。但是在不同机器上启动节点的时候,为了加入到同一集群,你需要配置一个可连接到的单播主机列表。之所以配置为单播发现,为了防止节点无意中加入集群。
如果启动了第二个节点,我们的集群将会拥有两个节点的集群 : 所有主分片和副本分片都已被分配
通过 elasticsearch-head 插件查看集群情况
1.3水平扩容
怎样为我们的正在增长中的应用程序按需扩容呢?当启动了第三个节点,我们的集群将会拥有三个节点的集群 : 为了分散负载而对分片进行重新分配
通过 elasticsearch-head 插件查看集群情况
但是如果我们想要扩容超过 6 个节点怎么办呢?
主分片的数目在索引创建时就已经确定了下来。实际上,这个数目定义了这个索引能够存储的最大数据量。(实际大小取决于你的数据、硬件和使用场景。) 但是,读操作——搜索和返回数据——可以同时被主分片或副本分片所处理,所以当你拥有越多的副本分片时,也将拥有越高的吞吐量。
在运行中的集群上是可以动态调整副本分片数目的
,我们可以按需伸缩集群。让我们把副本数从默认的 1 增加到 2
{
"number_of_replicas" : 2
}
users 索引现在拥有 9 个分片:3 个主分片和 6 个副本分片。 这意味着我们可以将集群扩容到 9 个节点,每个节点上一个分片。相比原来 3 个节点时,集群搜索性能可以提升 3 倍。
通过 elasticsearch-head 插件查看集群情况
当然,如果只是在相同节点数目的集群上增加更多的副本分片并不能提高性能,因为每个分片从节点上获得的资源会变少。 你需要增加更多的硬件资源来提升吞吐量。但是更多的副本分片数提高了数据冗余量:按照上面的节点配置,我们可以在失去 2 个节点的情况下不丢失任何数据。
1.4应对故障
我们关闭第一个节点,这时集群的状态为:关闭了一个节点后的集群。
我们关闭的节点是一个主节点。而集群必须拥有一个主节点来保证正常工作,所以发生的第一件事情就是选举一个新的主节点: Node 2 。在我们关闭 Node 1 的同时也失去了主分片 1 和 2 ,并且在缺失主分片的时候索引也不能正常工作。 如果此时来检查集群的状况,我们看到的状态将会为red:不是所有主分片都在正常工作。
幸运的是,在其它节点上存在着这两个主分片的完整副本, 所以新的主节点立即将这些分片在 Node 2 和 Node 3 上对应的副本分片提升为主分片, 此时集群的状态将会为yellow。这个提升主分片的过程是瞬间发生的,如同按下一个开关一般。
为什么我们集群状态是 yellow 而不是 green 呢?
虽然我们拥有所有的三个主分片,但是同时设置了每个主分片需要对应 2 份副本分片,而此时只存在一份副本分片。 所以集群不能为 green 的状态,不过我们不必过于担心:如果我们同样关闭了 Node 2 ,我们的程序依然可以保持在不丢任何数据的情况下运行,因为Node 3 为每一个分片都保留着一份副本。
如果我们重新启动 Node 1 ,集群可以将缺失的副本分片再次进行分配,那么集群的状态也将恢复成之前的状态。 如果 Node 1 依然拥有着之前的分片,它将尝试去重用它们,同时仅从主分片复制发生了修改的数据文件。和之前的集群相比,只是 Master 节点切换了。
1.5脑裂问题
在说ES脑裂问题之前我们需要知道什么脑裂?
所谓脑裂问题
,就是同一个集群中的不同节点,对于集群的状态有了不一样的理解,比如集群中存在两个master,造成两个master可能因为网络的故障,导致一个集群被划分成了两片,每片都有多个node,以及一个master, 但是因为master是集群中非常重要的一个角色,主宰了集群状态的维护,以及shard的分配,因此如果有两个master,可能会导致数据异常。
节点1在启动时被选举为主节点并保存主分片标记为0P,而节点2保存复制分片标记为0R,现在,如果在两个节点之间的通讯中断了,会发生什么?由于网络问题或只是因为其中一个节点无响应,这是有可能发生的。
两个节点都相信对方已经挂了。节点1不需要做什么,因为它本来就被选举为主节点。但是节点2会自动选举它自己为主节点,因为它相信集群的一部分没有主节点了。在elasticsearch集群,是由主节点来决定将分片平均的分布到节点上的。节点2保存的是复制分片,但它相信主节点不可用了。所以它会自动提升Node2节点为主节点。
现在我们的集群在一个不一致的状态了。打在节点1上的索引请求会将索引数据分配在主节点,同时打在节点2的请求会将索引数据放在分片上。在这种情况下,分片的两份数据分开了,如果不做一个全量的重索引很难对它们进行重排序。在更坏的情况下,一个对集群无感知的索引客户端(例如,使用REST接口的),这个问题非常透明难以发现,无论哪个节点被命中索引请求仍然在每次都会成功完成。问题只有在搜索数据时才会被隐约发现:取决于搜索请求命中了哪个节点,结果都会不同。
ES如何解决脑裂问题?
ES提供了一个参数解决脑裂问题,这个参数的作用,就是告诉es直到有足够的master候选节点投票给一个候选节点时,才可以选举出一个master,否则就不要选举出一个master。这个参数必须被设置为集群中master候选节点的quorum数量,也就是大多数。至于quorum的算法,就是:master候选节点数量 / 2 + 1。比如我们有10个节点,都能维护数据,也可以是master候选节点,那么quorum就是10 / 2 + 1 = 6。再比如我们有三个master候选节点,还有100个数据节点,那么quorum就是3 / 2 + 1 = 2,如果我们有2个节点,都可以是master候选节点,那么quorum是2 / 2 + 1 = 2。此时就有问题了,因为如果一个node挂掉了,那么剩下一个master候选节点,是无法满足quorum数量的,也就无法选举出新的master,集群就彻底挂掉了。此时就只能将这个参数设置为1,但是这就无法阻止脑裂的发生了。综上所述,一个生产环境的es集群,至少要有3个节点,同时将这个参数设置为quorum,也就是2discovery.zen.minimum_master_nodes设置为2,如何避免脑裂呢?比如我们有3个节点,quorum是2,现在网络故障,1个节点在一个网络区域,另外2个节点在另外一个网络区域,不同的网络区域内无法通信。这个时候有两种情况:
(1)如果master是单独的那个节点,另外2个节点是master候选节点,那么此时那个单独的master节点因为没有指定数量的候选master node在自己当前所在的集群内,因此就会取消当前master的角色,尝试重新选举,但是无法选举成功。然后另外一个网络区域内的node因为无法连接到master,就会发起重新选举,因为有两个master候选节点,满足了quorum,因此可以成功选举出一个master。此时集群中就会还是只有一个master。
(2)如果master和另外一个node在一个网络区域内,然后一个node单独在一个网络区域内。那么此时那个单独的node因为连接不上master,会尝试发起选举,但是因为master候选节点数量不到quorum,因此无法选举出master。而另外一个网络区域内,原先的那个master还会继续工作。这也可以保证集群内只有一个master节点。
综上所述,集群中master节点的数量至少3台,三台主节点通过在elasticsearch.yml中配置discovery.zen.minimum_master_nodes: 2,就可以避免脑裂问题的产生。
二 分片路由原理
当索引一个文档的时候,文档会被存储到一个主分片中。 Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?当我们创建文档时,它如何决定这个文档应当被存储在分片1 还是分片 2 中呢?首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:
routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过hash 函数生成一个数字,然后这个数字再除以number_of_primary_shards (主分片的数量)后得到余数 。这个分布在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求
的文档所在分片的位置。
这就解释了为什么我们要在创建索引的时候就确定好主分片的数量 并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
所有的文档 API( get 、 index 、 delete 、 bulk 、 update 以及 mget )都接受一个叫做 routing 的路由参数 ,通过这个参数我们可以自定义文档到分片的映射。一个自定义的路由参数可以用来确保所有相关的文档——例如所有属于同一个用户的文档——都被存储到同一个分片中。
三 分配控制
我们假设有一个集群由三个节点组成。 它包含一个叫 emps 的索引,有两个主分片,每个主分片有两个副本分片。相同分片的副本不会放在同一节点。
通过 elasticsearch-head 插件查看集群情况,所以我们的集群是一个有三个节点和一个索引的集群。
我们可以发送请求到集群中的任一节点。 每个节点都有能力处理任意请求。 每个节点都知道集群中任一文档位置,所以可以直接将请求转发到数据所在的节点上。
3.1 写流程
新建、索引和删除请求都是写操作, 必须在主分片上面完成之后才能被复制到相关的副本分片
- 客户端向 Node 2 发送新建、索引或者删除请求,此时,node2就成为一个coordinating node(协调节点)。
- 计算得到文档要写入的分片
shard = hash(routing) % number_of_primary_shards
, routing 是一个可变值,默认是文档的 _id - coordinating node会进行路由,将请求转发给对应的primary shard所在的DataNode(假设primary shard在node1、replica shard在node2)
- node1节点上的Primary Shard处理请求,写入数据到索引库中,并将数据同步到Replica shard
- Primary Shard和Replica Shard都保存好了文档,返回client
- 在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成,变更是安全的。有一些可选的请求参数允许您影响这个过程,可能以数据安全为代价提升性能。这些选项很少使用,因为 Elasticsearch 已经很快,但是为了完整起见,请参考下面表格:
参数 | 含义 |
---|---|
consistency | consistency,即一致性。在默认设置下,即使仅仅是在试图执行一个写操作之前,主分片都会要求必须要有规定数量(quorum)(或者换种说法,也即必须要有大多数)的分片副本处于活跃可用状态,才会去执行写操作(其中分片副本可以是主分片或者副本分片)。这是为了避免在发生网络分区故障的时候进行写操作,进而导致数据不一致。规定数量即:int( (primary + number_of_replicas) / 2 ) + 1,,consistency 参数的值可以设为 one (只要主分片状态 ok 就允许执行写操作),all(必须要主分片和所有副本分片的状态没问题才允许执行写操作), 或quorum 。默认值为 quorum , 即大多数的分片副本状态没问题就允许执行写操作。注意,规定数量的计算公式中 number_of_replicas 指的是在索引设置中的设定副本分片数,而不是指当前处理活动状态的副本分片数。如果你的索引设置中指定了当前索引拥有三个副本分片,那规定数量的计算结果即:**int( (primary + 3 replicas) / 2 ) + 1 = 3,**如果此时你只启动两个节点,那么处于活跃状态的分片副本数量就达不到规定数量,也因此您将无法索引和删除任何文档。 |
timeout | 如果没有足够的副本分片会发生什么? Elasticsearch 会等待,希望更多的分片出现。默认情况下,它最多等待 1 分钟。 如果你需要,你可以使用 timeout 参数使它更早终止。 |
新索引默认有1个副本分片,这意味着为满足规定数量应该需要两个活动的分片副本。 但是,这些默认的设置会阻止我们在单一节点上做任何事情。为了避免这个问题,要求只有当 number_of_replicas 大于 1 的时候,规定数量才会执行。
3.2 读流程
我们可以从主分片或者从其它任意副本分片检索文档。
从主分片或者副本分片检索文档的步骤顺序:
- client发起查询请求,某个DataNode接收到请求,该DataNode就会成为协调节点(Coordinating Node)
- 协调节点(Coordinating Node)将查询请求广播到每一个数据节点,这些数据节点的分片会处理该查询请求
- 每个分片进行数据查询,将符合条件的数据放在一个优先队列中,并将这些数据的文档ID、节点信息、分片信息返回给协调节点
- 协调节点将所有的结果进行汇总,并进行全局排序
- 协调节点向包含这些文档ID的分片发送get请求,对应的分片将文档数据返回给协调节点,最后协调节点将数据返回给客户端
在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。 一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的。
3.3 更新流程
部分更新一个文档结合了先前说明的读取和写入流程:
- 客户端向 Node 1 发送更新请求。
- 它将请求转发到主分片所在的 Node 3 。
- Node 3 从主分片检索文档,修改 _source 字段中的 JSON ,并且尝试重新索引主分片的文档。如果文档已经被另一个进程修改,它会重试步骤 3 ,超过 retry_on_conflict 次 后放弃。
- 如果 Node 3 成功地更新文档,它将新版本的文档并行转发到 Node 1 和 Node 2 上的 副本分片,重新建立索引。一旦所有副本分片都返回成功, Node 3 向协调节点也返回成功,协调节点向客户端返回成功。
当主分片把更改转发到副本分片时, 它不会转发更新请求。 相反,它转发完整文档的新版本。请记住, 这些更改将会异步转发到副本分片,并且不能保证它们以发送它们相同的顺序到达。 如果 Elasticsearch 仅转发更改请求,则可能以错误的顺序应用更改,导致得到损坏的文档。
3.4 多文档操作流程
mget 和 bulk API 的模式类似于单文档模式。区别在于协调节点知道每个文档存在于哪个分片中。它将整个多文档请求分解成每个分片的多文档请求,并且将这些请求并行转发到每个参与节点。
协调节点一旦收到来自每个节点的应答,就将每个节点的响应收集整理成单个响应,返回给客户端
用单个mget请求取回多个文档所需的步骤顺序:
- 客户端向 Node 1 发送 mget 请求.
- Node 1 为每个分片构建多文档获取请求,然后并行转发这些请求到托管在每个所需的主分片或者副本分片的节点上。一旦收到所有答复, Node 1 构建响应并将其返回给客户端。
bulk API,允许在单个批量请求中执行多个创建、索引、删除和更新请求。
bulk API 按如下步骤顺序执行:
- 客户端向 Node 1 发送 bulk 请求。
- Node 1 为每个节点创建一个批量请求,并将这些请求并行转发到每个包含主分片的节点主机。
- 主分片一个接一个按顺序执行每个操作。当每个操作成功时,主分片并行转发新文档(或删除)到副本分片,然后执行下一个操作。 一旦所有的副本分片报告所有操作成功, 该节点将向协调节点报告成功,协调节点将这些响应收集整理并返回给客户端。
四 分配原理
分片是 Elasticsearch 最小的工作单元。但是究竟什么是一个分片,它是如何工作的?
传统的数据库每个字段存储单个值,但这对全文检索并不够。文本字段中的每个单词需要被搜索,对数据库意味着需要单个字段有索引多值的能力。最好的支持是一个字段多个值能够被检索到的数据结构是倒排索引。
4.1 倒排索引
Elasticsearch 使用一种称为倒排索引的结构,它适用于快速的全文搜索。
见其名,知其意,有倒排索引,肯定会对应有正向索引。所谓的正向索引,就是搜索引擎会将待搜索的文件都对应一个文件 ID,搜索时将这个 ID 和搜索关键字进行对应,形成 K-V 对,然后对关键字进行统计计数
但是互联网上收录在搜索引擎中的文档的数目是个天文数字,这样的索引结构根本无法满足实时返回排名结果的要求。所以,搜索引擎会将正向索引重新构建为倒排索引,即把文件 ID对应到关键词的映射转换为关键词到文件ID的映射,每个关键词都对应着一系列的文件, 这些文件中都出现这个关键词。
一个倒排索引由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。例如,假设我们有两个文档,每个文档的 content 域包含如下内容:
- The quick brown fox jumped over the lazy dog
- Quick brown foxes leap over lazy dogs in summer
为了创建倒排索引,我们首先将每个文档的 content 域拆分成单独的词(我们称它为词条或 tokens ),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。结果如下所示:
现在,如果我们想搜索 quick brown ,我们只需要查找包含每个词条的文档:
两个文档都匹配,但是第一个文档比第二个匹配度更高。如果我们使用仅计算匹配词条数量的简单相似性算法,那么我们可以说,对于我们查询的相关性来讲,第一个文档比第二个文档更佳。
但是,我们目前的倒排索引有一些问题:
- Quick 和 quick 以独立的词条出现,然而用户可能认为它们是相同的词。
- fox 和 foxes 非常相似, 就像 dog 和 dogs ;他们有相同的词根。
- jumped 和 leap, 尽管没有相同的词根,但他们的意思很相近。他们是同义词。
使用前面的索引搜索 +Quick +fox 不会得到任何匹配文档。(记住,+ 前缀表明这个词必须存在。)只有同时出现 Quick 和 fox 的文档才满足这个查询条件,但是第一个文档包含 quick fox ,第二个文档包含 Quick foxes 。
我们的用户可以合理的期望两个文档与查询匹配。我们可以做的更好。
如果我们将词条规范为标准模式,那么我们可以找到与用户搜索的词条不完全一致,但具有足够相关性的文档。例如:
- Quick 可以小写化为 quick 。
- foxes 可以 词干提取 --变为词根的格式-- 为 fox 。类似的, dogs 可以为提取为 dog 。
- jumped 和 leap 是同义词,可以索引为相同的单词 jump 。
现在索引看上去像这样:
这还远远不够。我们搜索 +Quick +fox 仍然 会失败,因为在我们的索引中,已经没有 Quick 了。但是,如果我们对搜索的字符串使用与 content 域相同的标准化规则,会变成查询 +quick +fox,这样两个文档都会匹配!分词和标准化的过程称为分析。这非常重要。你只能搜索在索引中出现的词条,所以索引文本和查询字符串必须标准化为相同的格式。
4.2 文档搜索
早期
的全文检索会为整个文档集合建立一个很大的倒排索引并将其写入到磁盘。 一旦新的索引就绪,旧的就会被其替换,这样最近的变化便可以被检索到。倒排索引被写入磁盘后是不可改变的:它永远不会修改。不变性有重要的价值:
-
不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
-
一旦索引被读入内核的文件系统缓存,便会留在那里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
-
其它缓存(像 filter 缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
-
写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O和需要被缓存到内存的索引的使用量。
当然,一个不变的索引也有不好的地方。主要事实是它是不可变的! 你不能修改它。如果你需要让一个新的文档可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
4.3 动态更新索引
如何在保留不变性的前提下实现倒排索引的更新?
答案是: 用更多的索引。通过增加新的补充索引来反映新近的修改,而不是直接重写整个倒排索引。每一个倒排索引都会被轮流查询到,从最早的开始查询完后再对结果进行合并。
Elasticsearch 基于 Lucene,这个 java 库引入了按段搜索的概念。 每一段本身都是一个倒排索引, 但索引在 Lucene 中除表示所有段的集合外, 还增加了提交点的概念 — 一 个列出了所有已知段的文件
按段搜索会以如下流程执行:
1.新文档被收集到内存索引缓存
2.不时地, 缓存被提交
-
一个新的段 —一个追加的倒排索引—被写入磁盘。
-
一个新的包含新段名字的提交点被写入磁盘
-
磁盘进行同步 — 所有在文件系统缓存中等待的写入都刷新到磁盘,以确保它们被写入物理文件
3.新的段被开启,让它包含的文档可见以被搜索
4.内存缓存被清空,等待接收新的文档
当一个查询被触发,所有已知的段按顺序被查询。词项统计会对所有段的结果进行聚合,以保证每个词和每个文档的关联都被准确计算。 这种方式可以用相对较低的成本将新文档添加到索引。
段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del 文件,文件中会列出这些被删除文档的段信息。
当一个文档被 “删除” 时,它实际上只是在 .del 文件中被标记删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。 文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
4.4 近实时搜索
随着按段(per-segment)搜索的发展,一个新的文档从索引到可被搜索的延迟显著降低 了。新文档在几分钟之内即可被检索,但这样还是不够快。磁盘在这里成为了瓶颈。提交 (Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据。 但是 fsync 操作代价很大; 如果每次索引一个文档都去执行一 次的话会造成很大的性能问题。
我们需要的是一个更轻量的方式来使一个文档可被搜索,这意味着 fsync 要从整个过程中被移除。在 Elasticsearch 和磁盘之间是文件系统缓存。 像之前描述的一样, 在内存索引缓冲区中的文档会被写入到一个新的段中。 但是这里新段会被先写入到文件系统缓存—这一 步代价会比较低,稍后再被刷新到磁盘—这一步代价比较高。不过只要文件已经在缓存中, 就可以像其它文件一样被打开和读取了。
Lucene 允许新段被写入和打开—使其包含的文档在未进行一次完整提交时便对搜索可见。 这种方式比进行一次提交代价要小得多,并且在不影响性能的前提下可以被频繁地执行。
在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh 。 默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是近实时搜索: 文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。 这些行为可能会对新用户造成困惑: 他们索引了一个文档然后尝试搜索它,但却没有搜到。 这个问题的解决办法是用 refresh API 执行一次手动刷新:
/users/_refresh
尽管刷新是比提交轻量很多的操作,它还是会有性能开销。当写测试的时候, 手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。 相反,你的应用需要意识到 Elasticsearch 的近实时的性质,并接受它的不足。
并不是所有的情况都需要每秒刷新。可能你正在使用 Elasticsearch 索引大量的日志文件, 你可能想优化索引速度而不是近实时搜索, 可以通过设置 refresh_interval , 降低每个索引的刷新频率
{
"settings": {
"refresh_interval": "30s"
}
}
refresh_interval 可以在既存索引上进行动态更新。 在生产环境中,当你正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来
// 关闭自动刷新
PUT /users/_settings
{ "refresh_interval": -1 }
// 每一秒刷新
PUT /users/_settings
{ "refresh_interval": "1s" }
4.5 持久化变更
如果没有用 fsync 把数据从文件系统缓存刷(flush)到硬盘,我们不能保证数据在断电甚至是程序正常退出之后依然存在。为了保证 Elasticsearch 的可靠性,需要确保数据变化被持久化到磁盘。在动态更新索引,我们说一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点。Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。
即使通过每秒刷新(refresh)实现了近实时搜索,我们仍然需要经常进行完整提交来确保能从失败中恢复。但在两次提交之间发生变化的文档怎么办?我们也不希望丢失掉这些数据。Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录
整个流程如下:
1.一个文档被索引之后,就会被添加到内存缓冲区,并且追加到了 translog
2.刷新(refresh)使分片每秒被刷新(refresh)一次:
- 这些在内存缓冲区的文档被写入到一个新的段中,且没有进行 fsync 操作。
- 这个段被打开,使其可被搜索
- 内存缓冲区被清空
3.这个进程继续工作,更多的文档被添加到内存缓冲区和追加到事务日志
4.每隔一段时间—例如 translog 变得越来越大—索引被刷新(flush);一个新的 translog 被创建,并且一个全量提交被执行
- 所有在内存缓冲区的文档都被写入一个新的段。
- 缓冲区被清空。
- 一个提交点被写入硬盘。
- 文件系统缓存通过 fsync 被刷新(flush)。
- 老的 translog 被删除。
translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
translog 也被用来提供实时 CRUD 。当你试着通过 ID 查询、更新、删除一个文档,它会在尝试从相应的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。
执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 flush 分片每 30 分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新.
你很少需要自己手动执行 flush 操作;通常情况下,自动刷新就足够了。这就是说,在 重启节点或关闭索引之前执行 flush 有益于你的索引。当 Elasticsearch 尝试恢复或重新打开一个索引, 它需要重放 translog 中所有的操作,所以如果日志越短,恢复越快。
translog 的目的是保证操作不会丢失,在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。默认 translog 是每 5 秒被 fsync 刷新到硬盘, 或者在每次写请求完成之后执行(e.g. index, delete, update, bulk)。这个过程在主分片和复制分片都会发生。最终, 基本上,这意味着在整个请求被 fsync 到主分片和复制分片的 translog 之前,你的客户端不会得到一个200OK 响应。
在每次请求后都执行一个 fsync 会带来一些性能损失,尽管实践表明这种损失相对较 小(特别是 bulk 导入,它在一次请求中平摊了大量文档的开销)。
但是对于一些大容量的偶尔丢失几秒数据问题也并不严重的集群,使用异步的 fsync 还是比较有益的。比如,写入的数据被缓存到内存中,再每 5 秒执行一次 fsync 。如果你决定使用异步 translog 的话,你需要保证在发生 crash 时,丢失掉 sync_interval 时间段 的数据也无所谓。请在决定前知晓这个特性。如果你不确定这个行为的后果,最好是使用默 认的参数( “index.translog.durability”: “request” )来避免数据丢失。
4.6.段合并
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和 cpu 运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。 Elasticsearch 通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大 的段再被合并到更大的段。
段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
启动段合并不需要你做任何事。进行索引和搜索时会自动进行。
1.当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
2.合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
3.一旦合并结束,老的段被删除
(1)新的段被刷新(flush)到了磁盘。 写入一个包含新段且排除旧的和较小的段的新提交点。
(2)新的段被打开用来搜索。
(3)老的段被删除。
合并大的段需要消耗大量的 I/O 和 CPU 资源,如果任其发展会影响搜索性能。Elasticsearch 在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。
五 文档分析原理
分析下面的过程:
1.将一块文本分成适合于倒排索引的独立的词条
2.将这些词条统一化为标准格式以提高它们的“可搜索性”,或者 recall分析器执行上面的工作。分析器实际上是将三个功能封装到了一个包里:
-
字符过滤器
首先,字符串按顺序通过每个字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉 HTML,或者将 & 转化成 and。
-
分词器
其次,字符串被分词器分为单个的词条。一个简单的分词器遇到空格和标点的时候, 可能会将文本拆分成词条。
-
Token 过滤器
最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化 Quick ),删除词条(例如, 像 a, and, the 等无用词),或者增加词条(例如,像 jump 和 leap 这种同义词)。
5.1 内置分析器
Elasticsearch 还附带了可以直接使用的预包装的分析器。接下来我们会列出最重要的分析器。为了证明它们的差异,我们看看每个分析器会从下面的字符串得到哪些词条:
“Set the shape to semi-transparent by calling set_trans(5)”
-
标准分析器
标准分析器是 Elasticsearch 默认使用的分析器。它是分析各种语言文本最常用的选择。 它根据 Unicode 联盟定义的单词边界划分文本。删除绝大部分标点。最后,将词条小写。 它会产生:
set, the, shape, to, semi, transparent, by, calling, set_trans, 5
-
简单分析器
简单分析器在任何不是字母的地方分隔文本,将词条小写。它会产生:
set, the, shape, to, semi, transparent, by, calling, set, trans
-
空格分析器
格分析器在空格的地方划分文本。它会产生:
Set, the, shape, to, semi-transparent, by, calling, set_trans(5)
-
语言分析器
特定语言分析器可用于很多语言。它们可以考虑指定语言的特点。例如, 英语分析器附带了一组英语无用词(常用单词,例如 and 或者 the ,它们对相关性没有多少影响), 它们会被删除。 由于理解英语语法的规则,这个分词器可以提取英语单词的词干 。
英语分词器会产生下面的词条:
set, shape, semi, transpar, call, set_tran, 5
注意看 transparent、 calling 和 set_trans 已经变为词根格式
5.2 分析器使用场景
当我们索引一个文档,它的全文域被分析成词条以用来创建倒排索引。 但是,当我们在全文域搜索的时候,我们需要将查询字符串通过相同的分析过程 ,以保证我们搜索的词条格式与索引中的词条格式一致。
- 当你查询一个全文域时, 会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。
- 当你查询一个精确值域时,不会分析查询字符串,而是搜索你指定的精确值。
5.3 测试分析器
有些时候很难理解分词的过程和实际被存储到索引中的词条,特别是你刚接触Elasticsearch。为了理解发生了什么,你可以使用 analyze API 来看文本是如何被分析的。在消息体里,指定分析器和要分析的文本,访问http://ip:9200/_analyze
{
"analyzer": "standard",
"text": "Text to analyze"
}
请求结果:
结果中每个元素代表一个单独的词条:
{
"tokens": [
{
"token": "text",
"start_offset": 0,
"end_offset": 4,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "to",
"start_offset": 5,
"end_offset": 7,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "analyze",
"start_offset": 8,
"end_offset": 15,
"type": "<ALPHANUM>",
"position": 2
}
]
}
token 是实际存储到索引中的词条。
position 指明词条在原始文本中出现的位置。
start_offset 和 end_offset 指明字符在原始字符串中的位置。
5.4 修改分词器设置
指定自定义analyzer
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"es_std": {
"type": "standard",
"stopwords": "_english_"
}
}
}
}
}
使用标准分词器:
使用自定义分词器:
很显然我们的a is in这些常见的单词被过滤掉了。
5.5 IK分词器
我们先用ES默认分词器发送一句中文进行分词分析
GET /_analyze
{
"text": "测试单词"
}
从结果来看,ES 的默认分词器无法识别中文中测试、单词这样的词汇,而是简单的将每个字拆完分为一个词,这样的结果显然不符合我们的使用要求,所以我们需要下载 ES 对应版本的中文分词器。我们这里采用 IK 中文分词器。
GET /_analyze
{
"analyzer":"ik_max_word",
"text": "测试单词"
}
ik_max_word表示最粗粒度拆分单词。如果我们想要最小粒度拆分单词的话可以使用ik_smart(生产一般使用ik_max_word)
除此之外,IK也支持扩展词汇。
比如:
GET /_analyze
{
"analyzer":"ik_max_word",
"text": "弗雷尔卓德"
}
结果来看我们只可以得到每个字的分词结果,我们需要做的就是使分词器识别到弗雷尔卓德也是一个词语,首先进入 ES 根目录中的 plugins 文件夹下的 ik 文件夹,进入 config 目录,创建 custom.dic文件,写入弗雷尔卓德。同时打开 IKAnalyzer.cfg.xml 文件,将新建的 custom.dic 配置其中,重启 ES 服务器。
如果我们的ES的节点分布很多,每次这样自定义扩展词典是很麻烦的,IK提供了一个强大的热更新操作去扩展我们的词典,要想实现不重启,通过修改某个地方的词典,所有的节点立马可以拉取新的词典,我们有以下方式:
修改IKAnalyzer.cfg.xml
我们可以把我们的词典部署到一个服务器上,比如tomcat的一个目录下并运行,在下边的配置文件中配置字典的网络地址即可
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!‐‐用户可以在这里配置自己的扩展字典 ‐‐>
<entry key="ext_dict">location</entry>
<!‐‐用户可以在这里配置自己的扩展停止词字典‐‐>
<entry key="ext_stopwords">location</entry>
<!‐‐用户可以在这里配置远程扩展字典 ‐‐>
<entry key="remote_ext_dict">远程字典地址</entry>
<!‐‐用户可以在这里配置远程扩展停止词字典‐‐>
<entry key="remote_ext_stopwords">words_location</entry>
</properties>
5.6 自定义分词器
虽然 Elasticsearch 带有一些现成的分析器,然而在分析器上 Elasticsearch 真正的强大之处在于,你可以通过在一个适合你的特定数据的设置之中组合字符过滤器、分词器、词汇单元过滤器来创建自定义的分析器。一个分析器就是在一个包里面组合了三种函数的一个包装器, 三种函数按照顺序被执行:
-
字符过滤器
字符过滤器 用来整理一个尚未被分词的字符串。例如,如果我们的文本是 HTML 格式的,它会包含像
或者
这样的HTML 标签,这些标签是我们不想索引的。我 们可以使用 html 清除字符过滤器来移除掉所有的 HTML 标签,并且像把 Á 转换 为相对应的 Unicode 字符 Á 这样,转换 HTML 实体。一个分析器可能有 0 个或者多个字符过滤器。 -
分词器
一个分析器必须有一个唯一的分词器。 分词器把字符串分解成单个词条或者词汇单元。 标准分析器里使用的标准分词器把一个字符串根据单词边界分解成单个词条,并且移除掉大部分的标点符号,然而还有其他不同行为的分词器存在。 例如, 关键词分词器完整地输出接收到的同样的字符串,并不做任何分词。 空格分词器只根据空格分割文本 。 正则分词器根据匹配正则表达式来分割文本 。
-
词单元过滤器
经过分词,作为结果的词单元流会按照指定的顺序通过指定的词单元过滤器 。 词单元过滤器可以修改、添加或者移除词单元。我们已经提到过 lowercase 和 stop 词过滤器 ,但是在 Elasticsearch 里面还有很多可供选择的词单元过滤器。词干过滤器把单词遏制为词干。 ascii_folding 过滤器移除变音符,把一个像 “très” 这样的词转换为 “tres” 。
自定义一个自己的分词器
put /my_analyzer
{
"settings":{
"analysis":{
"char_filter":{
"&_to_and":{
"type":"mapping",
"mappings":["&=> and"]
}
},
"filter":{
"my_stopwords":{
"type":"stop",
"stopwords":["the","a"]
}
},
"analyzer":{
"my_analyzer":{
"type":"custom",
"char_filter":["html_strip","&_to_and"],
"tokenizer":"standard",
"filter":["lowercase","my_stopwords"]
}
}
}
}
}
索引被创建以后,使用 analyze API 来测试这个新的分析器
GET /my_analyzer/_analyze
{
"text":"The quick & brown fox",
"analyzer": "my_analyzer"
}
六 文档并发安全原理
6.1 文档冲突
当我们使用 index API 更新文档 ,可以一次性读取原始文档,做我们的修改,然后重新索引整个文档 。 最近的索引请求将获胜:无论最后哪一个文档被索引,都将被唯一存储在 Elasticsearch 中。如果其他人同时更改这个文档,他们的更改将丢失。
很多时候这是没有问题的。也许我们的主数据存储是一个关系型数据库,我们只是将数据复制到 Elasticsearch 中并使其可被搜索。 也许两个人同时更改相同的文档的几率很小。或者对于我们的业务来说偶尔丢失更改并不是很严重的问题。
但有时丢失了一个变更就是非常严重的 。试想我们使用 Elasticsearch 存储我们网上商城商品库存的数量, 每次我们卖一个商品的时候,我们在 Elasticsearch 中将库存数量减少。有一天,管理层决定做一次促销。突然地,我们一秒要卖好几个商品。 假设有两个 web 程序并行运行,每一个都同时处理所有商品的销售。
web_1 对 stock_count 所做的更改已经丢失,因为 web_2 不知道它的 stock_count 的拷贝已经过期。 结果我们会认为有超过商品的实际数量的库存,因为卖给顾客的库存商品并不存在,我们将让他们非常失望。变更越频繁,读数据和更新数据的间隙越长,也就越可能丢失变更。
在数据库领域中,有两种方法通常被用来确保并发更新时变更不会丢失。
- 悲观并发控制:这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。 一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。
- 乐观并发控制:Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。 然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。
6.2 ES乐观锁并发控制
Elasticsearch 是分布式的。当文档创建、更新或删除时, 新版本的文档必须复制到集群中的其他节点。Elasticsearch 也是异步和并发的,这意味着这些复制请求被并行发送,并且到达目的地时也许顺序是乱的 。 Elasticsearch 需要一种方法确保文档的旧版本不会覆盖新的版本。
当我们之前讨论 INDEX,GET 和 DELETE请求时,我们指出每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。 Elasticsearch 使用这个 version 号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。
我们可以利用 version 号来确保应用中相互冲突的变更不会导致数据丢失。我们通过指定想要修改文档的 version 号来达到这个目的。 如果该版本不是当前版本号,我们的请求将会失败。
老的版本 es 使用 version进行乐观锁更新,但是新版本不支持了,,提示我们用 if_seq_no和 if_primary_term,如果出现并发冲突,就会出现一下错误
PUT /es_db_tem/_doc/1
{
"name": "Jack",
"sex": 1,
"age": 25,
"book": "elasticSearch入门至精通",
"address": "广州车陂"
}
PUT /es_db_tem/_doc/2?if_seq_no=0&if_primary_term=1
{
"name": "Jack",
"sex": 1,
"age": 25,
"book": "elasticSearch入门至精通",
"address": "广州车陂"
}
seq_no和version的区别:
seq_no属于整个index,每次做更新操作都会增加1,而version属于一个文档
primary_term代表主分片的编号,每当主分片发生重新分片的时候,比如重启,primary选举时,primary_term会递增1,_primary_term主要是用来恢复数据时处理当多个文档的_seq_no一样时的冲突,比如当一个shard宕机了,raplica需要用到最新的数据,就会根据_primary_term和_seq_no这两个值来拿到最新的document
6.3 外部系统版本控制
一个常见的设置是使用其它数据库作为主要的数据存储,使用 Elasticsearch 做数据检索, 这意味着主数据库的所有更改发生时都需要被复制到 Elasticsearch ,如果多个进程负责这一数据同步,你可能遇到类似于之前描述的并发问题。
如果你的主数据库已经有了版本号 — 或一个能作为版本号的字段值比如 timestamp —那么你就可以在 Elasticsearch 中通过增加 version_type=external 到查询字符串的方式重用这些相同的版本号, 版本号必须是大于零的整数, 且小于 9.2E+18 — 一个 Java 中 long 类型的正值。
外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同,Elasticsearch 不是检查当前 _version 和请求中指定的版本号是否相同, 而是检查当前_version 是否小于指定的版本号。 如果请求成功,外部的版本号作为文档的新 _version 进行存储。
七 ES文档分值计算原理
7.1单个关键字分词原理
①:boolean model
根据关键字筛选出相关文档
query “hello world” ‐‐> hello / world / hello & world
bool ‐‐> must/must not/should ‐‐> 过滤 ‐‐> 包含 / 不包含 / 可能包含
doc ‐‐> 不打分数 ‐‐> 正或反 true or false ‐‐> 为了减少后续要计算的doc的数量,提升性能
②:打分
relevance score算法,简单来说,就是计算出,一个索引中的文本,与搜索文本,他们之间的关联匹配程度.
Elasticsearch使用的是 term frequency/inverse document frequency算法,简称为TF/IDF算法。
-
Term frequency:某单个关键词(term) 在某文档的某字段中出现的频率次数, 显然, 出现频率越高意味着该文档与搜索的相关度也越高,公式:tf(q in d) = sqrt(termFreq)
-
Inverse document frequency:某个关键词(term) 在索引(单个分片)之中出现的频次. 出现频次越高, 这个词的相关度越低. 相对的, 当某个关键词(term)在一大票的文档下面都有出现, 那么这个词在计算得分时候所占的比重就要比那些只在少部分文档出现的词所占的得分比重要低. 说的那么长一句话, 用人话来描述就是 “物以稀为贵”, 比如, ‘的’, ‘得’, ‘the’ 这些一般在一些文档中出现的频次都是非常高的, 因此, 这些词占的得分比重远比特殊一些的词(如’Solr’, ‘Docker’, ‘哈苏’)占比要低.
-
Field-length norm:字段长度, 这个字段长度越短, 那么字段里的每个词的相关度也就越大. 某个关键词(term) 在一个短的句子出现, 其得分比重比在一个长句子中出现要来的高.
我们也可以通过一下请求来查看_scope的计算过程
GET /es_db/_doc/1/_explain
{
"query": {
"match": {
"remark": "java developer"
}
}
}
7.2多关键字分词原理
多关键字分词原理是基于向量空间模型(vector space model)进行分词打分的
多个term对一个doc的总分数,基本过程如下:
hello world --> es会根据hello world在所有doc中的评分情况,计算出一个query向量
比如:
hello这个term,基于所有doc的一个评分就是3
world这个term,基于所有doc的一个评分就是6
两个关键字的组成的query向量坐标是[3,6]
比如我们有三个文档:
doc1:包含hello --> doc向量[3, 0]
doc2:包含world -->doc向量 [0, 6]
doc3:包含hello, world -->doc向量 [3, 6]
会给每一个doc,拿每个term计算出一个分数来,hello有一个分数,world有一个分数,再拿所有term的分数组成一个doc向量画在一个图中,取每个doc向量对query向量的弧度,给出每个doc对多个term的总分数最后基于这个弧度给出一个doc相对于query中多个term的总分数,弧度越大,分数越低; 弧度越小,分数越高,如果是多个term,那么就是线性代数来计算。
八 数据建模
8.1 嵌套文档
先看个案例:设计一个用户document数据类型,其中包含一个地址数据的数组,这种设计方式相对复杂,但是在管理数据时,更加的灵活。
数据准备:
PUT /user_index
{
"mappings": {
"properties": {
"login_name": {
"type": "keyword"
},
"age ": {
"type": "short"
},
"address": {
"properties": {
"province": {
"type": "keyword"
},
"city": {
"type": "keyword"
},
"street": {
"type": "keyword"
}
}
}
}
}
}
PUT /user_index/_doc/1
{
"login_name": "jack",
"age": 25,
"address": [
{
"province": "北京",
"city": "北京",
"street": "枫林三路"
},
{
"province": "天津",
"city": "天津",
"street": "华夏路"
}
]
}
PUT /user_index/_doc/2
{
"login_name": "rose",
"age": 21,
"address": [
{
"province": "河北",
"city": "廊坊",
"street": "燕郊经济开发区"
},
{
"province": "天津",
"city": "天津",
"street": "华夏路"
}
]
}
针对以上的数据建模来说,当对地址数据做数据搜索的时候,经常会搜索出不必要的数据,如:在下述数据环境中,搜索一个province为北京,city为天津的用户,按理说我们的省市是一体的,而不能拆开查询,我们先走一个must查询
GET /user_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"address.province": "北京"
}
},
{
"match": {
"address.city": "天津"
}
}
]
}
}
}
按照我们正常业务来说,我们应该查询的是用户的所有地址中,address的province和city必须同时满足北京和天津才可以被查询到,但是从结果来看并不是这样,所以如果我们想要address对象的内部字段具有绑定关系,可以使用嵌套文档的方式进行数据建模,如下:
DELETE /user_index
PUT /user_index
{
"mappings": {
"properties": {
"login_name": {
"type": "keyword"
},
"age": {
"type": "short"
},
"address": {
"type": "nested",
"properties": {
"province": {
"type": "keyword"
},
"city": {
"type": "keyword"
},
"street": {
"type": "keyword"
}
}
}
}
}
}
这个时候就需要使用nested对应的搜索语法来执行搜索了,语法如下:
GET /user_index/_search
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": "address",
"query": {
"bool": {
"must": [
{
"match": {
"address.province": "北京"
}
},
{
"match": {
"address.city": "天津"
}
}
]
}
}
}
}
]
}
}
}
从结果来看是正常的,把province和city都改为北京再进行搜索下
GET /user_index/_search
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": "address",
"query": {
"bool": {
"must": [
{
"match": {
"address.province": "北京"
}
},
{
"match": {
"address.city": "北京"
}
}
]
}
}
}
}
]
}
}
}
使用之前的语法再试试
GET /user_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"address.province": "北京"
}
},
{
"match": {
"address.city": "北京"
}
}
]
}
}
}
就算把city改为了北京,也搜不到结果,至于原因我们继续往下分析:
普通的数组数据在ES中会被扁平化处理,处理方式如下:(如果字段需要分词,会将分词数据保存在对应的字段位置,当然应该是一个倒排索引,这里只是一个直观的案例)
{
"login_name" : "jack",
"address.province" : [ "北京", "天津" ],
"address.city" : [ "北京", "天津" ],
"address.street" : [ "枫林三路", "华夏路" ]
}
这里province、city、street不会自动组合,所以当我们没有使用nested是可以查询到结果的。
nested object数据类型ES在保存的时候不会有扁平化处理,保存方式如下:所以在搜索的时候一定会有需要的搜索结果。
{
"login_name" : "jack"
}
{
"address.province" : "北京",
"address.city" : "北京",
"address.street" : "枫林三路"
}
{
"address.province" : "天津",
"address.city" : "天津",
"address.street" : "华夏路",
}
8.2 父子关系数据建模
nested object的建模,有个不好的地方,就是采取的是类似冗余数据的方式,将多个数据都放在一起了,维护成本就比较高,每次更新,需要重新索引整个对象(包括跟对象和嵌套对象),ES 提供了类似关系型数据库中 Join 的实现。使用 Join 数据类型实现,可以通过 Parent / Child 的关系,从而分离两个对象,父文档和子文档是两个独立的文档,更新父文档无需重新索引整个子文档。子文档被新增,更改和删除也不会影响到父文档和其他子文档。
要点:父子关系元数据映射,可以用于确保查询时候的高性能,但是有一个限制,就是父子数据必须存在于一个shard中
父子关系数据存在一个shard中,而且还有映射其关联关系的元数据,那么搜索父子关系数据的时候,不用跨分片,一个分片本地自己就搞定了,性能当然高
定义父子关系的几个步骤
- 设置索引的 Mapping
- 索引父文档
- 索引子文档
- 按需查询文档
我们以博客和评论为例,建立1对多的父子文档:
①:设置父子关系
DELETE my_blogs
# 设定 Parent/Child Mapping
PUT my_blogs
{
"mappings": {
"properties": {
"blog_comments_relation": {
"type": "join",
"relations": {
"blog": "comment"
}
},
"content": {
"type": "text"
},
"title": {
"type": "keyword"
}
}
}
}
②:创建父文档
PUT my_blogs/_doc/blog1
{
"title": "Learning Elasticsearch",
"content": "learning ELK is happy",
"blog_comments_relation": {
"name": "blog"
}
}
PUT my_blogs/_doc/blog2
{
"title": "Learning Hadoop",
"content": "learning Hadoop",
"blog_comments_relation": {
"name": "blog"
}
}
③:创建子文档
PUT my_blogs/_doc/comment1?routing=blog1
{
"comment": "I am learning ELK",
"username": "Jack",
"blog_comments_relation": {
"name": "comment",
"parent": "blog1"
}
}
PUT my_blogs/_doc/comment2?routing=blog2
{
"comment": "I like Hadoop!!!!!",
"username": "Jack",
"blog_comments_relation": {
"name": "comment",
"parent": "blog2"
}
}
PUT my_blogs/_doc/comment3?routing=blog2
{
"comment": "Hello Hadoop",
"username": "Bob",
"blog_comments_relation": {
"name": "comment",
"parent": "blog2"
}
}
- 父文档和子文档必须存在相同的分片上(确保查询join性能)
- 使用route参数保证分片到相同的分片
④:父子文档查询
查询所有文档(父子):
POST my_blogs/_search
根据id查询父文档:
GET my_blogs/_doc/blog1
根据子文档检索,返回父文档
POST my_blogs/_search
{
"query": {
"has_child": {
"type": "comment",
"query": {
"match": {
"username": "Jack"
}
}
}
}
}
使用parent_id查询子文档
POST my_blogs/_search
{
"query": {
"parent_id":{
"type":"comment",
"id":"blog2"
}
}
}
Has Parent 查询,返回相关的子文档
POST my_blogs/_search
{
"query": {
"has_parent": {
"parent_type": "blog",
"query": {
"match": {
"title": "Learning Hadoop"
}
}
}
}
}
通过子文档id访问子文档
GET my_blogs/_doc/comment2
通过子文档ID和routing ,访问子文档
GET my_blogs/_doc/comment3?routing=blog2
更新子文档
PUT my_blogs/_doc/comment3?routing=blog2
{
"comment": "Hello Hadoop??",
"blog_comments_relation": {
"name": "comment",
"parent": "blog2"
}
}
8.3 文件系统数据建模
思考一下,github中可以使用代码片段来实现数据搜索。这是如何实现的?在github中也使用了ES来实现数据的全文搜索。其ES中有一个记录代码内容的索引,大致数据内容如下:
{
"fileName" : "HelloWorld.java",
"authName" : "lx",
"authID" : 110,
"productName" : "first-java",
"path" : "/com/lx/first",
"content" : "package com.lx.first; public class HelloWorld { //code... }"
}
我们可以在github中通过代码的片段来实现数据的搜索。也可以使用其他条件实现数据搜索。但是,如果需要使用文件路径搜索内容应该如何实现?这个时候需要为其中的字段path定义一个特殊的分词器。具体如下:
创建索引:
PUT /codes
{
"settings": {
"analysis": {
"analyzer": {
"path_analyzer": {
"tokenizer": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"fileName": {
"type": "keyword"
},
"authName": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"authID": {
"type": "long"
},
"productName": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"path": {
"type": "text",
"analyzer": "path_analyzer",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"content": {
"type": "text",
"analyzer": "standard"
}
}
}
}
创建数据:
PUT /codes/_doc/1
{
"fileName": "HelloWorld.java",
"authName": "luoxue",
"authID": 110,
"productName": "first-java",
"path": "/com/luoxue/first",
"content": "package com.luoxue.first; public class HelloWorld { // some code... }"
}
索引数据:
GET /codes/_search
{
"query": {
"match": {
"path": "/com"
}
}
}
GET /codes/_search
{
"query": {
"match": {
"path": "/luoxue"
}
}
}
为什么/com可以检索出来而/luoxue却无法检索出来数据呢?这需要我们对path_hierarchy过滤器做一个了解
GET /codes/_analyze
{
"text": "/a/b/c/d",
"field": "path"
}
可以看到分词后,并没有中间路径单独的词。
这时候我们需要修改一下mapping映射关系
DELETE /codes
PUT /codes
{
"settings": {
"analysis": {
"analyzer": {
"path_analyzer": {
"tokenizer": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"fileName": {
"type": "keyword"
},
"authName": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"authID": {
"type": "long"
},
"productName": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"path": {
"type": "text",
"analyzer": "path_analyzer",
"fields": {
"keyword": {
"type": "text",
"analyzer": "standard"
}
}
},
"content": {
"type": "text",
"analyzer": "standard"
}
}
}
}
PUT /codes/_doc/1
{
"fileName": "HelloWorld.java",
"authName": "luoxue",
"authID": 110,
"productName": "first-java",
"path": "/com/luoxue/first",
"content": "package com.luoxue.first; public class HelloWorld { // some code... }"
}
把path的fields的type改为text
重新搜索一下/luoxue
GET /codes/_search
{
"query": {
"match": {
"path.keyword": "/luoxue"
}
}
}
九 Elasticsearch SQL
9.1 Elasticsearch SQL特点
-
本地集成
Elasticsearch SQL是专门为Elasticsearch构建的。每个SQL查询都根据底层存储对相关节点有效执行。
-
没有额外的要求
不依赖其他的硬件、进程、运行时库,Elasticsearch SQL可以直接运行在Elasticsearch集群上。
-
轻量而高效
像SQL那样简洁、高效地完成查询。
9.2 SQL和Elasticsearch对应关系
9.3 ElasticsearchSQL语法
SELECT select_expr [, ...]
[ FROM table_name ]
[ WHERE condition ]
[ GROUP BY grouping_element [, ...] ]
[ HAVING condition]
[ ORDER BY expression [ ASC | DESC ] [, ...] ]
[ LIMIT [ count ] ]
[ PIVOT ( aggregation_expr FOR column IN ( value [ [ AS ] alias ] [, ...] ) ) ]
目前FROM只支持单表
9.4 案例说明
1.查询es_db索引库的数据
GET /_sql?format=txt
{
"query": "SELECT * FROM es_db limit 1"
}
//format:表示指定返回的数据类型
2.把SQL转换成DSL
GET /_sql/translate
{
"query": "SELECT * FROM es_db limit 1"
}
十 模板搜索
搜索模板,search template,高级功能,可以将我们的一些搜索进行模板化,然后的话,每次执行这个搜索,就直接调用模板,给传入一些参数就可以了
1.入门案例
简单定义参数并传递
GET /cars/_search/template
{
"source": {
"query": {
"match": {
"remark": "{{kw}}"
}
},
"size": "{{size}}"
},
"params": {
"kw": "大众",
"size": 2
}
}
kw、size就是我们定义的参数名称
toJson方式传递参数
GET cars/_search/template
{
"source": """{ "query": { "match": {{#toJson}}parameter{{/toJson}} }}""",
"params": {
"parameter": {
"remark": "大众"
}
}
}
join方式传递参数
GET cars/_search/template
{
"source": {
"query": {
"match": {
"remark": "{{#join delimiter=','}}kw{{/join delimiter=','}}"
}
}
},
"params": {
"kw": [
"大众",
"标致"
]
}
}
默认值传递参数:
GET cars/_search/template
{
"source": {
"query": {
"range": {
"price": {
"gte": "{{start}}",
"lte": "{{end}}{{^end}}200000{{/end}}"
}
}
}
},
"params": {
"start": 100000
}
}
以上并不是我们想要的模板方式查询,我们真正使用模板查询是只提供参数就可以完成搜索,所以我们需要进行改造
2.template实现重复调用
可以使用Mustache语言作为搜索请求的预处理,它提供了模板,然后通过键值对来替换模板中的变量。把脚本存储在本地磁盘中,默认的位置为:elasticsearch\config\scripts,通过引用脚本名称进行使用,使用步骤如下:
①创建模板
POST _scripts/test
{
"script": {
"lang": "mustache",
"source": {
"query": {
"match": {
"remark": "{{kw}}"
}
}
}
}
}
②:使用模板搜索
GET cars/_search/template
{
"id": "test",
"params": {
"kw": "大众"
}
}
③:查询已定义的模板:
GET _scripts/test
④:删除已定义的模板:
DELETE _scripts/test
十一 建议搜索
suggest search(completion suggest):就是建议搜索或称为搜索建议,也可以叫做自动完成-auto completion。类似百度中的搜索联想提示功能。
ES实现suggest的时候,性能非常高,其构建的不是倒排索引,也不是正排索引,就是用于进行前缀搜索的一种特殊的数据结构,而且会全部放在内存中,所以suggest search进行的前缀搜索提示,性能是非常高。需要使用suggest的时候,必须在定义index时,为其mapping指定开启suggest。具体如下:
PUT /movie
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"suggest": {
"type": "completion",
"analyzer": "ik_max_word"
}
}
},
"content": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
PUT /movie/_doc/1
{
"title": "西游记电影系列",
"content": "西游记之月光宝盒将与2021年进行......"
}
PUT /movie/_doc/2
{
"title": "西游记文学系列",
"content": "某知名网络小说作家已经完成了大话西游同名小说的出版"
}
PUT /movie/_doc/3
{
"title": "西游记之大话西游手游",
"content": "网易游戏近日出品了大话西游经典IP的手游,正在火爆内测中"
}
进行suggest搜索:
GET /movie/_search
{
"suggest": {
"my-suggest": {
"prefix": "西游记",
"completion": {
"field": "title.suggest"
}
}
}
}
十二 地理位置搜索和聚合分析
ES支持地理位置的搜索和聚合分析,可实现在指定区域内搜索数据、搜索指定地点附近的数据、聚合分析指定地点附近的数据等操作。ES中如果使用地理位置搜索的话,必须提供一个特殊的字段类型:GEO - geo_point,地理位置的坐标点。具体用法如下:
①定义mapping
PUT /hotel_app
{
"mappings": {
"properties": {
"pin": {
"type": "geo_point"
},
"name": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
②导入数据
新增一个基于geo point类型的数据,可以使用多种方式。数据范围要求:纬度范围是-90-90之间,经度范围是-180~180之间。经纬度数据都是浮点数或数字串(数字组成的字符串),最大精度:小数点后7位。(latitude:纬度、longitude:经度)
多种类型描述geo_point类型字段的时候,在搜索数据的时候,显示的格式和录入的格式是统一的。不影响搜索。任何数据描述的geo_point类型字段,都适用地理位置搜索。
PUT /hotel_app/_doc/1
{
"name": "七天连锁酒店",
"pin": {
"lat": 40.12,
"lon": -71.34
}
}
PUT /hotel_app/_doc/2
{
"name": "维也纳酒店",
"pin" : "40.99, ‐70.81"
}
PUT /hotel_app/_doc/3
{
"name": " 红树林宾馆",
"pin" : [40, -73.81]
}
前两种按照顺序是维度经度,最后一种方式是经度纬度,推荐第一种方式导入数据,简单明了
③搜索数据
##搜索指定范围内的数据
矩阵搜索:传入的top_left和bottom_right坐标点是有固定要求的。地图中以北作为top,南作为bottom,西作为left,东作为right。也就是top_left应该从西北向东南。Bottom_right应该从东南向西北。Top_left的纬度应该大于bottom_right的纬度,top_left的经度应该小于bottom_right的经度。相当于四边形的对角两个点,形成的四边形地域内的位置
GET /hotel_app/_doc/_search
{
"query": {
"bool": {
"must": [
{
"match_all": {}
}
],
"filter": {
"geo_bounding_box": {
"pin": {
"top_left": {
"lat": 41.73,
"lon": -74.1
},
"bottom_right": {
"lat": 40.01,
"lon": -70.12
}
}
}
}
}
}
}
GET /hotel_app/_doc/_search
{
"query": {
"geo_bounding_box": {
"pin": {
"top_left": {
"lat": 41.73,
"lon": -74.1
},
"bottom_right": {
"lat": 40.01,
"lon": -70.12
}
}
}
}
}
多边形范围搜索:对传入的若干点的坐标顺序没有任何的要求。只要传入若干地理位置坐标点,即可形成多边形
GET /hotel_app/_doc/_search
{
"query": {
"bool": {
"must": [
{
"match_all": {}
}
],
"filter": {
"geo_polygon": {
"pin": {
"points": [
{
"lat": 40.73,
"lon": -74.1
},
{
"lat": 40.01,
"lon": -71.12
},
{
"lat": 50.56,
"lon": -90.58
}
]
}
}
}
}
}
}
搜索某地点附近的数据
//filter方式
GET /hotel_app/_doc/_search
{
"query": {
"bool": {
"must": [
{
"match_all": {}
}
],
"filter": {
"geo_distance": {
"distance": "200km",
"pin": {
"lat": 40,
"lon": -70
}
}
}
}
}
}
//query方式
GET hotel_app/_search
{
"query": {
"geo_distance": {
"distance": "90km",
"pin": {
"lat": 40.55,
"lon": -71.12
}
}
}
}
统计某位置附件区域内的数据
聚合统计分别距离某位置80英里,300英里,1000英里范围内的数据数量,其中unit是距离单位,常用单位有:米(m),千米(km),英里(mi),distance_type是统计算法:sloppy_arc默认算法、arc最高精度、plane最高效率
GET /hotel_app/_doc/_search
{
"size": 0,
"aggs": {
"agg_by_pin": {
"geo_distance": {
"distance_type": "arc",
"field": "pin",
"origin": {
"lat": 40,
"lon": -70
},
"unit": "mi",
"ranges": [
{
"to": 80
},
{
"from": 80,
"to": 300
},
{
"from": 300,
"to": 1000
}
]
}
}
}
}
建议使用filter来过滤geo_point数据。因为geo_point数据相关度评分计算比较耗时。使用query来搜索geo_point数据效率相对会慢一些。建议使用filter。
app/_doc/_search
{
“query”: {
“bool”: {
“must”: [
{
“match_all”: {}
}
],
“filter”: {
“geo_polygon”: {
“pin”: {
“points”: [
{
“lat”: 40.73,
“lon”: -74.1
},
{
“lat”: 40.01,
“lon”: -71.12
},
{
“lat”: 50.56,
“lon”: -90.58
}
]
}
}
}
}
}
}
[外链图片转存中...(img-qTWXAsPX-1711328915710)]
## 搜索某地点附近的数据
```json
//filter方式
GET /hotel_app/_doc/_search
{
"query": {
"bool": {
"must": [
{
"match_all": {}
}
],
"filter": {
"geo_distance": {
"distance": "200km",
"pin": {
"lat": 40,
"lon": -70
}
}
}
}
}
}
//query方式
GET hotel_app/_search
{
"query": {
"geo_distance": {
"distance": "90km",
"pin": {
"lat": 40.55,
"lon": -71.12
}
}
}
}
[外链图片转存中…(img-roFqbdaR-1711328915710)]
统计某位置附件区域内的数据
聚合统计分别距离某位置80英里,300英里,1000英里范围内的数据数量,其中unit是距离单位,常用单位有:米(m),千米(km),英里(mi),distance_type是统计算法:sloppy_arc默认算法、arc最高精度、plane最高效率
GET /hotel_app/_doc/_search
{
"size": 0,
"aggs": {
"agg_by_pin": {
"geo_distance": {
"distance_type": "arc",
"field": "pin",
"origin": {
"lat": 40,
"lon": -70
},
"unit": "mi",
"ranges": [
{
"to": 80
},
{
"from": 80,
"to": 300
},
{
"from": 300,
"to": 1000
}
]
}
}
}
}
[外链图片转存中…(img-X9ne0gVP-1711328915710)]
建议使用filter来过滤geo_point数据。因为geo_point数据相关度评分计算比较耗时。使用query来搜索geo_point数据效率相对会慢一些。建议使用filter。
🌟至此本篇就结束了,下一篇将介绍Java API操作ES以及集成Springboot框架的使用