Elasticsearch
es概述
es是一个分布式的近实时搜索引擎,能够高效的支持海量数据检索、分析操作。 基于磁盘存储使得它可以存储大量数据, 倒排索引的特性使得它可以高效支持全文检索,追加段的方式使得它具备优秀的写入性能,分片和副本机制可以做到高性能和高可用。
倒排索引
什么是倒排索引、和mysql的聚簇索引的区别
与传统的根据记录id去查找属性值不同, 倒排是根据属性值去查找记录id。
以全文检索为例子。 如果想查询包含Es这个词的文档,传统的正排索引需要遍历所有文档,判断是否包含。 而倒排索引会建立term->docId 的映射关系, 使用接近O(1)的复杂度就可以找到对应的文档列表。
而且与mysql最大的不同是, 当给多个字段建立索引时, mysql只能选择一个最优的使用。而es可以把所有条件字段的倒排链都拉出来合并。
数据结构和存储原理
底层存储结构
文本类型分为text和keyword, 区别是keyword不分词,整体当做一个term,而text会根据指定的分词器进行分词得到多个term。 文本类型底层存储方式为term字典。 为一个字符串数组,分块存储并且公共前缀不存储。 为了加快term的查找速度以及支持多种多样的查询场景, es使用FST的方式为term构建了索引, FST是一个有向无环状态机, 是一种类tire树的实现,每个节点存放一个字符,所有的term会共享相同的字符部分。 与trie树不同的是,fst不仅会共享前缀,还会共享后缀。基于fst的特性, 我们可以很方便的完成各种匹配和编辑距离查询等功能, 而且fst的设计会将term字典的大小压缩到数倍至数十倍,这样就可以将整个索引放到内存中了。
数值类型在5.0之前使用字符串存储, 5.0及之后版本使用bkdtree存储。 Bkdtree 是kdbtree的一种实现, 他是kdtree和b+树的混合实现。 我们常用的就是一维的kdtree, 会退化成B+树。 b+树的特点是非叶子节点不存值, 每个非叶子节点存储多个值,在es中每个叶子节点存储一个块数据, 每块有多个doc。 可以理解为非叶子节点存储一个范围, 他的子节点存储子范围,比如根节点是1-100, 左节点是1-50,右节点是51-100。我们常规的数值类型范围查询, 会将查询条件拆分成多个子范围,然后使用子范围查询。 所以一般建议范围值不要过大, 否则会出现大量的pointRangeQuery。 当kd tree的维度为多维时,会按维度切分数据。主要是均衡差值。从根节点开始,按维度依次排序每一层。一维的退化为二叉搜索树。 核心思想是查找与当前节点最近的节点, 按不同维度切分区域, 查询时跳过区域外的数据遍历。 可以简单理解为,查找上海坐标附近的城市,北京离上海很远,而且北京离天津很近,所以天津离上海也很远,天津就不用查找。
倒排链的存储, 倒排链本质上是一个整型数组。在磁盘上是使用FOR技术压缩存储,在内存中的filterCache中是使用的Roaring bitmap(一种压缩位图)。
FOR (frame of reference) 是一种差值压缩的方式, 将倒排链排序计算差值后分块, 每块使用动态bit存放,bit位由该块内的最大元素决定。 而且基于这种存储方式也能很快的建立跳表结构的索引。 docvalues的docIdSet也是这种方式存放, 而且首元素拿到dvm文件中单独存储了, 更进一步的压缩的块的最大bit位。
Roaring bitmap是一种压缩位图, 将数据对65535取余分块,防止大数字申请过多bit位。 如果块内元素少于4096使用数组存储, 否则使用位图。 防止一些稀疏位图占用过高。
docValues是一种特殊的正排数据, 在磁盘上以列存的方式存储。 是对filedData 的一种优化。 fieldData是在内存中逆转dict-posting结构,有oom风险。 es支持五种类型的docValues。 每种类型采用不同的压缩方式,比如FOR压缩docID, 字符串类型内部建立字典编码,存储整形编码等。 docID和value的对应关系是通过相同的数组索引下标来对应的。
es底层是使用的lucene来组织和检索数据。 lucene是以段的形式处理数据, 每个段写完后就不可更改,新增的数据会写入新的段,查询时从各个段查询数据后汇总。 对于删除的数据会通过.del记录删除的ID,等汇总完数据后再过滤掉删除的id。 更新操作会删除原有数据后新增一个数据。 这种方式可以保障更新数据都是顺序写的方式, 保障了优秀的写入性能。 但随着时间的增加段的数量会大大增加, 查询的开销也会随之增加, lucene会定期merge段。
es数据写入后不一定立刻就能查询到, 因为写入内存+transLog就算写入成功了。 但查询查的是文件缓存。 在内存到文件缓存的这个阶段是查询不到数据的。 所以说es是近实时系统。
整体结构:
暂时无法在飞书文档外展示此内容
集群启动和节点启动过程中大致都做了什么事
集群启动过程
集群启动的过程主要是选主和初始化的过程。 首先会进行主节点的选取,主节点的选取规则主要是基于bully算法的改进,简单描述就是选取节点id最大的机器。 然后进行集群元数据的选举,因为主节点的信息可能不是最新信息。 等主节点和元数据选举完成后会进行分片的分配。
选取规则为每个节点都有一个数字类型的唯一id,主要思路是对机器的id排序,选取一个id最大的作为master。
- 每个节点拉取它已知的节点列表,如果达到了法定人数则开始投票, 选出它看到的最大id节点, 成为它临时的主。向该节点发送领导投票。
- 如果一个节点收到的领导投票多于法定人数, 并且它也投向自己, 那么他就成为真正的主。否则重新选举。
选主流程可能会出现脑裂问题, 脑裂一般是主节点假死或网络分区导致, es通过延迟选举来解决假死,如果master不挂就不选举, 再通过法定人数过半的方式解决分区问题。
Master 节点确定后, 会进行集群元数据的选取, 根据版本号确定最新的元数据信息。 主要是确定哪个分片或副本的数据是最新的, 因为数据的写不经过master节点。
接下来master节点会进行分片分配。 根据副本组上报的数据确定哪个是主分片。 分配完后会根据transLog恢复数据。
数据模型
Es 中的数据量一般很大, 所以会水平拆分数据, 类似于mysql的分库分表, 每个分拆的数据块叫做分片。 为了保障这个分片的高可用,会给分片创建副本,分片及副本应处于不同的机器上。由于分片路由算法是基于分片总数取余的, 所以分片数一旦确定将不能更改。 机器扩容会迁移对应的分片到新机器中。
写过程
从es角度来说, 每个节点都会充当协调节点的角色。 当写请求进入协调节点时, 会根据路由规则计算该请求属于哪个分片,然后根据集群元数据中的分片路由表确定分片属于哪个节点。 将请求转发给该节点。 主分片完成数据校验及写入后, 会并发写入该分片的副本, 等待都副本写入完成后,主分片向协调节点汇报写入完成。
故障处理,每个主分片都会在master维护一个一致性副本列表,跟kafka的一致性副本是一样的。当主分片因为自身的原因写失败时,master会提升一致性列表中的某个副本为主分片。 当主分片写成功, 而某个副本分片失败时, 会通知master节点移除该分片。
从lucene角度来说, 是以段的方式增量添加数据的, 即使是更新或删除操作也会增加一个新的段,并添加一个del文件做过滤。 考虑到时间开销问题, lucene写入数据并不是每次都直接同步到磁盘。而是分为《内存 - 文件缓存 - 磁盘三个步骤》, 内存是lucene自己的内存, 宕机或进程结束都不会恢复, 文件缓存是操作系统级别的缓存, 只要不宕机就能恢复。 操作系统执行写命令一般不会直接刷盘,而是写入缓存。 显示或定期的执行fsync才会刷到磁盘。
Lucene 写数据会先写入内存级index buffer 和transLog, 等待合适的时机会将内存的数据生成一个新的段写入文件缓存。 这个过程称为refresh。 Refresh 一般默认每秒执行一次可配置。 写入文件缓存时该数据就可读取了, 因为有transLog保障持久性。 如果想加快写入速度, 可以考虑把这个值设置成-1,写入结束后再调整回来。
从文件系统刷盘称为flush, 一般受到transLog的影响,因为这个东西一般不会无限增大,要不然数据恢复时长过久。 可以配置每次都flush, 或者定期flush, 定期flush可以选择flush时间和transLog大小, 满足其一就会刷盘。
由于每次新增或修改数据都是以新增段的方式来操作的,如果段的数量过多不仅会占用较多的磁盘开销,而且查找的速度也会变慢,因为每次搜索都会扫描所有段。 lucene提供定期merge的能力, 把段进行合并,也删除那些.del的元素。
读过程
es的读可以分成两种,分别是指定id的get和不指定id的search。
指定id的get会由协调节点通过路由规则计算分片,然后对应的副本组中找一个分片,根据路由信息表找到对应的节点,将请求转发给对应节点。 节点接收到请求后,会从lucene中读取数据。 正排一般存储在fdx和fdt文件中。 根据_source指定的字段返回。需要注意的这种操作是实时的, 5.0之前的es版本会从transLog中读取, 5.0之后的版本会强制refresh后读取。 尽量避免大规模idget,可能会生成很多小段。
不指定id的search 可能有一点麻烦, 因为数据是分片存储的, 协调节点要把请求广播给所有的分片,每个分片从自己的副本组找到一个副本执行。 执行过程是一个query then fetch 的过程, 请求会带有size默认是50, 每个分片都会按规则查出50条,然后返回协调节点统一排序后返回。
搜索
匹配过程各种查询是怎么做的,等值查询,范围查询,前缀匹配,全词匹配、短语匹配。
前缀匹配,短语匹配,补全和搜索提示的区别
- 前缀匹配是prefix, 根据term的前缀来匹配。
- 短语匹配是term级别的匹配, 要求term 组必须相同并保持相同的顺序
- suggest -- search_as_your_type , 都是搜索补全和提示的。
分词和文本相关性评分方法
什么是分词,常见的分词策略有哪些
分词是将文档拆分成term的过程, 也是全文检索的基础。 分词流程大致可以分为两部分, tokenizer和filter,tokenizer是根据指定规则拆分词, 比如英文常见的按空格分隔, 中文常见的ik分词,jieba分词等。 filter是对上一步产生的token进行加工处理,比如大小写转换,停用词去除,以及转拼音处理。 我们目前用到的主要是结巴分词和停用词、大小写和转拼音, 结巴分词插件我们引入了自定义招聘词库, 对招聘词增加了权重处理,比如销售经理我们希望成为一个整词而不是两个词。 借鉴了github上一个开源的实现, 内部写了个定时器去查mysql加入分词字典, 权重设的较高。
文本相关性评分
同一个term可能存在于多个文档中, 需要根据指定的相关性算法对文档进行排序, 优先返回相关性最高的文档。
Es 采用的是基于tf-idf的算法, 这个算法能从词频和逆文档频率计算相关性。但最终评分结果可能会受tf过高导致结果出现偏差, 所以es5以后采用优化后的bm25算法来控制相关性, 主要的改动是增加了词频系数和长度系数。
Tf -idf 是经典的文本相似度计算方法,tf是指一个term在某个文档中出现的频率,频率越高相关性越高。 计算方式为term出现的次数除以文档的总词数。 逆文档频率是指包含该term的文档的倒数, 可以代表区分度。 包含该term的文档数越少,该term的区分度越高。
一个query语句和某个文档的相关性得分 = query分词后每个term在该文档中的tf得分和idf得分之积。
我们联想词服务的特点是文档的长度普遍偏短,大部分都不会超过10个字符,而且绝大部分的候选集文档来自用户的搜索词,我们是蓝领招聘,大部分用户的输入习惯会导致重复词过多, 比如与司机有关的词为《司机装车司机,司机司机货车司机,司机A本司机》。用户输入司机时,idf基本是确定的,tf就带来了绝对性的影响。 而且这些带有多个叠词的文档用户体验也很不好, 数据源的加工是另一个图谱算法团队负责的, 他们负责对词进行清洗过滤和纠错,但未能解决叠词问题,他们认为出现叠词后无法判断清除哪个词。 所以最终方案是在匹配是解决这些叠词问题。 这个问题是我负责解决的, 当时调研了一下bm25算法, es支持对索引的bm25算法微调, 提供了k和b值来控制词频影响和文档长度影响。 文档长度影响是指那些很长的文档, 他可能包含很多个term, 以我们的直接来说,如果一个800字作文提过一个词,那么肯定不如一个名人名言提过的这个词更有相关性。 由于我们文档都很短而且我们的主要痛点在词频,所以只需要看一下怎么调整词频的系数。 去除词长系数后, bm25中tf的公式可以简化为k*tf / tf + 1 。 上下同时除以tf后可以化简为k /( 1+1/tf), 随着tf增长,整体会趋向于k值。 我尝试调整了几次k值,效果并不理想,因为他适用于tf达到一定值后趋向固定, 但我们的搜索场景tf值并不高, 候选文档最大长度才为10个字符。用户输入的query为2-4个字符, tf最大只到2或3. 这个tf函数的差距并不大。
我最后使用的自定义script_score脚本的方式实现的, 主要是对三个得分进行加权求和, 分别是原始打分值,tf倒数,以及前缀标志。 优先出一些前缀。 改造后开启了下AB实验, 自定义打分脚本的点击率和转化都远远高于对照组。
Suggest 和search_as_your_type
Suggest 是传统的提示类型, 提供term,phrase,Context 和completion。 term是基于编辑距离做的,返回和该term编辑距离较近的文档。 Phrase 是对term加了短语匹配限制, phrase 对中文不是很友好,他要求term 和offset都一致才能返回, 但我们常用的中文分词往往可能会有多个字的词。 completion 只能完成前缀。 而且term 需要在内存中实时构建和计算编辑记录, 性能差一些。
es7.2版本后提供了search_as_your_type来支持搜索建议, 他的思想是给字段建立ngram的子字段, 比如一个词是abcd,es默认会建立2gram和3grem。 在磁盘上会额外存储ab,bc,cd和abc,bcd,匹配时使用子字段匹配就行。
高可用和高性能
Es 的选举过程, 脑裂的避免方式,分片路由算法, 数据倾斜处理
选取规则为每个节点都有一个数字类型的唯一id,主要思路是对机器的id排序,选取一个id最大的作为master。
- 每个节点拉取它已知的节点列表,如果达到了法定人数则开始投票, 选出它看到的最大id节点, 成为它临时的主。向该节点发送领导投票。
- 如果一个节点收到的领导投票多于法定人数, 并且它也投向自己, 那么他就成为真正的主。否则重新选举。
选主流程可能会出现脑裂问题, 脑裂一般是主节点假死或网络分区导致, es通过延迟选举来解决假死,如果master不挂就不选举, 再通过法定人数过半的方式解决分区问题。
分片路由算法是经典的对routeId hash后对总分片数取余的方式进行, 这也是为什么分片数一旦确定就不可更改。 因为变更后会导致之前的路由失效。如果使用自定义路由或业务指定docId, 可能会出现数据倾斜的情况, 导致某一个分片的数据量过大, es的解决办法是增加一个routing_partition_size的参数, 整体思想是根据route确定是数据到一组分片, 然后根据id确定具体的分片。 相当于增加了一个扰动函数。
shard = hash(routing) % number_of_primary_shards
shard_num = (hash(_routing) + hash(_id) % routing_partition_size) % num_primary_shards
优化措施
写优化
写入时可以先关闭副本, 等写入成功后再开启
加大refresh的间隔, 主要是减少段的创建和合并开销, 可以评估一下写入量和空闲内存, 如果足够的话甚至可以暂时关闭refresh,把参数设置为-1,写入完成后再刷新。
加大flush间隔,主要思想是增大transLog刷盘的间隔, 减少磁盘io
尽量使用bulk批量写入
关闭没必要的docValue和source。
读优化
可以通过search profile 看看耗时主要在哪, 是倒排链的查找还是合并过程。 哪个倒排链的查找耗时较高。
常见的优化策略有:
如果可以的话,升级ssd磁盘, 立马就能提升30%左右的查询性能。
尽量使用全文检索的特性, 能keyword就keyword查, 一些固定的数值类型也可以设置为keyword
可以尝试把一些范围查询改成等值查询, 因为fst的查找速度优于bkdtree,比如查询近七天的发帖,每个帖子发布日期存储一个天级别的keyword字段,使用in 7个值的方式。
需要排序的字段尽量使用indexsorting 预排序, 因为在段内分配docId时,会根据sorting的顺序分配 , 取出的倒排链天然有序, 美团有个最佳时间, 将有序的docId使用RLE算法压缩了。
如果使用范围查询, 尽量减少范围大小,防止底层生成的pointRangeQuery过多,比如简历索引查询候选人年龄时。 原先的生日存储的是精确到s的时间戳,后来改成了天级别。
排序字段增加docvalue和index sorting ,列存查的快, index sorting 可以使倒排链有序
如果要返回的字段不多,可以使用docValue代替source,因为source是行存,它需要把一行完整的数据取出来再使用部分字段,读取和序列化开销都很大。docvalue是列存,取指定列会很快。
Solr 和es选型对比
他们都是基于lucene做的全文检索系统, 最大的区别是slor适合于固定数据的检索, 针对于持续动态添加数据就有点吃力, es影响不大。 而且slor的运维成本略高一点, 需要配合zk做选举,需要使用Tomcat做容器启动, es是整体全内置。