最近因为一个项目涉及到了搜索相关的需求,elasticsearch可以说是目前最流行,最受欢迎的企业搜索引擎,所以在技术选型上自然的选择ES作为搜索引擎的实现,由于自己也是第一次接触elasticsearch,故写下这篇文章总结下自己在使用学习ES过程中的一些经验教训。
Elasticsearch是一个开源的分布式、RESTful 风格的搜索和数据分析引擎,它的底层是开源库Apache Lucene,Lucene 可以说是当下最先进、高性能、全功能的搜索引擎库——无论是开源还是私有,但它也仅仅只是一个库。为了充分发挥其功能,你需要使用 Java 并将 Lucene 直接集成到应用程序中,且lucene的使用十分负责,你需要了解很多搜索领域的专业知识才可能明白它的原理,为了解决lucene的易用性问题,Elasticsearch应运而生。
Elasticsearch不仅仅是对Lucene做了简单的封装,它本身是一个可以独立部署的高可用的分布式系统,可弹性伸缩支持多至上百个节点,且有配套的可视化管理平台(kibana),极大的降低elasticsearch的运维成本,它封装了一套统一而又简洁的REST接口,对外提供搜索能力,所以依赖的系统可以是任何语言实现的,只需要通过HTTP请求与elasticsearch交互即可,除此之外,依托于分布式的优势,Elasticsearch的性能十分优秀,可轻松支持数亿量级的文档搜索。由于Elasticsearch的功能强大和使用简单,维基百科、卫报、Stack Overflow、GitHub等都纷纷采用它来做搜索。现在,Elasticsearch已成为全文搜索领域的主流软件之一。
elasticsearch的应用场景非常广泛,由于它是一个搜索引擎,所有依赖于搜索能力的场景都可以用到elasticsearch,包括但不限以下所列举的场景:
1.日志检索,最常用的场景,事实上,elasticsearch+logstash+kibnana已经快成为日志检索的标准技术栈了。
2.指标的度量和观测,elasticsearch拥有强大的数据聚合能力,可以针对给定的数据做各种维度的聚合操作(求平均/求和/求最大值等),且响应速度很快,适合做一些系统指标的监控和度量(如CPU的负载变化趋势,最大值/最小值/平均值等)。
3.地理位置搜索,elasticsearch支持地址位置搜索,基于这个强大的能力我们可以实现一些有趣的能力:如搜索附近的人/店铺/停车场等信息。
4.任何需要用到全文搜索的场景,如媒体网站(文章搜索),社区问答(搜索答案或者问题)等等。
这里主要介绍下一些比较基础且重要的概念,elasticsearch本质上也是一种NoSql数据库,很多概念其实可以和关系型数据库匹配上。
●index:索引,类似于mysql中的database,一个索引就是一个库,一般具有相同特性的document会存放在一个index里,index需要定义对应的setiings,如配置索引的刷新时间/分片数量,分析器等配置信息。
●type:type是定义在index下的,类似于数据库中的表,一个索引下可以有多个type,type必须定一个mapping信息,即定一个这个type有哪些字段,分别是什么类型,索引时如何处理这个字段。(注:type在6.0版本后已经逐渐被废弃了,也就是说一个index就对应一张表了。)
●document:文档,是es中可被索引的最小单位,类似于数据库中的一行记录,以json格式序列化保存,每个document都会有一个ID主键和一个版本号,document是不可更改的,每次更新都是将之前的记录删除,再保存新的记录,并将版本号+1。
●shard:分片,elasticsearch是分布式的,分片就是用来定义索引的数据是如何分布的,每个index索引都需要定义主分片/和副本分片的数量,ES会根据分片的数量来将索引的数据均匀的分发到集群的节点中,充分利用集群的优势,不过这里需要注意主分片的数量是不可改变的,所以我们在使用ES的时候需要做好容量规划,如果后续需要增加分片时,则需要重新索引数据。
如果我们要搜索某段文本中是否包含某个关键字,如果是关系型数据库,如mysql,则需要用到like模糊匹配查询,如select * from table where conent like'%keyword%',我们都知道这样的模糊匹配是无法走索引的,这个sql会导致全表扫描,当数据量很大时,速度就很慢了,而elasticsearch却能很快的搜索出对应的文档,这是因为elasticsearch采用了一种叫“倒排索引”的数据结构来实现的,其实个人认为“倒排索引”这个翻译并不是很准确,叫“反向索引”可能更合适,如上诉所说,mysql的模糊匹配查询很慢是因为搜索的关键字没有的对应的索引,那如果在存储的时候,将文本内容中的关键字提取出来作为索引,不就可以通过关键字快速定位到某段文本内容了么,事实上elasticsearch就是这么做的,如果你将某个字段类型设置为text,elasticsearch在索引文档时,会对这个字段的值进行分析处理,将这个字段的值拆分成一个一个词条(Term),每个词条都会反向指向到文档,这样就可以通过关键字搜索到对应的文档内容了,举个例子,有以下3个文档:
文档1: "hello world"
文档2:"hello, nice to meet you"
文档3:"you are really nice"
在索引时,按照elasticsearch默认的分词器,文档1会被切分为"hello", "world"2个词条,文档2会被切分"hello","nice","to","meet","you"这几个词条,文档3则会被切分"you","are","really","nice"这几个词条。那么所构建出来的倒排索引如下表格所示:
词条 |
文档编号 |
hello |
["文档1","文档2"] |
world |
["文档1"] |
nice |
["文档2","文档3"] |
to |
["文档2"] |
meet |
["文档2"] |
you |
["文档2","文档3"] |
are |
["文档3"] |
really |
["文档3"] |
这样就可以很快的根据关键字搜索到对应的文档了,如果关键词不是很多的情况下,elasticsearch会将倒排索引都存放于内存中,所以搜索的速度很快,如果关键词很多的话,内存不能放下所有的内容,elasticsearch会前置再生成一层索引,类似于词典一样,如A开头的关键词在磁盘中的哪个位置,这样就可以减少磁盘的访问次数,提升性能,具体逻辑可以参考下图:
通过上诉分析我们可以发现,能否通过某个关键字搜索到对应的文档,取决于对文档字段的切词逻辑,在上面举的例子中,如果我搜索"hell"这个关键词是搜不到任何文档的,因为没有切分出"hell"这个词条,词条的切分在es中是非常重要的一部分,我们需要根据业务来选择合适的分词器,比如我们需要非常精准的匹配,那么我们可能就要选择ngram分词器(每1个或2个字符切分为一个词条,这样搜索的精准性很高,但是会产生大量的词条,性能和存储都消耗很大),又或者我们的文档内容都是中文,我们就需要选择适合中文的分词器(如ik分词器,中文的语法和英文不同,需要按照中文的规则来分词,才能产生比较好的搜索效果)。
当存在大量文档时,搜索某个关键字可能会匹配到非常多的文档,如果能将用户希望看到的文档排在前面,那将会极大的提高搜索的体验,但如何才能实现这个效果呢?elasticsearch默认采用了一种相关性评分的算法,ES会针对匹配到关键词的文档逐一打分,根据打分的结果进行排序,评分高的排在前面,即代表这个文档和关键字的相关性最高,这里简单介绍下ES的默认评分机制。
ES的评分机制与以下几个因素有关:
1.词频:词在文档中出现的频度是多少?频度越高,权重越高 。简单来说就是如果一个文档中出现了多次搜索的关键词,说明这个文档最有可能是用户想搜索到的。
2.逆向文档频率:词在所有文档中出现的频度是多少?频度越高,权重低,常用词如 “and” 或 “the” 对相关度贡献很少,因为它们在多数文档中都会出现。
3.字段长度归一值:字段的长度是多少?字段越短,字段的权重越高 。如果词出现在类似标题 title 这样的字段,要比它出现在内容 body这样的字段中的相关度更高。
elasticsearch会结合这几个因素计算出每个文档相对这个搜索的关键词的评分,并且按照评分排序返回结果,当然你也可以指定排序的字段,如按照日期倒序返回。
除此之外,elasticsearch支持自定义评分函数,如果你觉的内置的评分机制不符合业务诉求,可以在搜索的时候指定一个评分的函数(一段脚本),elasticsearch会用这段脚本去替换内置的评分逻辑。
上面有提到,elasticsearch是一个分布式的搜索引擎,有多个节点组成,每个索引都是由多个分片组成的(每个分片分布在不同的节点上),所以我们每次搜索的时候,收到请求的节点会作为客户端,向每个分片发起请求进行搜索,然后再将数据汇总进行处理(分页/排序),最终返回给接口调用方:
如果我们用过数据库的分库分表就知道,业务数据被打散在多个表时,如果需要同时查询多张表,那么数据的分页和排序都会有问题(排序不准确或者数量不正确),如果要解决这个问题就需要进行额外的操作,在性能上就会有一定的损失,elasticsearch的分片就类似分库分表,所以它也会遇到同样的问题,elasticsearch提供了4种搜索方式来供用户选择,是牺牲数据的准确性来提高性能,还是降低性能保证数据的准确:
1.query and fetch
向索引的所有分片都发出查询请求, 各分片返回的时候把元素文档 ( document)和计算后的排名信息一起返回。
这种搜索方式是最快的。因为相比下面的几种搜索方式,这种查询方法只需要去 shard查询一次。 但是各个 shard 返回的结果的数量之和可能是用户要求的size的n倍。
优点:这种搜索方式是最快的。因为相比后面的几种es的搜索方式,这种查询方法只需要去shard查询一次。
缺点:返回的数据量不准确,可能返回(N*分片数量)的数据并且数据排名也不准确,通过上面打分部分的描述我们知道,打分需要依赖“逆向文档频率”这个因素,这个因素需要计算关键词在所有文档种出现的频率,但是这种搜索方式只会在当前分片上去计算,所以这个因素其实是不准确的,导致评分也不准确。
2.query then fetch(ES默认搜索类型)
先向所有的 shard 发出请求,各分片只返回文档id(不包括文档内容)和排名相关的信息(也就是文档对应的分值), 然后按照各分片返回的文档的分数进行重新排序和排名,取前 size 个文档,然后再根据文档 id 去相关的 shard 取 document。这种方式返回的 document 数量与用户要求的大小是相等的。
优点:返回的数据量是准确的。
缺点:性能一般,并且数据排名不准确(不准确的原因同上)。
3.DFS query and fetch
逻辑大概和第一种搜索方式类似,只不过这种方式有个DFS的步骤,为了解决排名不准确的问题,也就是在进行查询之前, 先对所有分片发送请求, 把所有分片中的词频和文档频率等打分依据全部汇总到一块, 再执行后面的操作,由于有了全局的文档频率因素,所以排名是准确的
优点:排名准确。
缺点:返回的数据量不准确,性能一般。
4.DFS query then fetch
逻辑大概和第二种搜索方式类似,这种方式也解决了排名不准确的问题,而且返回的数据也是准确的,但是性能是最差的
优点:排名准确/数据准确。
缺点:性能很慢。
我们可以根据自己业务需求来选择对应的搜索方式,一般来讲默认的搜索方式可以满足大部分的诉求。
这里主要介绍下常用的一些API,elasticsearch提供了rest风格的API,要注意http的method类型,共有四种(GET/POST/PUT/DELETE)
1.创建一个索引
PUT /news-index // 索引名称
{
"mappings": { // 设置mappings
"news": { // 这是type
"properties": { // 设置属性字段
"create_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd'T'HH:mm:ss.SZ"
},
"content": {
"type": "text"
},
"author": {
"type": "text"
},
"news_type": {
"type": "keyword"
},
"title": {
"type": "text"
}
}
}
},
"settings": {
"index": {
"refresh_interval": "30s", // 索引刷新时间,索引写入不是立马生效,es会根据这个设置定时刷新索引
"number_of_shards": "5", // 主分片数量
"number_of_replicas": "1"// 副本分片数量
}
}
}
2.更新索引配置
一般只能修改副本数量和刷新时间,主分片数量不可修改
PUT news-index/_settings
{
"refresh_interval": "10s", // 修改刷新时间
"number_of_replicas" : "2" // 修改副本数量
}
3.写入文档
写入文档可以指定ID,也可以不指定,不指定的话elasticsearch会帮我们生成
//指定id,这个1就是ID
POST /news-index/_doc/1
{
"create_date":"2022-02-11 13:50:53",
"content":"这是一条新闻内容",
"title":"这是一条新闻标题",
"author":"作者名称",
"news_type":"type1"
}
4.更新文档
PUT /news-index/_update/101
{
"doc":{
"content":"更新内容",
}
}
5.搜索文档
搜索接口是elasicsearch最复杂的接口,ES设计了一套强大而又复杂的基于json格式的DSL搜索语法,详细的语法可以参考官网,这里只介绍一些常用的搜索语法
●精确匹配:
GET /news-index/_search
{
"size":10, // 分页参数
"from":0,
"query":{
"term":{
"news_type":"type1" // 精准匹配news_type
}
}
}
这里需要注意term query不能用于text类型的字段搜索,上面有提到,text类型的字段会被分析切词,切分成多个词条,而且有可能会过滤掉一些字符,term query属于精确匹配,去搜索text类型的字段基本上匹配不上。
●分词匹配:
GET /news-index/_search
{
"size":10, // 分页参数
"from":0,
"query":{
"match":{
"content":"quick brown fox"
}
}
}
match匹配属于比较宽松的匹配,只要倒排索引中命中这2个关键字即可,不会考虑关键词在字段内容中出现的顺序,比如上面的搜索,可以搜索到“The quick brown fox jumps over the lazy dog”,也可以搜索到“the brown fox was very quick”,如果需要更准确的匹配,可以用短语匹配。
●短语匹配
短语匹配和分词匹配的逻辑类似,会现在倒排索引里寻找对应的词条,然后根据词条找到对应的文档,但是短语匹配会检查词条在文档中的位置,必须要符合顺序且是相邻的才算匹配上
GET /news-index/_search
{
"size":10, // 分页参数
"from":0,
"query":{
"match_phrase":{
"content":"quick brown fox",
"slop":0
}
}
}
这里会严格匹配“quick brown fox”,以上面举的例子为例,这个搜索就只能搜到“The quick brown fox jumps over the lazy dog”,当然有时候我们不希望这么严格的匹配,这里有一个slop参数可以调节,slop参数代表可以搜索的词条之间最多可以相差几个位置,比如上面这个搜索 我们设置slop=1,搜索词改为“quick fox“,那么我们还是可以匹配到“The quick brown fox jumps over the lazy dog”,因为短语匹配会检查词条出现的顺序和配置,所以它的性能比match要慢的多,大概有10倍的差距。
●bool查询
elasticsearch的搜索DSL强大之处就在于它支持逻辑连接符,你可以用bool查询来组合搜索条件,类似于SQL中的"AND"和"OR"连接符。举个例子:
GET /news-index/_search
{
"from": 0,
"size": 10,
"query": {
"bool": {
"should": [
{
"bool": {
"must": [
{
"match_phrase": {
"content": "quick brown fox",
"slop": 0
}
},
{
"term": {
"news_type": "type1"
}
}
]
}
},
{
"bool": {
"must": [
{
"match_phrase": {
"content": "quick fox",
"slop": 1
}
},
{
"term": {
"news_type": "type2"
}
}
]
}
}
]
}
}
}
这里的must就相当于and,should代表or,上面的搜索代表需要短语匹配“quick brown fox”且news_type=type1 或者 短语匹配“quick fox”且news_type=type2的文档,elasticsearch的bool匹配十分强大,可以构造出逻辑很复杂的搜索请求。
上面介绍的是ES的一些入门的用法,下面我将结合自己在使用ES过程的一些经验,介绍下ES的一些进阶知识。
ES的主分片数量是不可修改的,一旦我们某个索引达到了容量上线,就必须要做索引迁移,将数据迁移到新的索引中(分片数量更多),如何才能在不影响业务的情况下平滑的迁移索引呢?
这里先介绍下ES的2个能力,可以基于这2个能力做到索引数据的平滑迁移
1.reindex:ES提供了reindex api,可以将一个索引的数据迁移到另一个索引,但需要注意的是迁移的数据是调用这个接口时间点的快照数据,迁移过程中的变更是不会迁移的。
2.别名:一个简单而又实用的能力,ES支持给索引设置别名,其实就一个映射关系,如给es-index1设置别名"es-index",调用接口搜索数据时通过别名来搜索,当es-index1的数据迁移到es-index2时,只需要将别名的映射改为es-index2,代码层面无需改动,即可搜索新的数据,数据的平滑迁移就是考别名来实现的。
有了这2个能力基本上可以做到数据的平迁,但是还需要解决一个问题就是迁移过程中的变更,比较简单粗暴的办法就是禁止数据写入,这样在迁移过程中不会产生新的变更,但是这样业务上有损,所以一个平滑迁移的办法就是做双写,在迁移的过程中,既向老索引里写入/变更数据,也向新索引里写入/变更数据,同时reindex需要设置为“只创建新索引里没有的数据”。
除此之外,双写的逻辑也要根据业务场景来考虑,比如更新或删除一条数据时,如果新的索引里还没有这条数据(可能reindex还没处理到这条数据),那么要先从老索引里同步过来再更新,或者写入ES每次都是全量更新,就不用考虑这个问题了。
elasticsearch的性能调优是个复杂的工程,要根据具体的场景来选择优化的手段,这里主要介绍下我在优化ES性能过程中的一些经验。
1.至少预留一半的系统内存给file system cache,我们都知道es的数据都是存储在磁盘上的,如果file system cache越大,能够缓存的数据越多,则性能越好
2.控制分片的大小,一个分片的大小尽量不超过30G,其实和上面的道理一样,如果分片容量过大,基本上是走磁盘访问了,缓存不了多少数据,性能会大大降低。
3.控制分片的数量,上面也有提到,搜索的时候会对每一个分片发起请求,而一个分片底层对应一个Lucene索引,都会占用系统的资源,如果分片数量过多,请求一次的话会导致CPU飙高,推荐是一个索引在一个节点上的主分片不超过5个。
4.使用自定义路由,因为我们要控制分片的大小,在数据量大的情况下势必会导致分片的数量很多,为了优化性能,我们可以利用elasticsearch的一个高级性能:自定义路由,es在搜索和索引数据时都可以传入一个路由参数,ES会根据这个参数计算这个文档应该索引到哪个分片上,我们可以根据业务场景选择一个合适的路由字段,如用户ID,这样在搜索的时候带入用户ID,就只会搜索一个分片数据,这个是提升性能的大杀器,当然并不是所有的场景都适合自定义路由,如需要搜索全局数据的情况下就不适合。
5.只索引需要搜索的字段内容,有些不需要搜索的内容可以不索引,只存储一个ID值,搜索到对应的数据后再根据ID去对应的源数据查询详情补全数据。