Skip to main content

Elastic Search

倒排索引

  • 倒排索引(inverted index)也叫反向索引,有反向索引必有正向索引(forward index)。通俗地来讲,正向索引是通过 keyvalue,反向索引则是通过 valuekey
  • 倒排索引主要由单词词典(Term Dictionary)和倒排列表(Posting List)及倒排文件(Inverted File)组成。

基本概念

  • Term(单词):一段文本经过分析器分析以后就会输出一串单词,这一个一个的就叫做 Term(直译为:单词)
  • Term Dictionary(单词字典):顾名思义,它里面维护的是 Term,可以理解为 Term 的集合
  • Term Index(单词索引):为了更快的找到某个单词,我们为单词建立索引
  • Posting List(倒排列表):倒排列表记录了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词。(PS:实际的倒排列表中并不只是存了文档ID这么简单,还有一些其它的信息,比如:词频(Term 出现的次数)、偏移量(offset)等)
  • Inverted File(倒排文件):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件即被称之为倒排文件,是存储倒排索引的物理文件
  • 在倒排索引中,通过 Term 索引可以找到 TermTerm Dictionary 中的位置,进而找到 Posting List,有了倒排列表就可以根据 ID 找到文档了

不可变性

  • ES 的底层是基于 LuceneLucene 中提出了按段搜索的概念,将一个索引文件拆分为多个子文件,则每个子文件叫作段,每个段都是一个独立的可被搜索的数据集,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改
  • 优势
    • 不需要锁,如果从来不更新索引,就不需要担心多进程同时修改数据的问题
    • 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性,只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘,这提供了很大的性能提升
    • 其缓存(像filter缓存)在索引的生命周期内始终有效,它们不需要在每次数据改变时被重建,因为数据不会变化
    • 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量

提交点

  • 为了提升写的性能,Lucene 并没有每新增一条数据就增加一个段,而是采用延迟写的策略,每当有新增的数据时,就将其先写入内存中,然后批量写入磁盘中。若有一个段被写到硬盘,就会生成一个提交点,提交点就是一个列出了所有已知段和记录所有提交后的段信息的文件。

索引合并

  • 索引文件中绝大部分数据都是只写一次,读多次,而只有用于保存文档删除信息的文件才会被多次更改。在某些时刻,当某种条件满足时,多个索引段会被拷贝合并到一个更大的索引段,而那些旧的索引段会被抛弃并从磁盘中删除,这个操作称为段合并(segment merging)。
  • 优点
    • 当多个索引段合并为一个的时候,会减少索引段的数量并提高搜索速度
    • 同时也会减少索引的容量(文档数),因为在段合并时会移除被标记为已删除的那些文档

合并策略

tiered(默认)

  • 能合并大小相似的索引段,并考虑每层允许的索引段的最大个数。
  • 在索引期,该合并策略会计算索引中允许出现的索引段个数,该数值称为阈值(budget)。如果正在构建的索引中的段数超过了阈值,该策略将先对索引段按容量降序排序(这里考虑了被标记为已删除的文档),然后再选择一个成本最低的合并。合并成本的计算方法倾向于回收更多删除文档和产生更小的索引段。
  • 如果某次合并产生的索引段的大小大于 index.merge.policy.max_merged_segment 参数值,则该合并策略会选择更少的索引段参与合并,使得生成的索引段的大小小于阈值。这意味着,对于有多个分片的索引,默认的 index.merge.policy.max_merged_segment 则显得过小,会导致大量索引段的创建,从而降低查询速度。用户应该根据自己具体的数据量,观察索引段的状况,不断调整合并策略以满足应用需求。

log_byte_size

  • 会不断地以字节数的对数为计算单位,选择多个索引来合并创建新索引。
  • 合并过程中,时不时会出现一些较大的索引段,然后又产生出一些小于合并因子(merge factor)的索引段,如此循环往复。
  • 能够保持较少的索引段数量并且极小化段索引合并的代价。

log_doc

  • log_byte_size 合并策略类似,不同的是以索引段的文档数为计算单位。
  • 以下两种情况中该合并策略表现良好
    • 文档集中的文档大小类似
    • 期望参与合并的索引段在文档数方面相当

ES 集群

相关概念

  • 集群(cluster):一组拥有共同的 cluster name 的节点
  • 节点(node):集群中的一个 ElasticSearch 实例
  • 索引(index):ElasticSearch 实例存储数据的地方,相当于关系数据库中的 database 概念
  • 分片(shard):索引可以被拆分为不同的部分进行存储,称为分片,在集群环境下,一个索引的不同分片可以拆分到不同的节点中
  • 主分片(Primary shard):相对于副本分片的定义
  • 副本分片(Replica shard):每个主分片可以有一个或者多个副本,数据和主分片一样

