小白学 ElasticSerach(一):原理介绍篇(上)

1,071 阅读16分钟

一、ES的初步介绍

1.ES与Lucene

提到ES必然绕不开Lucene。Lucene是一个Java语言的搜索引擎类库,是Apache组织的项目,DougCutting于1999年研发。Lucene方便扩展,基于倒排索引,高性能。但Lucene仅仅是一个基础类库,必须使用Java作为开发语言并将其直接集成到开发的应用中,也没有考虑到高并发和分布式的场景,且学习曲线陡峭,上手时间较长。

Elasticsearch使用Java开发,以 Lucene 作为其核心来实现所有索引和搜索的功能,数据的输入输出采用 JSON 格式。Elasticsearch通过简单的 RESTful API 隐藏了Lucene 的复杂性,从而让全文搜索变得简单。

相比与lucene,elasticsearch支持分布式,可水平扩展;提供Restful接口,可被任何语言调用。

2004 年,谢伊·班农(Shay Banon)为了帮助他的在学厨艺的夫人,着手开发管理和搜索菜谱的程序。他封装了Lucene,开发了一个名为Compass的程序框架,这个框架可以自动调用Lucene来构建索引,并实现字段级别的检索,实现全文搜索功能。Compass 开源以后,变得非常流行。

在 Compass 编写到 2.x 版本的时候,社区里面出现了更多需求,比如需要有处理更多数据的能力以及分布式的设计。为了更好地实现这些分布式搜索的需求,班农决定重写 Compass ,取而代之的是一个全新的项目,也就是Elasticsearch。

2.ELK

ELK 是Elasticsearch、Logstash 和 Kibana 这三个产品的首字母缩写。Elastic stack主要包括下面四个工具

image

Logstash是 ELK 的中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/MQ/redis/elasticsearch/kafka等)。Kibana可以将 elasticsearch 的数据通过友好的页面展示出来,提供实时分析的功能。

image.png

Elastic 后面又引入了 Beats 家族。这是一系列非常轻量级的数据收集端,比如:

  • Packetbeat 可以实时监听网卡流量,并实时解析网络协议数据,可用来做 NPM 网络数据分析;

  • Metricbeat 可以用来收集服务器,以及服务器上部署的应用服务的各项监控指标数据,这样就可以替代Zabbix 等传统的监控软件,来做服务器的性能指标分析;

  • Auditbeat 可以实时收集服务器的行为事件,用于安全方面的入侵检测和安全日志审计分析;

  • Winlogbeat 用于 Windows 平台的事件日志收集;

  • Filebeat 用于日志文件的收集等。

二、基础概念

1.文档Document

ES是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为Json格式后存储在ES中,而Json文档中往往包含很多的字段(Field),类似于数据库中的列。如下所示:

image.png

拓展:

文档元数据(Document MetaData),用于标注文档的相关信息

_index:文档所在的索引名

_type:文档所在的类型名

_id: 文档唯一id

_uid:组合id,由_type和_id组成(6.x _type不再起作用,同_id一样)

_source:文档的原始Json数据,可以从这里获取每个字段的内容

_all: 整合所有字段内容到该字段,默认禁用

2.索引Index

索引是由具有相同字段的文档列表组成。可以把索引类比成Mysql数据库中的表。数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。

比如我们可以在生成日志的时候可以按月建立索引存储:

ngix-log-202311

ngix-log-202312

ngix-log-202401

三、正向索引与倒排索引对比

在上一部分介绍Lucene和ES时,都提到了倒排索引这一概念,那什么是倒排索引呢。下面通过两个简化的过程来对比正向索引和倒排索引的差异。

1.正向索引查找过程

与倒排索引相对的是正向索引,传统数据库(如MySQL)采用正向索引

如下是个货物表(tb_goods)

idnamepricequantity
1小米耳机11910
2小米手环19920
3小米手机2000100
4华为手机500030
5华为手环29950

