ElasticSearch
简介
Elasticsearch(简称ES)是一个分布式、可扩展、实时的搜索与数据分析引擎。ES不仅仅只是全文搜索,还支持结构化搜索、数据分析、复杂的语言处理、地理位置和对象间关联关系等。
ES的底层依赖Lucene,Lucene可以说是当下最先进、高性能、全功能的搜索引擎库。但是Lucene仅仅只是一个库。为了充分发挥其功能,你需要使用Java并将Lucene直接集成到应用程序中。更糟糕的是,您可能需要获得信息检索学位才能了解其工作原理,因为Lucene非常复杂——《ElasticSearch官方权威指南》。
鉴于Lucene如此强大却难以上手的特点,诞生了ES。ES也是使用Java编写的,它的内部使用Lucene做索引与搜索,它的目的是隐藏Lucene的复杂性,取而代之的提供一套简单一致的RESTful API。
总体来说,ES具有如下特点:
- 一个分布式的实时文档存储引擎,每个字段都可以被索引与搜索
- 一个分布式实时分析搜索引擎,支持各种查询和聚合操作
- 能胜任上百个服务节点的扩展,并可以支持PB级别的结构化或者非结构化数据
为什么要使用ElasticSearch
关系型数据库有什么问题?
传统的关系数据库提供事务保证,具有不错的性能,高可靠性,久经历史考验,而且使用简单,功能强大,同时也积累了大量的成功案例。
后来,随着访问量的上升,几乎大部分使用 MySQL 架构的网站在数据库上都开始出现了性能问题,web 程序不再仅仅专注在功能上,同时也在追求性能。
读写分离
由于数据库的写入压力增加,读写集中在一个数据库上让数据库不堪重负,大部分网站开始使用主从复制技术来达到读写分离,以提高读写性能和读库的可扩展性。Mysql 的 master-slave 模式成为这个时候的网站标配了。
分表分库
开始流行使用分表分库来缓解写压力和数据增长的扩展问题。这个时候,分表分库成了一个热门技术,也是业界讨论的热门技术问题。
MySQL 的扩展性瓶颈
大数据量高并发环境下的 MySQL 应用开发越来越复杂,也越来越具有技术挑战性。分表分库的规则把握都是需要经验的。虽然有像淘宝这样技术实力强大的公司开发了透明的中间件层来屏蔽开发者的复杂性,但是避免不了整个架构的复杂性。分库分表的子库到一定阶段又面临扩展问题。还有就是需求的变更,可能又需要一种新的分库方式。
关系数据库很强大,但是它并不能很好的应付所有的应用场景。MySQL 的扩展性差(需要复杂的技术来实现),大数据下 IO 压力大,表结构更改困难,正是当前使用 MySQL 的开发人员面临的问题。
ElasticSearch有什么优势?
非关系型、搜索引擎、近实时搜索与分析、高可用、天然分布式、横向可扩展
ES使用场景
- 搜索引擎
电商网站的商品搜索、站内搜索、模糊查询、全文检索服务 - 非关系型数据库
业务宽表(数据库字段太多,查询太慢,索引没有办法再做优化)
数据库做统计查询 - 大数据近实时分析引擎
- 日志分析
架构
ElasticSearch集群原理
Document:文档,指一行数据;
Index:索引,是多个document的集合(和sql数据库的表对应);
Shard:分片,当有大量的文档时,由于内存的限制、磁盘处理能力不足、无法足够快的响应客户端的请求等,一个节点可能不够。这种情况下,数据可以分为较小的分片。每个分片放到不同的服务器上。
当你查询的索引分布在多个分片上时,ES会把查询发送给每个相关的分片,并将结果组合在一起,而应用程序并不知道分片的存在。即:这个过程对用户来说是透明的
Replia:副本,为提高查询吞吐量或实现高可用性,可以使用分片副本。
副本是一个分片的精确复制,每个分片可以有零个或多个副本。ES中可以有许多相同的分片,其中之一被选择更改索引操作,这种特殊的分片称为主分片。
当主分片丢失时,如:该分片所在的数据不可用时,集群将副本提升为新的主分片。
Node:节点,形成集群的每个服务器称为节点,一个节点可以包含多个shard
Cluster:集群,ES可以作为一个独立的单个搜索服务器。不过,为了处理大型数据集,实现容错和高可用性,ES可以运行在许多互相合作的服务器上。这些服务器的集合称为集群。
我们往 Elasticsearch 添加数据时需要用到 索引 —— 保存相关数据的地方。 索引实际上是指向一个或者多个物理 分片 的 逻辑命名空间 。
一个 分片 是一个底层的 工作单元 ,它仅保存了全部数据中的一部分。 一个分片是一个 Lucene 的实例,以及它本身就是一个完整的搜索引擎。 我们的文档被存储和索引到分片内,但是应用程序是直接与索引而不是与分片进行交互。
Elasticsearch 是利用分片将数据分发到集群内各处的。分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里。 当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。
一个分片可以是 主分片或者 副本分片。索引内任意一个文档都归属于一个主分片,所以主分片的数目决定着索引能够保存的最大数据量。
一个副本分片只是一个主分片的拷贝。副本分片作为硬件故障时保护数据不丢失的冗余备份,并为搜索和返回文档等读操作提供服务。
在索引建立的时候就已经确定了主分片数,但是副本分片数可以随时修改。
节点类型
ES的架构很简单,集群的HA不需要依赖任务外部组件(例如Zookeeper、HDFS等),master节点的主备依赖于内部自建的选举算法,通过副本分片的方式实现了数据的备份的同时,也提高了并发查询的能力。
ES集群的服务器分为以下四种角色:
1.列表项目master节点,负责保存和更新集群的一些元数据信息,之后同步到所有节点,所以每个节点都需要保存全量的元数据信息:
- 集群的配置信息
- 集群的节点信息
- 模板template设置
- 索引以及对应的设置、mapping、分词器和别名
- 索引关联到的分片以及分配到的节点
2.datanode:负责数据存储和查询
3.coordinator:
- 路由索引请求
- 聚合搜索结果集
- 分发批量索引请求
4.ingestor:
- 类似于logstash,对输入数据进行处理和转换
如何配置节点类型
一个节点的缺省配置是:主节点+数据节点两属性为一身。对于3-5个节点的小集群来讲,通常让所有节点存储数据和具有获得主节点的资格。
专用协调节点(也称为client节点或路由节点)从数据节点中消除了聚合/查询的请求解析和最终阶段,随着集群写入以及查询负载的增大,可以通过协调节点减轻数据节点的压力,可以让数据节点更多专注于数据的写入以及查询。
master选举架构
节点类型
ES的架构很简单,集群的HA不需要依赖任务外部组件(例如Zookeeper、HDFS等),master节点的主备依赖于内部自建的选举算法,通过副本分片的方式实现了数据的备份的同时,也提高了并发查询的能力。
ES集群的服务器分为以下四种角色:
1.列表项目master节点,负责保存和更新集群的一些元数据信息,之后同步到所有节点,所以每个节点都需要保存全量的元数据信息:
- 集群的配置信息
- 集群的节点信息
- 模板template设置
- 索引以及对应的设置、mapping、分词器和别名
- 索引关联到的分片以及分配到的节点
2.datanode:负责数据存储和查询
3.coordinator:
- 路由索引请求
- 聚合搜索结果集
- 分发批量索引请求
4.ingestor:
- 类似于logstash,对输入数据进行处理和转换
如何配置节点类型
一个节点的缺省配置是:主节点+数据节点两属性为一身。对于3-5个节点的小集群来讲,通常让所有节点存储数据和具有获得主节点的资格。
专用协调节点(也称为client节点或路由节点)从数据节点中消除了聚合/查询的请求解析和最终阶段,随着集群写入以及查询负载的增大,可以通过协调节点减轻数据节点的压力,可以让数据节点更多专注于数据的写入以及查询。
master选举
选举策略
- 如果集群中存在master,认可该master,加入集群
- 如果集群中不存在master,从具有master资格的节点中选id最小的节点作为master
选举时机
集群启动:后台启动线程去ping集群中的节点,按照上述策略从具有master资格的节点中选举出master
现有的master离开集群:后台一直有一个线程定时ping master节点,超过一定次数没有ping成功之后,重新进行master的选举
选举策略
- 如果集群中存在master,认可该master,加入集群
- 如果集群中不存在master,从具有master资格的节点中选id最小的节点作为master
选举时机
集群启动:后台启动线程去ping集群中的节点,按照上述策略从具有master资格的节点中选举出master
现有的master离开集群:后台一直有一个线程定时ping master节点,超过一定次数没有ping成功之后,重新进行master的选举
避免脑裂
脑裂问题是采用master-slave模式的分布式集群普遍需要关注的问题,脑裂一旦出现,会导致集群的状态出现不一致,导致数据错误甚至丢失。
ES避免脑裂的策略:过半原则,可以在ES的集群配置中添加一下配置,避免脑裂的发生
注意问题
- 配置文件中加入上述避免脑裂的配置,对于网络波动比较大的集群来说,增加ping的时间和ping的次数,一定程度上可以增加集群的稳定性
- 动态的字段field可能导致元数据暴涨,新增字段mapping映射需要更新mater节点上维护的字段映射信息,master修改了映射信息之后再同步到集群中所有的节点,这个过程中数据的写入是阻塞的。所以建议关闭自动mapping,没有预先定义的字段mapping会写入失败
- 通过定时任务在集群写入的低峰期,将索引以及mapping映射提前创建好
负载均衡
ES集群是分布式的,数据分布到集群的不同机器上,对于ES中的一个索引来说,ES通过分片的方式实现数据的分布式和负载均衡。创建索引的时候,需要指定分片的数量,分片会均匀的分布到集群的机器中。分片的数量是需要创建索引的时候就需要设置的,而且设置之后不能更改,虽然ES提供了相应的api来缩减和扩增分片,但是代价是很高的,需要重建整个索引。
考虑到并发响应以及后续扩展节点的能力,分片的数量不能太少,假如你只有一个分片,随着索引数据量的增大,后续进行了节点的扩充,但是由于一个分片只能分布在一台机器上,所以集群扩容对于该索引来说没有意义了。
但是分片数量也不能太多,每个分片都相当于一个独立的lucene引擎,太多的分片意味着集群中需要管理的元数据信息增多,master节点有可能成为瓶颈;同时集群中的小文件会增多,内存以及文件句柄的占用量会增大,查询速度也会变慢。
数据副本
ES通过副本分片的方式,保证集群数据的高可用,同时增加集群并发处理查询请求的能力,相应的,在数据写入阶段会增大集群的写入压力。
数据写入的过程中,首先被路由到主分片,写入成功之后,将数据发送到副本分片,为了保证数据不丢失,最好保证至少一个副本分片写入成功以后才返回客户端成功。
相关配置
5.0之后通过wait_for_active_shards参数设置
- 索引时增加参数:?wait_for_active_shards=3
- 给索引增加配置:index.write.wait_for_active_shards=3
水平扩容
Node 1 和 Node 2 上各有一个分片被迁移到了新的 Node 3 节点,现在每个节点上都拥有2个分片,而不是之前的3个。 这表示每个节点的硬件资源(CPU, RAM, I/O)将被更少的分片所共享,每个分片的性能将会得到提升。
分片是一个功能完整的搜索引擎,它拥有使用一个节点上的所有资源的能力。 我们这个拥有6个分片(3个主分片和3个副本分片)的索引可以最大扩容到6个节点,每个节点上存在一个分片,并且每个分片拥有所在节点的全部资源。
但是如果我们想要扩容超过6个节点怎么办呢?
主分片的数目在索引创建时就已经确定了下来。实际上,这个数目定义了这个索引能够 存储 的最大数据量。(实际大小取决于你的数据、硬件和使用场景。) 但是,读操作——搜索和返回数据——可以同时被主分片 或 副本分片所处理,所以当你拥有越多的副本分片时,也将拥有越高的吞吐量。
故障转移
我们关闭的节点是一个主节点。而集群必须拥有一个主节点来保证正常工作,所以发生的第一件事情就是选举一个新的主节点: Node 2 。
在我们关闭 Node 1 的同时也失去了主分片 1 和 2 ,并且在缺失主分片的时候索引也不能正常工作。 如果此时来检查集群的状况,我们看到的状态将会为 red :不是所有主分片都在正常工作。
幸运的是,在其它节点上存在着这两个主分片的完整副本, 所以新的主节点立即将这些分片在 Node 2 和 Node 3 上对应的副本分片提升为主分片。
路由机制
当索引一个文档的时候,文档会被存储到一个主分片中。 Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?当我们创建文档时,它如何决定这个文档应当被存储在分片 1 还是分片 2 中呢?
首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到 余数 。这个分布在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。
这就解释了为什么我们要在创建索引的时候就确定好主分片的数量 并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
ElasticSearch如何建立索引
索引的不变性
由于倒排索引的结构特性,在索引建立完成后对其进行修改将会非常复杂。再加上几层索引嵌套,更让索引的更新变成了几乎不可能的动作。
所以索性设计成不可改变的:倒排索引被写入磁盘后是不可改变的,它永远不会修改。
不变性有重要的价值
1.不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
2.一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
3.其它缓存(像filter缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
4.写入单个大的倒排索引允许数据压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量。
当然,一个不变的索引也有不好的地方。主要事实是它是不可变的,你不能修改它。如果你需要让一个新的文档 可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
数据写入
写操作, 必须在主分片上面完成之后才能被复制到相关的副本分片。
动态更新索引
怎样在保留不变性的前提下实现倒排索引的更新?答案是: 用更多的索引。
通过增加新的补充索引来反映新近的修改,而不是直接重写整个倒排索引。每一个倒排索引都会被轮流查询到—从最早的开始—查询完后再对结果进行合并。
Elasticsearch 基于 Lucene, 引入了 按段搜索 的概念。 每一 段 本身都是一个倒排索引, 但 索引 在 Lucene 中除表示所有 段 的集合外, 还增加了 提交点 的概念 — 一个列出了所有已知段的文件,新的文档首先被添加到内存索引缓存中,然后写入到一个基于磁盘的段。
在 lucene 中查询是基于 segment。每个 segment 可以看做是一个独立的 subindex,在建立索引的过程中,lucene 会不断的 flush 内存中的数据持久化形成新的 segment。多个 segment 也会不断的被 merge 成一个大的 segment,在老的 segment 还有查询在读取的时候,不会被删除,没有被读取且被 merge 的 segement 会被删除。
1 | 1)数据先写入内存buffer,在写入buffer的同时将数据写入translog日志文件,注意:此时数据还没有被成功es索引记录,因此无法搜索到对应数据; |
写入过程
几个概念:
- 内存buffer
- translog
- 文件系统缓冲区
- refresh
- segment(段)
- commit
- flush
translog
写入ES的数据首先会被写入translog文件,该文件持久化到磁盘,保证服务器宕机的时候数据不会丢失,由于顺序写磁盘,速度也会很快。
- 同步写入:每次写入请求执行的时候,translog在fsync到磁盘之后,才会给客户端返回成功
- 异步写入:写入请求缓存在内存中,每经过固定时间之后才会fsync到磁盘,写入量很大,对于数据的完整性要求又不是非常严格的情况下,可以开启异步写入
refresh
经过固定的时间,或者手动触发之后,将内存中的数据构建索引生成segment,写入文件系统缓冲区
commit/flush
超过固定的时间,或者translog文件过大之后,触发flush操作:
- 内存的buffer被清空,相当于进行一次refresh
- 文件系统缓冲区中所有segment刷写到磁盘
- 将一个包含所有段列表的新的提交点写入磁盘
- 启动或重新打开一个索引的过程中使用这个提交点来判断哪些segment隶属于当前分片
- 删除旧的translog,开启新的translog
merge
上面提到,每次refresh的时候,都会在文件系统缓冲区中生成一个segment,后续flush触发的时候持久化到磁盘。所以,随着数据的写入,尤其是refresh的时间设置的很短的时候,磁盘中会生成越来越多的segment:
- segment数目太多会带来较大的麻烦。 每一个segment都会消耗文件句柄、内存和cpu运行周期。
- 更重要的是,每个搜索请求都必须轮流检查每个segment,所以segment越多,搜索也就越慢。
merge的过程大致描述如下:
- 磁盘上两个小segment:A和B,内存中又生成了一个小segment:C
- A,B被读取到内存中,与内存中的C进行merge,生成了新的更大的segment:D
- 触发commit操作,D被fsync到磁盘
- 创建新的提交点,删除A和B,新增D
- 删除磁盘中的A和B
删改操作
segment的不可变性的好处
- segment的读写不需要加锁
- 常驻文件系统缓存(堆外内存)
- 查询的filter缓存可以常驻内存(堆内存)
删除
磁盘上的每个segment都有一个.del文件与它相关联。当发送删除请求时,该文档未被真正删除,而是在.del文件中标记为已删除。此文档可能仍然能被搜索到,但会从结果中过滤掉。当segment合并时,在.del文件中标记为已删除的文档不会被包括在新的segment中,也就是说merge的时候会真正删除被删除的文档。
更新
创建新文档时,Elasticsearch将为该文档分配一个版本号。对文档的每次更改都会产生一个新的版本号。当执行更新时,旧版本在.del文件中被标记为已删除,并且新版本在新的segment中写入索引。旧版本可能仍然与搜索查询匹配,但是从结果中将其过滤掉。
并发控制
在数据库领域中,有两种方法通常被用来确保并发更新时变更不会丢失:
悲观并发控制
这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。
乐观并发控制
Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。
Elasticsearch 是分布式的。当文档创建、更新或删除时, 新版本的文档必须复制到集群中的其他节点。Elasticsearch 也是异步和并发的,这意味着这些复制请求被并行发送,并且到达目的地时也许 顺序是乱的。Elasticsearch 需要一种方法确保文档的旧版本不会覆盖新的版本。
每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。 Elasticsearch 使用这个 _version 号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。
使用内部版本号:删除或者更新数据的时候,携带_version参数,如果文档的最新版本不是这个版本号,那么操作会失败,这个版本号是ES内部自动生成的,每次操作之后都会递增一。
1 | PUT /website/blog/1?version=1 |
使用外部版本号:ES默认采用递增的整数作为版本号,也可以通过外部自定义整数(long类型)作为版本号,例如时间戳。通过添加参数version_type=external,可以使用自定义版本号。内部版本号使用的时候,更新或者删除操作需要携带ES索引当前最新的版本号,匹配上了才能成功操作。外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同, Elasticsearch 不是检查当前 _version 和请求中指定的版本号是否相同, 而是检查当前 _version 是否 小于 指定的版本号。 如果请求成功,外部的版本号作为文档的新 _version 进行存储。
1 | PUT /website/blog/2?version=5&version_type=external |
批量操作
bulk API 允许在单个步骤中进行多次 create 、 index 、 update 或 delete 请求。如果你需要索引一个数据流比如日志事件,它可以排队和索引数百或数千批次。
bulk 请求不是原子的: 不能用它来实现事务控制。每个请求是单独处理的,因此一个请求的成功或失败不会影响其他的请求。
整个批量请求都需要由接收到请求的节点加载到内存中,因此该请求越大,其他请求所能获得的内存就越少。 批量请求的大小有一个最佳值,大于这个值,性能将不再提升,甚至会下降。 但是最佳值不是一个固定的值。它完全取决于硬件、文档的大小和复杂度、索引和搜索的负载的整体情况。
ElasticSearch数据类型
Elasticsearch 支持如下简单域类型:
- 字符串:
string
- 整数 :
byte
,short
,integer
,long
- 浮点数:
float
,double
- 布尔型:
boolean
- 日期:
date
映射
为了能够将时间域视为时间,数字域视为数字,字符串域视为全文或精确值字符串, Elasticsearch 需要知道每个域中数据的类型。这个信息包含在映射中。
索引中每个文档都有 类型 。每种类型都有它自己的 映射 ,或者 模式定义 。映射定义了类型中的域,每个域的数据类型,以及Elasticsearch如何处理这些域。映射也用于配置与类型有关的元数据。
当你索引一个包含新域的文档(之前未曾出现),Elasticsearch 会使用 动态映射,通过JSON中基本数据类型,尝试猜测域类型,使用如下规则:
JSON TYPE | 域Type |
---|---|
布尔型:true或者false | boolean |
整数:123 | long |
浮点数:123.45 | double |
字符串,有效日期:2022-07-11 | date |
字符串:foo bar | string |
原始文档存储(行式存储)
fdt文件
文档内容的物理存储文件,由多个chunk组成,Lucene索引文档时,先缓存文档,缓存大于16KB时,就会把文档压缩存储。
fdx文件
文档内容的位置索引,由多个block组成:
- 1024个chunk归为一个block
- block记录chunk的起始文档ID,以及chunk在fdt中的位置
fnm文件
文档元数据信息,包括文档字段的名称、类型、数量等。
原始文档的查询
注意问题:lucene对原始文件的存放是行式存储,并且为了提高空间利用率,是多文档一起压缩,因此取文档时需要读入和解压额外文档,因此取文档过程非常依赖CPU以及随机IO。
相关设置
- 压缩方式的设置
原始文档的存储对应_source字段,是默认开启的,会占用大量的磁盘空间,上面提到的chunk中的文档压缩,ES默认采用的是LZ4,如果想要提高压缩率,可以将设置改成best_compression。
- 特定字段的内容存储
查询的时候,如果想要获取原始字段,需要在_source中获取,因为所有的字段存储在一起,所以获取完整的文档内容与获取其中某个字段,在资源消耗上几乎相同,只是返回给客户端的时候,减少了一定量的网络IO。
ES提供了特定字段内容存储的设置,在设置mappings的时候可以开启,默认是false。如果你的文档内容很大,而其中某个字段的内容有需要经常获取,可以设置开启,将该字段的内容单独存储。
倒排索引
索引是构成搜索引擎的核心技术之一,索引在日常生活中其实也是非常常见的,比如当我们看一本书的时候,我们首先会看书的目录,通过目录可以快速定位到某一章节的页码,加快对内容的查询速度。
倒排索引,也常被称为反向索引,是一种索引方法,被用来存储在全文搜索下某个单词在一个文档或者一组文档中的存储位置的映射,它是文档检索系统中最常用的数据结构。
对于文档内容,先要经过词条化处理。对单词进行排序,像 B+ 树一样,可以在页里实现二分查找
倒排索引中记录的信息主要有:
- 文档编号:segment内部文档编号从0开始,最大值为int最大值,文档写入之后会分配这样一个顺序号
- 字典:字段内容经过分词、归一化、还原词根等操作之后,得到的所有单词
- 单词出现位置:分词字段默认开启,提供对于短语查询的支持;对于非常常见的词,例如the,位置信息可能占用很大空间,短语查询需要读取的数据量很大,查询速度慢
- 单词出现次数:单词在文档中出现的次数,作为评分的依据
- 单词结束字符到开始字符的偏移量:记录在文档中开始与结束字符的偏移量,提供高亮使用,默认是禁用的
- 规范因子:对字段长度进行规范化的因子,给予较短字段更多权重
倒排索引的查找过程本质上是通过单词找对应的文档列表的过程,因此倒排索引中字典的设计决定了倒排索引的查询速度,字典主要包括前缀索引(.tip文件)和后缀索引(.tim)文件。
字典前缀索引(.tip文件)
一个合格的词典结构一般有以下特点:
-查询速度快 -内存占用小 -内存+磁盘相结合
Lucene采用的前缀索引数据结构为FST,它的优点有:
词查找复杂度为O(len(str))
- 共享前缀、节省空间、内存占用率低,压缩率高,模糊查询支持好
- 内存存放前缀索引,磁盘存放后缀词块
- 缺点:结构复杂、输入要求有序、更新不易
字典后缀(.tim文件)
后缀词块主要保存了单词后缀,以及对应的文档列表的位置。
文档列表(.doc文件)
lucene对文档列表存储进行了很好的压缩,来保证缓存友好:
- 差分压缩:每个ID只记录跟前面的ID的差值
- 每256个ID放入一个block中
- block的头信息存放block中每个ID占用的bit位数,因为经过上面的差分压缩之后,文档列表中的文档ID都变得不大,占用的bit位数变少
ES的一个重要的查询场景是bool查询,类似于mysql中的and操作,需要将两个条件对应的文档列表进行合并。为了加快文档列表的合并,lucene底层采用了跳表的数据结构,合并过程中,优先遍历较短的链表,去较长的列表中进行查询,如果存在,则该文档符合条件。
倒排索引的查询过程
- 内存加载tip文件,通过FST匹配前缀找到后缀词块位置
- 根据词块位置,读取磁盘中tim文件中后缀块并找到后缀和相应的倒排表位置信息
- 根据倒排表位置去doc文件中加载倒排表
- 借助跳表结构,对多个文档列表进行合并
filter查询的缓存
对于filter过滤查询的结果,ES会进行缓存,缓存采用的数据结构是RoaringBitmap,在match查询中配合filter能有效加快查询速度。
- 普通bitset的缺点:内存占用大,RoaringBitmap有很好的压缩特性
- 分桶:解决文档列表稀疏的情况下,过多的0占用内存,每65536个docid分到一个桶,桶内只记录docid%65536
- 桶内压缩:4096作为分界点,小余这个值用short数组,大于这个值用bitset,每个short占两字节,4096个short占用65536bit,所以超过4096个文档id之后,是bitset更节省空间。
DocValues(正排索引&列式存储)
倒排索引保存的是词项到文档的映射,也就是词项存在于哪些文档中,DocValues保存的是文档到词项的映射,也就是文档中有哪些词项。
ES6.0(lucene7.0)
- docid的存储的通过分片加快映射到value的查询速度
- value存储的时候不再给空的值分配空间
因为value存储的时候,空值不再分配空间,所以查询的时候不能通过上述通过文档id直接映射到在bitset中的偏移量来获取对应的value,需要通过获取docid的位置来找到对应的value的位置。
所以对于DocValues的查找,关键在于DocIDSet中ID的查找,如果按照简单的链表的查找逻辑,那么DocID的查找速度将会很慢。lucene7借用了RoaringBitmap的分片的思想来加快DocIDSet的查找速度:
- 分片容量为2的16次方,最多可以存储65536个docid
- 分片包含的信息:分片ID;存储的docid的个数(值不为空的DocIDSet);DocIDSet明细,或者标记分片类型(ALL或者NONE)
- 根据分片的容量,将分片分为四种不同的类型,不同类型的查找逻辑不通:ALL:该分片内没有不存在值的DocID;NONE:该分片内所有的DocID都不存在值;SPARSE:该分片内存在值的DocID的个数不超过4096,DocIDSet以short数组的形式存储,查找的时候,遍历数组,找到对应的ID的位置;DENSE:该分片内存在值的DocID的个数超过4096,DocIDSet以bitset的形式存储,ID的偏移量也就是在该分片中的位置
DocIDSet的查找逻辑:
- 计算DocID/65536,得到所在的分片N
- 计算前面N-1个分片的DocID的总数
- 找到DocID在分片N内部的位置,从而找到所在位置之前的DocID个数M
- 找到N+M位置的value即为该DocID对应的value
分布式检索
可以从主分片或者从其它任意副本分片检索文档
在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。 一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的。
查询过程(query then fetch)
一个 CRUD 操作只对单个文档进行处理,文档的唯一性由 _index
, _type
, 和 routing
values的组合来确定。这表示我们确切的知道集群中哪个分片含有此文档。
搜索需要一种更加复杂的执行模型因为我们不知道查询会命中哪些文档: 这些文档有可能在集群的任何分片上。一个搜索请求必须询问我们关注的索引(index or indices)的所有分片的某个副本来确定它们是否含有任何匹配的文档。
但是找到所有的匹配文档仅仅完成事情的一半。在 search 接口返回一个 page 结果之前,多分片中的结果必须组合成单个排序列表。 为此,搜索被执行成一个两阶段过程,我们称之为 query then fetch 。
查询阶段包含以下三个步骤:
- 客户端发送一个
search
请求到Node 3
,Node 3
会创建一个大小为from + size
的空优先队列。 Node 3
将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为from + size
的本地有序优先队列中。- 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是
Node 3
,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。
当一个搜索请求被发送到某个节点时,这个节点就变成了协调节点。 这个节点的任务是广播查询请求到所有相关分片并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。
协调节点将这些分片级的结果合并到自己的有序优先队列里,它代表了全局排序结果集合。至此查询过程结束。
取回阶段
查询阶段标识哪些文档满足搜索请求,但是我们仍然需要取回这些文档,这是取回阶段的任务。
分布式阶段由以下步骤构成:
- 协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
- 每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。
- 一旦所有的文档都被取回了,协调节点返回结果给客户端。
协调节点首先决定哪些文档确实需要被取回。例如,如果我们的查询指定了 { “from”: 90, “size”: 10 } ,最初的90个结果会被丢弃,只有从第91个开始的10个结果需要被取回。这些文档可能来自和最初搜索请求有关的一个、多个甚至全部分片。
深分页
每个分片必须先创建一个 from + size 长度的队列,协调节点需要根据 number_of_shards * (from + size) 排序文档,来找到被包含在 size 里的文档。
取决于你的文档的大小,分片的数量和你使用的硬件,给 10,000 到 50,000 的结果文档深分页( 1,000 到 5,000 页)是完全可行的。但是使用足够大的 from 值,排序过程可能会变得非常沉重,使用大量的CPU、内存和带宽。
数据查询
- 协调节点将请求发送给对应分片
- 分片查询,返回from+size数量的文档对应的id以及每个id的得分
- 汇总所有节点的结果,按照得分获取指定区间的文档id
- 根据查询需求,像对应分片发送多个get请求,获取文档的信息
- 返回给客户端
get查询更快
默认根据id对文档进行路由,所以指定id的查询可以定位到文档所在的分片,只对某个分片进行查询即可。当然非get查询,只要写入和查询的时候指定routing,同样可以达到该效果。
主分片与副本分片
ES的分片有主备之分,但是对于查询来说,主备分片的地位完全相同,平等的接收查询请求。这里涉及到一个请求的负载均衡策略,6.0之前采用的是轮询的策略,但是这种策略存在不足,轮询方案只能保证查询数据量在主备分片是均衡的,但是不能保证查询压力在主备分片上是均衡的,可能出现慢查询都路由到了主分片上,导致主分片所在的机器压力过大,影响了整个集群对外提供服务的能力。
新版本中优化了该策略,采用了基于负载的请求路由,基于队列的耗费时间自动调节队列长度,负载高的节点的队列长度将减少,让其他节点分摊更多的压力,搜索和索引都将基于这种机制。
get查询的实时性
ES数据写入之后,要经过一个refresh操作之后,才能够创建索引,进行查询。但是get查询很特殊,数据实时可查。
ES5.0之前translog可以提供实时的CRUD,get查询会首先检查translog中有没有最新的修改,然后再尝试去segment中对id进行查找。5.0之后,为了减少translog设计的负责性以便于再其他更重要的方面对translog进行优化,所以取消了translog的实时查询功能。
get查询的实时性,通过每次get查询的时候,如果发现该id还在内存中没有创建索引,那么首先会触发refresh操作,来让id可查。
查询方式
两种查询上下文:
- query:例如全文检索,返回的是文档匹配搜索条件的相关性,常用api:match
- filter:例如时间区间的限定,回答的是是否,要么是,要么不是,不存在相似程度的概念,常用api:term、range
过滤(filter)的目标是减少那些需要进行评分查询(scoring queries)的文档数量。
分析器(analyzer)
当索引一个文档时,它的全文域被分析成词条以用来创建倒排索引。当进行分词字段的搜索的时候,同样需要将查询字符串通过相同的分析过程,以保证搜索的词条格式与索引中的词条格式一致。当查询一个不分词字段时,不会分析查询字符串,而是搜索指定的精确值。
可以通过下面的命令查看分词结果:
1 | GET /_analyze |
相关性
默认情况下,返回结果是按相关性倒序排列的。每个文档都有相关性评分,用一个正浮点数字段score来表示。score的评分越高,相关性越高。
ES的相似度算法被定义为检索词频率/反向文档频率(TF/IDF),包括以下内容:
- 检索词频率:检索词在该字段出现的频率,出现频率越高,相关性也越高。字段中出现过5次要比只出现过1次的相关性高。
- 反向文档频率:每个检索词在索引中出现的频率,频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。
- 字段长度准则:字段的长度是多少,长度越长,相关性越低。
检索词出现在一个短的title要比同样的词出现在一个长的content字段权重更大。
查询的时候可以通过添加?explain参数,查看上述各个算法的评分结果。
ElasticSearch实际使用过程中会有什么问题
分片的设定
分片数过小,数据写入形成瓶颈,无法水平拓展
分片数过多,每个分片都是一个lucene的索引,分片过多将会占用过多资源
如何计算分片数
需要注意分片数量最好设置为节点数的整数倍,保证每一个主机的负载是差不多一样的,特别的,如果是一个主机部署多个实例的情况,更要注意这一点,否则可能遇到其他主机负载正常,就某个主机负载特别高的情况。
一般我们根据每天的数据量来计算分片,保持每个分片的大小在 50G 以下比较合理。如果还不能满足要求,那么可能需要在索引层面通过拆分更多的索引或者通过别名 + 按小时 创建索引的方式来实现了。
ES数据近实时问题
ES数据写入之后,要经过一个refresh操作之后,才能够创建索引,进行查询。但是get查询很特殊,数据实时可查。
ES5.0之前translog可以提供实时的CRUD,get查询会首先检查translog中有没有最新的修改,然后再尝试去segment中对id进行查找。5.0之后,为了减少translog设计的负责性以便于再其他更重要的方面对translog进行优化,所以取消了translog的实时查询功能。
get查询的实时性,通过每次get查询的时候,如果发现该id还在内存中没有创建索引,那么首先会触发refresh操作,来让id可查。
深分页问题
解决方案1:服务端缓存 Scan and scroll API
为了返回某一页记录,其实我们抛弃了其他的大部分已经排好序的结果。那么简单点就是把这个结果缓存起来,下次就可以用上了。根据这个思路,ES提供了Scroll API。它概念上有点像传统数据库的游标(cursor)。
scroll调用本质上是实时创建了一个快照(snapshot),然后保持这个快照一个指定的时间,这样,下次请求的时候就不需要重新排序了。从这个方面上来说,scroll就是一个服务端的缓存。既然是缓存,就会有下面两个问题:
- 一致性问题。ES的快照就是产生时刻的样子了,在过期之前的所有修改它都视而不见。
- 服务端开销。ES这里会为每一个scroll操作保留一个查询上下文(Search context)。ES默认会合并多个小的索引段(segment)成大的索引段来提供索引速度,在这个时候小的索引段就会被删除。但是在scroll的时候,如果ES发现有索引段正处于使用中,那么就不会对它们进行合并。这意味着需要更多的文件描述符以及比较慢的索引速度。
其实这里还有第三个问题,但是它不是缓存的问题,而是因为ES采用的游标机制导致的。就是你只能顺序的扫描,不能随意的跳页。而且还要求客户每次请求都要带上”游标”。
解决方案2:Search After
Scroll API相对于from+size方式当然是性能好很多,但是也有如下问题:
- Search context开销不小。
- 是一个临时快照,并不是实时的分页结果。
针对这些问题,ES 5.0 开始推出了Search After机制可以提供了更实时的游标(live cursor)。它的思想是利用上一页的分页结果来提高下一页的分页请求。
调优
filesystem cache
你往 es 里写的数据,实际上都写到磁盘文件里去了,查询的时候,操作系统会将磁盘文件里的数据自动缓存到 filesystem cache
里面去。
es 的搜索引擎严重依赖于底层的 filesystem cache
,你如果给 filesystem cache
更多的内存,尽量让内存可以容纳所有的 idx segment file
索引数据文件,那么你搜索的时候就基本都是走内存的,性能会非常高。
归根结底,你要让 es 性能要好,最佳的情况下,就是你的机器的内存,至少可以容纳你的总数据量的一半。
生产环境实践经验,最佳的情况下,是仅仅在 es 中就存少量的数据,就是你要用来搜索的那些索引,如果内存留给 filesystem cache
的是 100G,那么你就将索引数据控制在 100G
以内,这样的话,你的数据几乎全部走内存来搜索,性能非常之高,一般可以在 1 秒以内。
数据预热
对于那些你觉得比较热的、经常会有人访问的数据,最好做一个专门的缓存预热子系统,就是对热数据每隔一段时间,提前访问,让数据进入 filesystem cache
里面去。这样下次别人访问的时候,性能一定会好很多。
冷热分离
es 可以做类似于 mysql 的水平拆分,就是说将大量的访问很少、频率很低的数据,单独写一个索引,然后将访问很频繁的热数据单独写一个索引。最好是将冷数据写入一个索引中,然后热数据写入另外一个索引中,这样可以确保热数据在被预热之后,尽量都让他们留在 filesystem os cache
里,别让冷数据给冲刷掉。
document 模型设计
对于 MySQL,我们经常有一些复杂的关联查询。在 es 里该怎么玩儿,es 里面的复杂的关联查询尽量别用,一旦用了性能一般都不太好。
最好是先在 Java 系统里就完成关联,将关联好的数据直接写入 es 中。搜索的时候,就不需要利用 es 的搜索语法来完成 join 之类的关联搜索了。
document 模型设计是非常重要的,很多操作,不要在搜索的时候才想去执行各种复杂的乱七八糟的操作。es 能支持的操作就那么多,不要考虑用 es 做一些它不好操作的事情。如果真的有那种操作,尽量在 document 模型设计的时候,写入的时候就完成。另外对于一些太复杂的操作,比如 join/nested/parent-child 搜索都要尽量避免,性能都很差的。
分页性能优化
分布式的,要查第 100 页的 10 条数据,必须得从每个 shard 都查 1000 条数据过来,然后根据需求进行排序、筛选等等操作,最后再次分页,拿到里面第 100 页的数据。翻页的时,翻的越深,每个 shard 返回的数据就越多,而且协调节点处理的时间越长。所以用 es 做分页的时,越翻到后面,就越是慢。
不允许深度分页
跟产品经理说,系统不允许翻那么深的页,默认翻的越深,性能就越差。
只允许一页页翻
用 scroll api
scroll 会一次性给你生成所有数据的一个快照,然后每次滑动向后翻页就是通过游标 scroll_id
移动,获取下一页下一页这样子,性能会比上面说的那种分页性能要高很多很多,基本上都是毫秒级的。
但是,唯一的一点就是,这个适合于那种类似微博下拉翻页的,不能随意跳到任何一页的场景。
初始化时必须指定 scroll
参数,告诉 es 要保存此次搜索的上下文多长时间。需要确保用户不会持续不断翻页翻几个小时,否则可能因为超时而失败。
除了用 scroll api
,你也可以用 search_after
来做, search_after
的思想是使用前一页的结果来帮助检索下一页的数据,显然,这种方式也不允许你随意翻页,只能一页页往后翻。初始化时,需要使用一个唯一值的字段作为 sort 字段。
设计阶段调优
(1)根据业务增量需求,采取基于日期模板创建索引,通过 roll over API 滚动索引;
(2)使用别名进行索引管理;
(3)每天凌晨定时对索引做 force_merge 操作,以释放空间;
(4)采取冷热分离机制,热数据存储到 SSD,提高检索效率;冷数据定期进行 shrink 操作,以缩减存储;
(5)采取 curator 进行索引的生命周期管理;
(6)仅针对需要分词的字段,合理的设置分词器;
(7)Mapping 阶段充分结合各个字段的属性,是否需要检索、是否需要存储等。……..
1.2、写入调优
(1)写入前副本数设置为 0;
(2)写入前关闭 refresh_interval 设置为-1,禁用刷新机制;
(3)写入过程中:采取 bulk 批量写入;
(4)写入后恢复副本数和刷新间隔;
(5)尽量使用自动生成的 id。
1.3、查询调优
(1)禁用 wildcard;
(2)禁用批量 terms(成百上千的场景);
(3)充分利用倒排索引机制,能 keyword 类型尽量 keyword;
(4)数据量大时候,可以先基于时间敲定索引再检索;
(5)设置合理的路由机制。
1.4、其他调优
部署调优,业务调优等。
分片的数量
经验值:
- 每个节点的分片数量保持在低于每1GB堆内存对应集群的分片在20-25之间。
- 分片大小为50GB通常被界定为适用于各种用例的限制。
JVM设置
- 堆内存设置:不要超过32G,在Java中,对象实例都分配在堆上,并通过一个指针进行引用。对于64位操作系统而言,默认使用64位指针,指针本身对于空间的占用很大,Java使用一个叫作内存指针压缩(compressed
oops)的技术来解决这个问题,简单理解,使用32位指针也可以对对象进行引用,但是一旦堆内存超过32G,这个压缩技术不再生效,实际上失去了更多的内存。 - 预留一半内存空间给lucene用,lucene会使用大量的堆外内存空间。
- 如果你有一台128G的机器,一半内存也是64G,超过了32G,可以通过一台机器上启动多个ES实例来保证ES的堆内存小于32G。
- ES的配置文件中加入bootstrap.mlockall: true,关闭内存交换。
通过_cat api获取任务执行情况
1 | GET http://localhost:9201/_cat/thread_pool?v&h=host,search.active,search.rejected,search.completed |
- 完成(completed)
- 进行中(active)
- 被拒绝(rejected):需要特别注意,说明已经出现查询请求被拒绝的情况,可能是线程池大小配置的太小,也可能是集群性能瓶颈,需要扩容。
小技巧
- 重建索引或者批量想ES写历史数据的时候,写之前先关闭副本,写入完成之后,再开启副本。
- ES默认用文档id进行路由,所以通过文档id进行查询会更快,因为能直接定位到文档所在的分片,否则需要查询所有的分片。
- 使用ES自己生成的文档id写入更快,因为ES不需要验证一次自定义的文档id是否存在。
对于 GC 方面,在使用 Elasticsearch 时要注意什么?
(1)倒排词典的索引需要常驻内存,无法 GC,需要监控 data node 上 segmentmemory 增长趋势。
(2)各类缓存,field cache, filter cache, indexing cache, bulk queue 等等,要设置合理的大小,并且要应该根据最坏的情况来看 heap 是否够用,也就是各类缓存全部占满的时候,还有 heap 空间可以分配给其他任务吗?避免采用 clear cache 等“自欺欺人”的方式来释放内存。
(3)避免返回大量结果集的搜索与聚合。确实需要大量拉取数据的场景,可以采用 scan & scroll api 来实现。
(4)cluster stats 驻留内存并无法水平扩展,超大规模集群可以考虑分拆成多个集群通过 tribe node 连接。
(5)想知道 heap 够不够,必须结合实际应用场景,并对集群的 heap 使用情况做持续的监控。
(6)根据监控数据理解内存需求,合理配置各类 circuit breaker,将内存溢出风险降低到最低
Elasticsearch 对于大数据量(上亿量级)的聚合如何实现?
Elasticsearch 提供的首个近似聚合是 cardinality 度量。它提供一个字段的基数,即该字段的 distinct 或者 unique 值的数目。它是基于 HLL 算法的。HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。其特点是:可配置的精度,用来控制内存的使用(更精确 = 更多内存);小的数据集精度是非常高的;我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关。
在并发情况下,Elasticsearch 如果保证读写一致?
(1)可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突;
(2)另外对于写操作,一致性级别支持 quorum/one/all,默认为 quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。
(3)对于读操作,可以设置 replication 为 sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置 replication 为 async 时,也可以通过设置搜索请求参数_preference 为 primary 来查询主分片,确保文档是最新版本。
来源:
https://www.jianshu.com/p/52b92f1a9c47