节点角色

  • Master
    • node.master: true 节点可以作为主节点
  • DataNode
    • node.data: true 默认是数据节点
  • Coordinate node
    • 协调节点,如果仅担任协调节点,将上两个配置设为 false

选主流程

  • ES 的选主是 ZenDiscovery 模块负责的,主要包含 PingUnicast两部分。
    1. 对所有可以成为 master 的节点根据 nodeId 字典排序,每次选举每个节点都把自己所知道的节点排一次序,然后选出第一个节点,暂且认为它是 master 节点
    2. 如果对某个节点的投票数达到一定的值(discovery.zen.minimum_master_nodes)并且该节点自己也选举自己,那这个节点就是 master,否则重新选举一直到满足上述条件
  • master 节点的职责主要包括集群、节点和索引的管理,不负责文档级别的管理

脑裂问题

  • 一个集群中只有一个 A 主节点,A 主节点因为需要处理的东西太多或者网络过于繁忙,从而导致其他从节点 ping 不通 A 主节点,这样其他从节点就会认为 A 主节点不可用了,就会重新选出一个新的主节点 B。过了一会 A 主节点恢复正常了,这样就出现了两个主节点,导致一部分数据来源于 A 主节点,另外一部分数据来源于 B 主节点,出现数据不一致问题,这就是脑裂。
  • 尽量避免脑裂,需要添加最小数量的主节点配置
    • discovery.zen.minimum_master_nodes: (N / 2) + 1
    • 这个参数控制的是选举主节点时需要的最少的具有 master 资格的活节点数,N 为具有 master 资格的节点的数量
  • 常用做法(中大规模集群)
    1. 角色分离
      • MasterdataNode 角色分开,限制角色,配置奇数个 master
    2. 单播发现机制
      • discovery.zen.ping.multicast.enabled: false 关闭多播发现机制,默认是关闭的
      • discovery.zen.ping.unicast.hosts: ["master1", "master2", "master3"] 配置单播发现的主节点 ip 地址,从节点要加入进来,就得去询问单播发现机制里面配置的主节点,主节点同意以后才能加入,然后主节点再通知集群中的其他节点有新节点加入
    3. 选主触发
      • discovery.zen.minimum_master_nodes: 2 选举主节点时需要的最少的具有 master 资格的活节点数
    4. 减少误判
      • discovery.zen.ping_timeout: 30(默认值是3秒) 其他节点 ping 主节点多长时间没有响应就认为主节点不可用了

分片数量设置

  • 分片数指定后不可变,除非重索引

分片过多的影响

  • 每个分片本质上就是一个 Lucene 索引,因此会消耗相应的文件句柄,内存和 CPU 资源
  • 每个搜索请求会调度到索引的每个分片中,如果分片分散在不同的节点问题不太,但当分片开始竞争相同的硬件资源时, 性能便会逐步下降
  • ES 使用词频统计来计算相关性,当然这些统计也会分配到各个分片上,如果在大量分片上只维护了很少的数据,则将导致最终的文档相关性较差

分片设置参考原则

  • ES 推荐的最大 JVM 堆空间是 30 ~ 32G, 所以分片最大容量可限制为 30GB, 然后再对分片数量做合理估算
  • 在开始阶段, 一个好的方案是根据节点数量按照 1.5 ~ 3 倍的原则来创建分片,当性能下降时,增加节点,ES 会平衡分片的放置
  • 对于基于日期的索引需求, 并且对索引数据的搜索场景非常少,也许这些索引量将达到成百上千,但每个索引的数据量只有 1GB 甚至更小,对于这种类似场景, 建议只需要为索引分配 1 个分片

分片副本设置

  • 副本数可以随时调整

副本的用途

  • 备份数据保证高可用数据不丢失,高并发的时候参与数据查询

副本设置基本原则

  • 为保证高可用,副本数设置为 2 即可,要求集群至少要有 3 个节点,来分开存放主分片、副本
  • 如发现并发量大时,查询性能会下降,可增加副本数,来提升并发查询能力

索引文档流程(index

  1. 选择某个 node 作为协调节点,默认使用 文档ID 将索引请求路由到合适的索引分片
  2. 索引分片所在的节点接收到来自协调节点的请求后,会将请求写入到 Memory Buffer,然后定时(默认是每隔 1 秒)写入到 Filesystem Cache,这个从 Memory BufferFilesystem Cache 的过程就叫做 refresh (数据在被 refresh 后才可被搜索到,近实时性)
  3. 在某些情况下,存在 Momery BufferFilesystem Cache 的数据可能会丢失,ES 会通过 translog 的机制来保证数据的可靠性,其实现机制是接收到请求后,同时也会写入索引到 translog 中,当 Filesystem Cache 中的数据写入到磁盘中时,才会清除掉,这个过程叫做 flushtranslog 默认每隔 5 秒刷一次到磁盘中,数据可能丢失)
  4. flush 过程中,内存中的缓冲将被清除,内容被写入一个新段,段的 fsync 将创建一个新的提交点,并将内容刷新到磁盘,旧的 translog 将被删除并开始一个新的 translogflush 触发的时机是定时触发(默认 30 分钟)或者 translog 变得太大(默认为 512M)时