当我们想要搜索"手环",使用MYSQL进行查询,过程可以简要概括如下(这里隐藏回表查询等具体细节)

image.png 上图的逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。

2.倒排索引查找过程

ES采用倒排索引:

文档(document):每条数据就是一个文档

词条(term):文档按照语义分成的词语

idnamepricequantity
1小米耳机11910
2小米手环19920
3小米手机2000100
4华为手机500030
5华为手环29950

倒排索引之后

词条(term)文档id
小米1,2,3
耳机1
手环2,5
手机3,4
华为4,5

1)用户输入条件"小米手环"进行搜索。

2)对用户输入内容分词,得到词条:小米、手环。

3)拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3、5。

4)拿着文档id到正向索引中查找具体文档。

image.png

四、倒排索引底层结构与实现

1.倒排索引的核心组成

倒排索引的实现必须有两个核心组成部分:单词词典和对应的倒排列表(每个词条对应的文档id组成的集合),类似下图

词条(term)文档id
小米1,2,3
耳机1
手环2,5
手机3,4
华为4,5

1.1 单词词典:(Term Dictionary)

  • 记录所有文档的单词,记录单词到倒排列表的关联关系

  • 单词词典一般比较大,可以通过B+树或哈希拉链法实现,以满足高性能的插入与查询

1.2 倒排列表:(Posting List)

记录了单词对应的文档集合,由倒排索引项组成

ES的倒排索引项:

  • 文档ID:用于获取原始信息

  • 词频TF:该单词在文档中出现的次数,用于相关性评分

  • 位置(Positon):单词在文档中分词的位置,用于语句搜索

  • 偏移(Offset):记录单词的开始结束位置,实现高亮显示

举个例子

image.png

2.Lucene/ES对于倒排索引的实现

Lucene/ES 的倒排索引,在上面的表格的基础上,在左边增加了一层字典树 Term Index,它不存储所有的单词,只存储单词前缀。查找过程可以概括为:

  • 先通过字典树找到单词所在的块,也就是单词的大概位置,

  • 在单词对应的块里进行二分查找,找到对应的单词

  • 通过单词找到单词对应的文档列表。

image

存储结构优化

  • 字典树缓存在内存中,内存大小有限,FST(Finite State Transducers)对 Term Index 做进一步压缩。

  • Term dictionary 在磁盘上是以分 block 的方式保存的,一个block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去。这样 term dictionary 可以比 B 树更节约磁盘空间。

原生的 Posting List 有两个可以改进的地方

  • 如何压缩以节省空间

  • 如何快速求并交集

3.Posting List压缩策略:FOR

在Lucene/ES里,数据按照 Segment 存储的,每个 Segment 最多存储的文档序号范围是[0, 2^32-1],也就是需要32位表示,所以如果不进行压缩,那么每个文档ID的表示都会占用 4 bytes 。

上面说的32位,在IndexWriter属性里面指明,具体如下:

IndexWriter属性 默认值 描述

MergeFactory 10 控制segment合并的频率和大小

MaxMergeDocs Int32.MaxValue 限制每个segment中包含的文档数

MinMergeDocs 10 当内存中的文档达到多少的时候再写入segment

Frame Of Reference(FOR)是一种压缩技术,主要分为:增量编码、分块压缩、按需分配空间三个步骤,下面通过一个文档ID组成的数组:[73, 300, 302, 332, 343, 372]来展示FOR所做的工作。如果不通过FOR,那么这个数组需要占用空间:6 * 2 bytes = 12 bytes。

3.1 增量编码

如果我们只记录元素与基准元素之间的增量,以73作为基准,其它元素用增量表示,数组变成了:

[73, 227, 2, 30, 11, 29]

3.2 分块操作

Lucene 里每个块包含 256 个文档 ID,这样可以保证每个块在经过增量编码后,每个元素都不会超过 256(1 byte),另外还方便进行后面求交并集的跳表运算。

为了方便演示,这里的例子假设每个块是 3 个文档 ID,那么上面的数组进一步变成:

