一、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接口,可被任何语言调用。
2.ELK
ELK 是Elasticsearch、Logstash 和 Kibana 这三个产品的首字母缩写。Elastic stack主要包括下面四个工具
Logstash是 ELK 的中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/MQ/redis/elasticsearch/kafka等)。Kibana可以将 elasticsearch 的数据通过友好的页面展示出来,提供实时分析的功能。
Elastic 后面又引入了 Beats 家族。这是一系列非常轻量级的数据收集端,比如:
Packetbeat 可以实时监听网卡流量,并实时解析网络协议数据,可用来做 NPM 网络数据分析;
Metricbeat 可以用来收集服务器,以及服务器上部署的应用服务的各项监控指标数据,这样就可以替代Zabbix 等传统的监控软件,来做服务器的性能指标分析;
Auditbeat 可以实时收集服务器的行为事件,用于安全方面的入侵检测和安全日志审计分析;
Winlogbeat 用于 Windows 平台的事件日志收集;
Filebeat 用于日志文件的收集等。
二、基础概念
1.文档Document
ES是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为Json格式后存储在ES中,而Json文档中往往包含很多的字段(Field),类似于数据库中的列。如下所示:
2.索引Index
索引是由具有相同字段的文档列表组成。可以把索引类比成Mysql数据库中的表。数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
比如我们可以在生成日志的时候可以按月建立索引存储:
ngix-log-202311
ngix-log-202312
ngix-log-202401
三、正向索引与倒排索引对比
在上一部分介绍Lucene和ES时,都提到了倒排索引这一概念,那什么是倒排索引呢。下面通过两个简化的过程来对比正向索引和倒排索引的差异。
1.正向索引查找过程
与倒排索引相对的是正向索引,传统数据库(如MySQL)采用正向索引
如下是个货物表(tb_goods)
id | name | price | quantity |
---|---|---|---|
1 | 小米耳机 | 119 | 10 |
2 | 小米手环 | 199 | 20 |
3 | 小米手机 | 2000 | 100 |
4 | 华为手机 | 5000 | 30 |
5 | 华为手环 | 299 | 50 |
当我们想要搜索"手环",使用MYSQL进行查询,过程可以简要概括如下(这里隐藏回表查询等具体细节)
上图的逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
2.倒排索引查找过程
ES采用倒排索引:
文档(document):每条数据就是一个文档
词条(term):文档按照语义分成的词语
id | name | price | quantity |
---|---|---|---|
1 | 小米耳机 | 119 | 10 |
2 | 小米手环 | 199 | 20 |
3 | 小米手机 | 2000 | 100 |
4 | 华为手机 | 5000 | 30 |
5 | 华为手环 | 299 | 50 |
倒排索引之后
词条(term) | 文档id |
---|---|
小米 | 1,2,3 |
耳机 | 1 |
手环 | 2,5 |
手机 | 3,4 |
华为 | 4,5 |
1)用户输入条件"小米手环"进行搜索。
2)对用户输入内容分词,得到词条:小米、手环。
3)拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3、5。
4)拿着文档id到正向索引中查找具体文档。
四、倒排索引底层结构与实现
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):记录单词的开始结束位置,实现高亮显示
举个例子
2.Lucene/ES对于倒排索引的实现
Lucene/ES 的倒排索引,在上面的表格的基础上,在左边增加了一层字典树 Term Index,它不存储所有的单词,只存储单词前缀。查找过程可以概括为:
-
先通过字典树找到单词所在的块,也就是单词的大概位置,
-
在单词对应的块里进行二分查找,找到对应的单词
-
通过单词找到单词对应的文档列表。
存储结构优化:
-
字典树缓存在内存中,内存大小有限,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 最多存 65536 个文档 ID, 对应的文档ID序号从 0 到 2^16-1,需要16位表示,所以如果不进行压缩,那么每个文档ID的表示都会占用 2 bytes 。
Frame Of Reference(FOR)是一种压缩技术,主要分为:增量编码、分块压缩、按需分配空间三个步骤,下面通过一个文档ID组成的数组:[73, 300, 302, 332, 343, 372]来展示FOR所做的工作。如果不通过FOR,那么这个数组需要占用空间:6 * 2 bytes = 12 bytes。
3.1 增量编码
如果我们只记录元素与基准元素之间的增量,以73作为基准,其它元素用增量表示,数组变成了:
Markdown
[73, 227, 2, 30, 11, 29]
3.2 分块操作
Lucene 里每个块包含 256 个文档 ID,这样可以保证每个块在经过增量编码后,每个元素都不会超过 256(1 byte),另外还方便进行后面求交并集的跳表运算。
为了方便演示,这里的例子假设每个块是 3 个文档 ID,那么上面的数组进一步变成:
css
[73, 227, 2], [30, 11, 29]
3.3 按需分配空间
对于第一个块,[73, 227, 2]:
-
最大元素是227,最少8 bits可以表示,所以就给每个元素都分配 8 bits的空间
-
三个元素占用的空间:3 * 8 bits=24 bits,占用3 bytes
对于第二个块,[30, 11, 29]:
-
最大的元素30,最少5 bits可以表示,所有给每个元素只分配 5 bits 的空间
-
三个元素占用的空间:3 * 5 bits=15 bits,占用2 bytes
两个块加起来只需要:5 bytes,比一开始的12 bytes,减少了7 bytes的空间占用,压缩率很高。
4.快速求交并集
设想这样一个场景,我们去一个北京旅游,需要提前预定酒店,我们在预定酒店的平台输入下列筛选条件:
-
城市:北京
-
价位区间:100---200
-
酒店名字:如家酒店
这样就需要根据三个字段,去三个倒排索引里去查,当然,磁盘里的数据用了 FOR 进行压缩,所以我们要把数据进行反向处理,即解压,才能还原成原始的文档 ID,然后把这三个文档 ID 数组在内存中做一个交集。
假设我们三个条件搜到的文档id数组分别是
Markdown
[2, 13, 17,20, 98]
[1, 13, 22, 35, 98, 99]
[1, 3, 13, 20,35,80,98]
那么同时满足我们想要条件的,就是这三个数组的交集。需要把这三个数组放到内存做运算,做运算,这里有两个策略可以选择。
4.1 位图和"与"运算
一种方式是用位图表示,我们可以拿一个简单的数组举例:
假设有这样一个数组:
csharp
[3,6,7,10]
那么可以这样通过使用 bitmap (位图)来表示:
用0表示对应的数字不存在,用1表示对应的数字存在
这样带来了两个好处:
(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 开始遍历,遍历的过程中各自可以跳过不少元素。
以上是三个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。
Roaring Bitmaps的目标是更好地利用好上面的两个选项,根据块内文件数量动态选择使用位图还是整数数组,数量小于4096使用整数数组,否则位图。
开始的时候我们把集合按16位的最大值(65536)来切分成数据块。这也就意味着,第一个数据块可以被0到65535之间的数值编码,第二个数据块编码范围是65536到131071。然后在每个数据块,我们使用16位来进行独立编码:如果它有少于4096个值,就会使用数组,否则的话就使用bitmap。
图源: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。