更新和删除文档的流程(update, delete

  • 删除和更新都是写操作,但 Elasticsearch 中的文档是不可变的,因此不能被删除或者改动以展示其变更
  • 磁盘上的每个段都有一个相应的 .del 文件,当删除请求发送后,文档并没有真的被删除,而是在 .del 文件中被标记为删除,该文档依然能匹配查询,但是会在结果中被过滤掉,当段合并时,在 .del 文件中被标记为删除的文档将不会被写入新段
  • 在新的文档被创建时,Elasticsearch 会为该文档指定一个版本号,当执行更新时,旧版本的文档在 .del 文件中被标记为删除,新版本的文档被索引到一个新段,旧版本的文档依然能匹配查询,但是会在结果中被过滤掉

搜索流程(bulk

  • 搜索被执行成一个两阶段过程,我们称之为 Query Then Fetch
    1. 在初始查询阶段时,协调节点将查询广播到索引中每一个分片拷贝(主分片或者副本分片),每个分片在本地执行搜索并构建一个匹配文档的大小为 from + size 的优先队列(在搜索的时候是会查询Filesystem Cache 的,但是有部分数据还在 Memory Buffer,所以搜索是近实时的)
    2. 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,协调节点合并这些值到自己的优先队列中来产生一个全局排序后的结果列表
    3. 接下来就是取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求,每个分片加载并丰富文档,如果有需要的话,接着返回文档给协调节点,一旦所有的文档都被取回了,协调节点返回结果给客户端
  • Query Then Fetch 的搜索类型在文档相关性打分的时候参考的是本分片的数据,这样在文档数量较少的时候可能不够准确,DFS Query Then Fetch 增加了一个预查询的处理,询问 TermDocument frequency,这个评分更准确,但是性能会变差。

字典树

  • 字典树又称单词查找树,Trie 树,是一种树形结构,是一种哈希树的变种。
  • 典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
  • 它的优点是利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
  • Trie 的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
  • 3 个基本性质
    • 根节点不包含字符,除根节点外每一个节点都只包含一个字符
    • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串
    • 每个节点的所有子节点包含的字符都不相同

调优

控制字段的存储选项

  • 可针对使用场景关闭部分字段的存储

开启最佳压缩

  • 对于存储了 _source 字段的 index,可以把 Lucene 适用的压缩算法替换成 DEFLATE,提高数据压缩率

bulk 批量写入

  • 写入数据时尽量使用 bulk 接口批量写入,提高写入效率,每个 bulk 请求的文档数量设定区间推荐为 1k~1w,具体可根据业务场景选取一个适当的数量

调整 translog 同步策略

  • 默认情况下,translog 的持久化策略是对于每个写入请求都做一次 flush,刷新 translog 数据到磁盘上,这种频繁的 磁盘IO 操作是严重影响写入性能的,如果可以接受一定概率的数据丢失(这种硬件故障的概率很小),可以调整 translog 持久化策略为异步周期性执行,并适当调整 translog 的刷盘周期

调整 refresh_interval

  • 写入 Lucene 的数据,并不是实时可搜索的,必须通过 refresh 的过程把内存中的数据转换成 Lucene 的完整 segment 后,才可以被搜索,默认情况下,每一秒会 refresh 一次,产生一个新的 segment,这样会导致产生的 segment 较多,从而 segment merge 较为频繁,系统开销较大,如果对数据的实时可见性要求较低,可以提高 refresh 的时间间隔,降低系统开销

merge 并发控制

  • index.merge.scheduler.max_thread_count 默认值是 Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)),当节点配置的 cpu 核数较高时,merge 占用的资源可能会偏高,影响集群的性能,可以调整某个 index merge 过程的并发

写入数据不指定 _id 而是自动产生

  • 当用户显示指定 _id 写入数据时,会先发起查询来确定 index 中是否已经有相同 _id 的文档存在,若有则先删除原有文档再写入新文档,这样每次写入时,都会耗费一定的资源做查询,如果用户写入数据时不指定 _id,则通过内部算法产生一个随机的 _id,并且保证 _id 的唯一性,这样就可以跳过查询 _id 的步骤,提高写入效率