[73, 227, 2], [30, 11, 29]

3.3 按需分配空间

对于第一个块,[73, 227, 2]:

  • 最大元素是227,最少8 bits可以表示,所以就给每个元素都分配 8 bits的空间

  • 三个元素占用的空间:3 * 8 bits=24 bits,占用3 bytes,再加上1 bytes说明当前块给每个元素分配了多少空间。

对于第二个块,[30, 11, 29]:

  • 最大的元素30,最少5 bits可以表示,所有给每个元素只分配 5 bits 的空间

  • 三个元素占用的空间:3 * 5 bits=15 bits,占用2 bytes,再加上1 bytes说明当前块给每个元素分配了多少空间。

两个块加起来只需要:(3+1+2+1)=7 bytes,比一开始的24 bytes,减少了一半以上的空间占用,压缩率很高。

image

4.快速求交并集

设想这样一个场景,我们去一个北京旅游,需要提前预定酒店,我们在预定酒店的平台输入下列筛选条件:

  • 城市:北京

  • 价位区间:100—200

  • 酒店名字:如家酒店

这样就需要根据三个字段,去三个倒排索引里去查,当然,磁盘里的数据用了 FOR 进行压缩,所以我们要把数据进行反向处理,即解压,才能还原成原始的文档 ID,然后把这三个文档 ID 数组在内存中做一个交集。

即使没有多条件查询, Lucene/ES 也需要频繁求并集,因为 Lucene 是分片存储的。

假设我们三个条件搜到的文档id数组分别是

[2, 13, 17,20, 98]
[1, 13, 22, 35, 98, 99]
[1, 3, 13, 20,35,80,98]

那么同时满足我们想要条件的,就是这三个数组的交集。需要把这三个数组放到内存做运算,做运算,这里有两个策略可以选择。

4.1 位图和“与”运算

一种方式是用位图表示,我们可以拿一个简单的数组举例:

假设有这样一个数组:

[3,6,7,10]

那么可以这样通过使用 bitmap (位图)来表示:

用0表示对应的数字不存在,用1表示对应的数字存在

image.png 这样带来了两个好处:

(1)节省空间

假设有100M 个文档 ID,每个文档 ID 占 2 bytes,那已经是 200 MB,而这些数据是要放到内存中进行处理的,把这么大量的数据,从磁盘解压后丢到内存,内存肯定撑不住。

如果使用位图,只需要 0 和 1,那每个文档 ID 就只需要 1 bit,还是假设有 100M 个文档,那只需要 100M bits = 100M * 1/8 bytes = 12.5 MB,比用 Integer 数组的 200 MB 节省了大量的内存。

(2)运算更快

0 和 1,天然就适合进行位运算,通过与运算就可以快速求交集。

那么对于上面我们想求的三个数组交集,我们只需要将它们做与运算,就可以求出来交集。

4.2 Integer和跳表

4.2.1 使用场景分析

上面使用的位图有个问题:不管有多少个文档,占用的空间都是一样的。在上面说过,Lucene Posting List 的每个 Segement 最多放 65536 个文档ID。

有的时候想做交集的数组,可能比较稀疏。举一个极端的例子,有一个数组,里面只有两个文档 ID:

[0, 65535]

如果使用位图表示,那就需要:

[1,0,0,0,….(超级多个0),…,0,0,1]

需要 65536 个 bit,也就是 65536/8 = 8192 bytes,而用 Integer 数组,只需要 2 * 2 bytes = 4 bytes

可见在文档数量不多的时候,使用 Integer 数组更加节省内存。

4.2.2 求交集过程

需要查找的每一个 int 数组建立跳表,然后由最短的 posting list 开始遍历,遍历的过程中各自可以跳过不少元素。

image

以上是三个posting list。现在需要把它们用AND的关系合并,得出posting list的交集。首先选择最短的posting list,然后从小到大遍历。遍历的过程可以跳过一些元素,比如我们遍历到绿色的13的时候,就可以跳过蓝色的3了,因为3比13要小。

