elasticsearch是近年来比较火的分布式查询中间件了,elasticsearch的搜索引擎是LUCENE,前几年比较火的solr的搜索引擎也是lucene,能被那么多的中间件作为搜索引擎,我们看下他到底有什么神奇之处,先简单的说下lucene的数据结构:
常规的关系型数据库都是正排索引:
id | name | age | sex |
1 | 小明 | 10 | 女 |
2 | 小黑 | 12 | 男 |
从结构上来看,关系型数据库存储的结构化数据,对于扩展数据不太友好,增加一列的数据成本很高,特别是表中数据很多的时候,这种数据对于关联的查询体验非常好,同时也支持事务的概念,这点很重要,从这个方面来看,关系数据库更侧重于数据的完整性和一致性,强一致性的数据模型,如果要做分片支持的话不太容易,开源的组件不是特别多,下面看下非关系数据的特点:
name | id |
小明 | 1 |
age | id |
12 | 2 |
从结构上看非关系型数据库的数据是列式存储,扩展数据是很简单的,增加一列的成本很低,非关系型数据库追求数据的最终一致性,所以对于事务是不支持的,并且天然支持数据的分片,适合数据关系不复杂的情况,如果说查询快的话,一般是非关系型数据库,相对来说插入就有点慢了,如何实现倒排索引这种结构呢,一般就是我们所熟知的结构,字典,怎么实现字典,字符类型和数字类型的不太一样,现在分别说一下。
字典分为key和value,key的实现是FST,value(docId)的实现是倒排链,为什么这么实现?数据不一样,key为字符类型,value(docId)是数字类型,这里的字符类型特指key,
对于字符串alan和alice这二个字符串,共有了al这个共享的前缀,为什么要采用这个结构,这样就可以重复的利用共有的前缀,1,2的不明显,如果成千上万的话,可以节省很大一部分的空间,怎么实现FST,简单的说一下:比如我有alan和alice还有alert三个字符串,先对三个字符串进行排序,alan->alert->alice,然后通过char的字符数组,提取公共的字符,基本是这么实现的,如果有三个alan,docId为1,2,7,有二个alert,docId为3,5,有二个alice,docId为4,6,那么value该采用何种结构,就是下面要说的倒排链。
所谓的倒排链就是保存了排序的一堆docId数字,这些docID的key是一样的,存储是怎么存储的,很多人可能说是保存1,2,3,5,7,8,10,12,15这种办法,很显然不可能,采用的是差值存储,1,1,1,2,2,1,2,2,3,那么这些差值怎么存储,很显然也不是直接存储,感兴趣的直接搜索下google的zigzag,但是这里因为只有正数,压缩稍有不同,但基本一样。这种压缩对于比较小的数字压缩比较好,大的就不行了,既然是顺序的结构,后面还需要合并结果集,必然要进行加速,这里加速的结构就是skiplist跳表。
这种结构就类似于二分查找了,例如查询5,第一层是15,大于5,查找第0层的8,大于5,3小于5,位于3-8之间,再从小到大遍历原链接,就找到了5,后面会详细的说这个,为什么要这个,怎么构建skiplist,lucene里是每128算一个阶段,来了第一个128,也就是到了3的位置,在第0层插入128的docId,第二个128就到了8的位置,在第0层把第二个128的docId插入到8的位置,知道第8个128,在第0层插入第8个的128,通过时在第一层15的位置插入第8个128的docId,一层层的往上,除了原链接那一层是128作为一个阶段,上面的每一层都是以8位阶段,第0层代表128,第1层代表128*8,以此往上代表每隔阶段有的docId的个数。
从上面也能看出字符类型结构的设计就是为了做精确查询,而现在我们说下数字类型,数字类型一般就是做range范围查询,所以结构就是为了range查询而设计,如果在实际的运用当中,你的数字类型不是为了范围查询,不放把数字类型设置为字符串类型,会给你一个惊喜的。
bkd主要的服务领域是多维数据,我们大多数用的是一维的模型,就是二叉树,大家都很熟悉,lucene都是以块存储的,1024作为一个块也就是一个leaf,就是最下面的那个,到此为止,本篇也不熟主要讲这些结构,讲这些每篇都可以单独讲,我在这只是简单的说了一下,下面到了本篇的重点:lucene查询分析-结果合并。
下面先贴代码,给予lucene6.5.1,这些数据都是为了测试,后面会说查询,感兴趣可以运行一下:
public void testLucene2() throws Exception {
Article article = new Article();
article.setId(116L);
article.setAuthor("lucene");
article.setTitle("学习lucene");
article.setContent("学lucene!");
article.setUrl("https://blog.csdn.net/fly910905/article/details/81190382");
String indexPath = "/Users/wangliyong1/wly/lucene";
Path path = Paths.get(indexPath);
FSDirectory fsDirectory = FSDirectory.open(path);
IndexWriterConfig config=new IndexWriterConfig();
config.setUseCompoundFile( false);
//指定了写入数据目录和配置
IndexWriter indexWriter = new IndexWriter(fsDirectory, config);
String s = "";
//通过IndexWriter写入
//128 * 8 * 8
for ( int i = 0; i < 128 * 8 * 8; i++) {
if (i < 100) {
s = "1010101010101010" + "0" + "大熊猫000";
} else if (i % 10 == 1) {
s = "1010101010101010" + i + "大熊猫111";
} else if (i % 10 == 2) {
s = "1010101010101010" + i + "大熊猫222";
} else if (i % 10 == 3) {
s = "1010101010101010" + i + "大熊猫333";
} else if (i % 10 == 4) {
s = "1010101010101010" + i + "大熊猫444";
} else if (i % 10 == 5) {
s = "1010101010101010" + i + "大熊猫555";
} else if (i % 10 == 6) {
s = "1010101010101010" + i + "大熊猫666";
} else if (i % 10 == 7) {
s = "1010101010101010" + i + "大熊猫777";
} else if (i % 10 == 8) {
s = "1010101010101010" + i + "大熊猫888";
} else if (i % 10 == 9) {
s = "1010101010101010" + i + "大熊猫999";
}
article.setTitle(s);
/* "学数据,像毕老师一样牛"+ s +"!" */
article.setContent(s);
if (i < 10 ) {
article.setId(10L);
article.setStudentId(10L);
} else {
article.setId((long) i);
article.setStudentId((long) i);
}
//创建一个文档对象
Document document = article.toDocument();
System.out.println(document);
indexWriter.addDocument(document);
}
indexWriter.close();
}
public class Article {
/**
* 主键
*/
private Long id;
/**
* 学生id
*/
private Long studentId;
/**
* 标题
*/
private String title;
/**
* 内容
*/
private String content;
/**
* 作者
*/
private String author;
/**
* 链接
*/
private String url;
public Article() {
}
public Article(Long id, String title, String content, String author,
String url) {
super();
this.id = id;
this.title = title;
this.content = content;
this.author = author;
this.url = url;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Long getStudentId() {
return studentId;
}
public void setStudentId(Long studentId) {
this.studentId = studentId;
}
/**
* 生成Lucene存储的格式
*/
public Document toDocument() {
//Lucene存储的格式(Map装的k,v)
Document doc = new Document();
//向文档中添加一个long类型的属性,建立索引 index point
doc.add(new LongPoint("id", id));
//在文档中存储 存储
doc.add(new StoredField("id", id));
doc.add(new NumericDocValuesField("id", id));
doc.add(new LongPoint("studentId", studentId));
//在文档中存储 存储
doc.add(new StoredField("studentId", studentId));
doc.add(new NumericDocValuesField("studentId", studentId));
//设置一个文本类型,会对内容进行分词,建立索引,并将内容在文档中存储
//TextField term index 存储
doc.add(new StringField("title", title, Store.YES));
//设置一个文本类型,会对内容进行分词,建立索引,存在文档中存储 / No代表不存储
doc.add(new StringField("content", content, Store.YES));
//StringField,不分词,建立索引,文档中存储
doc.add(new StringField("author", author, Store.YES));
//不分词,不建立索引,在文档中存储, 存储
doc.add(new StoredField("url", url));
return doc;
}
/**
* 生成Lucene存储的格式
*/
public Document toDocument2() {
//Lucene存储的格式(Map装的k,v)
Document doc = new Document();
//向文档中添加一个long类型的属性,建立索引
doc.add(new LongPoint("id", id));
//在文档中存储
doc.add(new StoredField("id", id));
doc.add(new LongPoint("id111", id));
//在文档中存储
doc.add(new StoredField("id111", id));
//设置一个文本类型,会对内容进行分词,建立索引,并将内容在文档中存储
//TextField
doc.add(new StringField("title", title, Store.YES));
//设置一个文本类型,会对内容进行分词,建立索引,存在文档中存储 / No代表不存储
doc.add(new StringField("content", content, Store.YES));
//StringField,不分词,建立索引,文档中存储
doc.add(new StringField("author", author, Store.YES));
//不分词,不建立索引,在文档中存储,
doc.add(new StoredField("url", url));
return doc;
}
/**
* 解析Lucene存储的格式
*/
public static Article parseArticle(Document doc) {
Long id = Long.parseLong(doc.get("id"));
String title = doc.get("title");
String content = doc.get("content");
String author = doc.get("author");
String url = doc.get("url");
Article article = new Article(id, title, content, author, url);
return article;
}
@Override
public String toString() {
return "id : " + id + " , title : " + title + " , content : " + content + " , author : " + author + " , url : " + url;
}
}
TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
这个查询很简单,就是精确查询单个term,这里i<100,也就是前100个document,结果docId(1,2,3,4,5,6,7......100),然后遍历倒排链,查询所有的倒排链的document,在这里也用不上skiplist
TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
TermQuery query2 = new TermQuery(new Term("author", "lucene"));
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
builder.add(query2, BooleanClause.Occur.MUST);
这个查询是二个term的查询,少的是lead1,大的是lead2,小的去查大的,查询次数少嘛,第一个i<100,第二个是全部的数据,lucene这个时候就会拿着100个数据查询在第二个里面存不存在,当然这个查询并不走skiplist,因为我的数据i<100是顺序的,跳跃性不大,如果跳跃性很大,是会走skiplist,比如我第一个查1,第二个查2000,就会构建skiplist,加速query1在query2的情况下的查询,当然不会拿query2去查query1,除非脑子有问题,上面的例如query1结果docId(1,2,3,4,5,6,7......100),query2的结果集(1,2,3,4,5,6,7.......100......1000.....3000.....8192),这样就会拿1,去查query中有没有,有的话就放到list,这样就过滤了结果集。
TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
TermRangeQuery query2 = TermRangeQuery.newStringRange("title", "0",
"101010101010101099大熊猫999", true, true);
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
builder.add(query2, BooleanClause.Occur.MUST);
在说之前先说一下,如非必要不要进行term的range查询,效果不好,上面的queri1查询的结果集docId(1,2,3,4,5,6,7......100),query2的查询怎么实现,query2县构建一个结构就是int数组,数组包含query2的range左边的值作为min,也就是0,右边的值作为max,实际上是下面这种结构:
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | min |
1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | max |
然后会搜索所有的term然后和这个构建的结构比较,得到所有的term,然后把所有的term构建一个bitMap位图,也可以叫bitSet,叫法不一样,然后拿query1的结果集去查query2,这里不论多少,都是拿query1去查query2。
TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
TermQuery query2 = new TermQuery(new Term("author", "lucene"));
TermRangeQuery query3 = TermRangeQuery.newStringRange("title", "0",
"101010101010101099大熊猫999", true, true);
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
builder.add(query2, BooleanClause.Occur.MUST);
builder.add(query3, BooleanClause.Occur.MUST);
这个组合就是拿1个query1的值比如docId=1,查询query2中是不是存在docId=1,这里可能用到skiplist,看数据跨越性,然后返回docId=1,然后拿docId=1去查query3的bitSet里有没有,如果有的话就放到list,就这样遍历完所有的query1的数据,就把最后的结果集返回。
Query query1 = LongPoint.newExactQuery("id", 116);
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
这里是很具有迷惑性的一个行为,其实在point的精确查询是不存在的,所有的查询是被转化为:
Query query1 = LongPoint.newRangeQuery("id", 116, 116);
所有的精确查询都会被转化为range查询,数字类型和字符类型的range不太一样,因为term的倒排链是顺序的,而range的是没排序的,例如下面:
point | 1 | 2 | 3 | 4 | 5 | 5 |
docId | 10 | 4 | 5 | 1 | 3 | 2 |
对于不同的point(1,2,3,4,5),docId是无序的很好理解,对于同一个point怎么也是无序的,这就得说lucene的排序采用了基数排序(稳定排序)和内省排序(不稳定排序),因为在数据量小的情况采用内省排序,是不稳定的排序,会存在乱序的情况。查询的结果是无序的,因此会构建一个bitset,返回结果集。
Query query1 = LongPoint.newRangeQuery("id", 11, 200);
TermQuery query2 = new TermQuery(new Term("author", "lucene"));
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
builder.add(query2, BooleanClause.Occur.MUST);
这个就和4.3完全一致,可以看上面的,不多做分析了。
其实这些就是lucene里的ConjunctionDISI,BitSetConjunctionDISI的实现,当然还有一个ConjunctionTwoPhaseIterator,原理基本上是一致的和上面,比如加入下面的语句:
Query query4 = LongPoint.newExactQuery("id", 116);
builder.add(query4, BooleanClause.Occur.MUST_NOT);
这句就会执行上面的ConjunctionTwoPhaseIterator,就是拿到上面的结果集再进行过滤一次,因为上面是must_not。
lucene其实是有IndexOrDocValueQuery的,只是没有做优化,这也突出了lucene是查询引擎,底层的东西都有,根据实际情况,使用者可以自己优化,具体操作是下面:
Block k-d tree的特性可以看出,rangeQuery的结果集比较小的时候,其构造bitset的代价很低,不管是从他开始迭代做nextdoc(),或者从其他结果集开始迭代,对其做advance,都会比较快。 但是如果rangeQuery的结果集非常巨大,则构造bitset的过程会大大延缓scorer对象的构造过程,造成结果合并过程缓慢。
这个问题官方其实早已经意识到了,所以从ES5.4开始,引入了indexOrDocValuesQuery作为对RangeQuery的优化。(参考: better-query-planning-for-range-queries-in-elasticsearch)。 这个Query包装了上面的PointRangeQuery和SortedSetDocValuesRangeQuery,并且会根据Rang查询的数据集大小,以及要做的合并操作类型,决定用哪种Query。 如果Range的代价小,可以用来引领合并过程,就走PointRangeQuery,直接构造bitset来进行迭代。 而如果range的代价高,构造bitset太慢,就使用SortedSetDocValuesRangeQuery。 这个Query利用了DocValues这种全局docID序,并包含每个docid对应value的数据结构来做文档的匹配。 当给定一个docid的时候,一次随机磁盘访问就可以定位到该id对应的value,从而可以判断该doc是否match。 因此它非常适合从其他查询条件得到的一个小结果集作为迭代起点,对于每个docid依次调用其内部的matches()函数判断匹配与否。也就是说, 5.4新增的indexOrDocValuesQuery将Range查询过程中的顺序访问任务扔给Block k-d Tree索引,将随机访任务交给doc values。 值得注意的是目前这个优化只针对RangeQuery!对于TermQuery,因为实际的复杂性,还未做类似的优化,也就导致对于数值型字段,Term和Range Query的性能差异极大。
lucene到这里可以小结一下了,当然lucene的优秀的东西不知这些,这些只是一部分,这些我讲的也比较粗,lucene的压缩存储也是很优秀额一部分,索引组织形式和储存形式,很优秀,这篇没有机会讲了,有时间我再挑时间说吧。