使用 routing

  • 对于数据量较大的 index,一般会配置多个 shard 来分摊压力。这种场景下,一个查询会同时搜索所有的 shard,然后再将各个 shard 的结果合并返回给用户。对于高并发的小查询场景,每个分片通常仅抓取极少量数据,此时查询过程中的调度开销远大于实际读取数据的开销,且查询速度取决于最慢的一个分片。
  • 开启 routing 功能后,会将 routing 相同的数据写入到同一个分片中(也可以是多个,由 index.routing_partition_size 参数控制)。如果查询时指定 routing,那么只会查询routing 指向的分片,可显著降低调度开销,提升查询效率。

string 类型的字段选取合适的存储方式

text 类型(默认)

  • 做分词后存储倒排索引,支持全文检索,可以通过下面几个参数优化其存储方式
    • norms - 用于在搜索时计算该文档的 _score(代表这条数据与搜索条件的相关度),如果不需要评分,可以将其关闭
    • index_options - 控制倒排索引中包括哪些信息(docs、freqs、positions、offsets)。对于不太注重 _score/highlighting 的使用场景,可以设为 docs 来降低内存/磁盘资源消耗
    • fields - 用于添加子字段,对于有 sort 和聚合查询需求的场景,可以添加一个 keyword 子字段以支持这两种功能

keyword 类型

  • 不做分词,不支持全文检索,text 分词消耗 CPU 资源,冗余存储 keyword 子字段占用存储空间,如果没有全文索引需求,只是要通过整个字段做搜索,可以设置该字段的类型为 keyword,提升写入速率,降低存储成本

使用 query-bool-filter 组合取代普通 query

  • 默认情况下,ES 通过一定的算法计算返回的每条数据与查询语句的相关度,并通过 _score 字段来表征,但对于非全文索引的使用场景,用户并不关心查询结果与查询条件的相关度,只是想精确的查找目标数据,此时,可以通过 query-bool-filter 组合来让 ES 不计算 _score,并且尽可能的缓存 filter 的结果集,供后续包含相同 filter 的查询使用,提高查询效率

index 按日期滚动

  • 写入的数据最好通过某种方式做分割,存入不同的 index,比如将数据按模块/功能分类,写入不同的 index,然后按照时间去滚动生成 index,这样做的好处是各种数据分开管理不会混淆,也易于提高查询效率,同时 index 按时间滚动,数据过期时删除整个 index,要比一条条删除数据或 delete_by_query 效率高很多,删除整个 index 是直接删除底层文件,而 delete_by_query 是查询-标记-删除

按需控制 index 的分片数和副本数

  • 对于查询压力较大的 index,可以考虑提高副本数(number_of_replicas),通过多个副本均摊查询压力
  • shard 数量过多,则批量写入/查询请求被分割为过多的子写入/查询,导致该 index的写入、查询拒绝率上升
  • 对于数据量较大的 index,当其 shard 数量过小时,无法充分利用节点资源,造成机器资源利用率不高或不均衡,影响写入/查询的效率

分词器

英文

Standard(标准分词器), Simple Analyzer(简单分词器), Whitespace Analyzer(空格分词器), Stop Analyzer(语气助词分词器), Keyword Analyzer(关键字分词器), Pattern Analyzer(正则分词器), Langueage Analyzer(多语言分词器)

中文

IK分词器, jieba 分词器, Hanlp 分词器, THULAC 分词器

分页

传统方式(from & size

顶部查询,查询 10000 以内的文档 默认 max_result_window = 10000

Scroll 滚动游标

深度分页,用于非实时查询场景 对一次查询生成一个游标 scroll_id, 后续的查询只需要根据这个游标 scroll_id 去取数据,直到结果集中返回的 hits 字段为空,就表示遍历结束 可以使用 search.max_open_scroll_context 设置配置可允许打开的 Scroll 数量

Search After

深度分页,用于实时查询场景 第一页的请求和正常的请求一样,第二页的请求,使用第一页返回结果的最后一个数据的值,加上 search_after 字段来取下一页 必须先要指定排序,必须从第一页查起,每一页的数据依赖于上一页最后一条数据,无法处理跳页

其他

textkeyword 类型的区别

keyword 类型是不会分词的,直接根据字符串内容建立倒排索引,所以 keyword 类型的字段只能通过精确值搜索到 Text 类型在存入 Elasticsearch 的时候,会先分词,然后根据分词后的内容建立倒排索引

queryfilter 的区别

query:查询操作不仅仅会进行查询,还会计算分值,用于确定相关度 filter:查询操作仅判断是否满足查询条件,不会计算任何分值,也不会关心返回的排序问题,同时,filter 查询的结果可以被缓存,提高性能

在高并发下如何保证读写一致性

对于更新操作,可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖 对于写操作,一致性级别支持 quorum/one/all,默认为 quorum,即只有当大多数分片可用时才允许写操作 对于读操作,可以设置 replicationsync (默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置 replicationasync ,也可以通过设置搜索请求参数 _preferenceprimary 来查询主分片,确保文档是最新版本