详细过程如下:

  • 选择最短的绿色数组,获取当前的最小元素 ---> 2

  • 在橙色数组找大于等于2的第一个元素 ---> 13,不等于2,2必然不是三个数组的交集

  • 在最短的绿色数组找大于等于13的第一个元素 ---> 13

  • 去蓝色数组找大于等于13的第一个元素 ---> 13,13出现三次,属于交集的元素

  • 去绿色数组找下一个元素 ---> 17

  • 在橙色数组找大于等于17的第一个元素 ---> 22,不等于17,17必然不是三个数组的交集

  • 在最短的绿色数组找大于等于22的第一个元素 ---> 98,不等于17,17必然不是三个数组的交集

  • 在橙色数组找大于等于98的第一个元素 ---> 98

  • 去蓝色数组找大于等于98的第一个元素 ---> 98,98出现三次,属于交集的元素

  • 最短的绿色数组已经没有元素了,遍历结束,交集为[13,98]

    Next -> 2
    Advance(2) -> 13
    Advance(13) -> 13
    Already on 13
    Advance(13) -> 13 MATCH!!!
    Next -> 17
    Advance(17) -> 22
    Advance(22) -> 98
    Advance(98) -> 98
    Advance(98) -> 98 MATCH!!!

4.3 Roaring Bitmaps

在4.2我们提到:在文档数量不多的时候,使用整数数组更省空间,那么多少可以称作不多呢。

这里我们可以计算一下临界值:

  • 对于使用整数的形式,占据空间大小为2x bytes(x 表示文档数量);

  • 对于使用位图的形式,无论文档数量,大小固定为8192 bytes;

  • 当x=4096,两者相等。也就是说,当文档数量少于 4096 时,用 Integer 数组,否则,用 bitmap。

image

Roaring Bitmaps的目标是更好地利用好上面的两个选项,根据块内文件数量动态选择使用位图还是整数数组,数量小于4096使用整数数组,否则位图。

开始的时候我们把集合按16位的最大值(65536)来切分成数据块。这也就意味着,第一个数据块可以被0到65535之间的数值编码,第二个数据块编码范围是65536到131071。然后在每个数据块,我们使用16位来进行独立编码:如果它有少于4096个值,就会使用数组,否则的话就使用bitmap。

image

图源:www.elastic.co/cn/blog/fra…

对上图的过程解释:

  • 我们当前的索引集合为:[ 1000,62101,131385,132052,191173,196658 ],我们要对其分块,每块的文档ID数值编码为16位,那么第一块可以表示[0,65535],第二块可以表示[65536,131071]........

以此类推,第n块数字范围是:[(n-1)*65536,(n-1)*65536+65535]

  • 对于每一个文档id,我们通过除65536的除数确定它在第几块,余数就是它压缩编码之后的新数字表示比如1000:1000/65536=0,1000%65536=10000,在第一块。

  • 因此我们可以知道:

第一块有:1000,62101

第三块有:313,980,60101。 实际上是[131385,132052,191173]分别对65536进行取模运算

第四块有:50。实际上是[196658]对65536进行取模运算

5.总结:FOR、位图、整数数组

  • Frame Of Reference 是压缩数据,减少磁盘占用空间,所以当从磁盘取数据时,也需要一个反向的过程,即解压

  • 解压后才这样的文档ID数组:[73, 300, 302, 303, 343, 372]

  • 解压后需要对数据进行处理,求交集或者并集,这时候数据是需要放到内存进行处理的,我们有三个这样的数组,这些数组可能很大,而内存空间比磁盘还宝贵,于是需要更强有力的压缩算法,同时还要有利于快速的求交并集,于是有了Roaring Bitmaps 算法(根据文档数量选择用整数数组还说位图)

另外,Lucene 还会把从磁盘取出来的数据,通过 Roaring bitmaps 处理后,缓存到内存中,Lucene 称之为 filter cache。