一、es简介
纯java开发,采用倒排索引进行文档的索引,同时通过TF-IDF(词频-逆文档词频)排序算法确保结果的相关性。在索引过程中还可以通过脚本的方式自定义:1、处理拼写错误;2、关键词变体,分词;3、查询内容时附带统计信息;4、自动提示功能等。可以用于主存储也可以用于文档搜索。需要注意的是es不支持事务,所以用做主存储时需要特别留意。es的优点是:1、以文档为基础,可用作nosql存储;2、同时提供了自动分片功能,横向扩展性比较好;3、同一文档可以建立多个索引;
在es官方里,还有一个称为Elastic Stack的项目,这是一个可视化于一体的解决方案,是由es, kibana, logstash三个组件组成的一个日志管理平台,简称ELK。
es可以简单理解为用户和数据源的一个中间层,用来简化跨表的复杂查询和加快速度。es插入数据时会将文档进行关键词分类。es的功能大体上就三个:分析、索引和搜索:
- 分析是通过一定的分析算法把字段的值切分为Term的过程;
- 索引(in-exing)是将数据源通过一定方式提取信息,建立倒排索引的过程;
- 搜索search)是根据用户查询请求,搜索创建的索引,返回索引内容的过程;
ES架构设计
es本身也可以简单理解为存储,为了方便理解,现把es与传统数据库做下对比。
Relational DB |
Elasticsearch |
数据库(database) |
索引 index |
表(tables) |
类型 types |
行(rows) |
文档 documents |
字段(columns) |
fields |
概要设计
与关系型数据库对比大即如上表所示,elasticsearch多数是集群方式部署的,一个(集群)可以包含多个索引(数据库),每个索引中可以包含多个类型(表),每个类型下又包含多个文档(行),每个文档中又包含多个字段(列),最后还有一个叫type的概念,在高级版本中删除掉了,所以这里也就不再描述了。
物理设计:elasticsearch 在后台把每个索引划分成多个分片,每个分片存储一部分索引数据,分片可以在集群中的不同服务器间迁移,它是es数据迁移的最小单位;
逻辑设计:elasticsearch是面向文档的,那么就意味着索引和搜索数据的最小单位是文档,文档有几个非常重要的设计,这几个特性会影响我们的映射设计甚至编码 :
- 自我包含:一篇文档同时包含字段和对应的值,也就是同时包含 key和value;
- 支持分层次:一个文档中包含自文档,可以理解为一个json对象,在es底层是通过fastjson进行转换的;
- 结构灵活:文档不依赖预先定义好的结构,可以动态的添加一个新的字段,elasticsearch会保存字段和类型之间的映射及其他的设置,类型有时候也称为映射类型。
主要特性
- 准实时:一般延迟在1s以内,笔者曾经工作过的公司因为基础部门对原生es进行了二次封装,延迟达到了2s,这个需要特别留意。因为文档会先更新再刷新默认情况下这是一个异步过程,针对get操作有个特殊的设置,如果get操作正好处在更新但未完成刷新时,当refresh参数=true时,则es会先执行刷新操作,但不建议设置成true,因为会影响性能;
- 集群:在.yml文件中配置的cluster.name参数来指定。如果是集群,就存在主节点,默认的轮询时间是1秒,重试3次。采用半数同意的方式选举。主节点负责管理集群的状态、分片、索引、节点的状态信息,只有一个节点时那么它就是主节点 。可以设置discovery.zen.minimun_master_nodes=int数量来设置集群中有多 少个节点有资格成为主结点,此数字一般设置为总数/2+1,即半数加一;
- 节点:一个运行实例,多个节点组成集群,一个节点包含多个分片,如果节点数量足够多,那么主从分片不会落在同一个节点上,节点存储数据并参与集群的索引、搜索和分析,在系统启动时会随机分配一个UUID值,也可以自定义。集群环境时会有几种节点类型:管理节点(master)、数据(data)节点、网关(协同)节点,master会监控数据节节点的primary,如果性能不好则会把primary降级为replica;如果删除一个节点,可能意味着副本要重新声均衡分片。意外情况时,比如宕机,es会进行两个工作,重新选举主分片和重新更新丢失的副本。这个过程中数据是可访问的,建议副本数量要大于1,即使有些副分片和主分片落在同一个节点上,防止单个节点丢失造成的数据丢失问题;
节点类型
在es中一共有4类节点:候选主节点、数据节点、索引预处理节点、协调节点。在规划时建议设置3个以上的节点为候选主节点,如果有新节点加入到集群中时,数据会重新均衡。
主节点
主节点是从候选主节点中选出来的,一个集群只能有一个主节点。用来管理集群,如索引操作、分片管理、健康管理,一般来讲要分离主节点和数据节点,使主节点不再接受数据请求专事专干,可在yml文件中显性的创建。有一些管理客户端里会用星号等特殊标识出来。主节点如果有故障,则集群是不能正常工作的,建议配置如下:
node.master:true
node.data:false
node.ingest:false
数据节点
用来存储数据,主要是对文档的操作。对机器配置要求比较高,建议配置如下:
node.data:true
node.ingest:false
node.master:false
预处理节点
有几个作用:1、在索引前预处理文档;2、拦截bulk、index请求,然后回传给bulk和index API。可以自定义管道,指定一系列的预处理器,建议配置如下:
node.ingest:true
协调节点
有时也称为网关节点,这个节点不会成为主节点也不会存储数据,主要用来转发请求,在海量数据时负责负载均衡,建议配置如下:
node.data:false
node.ingest:false
node.master:false
二、数据存储结构
索引
索引是映射类型的容器,存储了映射类型的字段和其他设置。elasticsearch中的索引是一个非常大的文档集合,被存储到了各个分片上了。索引名称必须是全小写,单个集群中可以定义多个索引;
别名
索引另外的名称,可用来实现跨索引查询,无缝切换等。每个索引可以有多个别名,不同的索引也可以有相同的别名。在实际生产环境中建议在可控的环境中使用,否则后期的维护是个大麻烦。
类型/映射类型
类型是文档的逻辑容器,就像关系型数据库一样,表格是行的容器。映射是一个可选操作,可以不需要人为指定,在创建索引数据时会动态生成的。 类型中对于字段的定义称为映射, 比如 name 映射为字符串类型。 又由于es的文档是无模式的,不需要一定拥有映射中所定义的所有字段, 比如新增一个字段时elasticsearch会自动的将新字段加入映射,但是这个字段不确定是什么类型,elasticsearch就会开始猜,如果这个值是18,那么elasticsearch会认为它是整形,同样的也可能会猜错。为了避免不必要的麻烦,建议还是提前定义好所需要的映射,这点跟关系型数据库殊途同归了,先定义好字段,然后再使用,强烈建议,否则也有滥用的风险,不利于系统的后期维护和规划。
文档
可以被索引的基本信息单元,在一个索引中默认可以存储大约21亿条文档。一个文档的实际存储大小一般是真实数据的3~5倍,在评估容量时要特别注意。文档标识由三部分组成:索引名+类型名+文档ID,这里的ID不是全局唯一值,只是为了区分结构,es索引时底层还会对每个文档会生成一个UID值,这个是文档的全局唯一ID。在实际开发过程中,不太会用到UID或是根本取不到,所以建议在开发时文档ID设置成唯一值,比如数据库主键。文档的数据设计是一种tree结构,是有层次的,一个文档除了数据外还有元数据部分:
- _index:文档存储的地方
- _type:文档代表的对象的类
- _id:文档的唯一标识
字段
默认情况下,每一个字段的数据都会被索引的,默认为source存储,另一种是store存储,这两种是非互斥关系(store有适用的场景,store设置默认为false,如无必要不要开启,因为会增加内存),ES的字段类型包含三种:1、基础(字符、数值、日期、布尔);2、数组和对象;3、预定义,比如time。
- 字符:注意分词;
- 数据:建议让ES自动识别;
- 日期:ES默认是ISO8601格式,但可 以通过format选项自定义
- 布尔:在ES存储为TF。
- 数组:所有核心类型都支持数组类型
- 预定:以_开头,包含_size、_ttl、_all
倒排索引
正排是根据key找Value,而倒排是根据Value找key。倒排索引的创建过程就是建立Term到文档ID的映射关系,输入是文档,输出是Term关系表,后面会详细介绍其结构。
路由
默认情况下,数据存放到哪个分片,是通过文档ID的hash值来控制的。公式为:shard = hash(routing) % number_of_primary_shards,也可以通过脚本的方式自定义。
三、读写模型
这一小节可以看成是描述primary-replica分片的。
写模型
模型设计
写操作会先基于文档ID解析到一个复制组(主从分片),定位后在此组内部转到到该组的primary中,它首先验证数据正确性,primary负责操作并把数据同步到其它replica上,在primary中会维护一个replica列表,这个列表包含必须要同步的和不需要必须要同步的,必须所有replica必须要同步的节点完成数据同步,此次操作才算成功。同步replica的过程可以是并发的。
另外还有一个特殊配置,即detect_noop=true,在更新之与原文档对比,如果没有字段值变化,则不做更新操作。
写错误处理
如果primary本身有问题,则primary所在的节点将向master发送错误消息,索引写操作将等待master从replica中重选举一个primayr。然后再进行数据处理。
如果设置了必须要同步的副本数量,则primary一直会等待特定数量的replica重启或超时,如果replica有问题:则也是由primary所在的节点将向master发送错误消息,请求从此组副本中删除,当master确认删除后,primary再执行,同时master也会指定另一个节点生成一个新的副本。
至于副本有多个个同步完成才算执行成功,则是由wait_for_active_shards来设置的,如果设置成1,由只要primary完成,就认为此次写请求完成了。
读模型
模型设计
当协调节点收到读请求时,则将请求解析到相关分片组,每个分片组选择一个分片搜索数据,最后将结果整合发送给客户端。如果是唯一ID查询时则省了整合这一步。此于从哪个分片取,一般是随机选择的。搜索时ES会自动均衡,默认采用轮询随机(包含主从分片)。如果是聚合搜索,由接收请求的节点负责聚合其它节点搜索上来的数据,统一返。
读错误处理
当一个分片未能响应时,协调节点会将请求发给同组的另一个分片,如果没有分片可用了,则相关的API也会返回部分数据,不会直接失败,但这时会少部分数据,状态码是200,失败信息会保存到响应head中。
读和写是一个同步的过程,由于primary会先写,然后再复制分发请求到其它replica中,所以并发读可能看到老数据。
四、索引处理过程
本身来讲这个过程并不简单,但在日常开发时开发同学并不会需要很多的个性化处理,但这些知识还是很重要的,一是影响性能,二成本(笔记曾经设计过的一个应用,设计3年的业务容量,采用64节点128分片,硬件选择32G,8T硬盘,最终每月成本是15W,后续在目标不变的情况下优化后成本降了一半,大概7W左右/月)整体流程如下:
数据分析
IK分词器
中文分词算法实现的形式有多种,例如词典分词算法、统计分词算法,还有基于机器学习和深度学习的分词算法等。IK一种基于词典的分词算法,IK分词器是基于正向匹配的分词算法。其基本上可分为两种模式,一种为smart模式,一种为非smart模式。非smart模式所做的就是将能够分出来的词全部输出;smart模式下,K分词器则会根据歧义判断方法输出一个认为最合理的分词结果。IK分词器涉及两个概念:Lexeme(词元)、LexemePath(词元链)
- Lexeme.就是词典中的一个词;
- Lexe-mePath表示一种可能的分词结果。
比如:现在有待分词文本:张三说的确实在理。通过IK分词器切分后的结果如下:
L1{张三,张,三}
L2{说}
L3{的确,的,确实,确,实在,实,在理,在,理}
其中L1、L2、L3是词元链,“张三”“的确”等是词元。根据K分词器的规则,LexemePath之间是不交叉的,Lexe-mePath内部的词元间是交叉的。如果是非smart模式,分词到此结束,把所有的词元全部返回即可,在smart模式下需进行消除歧义。消除歧义的算法和步骤如下:
1、取LexemePath中不交叉词元组成新的LexemePath:
--L1对应的词元链如下:
L11:{张三}
L12:张}
L13:{三}
--L3对应的词元链如下:
L31:{的,确实,在理
L32{的确,实,在理}
L33:{的确,实在,理
L34:{的确,实在}
L35:{确实,在理}
L36:{确实}
2、比较有效文本长度:
L31:{的,确实,在理}
L32:{的确,实,在理}
L33:{的确,实在,理}
3、比较词元个数,越少越好。
4、路径跨度越大越好。
5、根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的越优先。
6、词长越平均越好(词元长度相乘)
7、词元位置权重比较(词元长度积),含义是选取长的词元位置在后的集合。
L31:{的,确实,在理}1*1+2*2+3*2=11
L32:{的确,实,在理}1*2+2*1+3*2=10
L33:{的确,实在,理}1*2+2*2+3*1=9
最后的分词结果为:张三、说、的、确实、在理
数据索引
下面以索引中文为例,假设切分后的结果为运动、鞋子、充电宝为便。倒排索引的逻辑结构图如下:
倒排索引的存储结构如下,下图中左侧为Term,右侧为内存中结构(第一列:为文档ID,第二列为TermID)
数据搜索
数据搜索流程如下:
- 用户输入查询语句;
- 对查询语句进行语法分析和语言分析,得到一系列词(Term)。
- 通过语法分析得到一个查询树。
- 利用查询树搜索索引,从而得到每个词的文档链表,对文档链表进行交、差操作,并得到结果文档,这个合并的过程会执行过滤
- 将搜索到的结果文档按查询的相关性进行排序。
- 返回查询结果给用户。
数据排序
在es中支持相关性排序和自定义排序两种主式。
相关性分数排序
Lucene中的相关性分数排序,指的是按搜索关键词(Term)与搜索结果之间的相关性所进行的排序。例如,搜索oookname域中包含Java的图书,则根据Java在bookname中出现的次数和位置来判断结果的相关性。Lucene:采用的是BM25算法的改进版本。IDF指在倒排文档中出现的次数。
每个文档都有相关性评分,用一个相对的浮点数字段 _score 来表示评分越高,相关性越高。查询语句会为每个文档添加一个 _score 字段。评分的计算方式取决于查询类型 ,不同的查询语句用于不同的目的,比如fuzzy 查询会计算与关键词的拼写相似程度, terms 查询会计算 找到的内容与关键词组成部分匹配的百分比,但是一般 意义上我们说的全文本搜索是指计算内容与关键词的类似程度。可以用explain语句来分析。
ES相关性算法采用了TF-IDF来打分的,其次是概念率打分BM25。其它的还有随机性分歧、基于信息、LM Dirichlet相信度、LM Jelinek Merccer相似度几种方式。可通过以下语句来配置:
mappings:{similarity:'bm25'}
关于TF-IDF
- 检索词频率:检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过5次要比只出现过1次的相关性高;
- 反向文档频率:每个检索词在索引中出现的频率?频率越高,相关性越低。 检索词出现在多数文档中会比出现在少数文档中的权重更低, 即 检验一个检索词在文档中的普遍重要性。
- 字段长度准则:字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段。
单个查询可以使用TF/IDF评分标准或其他方式,比如短语查询中检索词的距离或模糊查询里的检索词相似度。相关性并不只是全文本检索的专利。也适用于 yes|no 的子句,匹配的子句越多,相关性评分越高。如果多条查询子句被合并为一条复合查询语句,比如 bool 查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。
场景化应用运行中打分加权
- boosting加权:可用于索引和搜索这两个过程,用于搜索过程会比较灵活, mapping{boost:2.0}, query{boost:2.5},通过指定explain:true来显示得分情况。
- 定制化function加权:加权过程也可以通过function_score来定制加权算法。也可以通过functions:[weight]来加权。boosting应用于单个字段,weight应用于整体。得分合并
- 第一种方式:score_mode,可选值有multiply、sum、avg、first、max、min。默认是所有函数得分相乘;
- 第二种方式:boost_mode,原始得分与函数得分合并,可选值有sum、avg、max、min、replace
- 动态加权file_value_factor:采用文档中的数据字段值来影响文档得分。
- 脚本:以groovy脚本增强功能。
- 随机:随机分,应用场景是可以随机显示权重分低的文档
- 字段衰减:比如,我们一些活动,往往会根据举办次数的增多其关注度会越来越小,或是以中心为点,往外不同距离其关注点分数不同。所以可以用衰减函数,通过设 置origin(中心点)、offset(分数开始衰减的位置)、scale(段)、decay(衰减比例)来定义如下曲线,可细分为:
- linner:线性,匀速
- gauss:高斯曲线,分数离scale越低上升越来越慢,超过最高点后同理;
- exp:指数,从origin开始分数急速下降;
- gauss:{origin:"40,40", offset:"100m", scale:"2km", decay:0.5},可以解决为在离origin位置100m内分数不变,在2km时分数衰减为原来的一半。
自定义排序
自定义排序就是根据用户指定的字段进行排序,进行排序的字段的stoe属性值必须是true。
五、分布式原理
es的分式式设计主要的机制有4种:分片机制、副本机制、集群自动发现机制、负载均衡。最后一张图是集群构建过程:
- 分片机制(Shard):将完整数据切割成W份存储在不同的节点上,解决单个节点资源的限制;
- 副本机制(Replica):在设置了副本后,集群中某个节点宕机后,通过副本可以快速对缺失数据进行恢复;
- 集群发现机制(Discovery):当启动了一个节点(其实是一个Java进程)时,会自动发现集群并加入。
- 负载均衡(Relocate):es会对所有的分片进行均衡地分配。当新增或减少节点时,不用任何干预,Elasticsearch会自动进行数据重新分配。
集群构建过程
发现节点过程从一个或多个种子主机提供程序的种子地址列表开始,同时还包括已知的候选主节点的地址。种子列表配置在.yml文件中,这个种子地址和初始主节点列表的配置是非常重要的,整个发现过程分为两步:
- 第一阶段,每个节点通过连接到每个地址并试图识别它所连接的节点来探测种子地址。
- 第二阶段,它与远程节点共享其所有已知的候选主节点的列表,并且远程节点依次与其对等响应。然后,节点探测它刚刚发现的所有新节点,并依次请求它们的对等节点
如果在此发现过程中发现选定的主节点,则直接加入集群。如果未发现选定的主节点或节点非候选主节点,则节点将在运行discovery.find_peers_.interval之后重试,默认延迟时间为1s。如果发现节点符合主节点的条件,那么它将继续此发现过程,直到它发现选定的主节点,或者发现足够的候选主节点直到完成选择为止。
分片和副本
shard(为了水平扩容),可以跨节点(提高并发),一个分片相当于一个目录,它是迁移的最小单位。因为数据一样,所以当主分片有问题时 ,副分片会自动升级为主分片;es在设计时默认不会把主副分片放在同一个节点上。如果节点数量过少则另算。创建索引时可以指定分片和副本数量,副本数据可以随时更改,但分片不能改,分片数量变更时只能重新索引。默认情况下每个索引会分配一主一副两个分片,比如:一个索引定义了3个分片、每个分片2个副本,则一共有9个分片。下面是合理规划的建议:
- 主分片数量合理:当分片大于节点数量时未来可以扩容,当小于时是无法扩容的。因为这和HASH算法相关,ES默认分片数量是5。所以一般时分片数量要大于节点数量,同时考虑一个分片21亿数据或多于2740亿个词条量再结合业务共同评估。
- 因主分片数量不能改变:但可以通过合理的索引划分来达到间接达到改变分片数量的目的,另外可以使用 alias 别名技术(可随时修改)来指向索引来使数据分段。可以基于同一索引创建多个不同的别名。结合自定义route技术来使数据落在同一分片上来达到提速的目的。
- 通过设置副本的node.data和node.master属性为fase来扩展其接收外部请求的能力达到最大的吞量。
- 因为ES是基于内存的,所以JVM堆大小设置为JVM的一半,最大不要超过32GB。
副本的作是故障转移,保证在主分片出现故障后保障读和选举用。es的数据复制是基于主备模型的,实现机制是primary通过复制分发请求到其它replica中来实现的主从复制。主分片称为primary,副本称为replica。ES主要通过广播和单播的方式扩容集群,前者是默认方式,在配置错误的时候有可能意外加入错误的节点,适合于IP不固定的情况,后者是在预加入的新节点上设置范围,适合IP固定的情况。
搜索的副本选择有两种方式:1、es内部根据公式来选择数据,通过cluster.routing.use_adaptive_replica_selection=falses来关闭这个功能,关闭后则会在所有副本之间以循环方式将搜索请求发送到相关分片;2、根据routing路由值来指定,其中路由值可以是多个用逗号分隔。
主从同步数据
es是采用乐观锁机制实现的,被设计成异步并发的机制,这意味着复制请求有可能不能按顺序到达副本上,这就会引起一个时序问题。为了确保不会覆盖,es对于每个操作都由主分片设置一个自增的序列号。也就是版本控制。
六、性能优化
本节只描述几个重点需要关注的点,再深入的需要自行了解了哈。
字段数据缓存
在整个搜索过程中,倒排索引可返回文档,但如果在这基础上再进行排序或子查询时就会是非常耗时,为了减少这个二次计算时间。ES提供了字段内存级的缓存功能,这个缓存可发生在第一次检索或索引时。即定义mapping和query时。设置字段名为 fielddata。
因为是内存型,所以必须要增加限制,这个设置是整体设置,相当于JVM参数一样,下面三种缓存方式各有利弊,但好处是ES支持混合使用。
- 字段内存:indices.fielddata.cache.size和indices.fielddata.cache.exire,注意这个值和JVM大小相关,因为ES是用java编写的。淘汰机制是LRU;不可动态设置,必须要重启ES;可设置具体值,也可设置百分比;
- 断路器:可动态设置,transient:{indices.breaker.fielddata.limit},这相当于一种监控,就内存超过此值时就禁止缓存操作;可设置具体值,也可设置百分比;
- 文档值:上面两种是内存操作,文档值是使用磁盘空间。以防内存不足问题,缺点是性能会差一些;
独立的过滤缓存
每个过滤器都被独立计算和缓存,而不管它们在哪里使用。如果两个不同的查询使用相同的过滤器,则会使用相同的字节集。同样,如果一个查询在多处使用同样的过滤器,只有一个字节集会被计算和重用。
控制缓存
大部分直接处理字段的枝叶过滤器(例如 term )会被缓存,而像 bool 这类的组合过滤器则不会被缓存。枝叶过滤器需要在硬盘中检索倒排索引,所以缓存它们是有意义的。另一方面来说,组合过滤器使用快捷的字节逻辑来组合 它们内部条件生成的字节集结果,所以每次重新计算它们也是很高效的。
使用 now 方法的日期范围(例如 "now-1h" ),结果值精确到毫秒。每次这个过滤器执行时, now 返回一个新的值。老的 过滤器将不再被使用,所以默认缓存是被禁用的。然而,当 now 被取整时(例如, now/d 取最近一天),缓存默认是被启用的。
后续笔者会用4章左右文章,分别描述下es-api的相关内容。