[11] ElasticSearch
1. 引入
1.1 什么是搜索?
比如说我们想找寻任何的信息的时候,就会上百度去搜索一下,比如说找一部自己喜欢的电影,或者说找一本喜欢的书,或者找一条感兴趣的新闻。这是提到搜索的第一印象,但要注意,百度 != 搜索。
- 互联网的搜索:电商网站,招聘网站,新闻网站,各种 app
- IT 系统的搜索:OA 软件,办公自动化软件,会议管理,日程管理,项目管理。
搜索,就是在任何场景下,找寻你想要的信息,这个时候,会输入一段你要搜索的关键字,然后就期望找到这个关键字相关的有些信息。
常见的全网搜索引擎,像 Google、Bing、Baidu 这样的。但是除此以外,搜索技术在垂直领域也有广泛的使用,比如淘宝、京东上搜索商品,StackOverFlow、cnblogs 中搜索问题贴,也都是基于海量数据的搜索。
1.2 处理搜索的方式
a. 精确匹配
- 对于传统的关系性数据库对于关键词的查询,只能逐行逐字的匹配,性能非常差。
- 匹配方式不合理,比如搜索“小密手机” ,如果用 LIKE 进行匹配, 根本匹配不到。但是考虑使用者的用户体验的话,除了完全匹配的记录,还应该显示一部分近似匹配的记录,至少应该匹配到“手机”。
b. 全文检索
先对这两种检索方式有个直观比较:
- 缩写 vs. 全称:cn vs. china
- Exact Value:字符串完全匹配
- Full Text:搜索 cn,也可以将 china 搜索出来
- 格式转化:like liked likes
- Exact Value:字符串完全匹配
- Full Text:搜索 like,也可以将 likes 搜索出来
- 大小写:Tom vs tom
- Exact Value:字符串完全匹配
- Full Text:搜索 tom,也可以将 Tom 搜索出来
- 同义词:like vs love
- Exact Value:字符串完全匹配
- Full Text:搜索 love,也可以将同义词 like 搜索出来
- 日期、不完整的关键词(如“笔记电脑”)
- Exact Value:必须输入 2022-01-01 才能搜索出来;“笔记本电脑”词条的有关结果不会出现
- Full Text:搜索 2022 或 01 都可以搜索出来;“笔记本电脑”词条的有关结果也会出现
简单来说,“全文检索”就不是单纯地只匹配完整的一个值了,而是可以对值进行拆分词语后(分词)进行匹配,也可以通过缩写、时态、大小写、同义词等进行匹配。详情可深入 NPL(自然语义处理)。
“全文检索”是指计算机索引程序通过扫描文章中的每一个词(→ 拆分出的各个单词进行相应的处理,如时态的转换/单复数的转换/同义词的转换/大小写的转换 →)对每一个词建立一个索引,指明该词在文章中出现的次数(相关度)和位置;当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。
把上面这种思想抽象成具体的模型 —— 单词-文档矩阵。这是表达两者之间所具有的一种包含关系的概念模型,下图展示了其含义。每列代表一个文档,每行代表一个单词,打对勾的位置代表包含关系。
从纵向即文档这个维度来看,每列代表文档包含了哪些单词,比如文档 1 包含了词汇 1 和词汇 4,而不包含其它单词。从横向即单词这个维度来看,每行代表了哪些文档包含了某个单词。比如对于词汇 1 来说,文档 1 和文档 4 中出现过单词 1,而其它文档不包含词汇 1。矩阵中其它的行列也可作此种解读。
搜索引擎的索引其实就是实现“单词-文档矩阵”的具体数据结构。可以有不同的方式来实现上述概念模型,比如“倒排索引”、“签名文件”、“后缀树”等方式。但是各项实验数据表明,“倒排索引”是实现单词到文档映射关系的最佳实现方式,所以以下主要介绍“倒排索引”。
这个过程类似于通过字典中的检索字表查字的过程。
但其实话说回来,无论是关系型数据库的索引还是 Es 中的索引,其实都是同一个目的 —— 加快查询速度。
只是说每种数据库都有自己要解决的问题(或者说擅长的领域),对应的也就有自己的数据结构,而不同的使用场景和数据结构,需要用不同的索引方式,才能起到最大化加快查询的目的。
对 MySQL 来说,快速查询结构化数据的方式是建立 B+ 树索引,而对 Es/Lucene 来说,对其存储的非结构化数据最快的查询方式是建立倒排索引。
1.3 倒排索引
a. 正向索引
见其名知其意,有倒排索引(Inverted Index),对应肯定有正向索引(Forward Index)。
在搜索引擎中每个文件都对应一个文件 ID,文件内容被表示为一系列关键词的集合(实际上在搜索引擎索引库中,关键词也已经转换为关键词 ID)。例如“文档1”经过分词,提取了 20 个关键词,每个关键词都会记录它在文档中的出现次数和出现位置。
得到正向索引的结构如下:
- “文档1”的 ID > 单词1:出现次数,出现位置列表;单词2:出现次数,出现位置列表;…
- “文档2”的 ID > 单词1:出现次数,出现位置列表;单词2:出现次数,出现位置列表;…
- ...
当用户在主页上搜索关键词“锤子手机”时,假设只存在正向索引(forward index),那么就需要扫描索引库中的所有文档,找出所有包含关键词“锤子手机”的文档,再根据打分模型进行打分,排出名次后呈现给用户。因为互联网上收录在搜索引擎中的文档的数目是个天文数字,这样的索引结构根本无法满足实时返回排名结果的要求。
所以,搜索引擎会将正向索引重新构建为“倒排索引”,即把文件 ID 对应到关键词的映射转换为关键词到文件 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现这个关键词。得到倒排索引的结构如下:
- “关键词1”:“文档1”的 ID,“文档2”的 ID,…
- “关键词2”:“文档1”的 ID,“文档2”的 ID,…
- ...
b. 基本概念
- 【文档/Document】一般搜索引擎的处理对象是互联网网页,而“文档”这个概念要更宽泛些,代表以文本形式存在的存储对象,相比网页来说,涵盖更多种形式,比如 Word/PDF/HTML/XML 等不同格式的文件都可以称之为“文档”。再比如一封邮件,一条短信,一条微博也可以称之为“文档”。在本书后续内容,很多情况下会使用“文档”来表征文本信息。
- 【文档集合/Document Collection】由若干文档构成的集合称之为“文档集合”。比如海量的互联网网页或者说大量的电子邮件都是“文档集合”的具体例子。
- 【文档编号/Document ID】在搜索引擎内部,会将“文档集合”内每个文档赋予一个唯一的内部编号,以此编号来作为这个文档的唯一标识,这样方便内部处理,每个文档的内部编号即称之为“文档编号”,后文有时会用 DocID 来便捷地代表“文档编号”。
- 【单词编号/Word ID】与文档编号类似,搜索引擎内部以唯一的编号来表征某个单词,“单词编号”可以作为某个单词的唯一表征。
- 【倒排索引/Inverted Index】倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过“倒排索引”,可以根据单词快速获取包含这个单词的文档列表。“倒排索引”主要由两个部分组成:“单词词典”和“倒排文件”。
- 【单词词典/Lexicon】搜索引擎的通常索引单位是“单词”,“单词词典”是由“文档集合”中出现过的所有单词构成的字符串集合,“单词词典”内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。
- 【倒排列表/PostingList】“倒排列表”记载了出现过某个单词的所有文档的“文档列表”及单词在该文档中出现的位置信息,每条记录称为一个“倒排项(Posting)”。根据“倒排列表”,即可获知哪些文档包含某个单词。
- 【倒排文件/Inverted File】所有单词的“倒排列表”往往顺序地存储在磁盘的某个文件里,这个文件即被称之为“倒排文件”,“倒排文件”是存储“倒排索引”的物理文件。
c. 核心组成
- 单词词典(Term Dictionary):记录所有文档的单词,一般都比较大。还会记录单词到倒排列表的关联信息;
- 倒排列表(Posting List):记录了单词对应的文档集合,由倒排索引项(Posting)组成。倒排索引项包含如下信息:
- 文档 ID,用于获取原始信息;
- 单词频率 TF,记录该单词在该文档中的出现次数,用于后续相关性算分;
- 位置 Position,记录单词在文档中分词的位置,用于语句搜索(phrase query);
- 偏移 Offset,记录单词在文档的开始和结束位置,实现高亮显示。
单词词典是倒排索引中非常重要的组成部分,它用来维护文档集合中出现过的所有单词的相关信息,同时用来记载某个单词对应的倒排列表在倒排文件中的位置信息。在支持搜索时,根据用户的查询词,去单词词典里查询,就能够获得相应的倒排列表,并以此作为后续排序的基础。
对于一个规模很大的文档集合来说,可能包含几十万甚至上百万的不同单词,能否快速定位某个单词,这直接影响搜索时的响应速度,所以需要高效的数据结构来对单词词典进行构建和查找,常用的数据结构包括〈哈希加链表结构〉和〈树形词典结构〉。
2. ElasticSearch
2.1 概述
上面说的处理分词、构建倒排索引等,其实都是一个叫 Lucene 的做的 —— Lucene,就是一个 jar 包,里面包含了封装好的各种建立倒排索引,以及进行搜索的代码,包括各种算法。那么能不能说这个 Lucene 就是搜索引擎呢?
还不能,Lucene 只是一个提供全文搜索功能类库的核心工具包,而真正使用它还需要一个完善的服务框架搭建起来的应用。好比 Lucene 是类似于 JDK,而搜索引擎软件就是 Tomcat 的。
目前市面上流行的搜索引擎软件,主流的就两款:ElasticSearch 和 Solr,这两款都是基于 Lucene 搭建的,可以独立部署启动的搜索引擎服务软件。由于内核相同,所以两者除了服务器安装、部署、管理、集群以外,对于数据的操作,修改、添加、保存、查询等等都十分类似。就好像都是支持 SQL 语言的两种数据库软件。只要学会其中一个另一个很容易上手。
ElasticSearch,基于 Lucene,隐藏复杂性,提供简单易用的 RESTful 接口、JavaAPI 接口(还有其他语言的 API 接口)。
从实际企业使用情况来看,ElasticSearch 的市场份额逐步在取代 Solr,国内百度、京东、新浪都是基于 ElasticSearch 实现的搜索功能。国外就更多了,像维基百科、GitHub、Stack Overflow 等也都是基于 Es 的。
ElasticSearch 是一个实时分布式搜索和分析引擎。它用于全文搜索、结构化搜索、数据分析。著名的 ELK 框架(ElasticSearch-Logstash-Kibana),实现企业海量日志的处理分析的解决方案。
现如今,在发展的过程中,又有新成员 Beats 的加入,所以就形成了 Elastic Stack。所以说,ELK 是旧的称呼,Elastic Stack 是新的名字。全新的 Elastic Stack 技术栈包括:
- Kibana:数据可视化工具。Kibana 能够以图标的形式呈现数据,并且具有可扩展的用户界面,供你全访问配置和管理 ES;
- LogStash:动态数据处理管道,拥有可扩展的插件生态系统,支持从不同来源采集数据,转换数据,并将数据发送到不同的存储库中。
- Beats 是一个面向轻量级采集器的平台,集合了多种单一用途数据采集器。它们从成百上千或成千上万台机器和系统向 Logstash 或 Elasticsearch 发送数据。Beats 由如下组成:
- Packetbeat:轻量型网络数据采集器,用于深挖网线上传输的数据,了解应用程序动态。Packetbeat 是一款轻量型网络数据包分析器,能够将数据发送至 Logstash 或 Elasticsearch。其支持 ICMP (v4 and v6)、DNS、HTTP、MySQL、PostgreSQL、Redis、MongoDB、Memcache 等协议。
- Filebeat:轻量型日志采集器。当您要面对成百上千、甚至成千上万的服务器、虚拟机和容器生成的日志时,请告别 SSH 吧。Filebeat 将为您提供一种轻量型方法,用于转发和汇总日志与文件,让简单的事情不再繁杂。
- Metricbeat:轻量型指标采集器。Metricbeat 能够以一种轻量型的方式,输送各种系统和服务统计数据,从 CPU 到内存,从 Redis 到 Nginx,不一而足。可定期获取外部系统的监控指标信息,其可以监控、收集 Apache http、HAProxy、MongoDB、MySQL、Nginx、PostgreSQL、Redis、System、Zookeeper 等服务。
- Winlogbeat:轻量型 Windows 事件日志采集器。用于密切监控基于 Windows 的基础设施上发生的事件。Winlogbeat 能够以一种轻量型的方式,将 Windows 事件日志实时地流式传输至 Elasticsearch 和 Logstash。
- Heartbeat:面向运行状态监测的轻量型采集器。通过主动探测来监测服务的可用性。通过给定 URL 列表,Heartbeat 仅仅询问:网站运行正常吗?Heartbeat 会将此信息和响应时间发送至 Elastic 的其他部分,以进行进一步分析。
- Auditbeat:轻量型审计日志采集器。收集您 Linux 审计框架的数据,监控文件完整性。Auditbeat 实时采集这些事件,然后发送到 Elastic Stack 其他部分做进一步分析。
- Functionbeat:面向云端数据的无服务器采集器。在作为一项功能部署在云服务提供商的功能即服务 (FaaS) 平台上后,Functionbeat 即能收集、传送并监测来自您的云服务的相关数据。
2.2 安装
a. Win10
Windows 版的 ElasticSearch 的安装很简单,解压即安装完毕,解压后的 ElasticSearch 的目录结构如下 // 7.8 版本的 ES 需要 JDK 版本 1.8 以上,默认安装包带有 JDK 环境,如果系统配置过 JAVA_HOME,那么使用系统默认的 JDK;如果没有配置则使用自带的 JDK,一般建议使用系统配置的 JDK。
解压后,进入 bin 文件目录,点击 elasticsearch.bat 文件启动 ES 服务(注意!9300 端口为 ElasticSearch 集群间组件的通信端口,9200 端口为浏览器访问的 HTTP 协议 RESTful 端口):
打开浏览器,输入地址 http://localhost:9200
,测试结果:
b. Linux
// TODO
2.3 核心概念
a. NRT
ES 是一个接近实时(NRT,Near Real Time)的搜索平台。这意味着,从索引一个文档直到这个文档能够被搜索到有一个轻微的延迟(通常是 1s)。
b. Node
一个节点就是集群中的一个服务器(一个 ES 的实例,本质上就是一个 Java 进程),作为集群的一部分,它存储用户的数据,参与集群的索引和搜索功能。
和集群类似,一个节点也是由一个名字来标识的,默认情况下,这个名字是随机分配的,名字会在启动的时候赋予节点(可通过配置文件配置或启动时候 -E node.name=LIUJIAQI
指定)。这个名字对于管理工作来说十分重要,因为在这个管理过程中,用户会去确定网络中的哪些服务器对应于 ES 集群中的哪些节点。
一个节点可以通过配置集群名称的方式来加入一个指定的集群。默认情况下,每个节点都会被安排加入到一个叫做“elasticsearch”的集群中,这意味着,如果用户在自己的网络中启动了若干个节点,并假定它们能够相互发现彼此,它们将会自动地形成并加入到一个叫做“elasticsearch”的集群中。
在一个集群里,可以拥有任意多个节点。而且,如果当前用户的网络中没有运行任何 ES 节点,这时启动第一个节点,会默认创建并加入一个叫做“elasticsearch”的集群。
c. Cluster
集群包含多个节点,每个节点属于哪个集群是通过一个配置(集群名称,默认是 elasticsearch)来决定的,对于中小型应用来说,刚开始一个集群就一个节点很正常。
每个节点启动后,默认就是一个 Master-eligible 节点。Master-eligible 节点可以参加选主流程,成为 Master 节点。当第一个节点启动时候,它会将自己选举成 Master 节点。
每个节点上都保存了集群的状态,只有 Master 节点才能修改集群的状态信息(任意节点都能修改信息会导致数据的不一致性)。
Es 的一个概念就是去中心化,字面上理解就是无中心节点,这是对于集群外部来说的,因为从外部来看 Es 集群,在逻辑上是个整体,你与任何一个节点的通信和与整个 Es 集群通信是等价的。
集群状态(Cluster State)维护了一个集群中必要的信息:① 所有的节点信息;② 所有的索引和其相关的 Mapping 与 Setting 信息;③ 分片的路由信息。
d. Index
一个索引就是一个包含一堆有相似结构的文档数据的集合,比如可以有一个客户索引 / 商品分类索引 / 订单索引,每个索引有一个名称。
一个 index 包含很多 document,一个 index 就代表了一类类似的或者相同的 document。比如说建立一个 product Index(商品索引),里面可能就存放了所有的商品数据,即所有商品 document。
e. Type
6.0 版本之前每个索引里都可以有多个 Type,而每一个 Type 里面,又都会包含一堆 document;6.0 开始,Type 已经被 Deprecated。7.0 开始,一个索引只能创建一个 Type - “_doc”。
为何要去除 type 的概念?
【逻辑】因为 ES 设计初期,是直接参考了关系型数据库的设计模式,存在了 type(数据表)的概念。但是,如此设计有点牵强,因为其搜索引擎是基于 Lucene 的, 而 Lucene 的全文检索功能之所以快,是因为“倒序索引”的存在。而这种“倒序索引”的生成是基于 index 的,而非 type!多个 type 反而会减慢搜索的速度。所以,适当的做些改变(去除 type)也是无可厚非的,属于拨乱反正之壮举。
【物理】同一索引下,不同 type 的数据不仅要存储当前文档的 type 类型还要存储其他 type 独有的 field,从而产生大量空值,造成资源浪费。所以,不同类型数据,最好还是要放到不同的索引中。
- 新增不同 type 的文档
PUT /goods/electronic_goods/1 { "name": "小米空调", "price": 1999.0, "service_period": "one year" } ····································· PUT /goods/fresh_goods/1 { "name": "澳洲龙虾", "price": 199.0, "eat_period": "one week" }
- Es 文档在底层的存储是这样子的
{ "goods": { "mappings": { "_type": { "type": "string", "index": "false" }, "name": { "type": "string" } "price": { "type": "double" } "service_period": { "type": "string" }, "eat_period": { "type": "string" } } } }
- 底层数据存储
{ "_type": "electronic_goods", "name": "小米空调", "price": 1999.0, "service_period": "one year", "eat_period": "" } ····································· { "_type": "fresh_goods", "name": "澳洲龙虾", "price": 199.0, "service_period": "", "eat_period": "one week" }
f. Document
Es 是面向文档的,文档是所有可搜索数据的最小单位。
- 日志文件中的日志项
- 一部电影的具体信息 / 一张唱片的详细信息
- MP3 播放器里的一首歌 / 一篇 PDF 文档中的具体内容
文档会被序列化成 JSON 格式(JSON 对象由字段组成,每个字段都有对应的字段类型),保存在 ES 中。
文档是 ES 中的最小数据单元,一个文档可以是一条客户数据,一条商品分类数据,一条订单数据,每个索引下都可以去存储多个〈JSON 文档〉。每个〈JSON 文档〉都有一个 UniqueID,生成方式可以是自己指定或者通过 Es 自动生成。
下图是 Elasticsearch 中数据索引过程的流程。Es 由 Analyzer 组件对〈JSON 文档〉执行一些操作并将具体子句拆分为 token/term,简单说就是“分词”,然后将这些术语作为倒排索引存储在磁盘中。
ES 的〈JSON 文档〉中的每一个字段,都有自己的倒排索引,当然你可以指定某些字段不做索引,优点是这样可以节省磁盘空间。但是不做索引的话字段无法被搜索到。
_
开头的字段,是文档的元数据,用来标注文档的相关信息:
g. Field
Field 是 Elasticsearch 的最小单位。一个 document 里面有多个 field,每个 field 就是一个数据字段。
h. Mapping
所有文档写进索引之前都会先进行分析,数据如何存放到索引对象上,需要有一个映射配置,包括:数据类型、是否存储、是否分词等,这种行为叫做映射(mapping)。一般由用户自己定义规则。
Mapping 用来定义 Document 中每个字段的类型,即所使用的分词器、是否索引等属性,非常关键等。
i. Shards&Replicas
一个索引可以存储超出单个结点硬件限制的大量数据。比如,一个具有 10 亿文档的索引占据 1TB 的磁盘空间,而任一节点都没有这样大的磁盘空间,或者单个节点处理搜索请求,响应太慢。
为了解决这个问题,ES 提供了将索引划分成多份的能力,这每一份就称作“分片”。当你创建一个索引的时候,你可以指定想要的分片的数量。每个分片本身也是一个功能完善并且独立的“子索引”,这个“子索引”可以被放置到集群中的任何节点上。
事实上,我们的数据被存储和索引在分片(Shards)中,索引只是一个把一个或多个分片合在一起的逻辑概念。对于我们的程序而言,文档存储在索引(index)中,剩下的细节由 ES 关心既可。
[路由算法 - 哈希值对主分片数取模] shard = hash(routing) % number_of_primary_shards
决定一个 document 在哪个 shard 上,最重要的一个值就是 routing 值,默认是 _id
也可以手动指定,相同的 routing 值每次过来从 hash 函数中产出的 hash 值一定是相同的
PUT /test_index/_doc/15?routing=num
手动指定已有数据的一个属性为路由值,好处是可以定制一类文档数据存储到一个分片中。
缺点是设计不好,会造成数据倾斜。
分片之所以重要,主要有两方面的原因:
- 允许用户水平分割/扩展系统的内容容量
- 允许用户在分片(潜在地,位于多个节点上)之上进行分布式的、并行的操作,进而提高性能/吞吐量。至于一个分片怎样分布,它的文档怎样聚合回搜索请求,是完全由 ES 管理的,对于作为用户来说,这些都是透明的。
在一个网络/云的环境里,失败随时都可能发生,在某个分片/节点不知怎么的就处于离线状态,或者由于任何原因消失了,这种情况下,有一个「故障转移机制」是非常有用并且是强烈推荐的。为此目的,ES 允许你创建分片的一份或多份拷贝,这些拷贝叫做“副本分片”。副本分片之所以重要,有两个主要原因:
- 在分片/节点失败的情况下,提供了高可用性。因为这个原因,注意到副本分片从不与原/主要(original/primary)分片置于同一节点上是非常重要的。
- 扩展系统的搜索量/吞吐量,因为搜索可以在所有的复制上并行运行。
总之,每个索引可以被分成多个分片。一个索引也可以被复制 0 次(意思是没有副本)或多次。一旦复制了,每个索引就有了「主分片(作为复制源的原来的分片)」和「副本分片(主分片的拷贝)」之别。
「主分片」和「副本分片」的数量可以在索引创建的时候指定。在索引创建之后,你可以在任何时候动态地改变副本分片的数量,但是绝不能改变主分片的数量。如果需要修改主分片数,需要重建索引:
- 按照需要创建一个新的索引;
- reindex 把索引现有的数据同步到新索引中;
- 别名绑定新创建的索引上。
在 7.0 版本之前,默认情况下,ES 中的每个索引被分片 5 个主分片和 1 个复制,这意味着,如果你的集群中至少有两个节点,你的索引将会有 5 个主分片和另外 5 个副本分片,这样的话每个索引总共就有 10 个分片。
而在 7.0 版本之后,默认的主分片数从 5 改为 1,避免分片过度。分片也是一种资源,分片过多会影响集群的稳定性。因为分片过多,元信息会变多,这些元信息会占用堆内存。分片过多也会影响读写性能,因为每个读写请求都需要一个线程。所以,如果索引没有很大的数据量,不需要设置很多分片。
3. 如何做到快速索引?
3.1 回顾基本概念
ElasticSearch 是一个分布式可扩展的实时搜索和分析引擎,一个建立在全文搜索引擎 Apache Lucene(TM) 基础上的搜索引擎。当然 ElasticSearch 并不仅仅是 Lucene 那么简单,它不仅包括了全文搜索功能,还可以进行以下工作:
- 分布式实时文件存储,并将每一个字段都编入索引,使其可以被搜索;
- 实时分析的分布式搜索引擎;
- 可以扩展到上百台服务器,处理 PB 级别的结构化或非结构化数据。
先说 Es 的文件存储,Es 是面向文档型数据库,一条数据在这里就是一个文档,用 JSON 作为文档序列化的格式,比如下面这条用户数据:
{
"name" : "John",
"sex" : "Male",
"age" : 25,
"birthDate": "1990/05/01",
"about" : "I love to go rock climbing",
"interests": [ "sports", "music" ]
}
3.2 Es 数据结构
Es 最关键的就是提供强大的索引能力了,Es 索引的精髓:一切设计都是为了提高搜索的性能。
为了提高搜索的性能,难免会牺牲某些其他方面,比如插入/更新,否则其他数据库不用混了。往 Elasticsearch 里插入一条记录,其实就是直接 PUT 一个 JSON 对象,这个对象有多个 field,比如上面例子中的 name, sex, age, about, interests
,那么在插入这些数据到 ES 的同时,ES 还默默的为这些字段建立索引 —— 倒排索引,因为 ES 最核心功能是“搜索”。
a. 什么是 B-Tree 索引?
二叉树查找效率是 logN,同时插入新的节点不必移动全部节点,所以用树型结构存储索引,能同时兼顾插入和查询的性能。因此在这个基础上,再结合磁盘的读取特性(顺序读/随机读),传统关系型数据库采用了 B-Tree/B+Tree 这样的数据结构:
b. 什么是倒排索引?
ID | Name | Age | Sex |
---|---|---|---|
1 | Kate | 24 | Female |
2 | John | 24 | Male |
3 | Bill | 29 | Male |
ID 是 ES 自建的文档 id,那么 ES 建立的索引如下:
- Name
Term Posting List Kate 1 John 2 Bill 3 - Age
Term Posting List 24 [1,2] 29 3 - Sex
Term Posting List Female 1 Male [2,3]
(1)Posting List
ES 分别为每个字段值经分词后得到的单词都建立了一个倒排索引,Kate、John、24、Female 这些叫 term,而 [1,2] 就是 Posting List。Posting List 就是一个 int 的数组,存储了所有符合某个 term 的文档 id。
通过 Posting List 这种索引方式似乎可以很快进行查找,比如要找 age=24 的同学,id 是 1、2 的同学。但是,如果这里有上千万的记录呢?如果是想通过 name 来查找呢?
(2)Term Dictionary
Es 为了能快速找到某个 term,将所有的 term 排个序,二分法查找 term,logN 的查找效率,就像通过字典查找一样,这就是 Term Dictionary。现在再看起来,似乎和传统数据库通过 B-Tree 的方式类似啊,为什么说比 B-Tree 的查询快呢?
(3)Term Index
B-Tree 通过减少磁盘寻道次数来提高查询性能,ES 也是采用同样的思路,直接通过内存查找 term,不读磁盘,但是如果 term 太多,term Dictionary 也会很大,放内存不现实,于是有了 Term Index,就像字典里的索引页一样,A 开头的有哪些 term,分别在哪页,可以理解 Term Index 是一颗树:
这棵树不会包含所有的 Term,它包含的是 Term 的一些前缀。通过 Term Index 可以快速地定位到 Term Dictionary 的某个 offset,然后从这个位置再往后顺序查找。
所以 Term Index 不需要存下所有的 term,而仅仅是他们的一些前缀与 Term Dictionary 的 block 之间的映射关系,再结合 FST(Finite State Transducers,有限状态置换器)的压缩技术,可以使 Term Index 缓存到内存中。从 Term Index 查到对应的 Term Dictionary 的 block 位置之后,再去磁盘上找 term,大大减少了磁盘随机读的次数。
假设我们现在要将 mop, moth, pop, star, stop and top(Term Index 里的 term 前缀)映射到序号:0,1,2,3,4,5(Term Dictionary 的 block 位置)。最简单的做法就是定义个 Map<String, Integer>
,大家找到自己的位置对应入座就好了,但从内存占用少的角度想想,有没有更优的办法呢?答案就是:FST(Weighted Finite-State Transducer Algorithms,加权有限状态置换器算法)。
◯ 表示一种状态,→ 表示状态的变化过程,上面的字母/数字表示状态变化和权重。将单词分成单个字母,通过 ◯ 和 → 表示出来,0 权重不显示。如果 ◯ 后面出现分支,就标记权重,最后整条路径上的权重加起来就是这个单词对应的序号。
FSTs are finite-state machines that map a term (byte sequence) to an arbitrary output.
FST 以字节的方式存储所有的 term,这种压缩方式可以有效的缩减存储空间,使得 Term Index 足以放进内存,但这种方式也会导致查找时需要更多的 CPU 资源。
(4)Frame Of Reference
ES 里除了上面说到用 FST 压缩 Term Index 外,对 Posting List 也有压缩技巧。
不是已经只存储文档 id 了吗?还需要压缩?
嗯,我们再看回最开始的例子,如果 ES 需要对同学的性别进行索引(这时传统关系型数据库已经哭晕在厕所……),会怎样?如果有上千万个同学,而世界上只有男/女这样两个生理性别,每个 Posting List 都会有至少百万个文档 id。 ES 是如何有效的对这些文档 id 压缩的呢?
Frame Of Reference:增量编码压缩,将大数变小数,按字节存储。
首先,ES 要求 Posting List 是有序的(为了提高搜索的性能,再任性的要求也得满足),这样做的一个好处是方便压缩,看下面这个图例:
原理就是通过增量,将原来的大数变成小数即仅存储「增量值」,再精打细算按 bit 排好队,最后通过字节存储,而不是大大咧咧的尽管是 2 也是用 int(4 个字节)来存储。
- 将排序的整数列表转换成 delta 列表;
- 切分成 blocks。具体是怎么做的呢?Lucene 规定每个 block 是 256 个 delta,这里简化一下,搞成 3 个 delta;
- 看下每个 block 最大的 delta 是多少。上图的第一个 block,最大的 delta 是 227,最接近的 2 次幂是 256(8 bits),于是规定这个 block 里都用 8bits 来编码(看绿色的 header 就是 8),第二个 block,最大的 delta 是30,最接近的 2 次幂是 32(5 bits),于是规定这个 block 里都用 5bit 来编码(看绿色的 header 就是5)。
(5)Roaring Bitmaps
https://www.elastic.co/cn/blog/frame-of-reference-and-roaring-bitmaps
说到 Roaring Bitmaps,就必须先从 Bitmap 说起。Bitmap 是一种数据结构,假设有某个 Posting List:[1,3,4,7,10],对应的 Bitmap 就是:[1, 0, 1, 1, 0, 0, 1, 0, 0, 1]。
非常直观,用 0/1 表示某个值是否存在,比如 10 这个值就对应第 10 位,对应的 bit 值是 1,这样用一个字节就可以代表 8 个文档 id。1个 int 占 32 位,那么我们只需要申请一个 int[] 长度为 int tmp[1+N/32]
即可存储(N 表示要存储的这些数中的最大值),于是乎:
tmp[0]:可以表示 0 ~31
tmp[1]:可以表示 32~63
tmp[2]:可以表示 64~95
...
如此一来,给定任意整数 M,那么 M/32 就得到下标,M%32 就知道它在此下标的哪个位置。
旧版本(5.0 之前)的 Lucene 就是用这样的方式来压缩的,但这样的压缩方式仍然不够高效,如果有 1 亿个文档,那么需要 12.5MB 的存储空间,这仅仅是对应一个索引字段(我们往往会有很多个索引字段)。于是有人想出了 Roaring Bitmaps 这样更高效的数据结构。
BitMap 的缺点是存储空间随着文档个数线性增长,Roaring Bitmaps 需要打破这个魔咒就一定要用到某些指数特性:
将 Posting List 按照 65535 为界限分块,比如第一块所包含的文档 id 范围在 0~65535 之间,第二块的 id 范围是 65536~131071,以此类推。再用 <商, 余数>
的组合表示每一组 id,这样每组里的 id 范围都在 0~65535 内了,剩下的就好办了,既然每组 id 不会变得无限大,那么我们就可以通过最有效的方式对这里的 id 存储。
为什么是以 65535 为界限?
程序员的世界里除了 1024 外,65535 也是一个经典值,因为它等于 2^16-1
,正好是用 2 个字节能表示的最大数,一个 short 的存储单位。还要注意上图里的最后一行“If a block has more than 4096 values, encode as a bit set, and otherwise as a simple array using 2 bytes per value”,如果是大块,节省点用 Bitset 存,小块就豪爽点,2 个字节我也不计较了,用一个 short[] 存着方便。
那为什么用 4096 来区分大块还是小块呢?
这个是从内存大小考虑的,当 block 块里元素超过 4096 后,用 Bitmap 更省空间 —— 采用 Bitmap 需要的空间是恒定的 65536/8 = 8192 bytes
,而如果采用 short[],所需的空间是 2*VALUES bytes
,而 N=4096 刚好是边界(如图所示)。
c. 联合索引
上面说了半天都是单 field 索引,如果多个 field 索引的联合查询,倒排索引如何满足快速查询的要求呢?
- 利用跳表(SkipList)的数据结构快速做“与”运算
- 利用上面提到的 Bitset 按位“与”
假设有下面三个 Posting List 需要联合索引:
- 如果使用 SkipList,对最短的 Posting List 中的每个 id,逐个在另外两个 Posting List 中查找看是否存在,最后得到交集的结果;
- 如果使用 Bitset,就很直观了,直接按位“与”,得到的结果就是最后的交集。
3.3 小结
ES 索引思路:将磁盘里的东西尽量搬进内存,减少磁盘随机读取次数(同时也利用磁盘顺序读特性),结合各种奇技淫巧的压缩算法,用及其苛刻的态度使用内存。
所以,对于使用 ES 进行索引时需要注意:
- 不需要索引的字段,一定要明确定义出来,因为默认是自动建索引的;
- 同样的道理,对于 String 类型的字段,不需要 Analysis 的也需要明确定义出来,因为默认也是会 Analysis 的;
- 选择有规律的 ID 很重要,随机性太大的 ID(如 UUID)不利于查询。
关于最后一点,个人认为有多个因素:
- 其中一个(也许不是最重要的)因素:上面看到的压缩算法,都是对 Posting List 里的大量 ID 进行压缩的,那如果 ID 是顺序的,或者是有公共前缀等具有一定规律性的 ID,压缩比会比较高;
- 另外一个因素:可能是最影响查询性能的,应该是最后通过 Posting List 里的 ID 到磁盘中查找 Document 信息的那步,因为 ES 是分 Segment 存储的,根据 ID 这个大范围的 Term 定位到 Segment 的效率直接影响了最后查询的性能,如果 ID 是有规律的,可以快速跳过不包含该 ID 的 Segment,从而减少不必要的磁盘读次数。
评论区: