1、搜索引擎为什么快?
倒排索引核心原理
概括的说,一个优秀的搜索引擎的设计,至少应该具备以下几点要求:
高效的压缩算法
快速的编码和解码算法
合理的数据结构
通用最小化算法
结合以上几点,后面我将通过一个案例来讲解,倒排索引的基本原理是什么。在了解“倒排索引”之前,我们先来看一下何为“索引”。
一本汉语字典,如果我们想要从中找到某个字,通常我们会通过字典最前面的拼音检索或者是部首检索来查找。其实汉语字典的正文本身就是一个索引,比如我们要查找“吴”字,很自然的就想到了“吴”的拼音是“wu”,w在26个字母中在很靠后的位置,基本上就可以确定“吴”字的大致位置,然后按照字典序可以在w字母的汉字里精确的找到这个字,因为汉字本身就是按照字典序排列的,这种按照一定规则排序的目录在关系型数据库中一般叫做“聚集索引”
除了这种索引,通常我们还了解一种类似于“偏旁部首”的检索方式称之为“非聚集索引”,我们这里不展开来讨论什么是聚集索引和非聚集索引。但是我们可以确定的是,不管是什么索引,它的目的都是帮助我们快速检索数据的。
在数据库领域里,索引可以概括为一种帮助我们快速检索数据的以文件形式落地的数据结构。
以MySQL为例,如图1-1所示:
左侧是MySql安装文件的data目录,右侧使我们使用数据库客户端打开数据库后的样式,左侧文件分别对应了右侧数据库中的数据库名,我们以“mysql”这个数据库为例,文件夹中每个文件都有若干个不同后缀的同名文件,分别对应右侧某个数据表,不同的后缀代表不同的数据类型,其中.frm文件代表当前文件存储的是数据表的表结构,.MYD和.MYI文件则代表了当前文件是myisam存储引擎下的数据文件和索引文件,.ibd则代表当前文件是innodb存储引擎下的索引文件,只不过innodb的数据和索引使用了同一个文件。
不管是元数据还是索引数据,他们最终都是以“文件”的形式存储在磁盘中的,只不过不同的文件内部使用的数据结构各不相同。而MySql使用的数据结构是B+Trees。但是这种数据结构并不适用于倒排索引,原因我们会在后面的文章中提到。
ES倒排原理
假设需要对下面两个工地的信息进行倒排索引的创建:
文档ID为001,工地名称为“北京海淀花园小区”;
文档ID为002,工地名称为“北京朝阳星空小区”。
首先,ES将文档交给分析器进行处理,处理的过程包括字符过滤、分词和分词过滤,最终的处理结果是文档内容被表示为一系列关键词信息(关键词本身以及它在文档中出现的位置信息和词性信息)的集合。如下图所示。
3、其次,ES根据分析结果建立文档-词语矩阵,用以表示词语和文档的包含关系,如图所示。
4、文档-词语矩阵建立完成之后,接着需要建立基于词语的倒排索引。ES会遍历文档词语矩阵中的每一个词语,然后将包含该词语的文档信息与该词语建立一种映射关系。映射关系中的词语集合叫作Term Dictionary。映射中的文档集合信息不仅包含文档ID,还包含词语在文档中的位置和词频信息,包含这些文档信息的结构叫作Posting List。对于一个规模很大的文档集合来说,可能包含几十万甚至上百万的词语集合,能否快速定位某个词语,直接影响搜索时的响应速度。因此需要一种高效的数据结构对映射关系中的词语集合进行索引,这种结构叫作Term Index。上述3种结构结合在一起就构成了ES的倒排索引结构,逻辑关系如图所示。
本例中的倒排索引结构如图所示。
2、实际es中倒排索引的存储结构
2.1 倒排表(Posting List)
索引文件中分别存储了不同的数据
其中倒排表包含某个词项的所有id的数据存储了在.doc文件中;
2.2 词项字典(Term Dictionary)
词项字典包含了index field的所有经过normalization token filters处理之后的词项数据,最终存储在.tim文件中。
所谓normalization其实是一个如去重、时态统一、大小写统一、近义词处理等类似的相关操作;词项索引就是为了加速词项字典检索的一种数据结构,落地文件为.tip。.tip文件和.tim文件的数据结构如下图2-2所示
2.3 词项索引(Term Index)
需要一种高效的数据结构对映射关系中的词语集合也就是Term Dictionary进行索引,这种结构叫作Term Index。Term Index最开始是用字典树实现,luncence4.0之后采用FST来优化。
3、字典树:Trie(Prefix Tree)原理
Lucene中对倒排索引Term DIctionary以及Term Index的数据压缩和优化算法,最简单的理解,Term Index 就像一本词典的目录一样
但是实际上Term可以是任意的 byte 数组,而不仅仅是英文字符,因此实际的 Term Index 是一棵 Trie Tree:
如图所示是一棵 Trie Tree。这棵 Trie Tree 不会包含所有的 term,它包含的是 term 的一些前缀。通过 Term Index 可以快速地定位到 Term Dictionary 的某个 offset,然后从这个位置再往后顺序查找。再加上一些压缩技术 e.g. FST,Term Index 需要的存储空间非常小,可能只有所有 term 占用空间的几十分之一,因此 Term Index 被完全缓存在内存中是没有问题的。
使用了 Term Index 后,ES的倒排索引整体效果如下
现在我们可以很清楚地知道为什么ES的检索要比MySQL快得多了。在MySQL中,即使建立了索引,但索引是以B+ Tree的形式存储在磁盘上的,在数据量比较大的情况下,想要检索到一条记录,需要若干次的random access的磁盘操作。而ES在Term Dictionary(相当于MySQL中的索引)的基础上,又添加了Term Index这一结构,并且直接以Trie Tree的形式缓存在内存中。在检索一个term时,只需要从Term Index查找到对应的Term Dictionary的block的位置,再到磁盘上去访问对应的数据,大大减少了对磁盘的random access操作的次数。
2.4减小Term Index存储空间 —— FST
Term Index要被直接缓存在内存中以提升查找速度,那么就必然要采用某种结构来压缩Term Index。Term Index在内存中就是以FST(Finite State Transducers,有限状态转换机)的形式来存储的。
FST是一种类Trie Tree,使用FSM作为数据结构,可以理解为key -> value形式。
空间占用小:通过对前缀和后缀的重复利用,压缩了存储空间;
查询速度快:时间复杂度为O(len(str));
3、Elasticsearch其它优化
Elasticsearch 的基础是 Lucene,所有的索引和文档数据是存储在本地的磁盘中,具体的路径可在 ES 的配置文件…/config/elasticsearch.yml中配置,如下:
#
# Path to directory where to store the data (separate multiple locations by comma):
#
path.data: /path/to/data
#
# Path to log files:
#
path.logs: /path/to/logs
磁盘在现代服务器上通常都是瓶颈。Elasticsearch重度使用磁盘,你的磁盘能处理的吞吐量越大,你的节点就越稳定。这里有一些优化磁盘I/O的技巧:
使用SSD就像其他地方提过的,他们比机械磁盘优秀多了。
使用RAID0。条带化RAID会提高磁盘IO,代价显然就是当一块硬盘故障时整个就故障了。不要使用镜像或者奇偶校验RAID,因为副本已经提供了这个功能。
另外,使用多块硬盘,并允许Elasticsearch 通过多个path data目录配置把数据条带化分配到它们上面。
不要使用远程挂载的存储,比如NFS或者SMB/CIFS。这个引入的延迟对性能来说完全是背道而驰的。
优化-分片策略
合理设置分片数
分片和副本的设计为 ES 提供了支持分布式和故障转移的特性,但并不意味着分片和副本是可以无限分配的。而且索引的分片完成分配后由于索引的路由机制,我们是不能重新修改分片数的。
可能有人会说,我不知道这个索引将来会变得多大,并且过后我也不能更改索引的大小,所以为了保险起见,还是给它设为 1000 个分片吧。但是需要知道的是,一个分片并不是没有代价的。需要了解:
一个分片的底层即为一个 Lucene 索引,会消耗一定文件句柄、内存、以及 CPU运转。
每一个搜索请求都需要命中索引中的每一个分片,如果每一个分片都处于不同的节点还好, 但如果多个分片都需要在同一个节点上竞争使用相同的资源就有些糟糕了。
用于计算相关度的词项统计信息是基于分片的。如果有许多分片,每一个都只有很少的数据会导致很低的相关度。
一个业务索引具体需要分配多少分片可能需要架构师和技术人员对业务的增长有个预先的判断,横向扩展应当分阶段进行。为下一阶段准备好足够的资源。 只有当你进入到下一个阶段,你才有时间思考需要作出哪些改变来达到这个阶段。一般来说,我们遵循一些原则:
控制每个分片占用的硬盘容量不超过 ES 的最大 JVM 的堆空间设置(一般设置不超过 32G,参考下文的 JVM 设置原则),因此,如果索引的总容量在 500G 左右,那分片大小在 16 个左右即可;当然,最好同时考虑原则 2。
考虑一下 node 数量,一般一个节点有时候就是一台物理机,如果分片数过多,大大超过了节点数,很可能会导致一个节点上存在多个分片,一旦该节点故障,即使保持了 1 个以上的副本,同样有可能会导致数据丢失,集群无法恢复。所以, 一般都设置分片数不超过节点数的 3 倍。
主分片,副本和节点最大数之间数量,我们分配的时候可以参考以下关系:
节点数<=主分片数 *(副本数+1)
推迟分片分配
对于节点瞬时中断的问题,默认情况,集群会等待一分钟来查看节点是否会重新加入,如果这个节点在此期间重新加入,重新加入的节点会保持其现有的分片数据,不会触发新的分片分配。这样就可以减少 ES 在自动再平衡可用分片时所带来的极大开销。
通过修改参数 delayed_timeout ,可以延长再均衡的时间,可以全局设置也可以在索引级别进行修改:
#PUT /_all/_settings
{
"settings": {
"index.unassigned.node_left.delayed_timeout": "5m"
}
}
优化-路由选择
当我们查询文档的时候, Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?它其实是通过下面这个公式来计算出来:
shard = hash(routing) % number_of_primary_shards
routing 默认值是文档的 id,也可以采用自定义值,比如用户 id。
不带routing查询
在查询的时候因为不知道要查询的数据具体在哪个分片上,所以整个过程分为2个步骤
分发:请求到达协调节点后,协调节点将查询请求分发到每个分片上。
聚合:协调节点搜集到每个分片上查询结果,在将查询的结果进行排序,之后给用户返回结果。
带routing查询
查询的时候,可以直接根据routing 信息定位到某个分配查询,不需要查询所有的分配,经过协调节点排序。向上面自定义的用户查询,如果routing 设置为userid 的话,就可以直接查询出数据来,效率提升很多。
写入速度优化
ES 的默认配置,是综合了数据可靠性、写入速度、搜索实时性等因素。实际使用时,我们需要根据公司要求,进行偏向性的优化。
针对于搜索性能要求不高,但是对写入要求较高的场景,我们需要尽可能的选择恰当写优化策略。综合来说,可以考虑以下几个方面来提升写索引的性能.
加大Translog Flush,目的是降低Iops、Writeblock。
增加Index Refesh间隔,目的是减少Segment Merge的次数。
调整Bulk 线程池和队列。
优化节点间的任务分布。
优化Lucene层的索引建立,目的是降低CPU及IO
优化存储设备
ES 是一种密集使用磁盘的应用,在段合并的时候会频繁操作磁盘,所以对磁盘要求较高,当磁盘速度提升之后,集群的整体性能会大幅度提高。
合理使用合并
Lucene 以段的形式存储数据。当有新的数据写入索引时, Lucene 就会自动创建一个新的段。
随着数据量的变化,段的数量会越来越多,消耗的多文件句柄数及 CPU 就越多,查询效率就会下降。
由于 Lucene 段合并的计算量庞大,会消耗大量的 I/O,所以 ES 默认采用较保守的策略,让后台定期进行段合并。
减少 Refresh 的次数
Lucene 在新增数据时,采用了延迟写入的策略,默认情况下索引的refresh_interval 为1 秒。
Lucene 将待写入的数据先写到内存中,超过 1 秒(默认)时就会触发一次 Refresh,然后 Refresh 会把内存中的的数据刷新到操作系统的文件缓存系统中。
如果我们对搜索的实效性要求不高,可以将 Refresh 周期延长,例如 30 秒。
这样还可以有效地减少段刷新次数,但这同时意味着需要消耗更多的 Heap 内存。
加大 Flush 设置
Flush 的主要目的是把文件缓存系统中的段持久化到硬盘,当 Translog 的数据量达到 512MB 或者 30 分钟时,会触发一次 Flush。
index.translog.flush_threshold_size 参数的默认值是 512MB,我们进行修改。
增加参数值意味着文件缓存系统中可能需要存储更多的数据,所以我们需要为操作系统的文件缓存系统留下足够的空间。
减少副本的数量
ES 为了保证集群的可用性,提供了 Replicas(副本)支持,然而每个副本也会执行分析、索引及可能的合并过程,所以 Replicas 的数量会严重影响写索引的效率。
当写索引时,需要把写入的数据都同步到副本节点,副本节点越多,写索引的效率就越慢。
如果我们需要大批量进行写入操作,可以先禁止Replica复制,设置
index.number_of_replicas: 0 关闭副本。在写入完成后, Replica 修改回正常的状态。
内存设置
ES 默认安装后设置的内存是 1GB,对于任何一个现实业务来说,这个设置都太小了。如果是通过解压安装的 ES,则在 ES 安装文件中包含一个 jvm.option 文件,添加如下命令来设置 ES 的堆大小, Xms 表示堆的初始大小, Xmx 表示可分配的最大内存,都是 1GB。
确保 Xmx 和 Xms 的大小是相同的,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源,可以减轻伸缩堆大小带来的压力。
假设你有一个 64G 内存的机器,按照正常思维思考,你可能会认为把 64G 内存都给ES 比较好,但现实是这样吗, 越大越好?虽然内存对 ES 来说是非常重要的,但是答案是否定的!
因为 ES 堆内存的分配需要满足以下两个原则:
不要超过物理内存的 50%: Lucene 的设计目的是把底层 OS 里的数据缓存到内存中。Lucene 的段是分别存储到单个文件中的,这些文件都是不会变化的,所以很利于缓存,同时操作系统也会把这些段文件缓存起来,以便更快的访问。如果我们设置的堆内存过大, Lucene 可用的内存将会减少,就会严重影响降低 Lucene 的全文本查询性能。
堆内存的大小最好不要超过 32GB:在 Java 中,所有对象都分配在堆上,然后有一个 Klass Pointer 指针指向它的类元数据。这个指针在 64 位的操作系统上为 64 位, 64 位的操作系统可以使用更多的内存(2^64)。在 32 位的系统上为 32 位, 32 位的操作系统的最大寻址空间为 4GB(2^32)。但是 64 位的指针意味着更大的浪费,因为你的指针本身大了。浪费内存不算,更糟糕的是,更大的指针在主内存和缓存器(例如 LLC, L1 等)之间移动数据的时候,会占用更多的带宽。
最终我们都会采用 31 G 设置
-Xms 31g
-Xmx 31g
假设你有个机器有 128 GB 的内存,你可以创建两个节点,每个节点内存分配不超过 32 GB。也就是说不超过 64 GB 内存给 ES 的堆内存,剩下的超过 64 GB 的内存给 Lucene。