QBM、MFS的试题检索、试题查重、公式转换映射等业务场景以及XOP题库广泛使用搜索中间件,业务场景有着数据量大、对内容搜索性能要求高等特点,其中XOP题库数据量更是接近1亿,对检索性能以及召回率要求高。目前QBM、MFS使用的搜索中间件是Solr,后续需要升级为ES。
看的书是《ElasticSearch源码解读与优化实战》的前半部分(与这篇博客部分内容重合),主要是ES的一些工程模块,分布式集群的一些理论知识。Lucene的部分知识主要来源一些写的比较全面的博客,Lucene涉及的数据结构与算法比较复杂,其中涉及的如FST前缀字典、列式存储数据压缩、ES相关的分布式Paxos算法细节等都是很复杂,还是值得思考研究下。
//TODO 该博客主要使用ES、Lucene过程一些小计以及一些原理分析,初学原理涉及的深度难免不够,不过后续随着学习内容持续更新ing…
ES是什么?
非关系型、搜索引擎、近实时搜索与分析、高可用、天然分布式、横向可扩展。
ElasticSearch是一款非常强大的、基于Lucene的开源搜索及分析引擎;它是一个实时的分布式搜索分析引擎,它能让你以前所未有的速度和规模,去探索你的数据。属于NoSQL文档性DB的一种,内容检索性能是最大的优势。
实时搜索:实时搜索(Real-time Search)很好理解,对于一个数据库系统,执行插入以后立刻就能搜索到刚刚插入到数据。而近实时(Near Real-time),所谓“近”也就是说比实时要慢一点点。像常用的MySQL等关系型数据库不能称之为实时搜索数据库,MySQL可以配置为提供较低的延迟和更高的实时性能。但是,MySQL的实时性取决于多个因素,包括硬件性能、数据库设计、查询优化和负载等因素。
全文搜索属于最常见的需求,开源的 Elasticsearch (以下简称 Elastic)是目前全文搜索引擎的首选。
它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github 都采用它。
图源:https://db-engines.com/en/ranking
主要功能:
1)海量数据的分布式存储以及集群管理,达到了服务与数据的高可用以及水平扩展;
2)近实时搜索,性能卓越。对结构化、全文、地理位置等类型数据的处理;
3)海量数据的近实时分析(聚合功能)
应用场景:
1)网站搜索、垂直搜索、代码搜索
2)日志管理与分析、安全指标监控、应用性能监控
常用非结构化数据存储中间件区别?ES、Solr、MongoDB都属于NoSQL的家族的一员
参考:
ES下载:https://www.elastic.co/cn/downloads/elasticsearch
ES在使用容器安装:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
练习测试直接使用docker安装8.9.1 版本的ES、Kibana。(建议不要安装这么新的,否则会有很多坑)
# 一、安装es和kibana
# 拉取es镜像
docker pull docker.elastic.co/elasticsearch/elasticsearch:8.9.1
# 创建一个新的Docker网络,通过创建自定义的Docker网络,你可以轻松地管理容器之间的通信,并根据需要隔离它们。这在多容器应用程序和微服务架构中特别有用。
docker network create elastic-demo
# 启动es容器
# 使用-m标志设置容器的内存限制。这样就不 需要手动设置JVM大小。
docker run --name es01 --net elastic-demo -p 9200:9200 -it -m 1GB docker.elastic.co/elasticsearch/elasticsearch:8.9.1
# 该命令打印elastic用户密码和Kibana的注册令牌。
# 从新生成,为elastic用户设置密码elastic
docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic -i elastic
docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana
# kibana的token,等会要用,eyJ2ZXIiOiI4LjkuMSIsImFkciI6WyIxNzIuMTguMC4yOjkyMDAiXSwiZmdyIjoiNTU0ZDQyY2Y3OGZmZTUwYmEzZWExZjk2ZTljOWM4YmQyOTIwYjc2OTA0ZWY4OWEwZWI5YzkwNDU4YjUzNjNmNyIsImtleSI6InRQRTFXb29CRWF5NzJYQmM1cTFqOkNUU2tOOHF0VDc2b2xLbHRwNWtLTEEifQ==
# 启动es报错1:https://discuss.elastic.co/t/elasticsearch-bootstrap-checks-failing/302442
# 需要设置 vm.max_map_count 至少 262144
# 编辑vm.max_map_count内核设置必须至少设置为262144,以供生产使用。
# 查看
grep vm.max_map_count /etc/sysctl.conf
# 临时设置
# 永久设置要永久更改vm.max_map_count设置的值,请更新 /etc/sysctl.conf的值。
sysctl -w vm.max_map_count=262144
# 二、本地测试es
# 我们建议将elastic密码作为环境变量存储在shell中。范例:
export ELASTIC_PASSWORD="elastic"
# 将http_ca.crt SSL证书从容器复制到本地计算机。
docker cp es01:/usr/share/elasticsearch/config/certs/http_ca.crt .
curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD https://localhost:9200
# 输出
[root@hecs-148865 ~]# curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD https://localhost:9200
{
"name" : "7e48b6d68e30",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "zak41dJ-Q6qb4DPckXn7fQ",
"version" : {
"number" : "8.9.1",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "a813d015ef1826148d9d389bd1c0d781c6e349f0",
"build_date" : "2023-08-10T05:02:32.517455352Z",
"build_snapshot" : false,
"lucene_version" : "9.7.0",
"minimum_wire_compatibility_version" : "7.17.0",
"minimum_index_compatibility_version" : "7.0.0"
},
"tagline" : "You Know, for Search"
}
# 关闭es的ssl证书校验,否则整合springboot使用需要证书,很麻烦,修改elasticsearch.yml文件设置 xpack.security.enabled: false
# 将docker文件复制到本地修改完再上传
docker cp es01:/usr/share/elasticsearch/config/elasticsearch.yml /home/docker/mydata/elastic-search/elasticsearch.yml
docker cp /home/docker/mydata/elastic-search/elasticsearch.yml es01:/usr/share/elasticsearch/config/elasticsearch.yml
# 修改完es,发现kibana连接不上了,kibana.yml也需要修改
docker cp kibana:/usr/share/kibana/config/kibana.yml /home/docker/mydata/elastic-search/kibana.yml
docker cp /home/docker/mydata/elastic-search/kibana.yml kibana:/usr/share/kibana/config/kibana.yml
# 三、安装启动kibana
docker pull docker.elastic.co/kibana/kibana:8.9.1
docker run --name kibana --net elastic-demo -p 5601:5601 docker.elastic.co/kibana/kibana:8.9.1
# 四、访问kibana面板
# http://0.0.0.0:5601/?code=376811
# http://120.46.82.xxx:5601/?code=537195
# Kibana:http://120.46.82.xxx:5601/app/dev_tools#/console
# ES-API:https://120.46.82.xxx:9200/,(8.x版本之后开启了SSL校验,需要HTTPS验证https://www.cnblogs.com/chaos-li/p/13667687.html,也可修改elasticsearch.yml关闭)
方便理解,类比关系型数据库的数据模型,ES的数据模型分为
Elasticsearch 支持如下简单域类型:
其他还有object、array、geo、binary
ES如何查询参考
以下是 Elasticsearch 中最常用的一些命令汇总,简单列举具体看文档
1、Index 命令:
创建一个索引:PUT /<index_name>
删除一个索引:DELETE /<index_name>
列出所有索引:GET /_cat/indices?v
2、Document 命令:
添加或更新文档:PUT /<index_name>/_doc/<document_id>
获取文档:GET /<index_name>/_doc/<document_id>
删除文档:DELETE /<index_name>/_doc/<document_id>
3、检索命令:
使用查询字符串搜索:GET /<index_name>/_search?q=<query_string>
使用请求体搜索:POST /<index_name>/_search
{
"query": {
...
}
}
4、聚合命令:
执行聚合操作:POST /<index_name>/_search
{
"aggs": {
...
},
"size": 0
}
5、映射命令:
获取索引映射定义:GET /<index_name>/_mapping
更新索引映射:PUT /<index_name>/_mapping
{
"properties": {
...
}
}
6、设置命令:
获取集群设置:GET /_cluster/settings
修改集群设置(实时生效):PUT /_cluster/settings
{
"persistent": {
...
},
"transient": {
...
}
}
DSL的写法很多,这里列举出练习demo的聚合查询
# 1、统计每个州的state聚合查询
GET /accounts/_search
{
"size": 0,
"aggs": {
"group_by_state": {
"terms": {
"field": "state.keyword"
}
}
}
}
GET /accounts/_search
# 2、嵌套聚合查询
# 对每个州的state分组的基础上,聚合求出平均balance
GET /accounts/_search
{
"size": 0,
"aggs": {
"sichaolong": {
"terms": {
"field": "state.keyword"
},
"aggs": {
"average_balance": {
"avg": {
"field": "balance"
}
}
}
}
}
}
# 3、聚合结果排序查询
GET /accounts/_search
{
"size": 0,
"aggs": {
"group_by_state": {
"terms": {
"field": "state.keyword",
"order": {
"average_balance": "desc"
}
},
"aggs": {
"average_balance": {
"avg": {
"field": "balance"
}
}
}
}
}
}
多种条件组合的查询,在ES中叫做复合查询,ES提供5种复合查询方式。
具体的用法直接看官网文档。
match以及相关的match_phrase、match_phrase_prefix 查询本质上是term查询的组合。
match查询和term查询是Elasticsearch中两种常用的查询类型,它们在处理方式上略有不同。需要注意的是,在Elasticsearch中,text字段通常适合使用Match查询,而keyword字段适合使用Term查询,这取决于你想要实现的具体需求和查询场景。
Match查询
例如,对于一个名为"title"的字段,使用Match查询可以执行如下查询:
{
"query": {
"match": {
"title": "quick brown fox"
}
}
}
Term查询
例如,对于一个名为"user.keyword"的关键字字段,使用Term查询可以执行如下查询:
{
"query": {
"term": {
"user.keyword": {
"value": "john smith"
}
}
}
}
match查询的步骤
下面查询1、2两个查询结果是一致的,查询3、4两个查询结果是一致的
# 查询1
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": "BROWN DOG",
"operator": "or" # 默认被省略了,缺省就是or
}
}
}
# 查询2
GET /test-dsl-match/_search
{
"query": {
"bool": {
"should": [
{
"term": {
"title": "brown"
}
},
{
"term": {
"title": "dog"
}
}
]
}
}
# 查询3
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": "BROWN DOG",
"operator": "and"
}
}
}
# 查询4
GET /test-dsl-match/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"title": "brown"
}
},
{
"term": {
"title": "dog"
}
}
]
}
}
另外match的匹配精度也是可以配置的,如果用户给定 3 个查询词,想查找至少包含其中 2 个的文档,该如何处理?
将 operator 操作符参数设置成 and 或者 or 都是不合适的。
match 查询支持 minimum_should_match 最小匹配参数,这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。
我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量,查询5、6结果也是等价的。
# 查询5
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": {
"query":"quick brown dog",
"minimum_should_match": "75%"
}
}
}
}
# 查询6
GET /test-dsl-match/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "quick" }},
{ "match": { "title": "brown" }},
{ "match": { "title": "dog" }}
],
"minimum_should_match": 2
}
}
}
match_phrase本质上是多个有序term查询。
前面说match如果涉及多个词会被拆分为多个term查询,而且多个term是按照or查询的。
如果想查询某个段落,可以使用match_pharse、match_phrase_prefix。关于两者是有差别的,match_phrase往往会被认为是查询字符串不被分词,直接去文档检索,这是错误的,其实match_phrase也是会对查询字符串进行分词的,只不过相比match那种方式,分词之后的顺序是保证的。而match_phrase_prefix对应的是上述情况。
match_phrase本质是连续的term的查询,所以f并不是一个分词,不满足term查询,所以最终查不出任何内容了。
而match_phrase_prefix可以查到。另外还有一个match_bool_prefix,本质上可以转化为
GET /test-dsl-match/_search
{
"query": {
"bool" : {
"should": [
{ "term": { "title": "quick" }},
{ "term": { "title": "brown" }},
{ "prefix": { "title": "f"}}
]
}
}
}
类似match的查询
对于term查询 // TODO
使用方式很多,官网推荐使用Java API Client,Spring Data Elasticsearch是高级封装,随着依赖的升级以及ES的升级,可能后续不是很容易维护。
参考:
全文检索可以分为索引、搜索返回两个阶段,过程主要分为四个部分
Lucene概述
Apache Lucene™是一个 完全用Java编写的高性能、全功能搜索引擎库。https://lucene.apache.org/core/index.html
Lucene的目的是为软件开发人员提供一个简单易用的工具包,You need four JARs: the Lucene JAR, the queryparser JAR, the common analysis JAR, and the Lucene demo JAR.
以下是常见的Lucene JAR包:
使用Lucene代码demo
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.*;
import org.apache.lucene.search.*;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import pojo.Student;
import java.io.IOException;
import java.nio.file.Paths;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* @Auther: sichaolong
* @Date: 2023/9/5 14:29
* @Description: 简单使用Lucen工具包
*/
public class LuceneDemo {
// 模拟数据
public static final List<Student> STUDENT_LIST = new ArrayList<Student>(
Arrays.asList(
new Student("1", "张三", 18, "北京市海淀区温泉镇", "法外狂徒"),
new Student("2", "李四", 19, "北京市海底区东升镇", "唱、跳、rap"),
new Student("3", "王武", 20, "北京市海淀区上庄镇", "吸烟、喝酒、烫头"),
new Student("4", "王五", 21, "北京市海淀区苏家坨镇", "点烟、倒酒、给别人烫头"),
new Student("5", "麻六", 18, "北京市海淀区西北旺镇", "吃饭、喝酒"),
new Student("6", "酸菜", 17, "统一老坛酸菜牛肉面", "带着酸菜"),
new Student("7", "麻辣", 10, "统一麻辣牛肉面", "带着没有牛肉的牛肉面"),
new Student("8", "老母鸡", 14, "康师傅老母鸡汤面", "没有老母鸡的老母鸡面"),
new Student("9", "酱香", 15, "酱香味小龙虾", "88元一斤"),
new Student("10", "蒜蓉", 19, "蒜蓉味小龙虾", "100元一斤")));
// 数据存储路径
private static final String INDEX_PATH = "./lucene-data-demo/index";
public static void main(String[] args) throws IOException, ParseException {
// createIndex();
search();
}
/**
* 创建索引功能的测试
*
* @throws Exception
*/
public static void createIndex() throws IOException {
// 1. 创建文档对象
List<Document> documents = new ArrayList<Document>();
for (Student student : STUDENT_LIST) {
Document document = new Document();
// 2. 给文档对象添加域
// add方法: 把域添加到文档对象中, field参数: 要添加的域
// TextField: 文本域, 属性name:域的名称, value:域的值, store:指定是否将域值保存到文档中
document.add(new TextField("id", student.getId() + "", Field.Store.YES));
document.add(new TextField("name", student.getName(), Field.Store.YES));
document.add(new TextField("age", student.getAge() + "", Field.Store.YES));
document.add(new TextField("address", student.getAddress(), Field.Store.YES));
document.add(new TextField("desc", student.getDesc(), Field.Store.YES));
// 将文档对象添加到文档对象集合中
documents.add(document);
}
// 3. 创建分析器对象(Analyzer), 用于分词
Analyzer analyzer = new StandardAnalyzer();
// 4. 创建索引配置对象(IndexWriterConfig), 用于配置Lucene
IndexWriterConfig indexConfig = new IndexWriterConfig(analyzer);
// 5. 创建索引库目录位置对象(Directory), 指定索引库的存储位置,创建一个indexWriter对象,
IndexWriter indexWriter = new IndexWriter(FSDirectory.open(Paths.get(INDEX_PATH)), indexConfig);
indexWriter.addDocuments(documents);
indexWriter.commit();
// 6、关闭indexWriter对象。java11报错解决参考:http://community.jedit.org/?q=node/view/37964
indexWriter.close();
}
/**
* 搜索索引测试
*/
public static void search() throws IOException, ParseException {
// 创建一个Directory对象,也就是索引库存的位置。
Directory directory = FSDirectory.open(Paths.get(INDEX_PATH));
// 创建一个IndexReader对象,需要指定Directory对象。
IndexReader indexReader = DirectoryReader.open(directory);
// 创建一个indexSearcher对象,需要指定IndexReader对象。
IndexSearcher indexSearcher = new IndexSearcher(indexReader);
// 创建一个TermQuery对象,指定查询的域和查询的关键词。
Query query = new TermQuery(new Term("name", "张"));
// 执行查询
TopDocs topDocs = indexSearcher.search(query, 10);
System.out.println("查询结果的总条数:" + topDocs.totalHits);
// 返回查询结果,遍历查询结果并输出
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
//scoreDoc.doc属性就是document对象的id
//根据document的id找到document对象
Document document = indexSearcher.doc(scoreDoc.doc);
System.out.println(document.get("id"));
//System.out.println(document.get("content"));
System.out.println(document.get("name"));
System.out.println(document.get("address"));
System.out.println(document.get("desc"));
System.out.println("-------------------------");
}
/**
* 输出:
* 查询结果的总条数:1
* 1
* 张三
* 北京市海淀区温泉镇
* 法外狂徒
* -------------------------
*/
// 关闭indexReader对象
indexReader.close();
}
}
// TODO
如何实现"快"、"准"搜索,Lucene很复杂, 检索引擎最核心的部分就是索引的设计、数据的存储,重点关注索引如何设计?如何储存?用什么数据结构?数据如何组织?如何压缩?
从数据层面分析,整个Lucene把需要处理的数据分为这么几类, 前四种是所有检索引擎都会保存的数据,后三种是Lucene特有的
针对不同的数据结构采用不同的字典索引,倒排索引基于字典树使用了FST模型压缩索引,使用SkipList和BitSet加速多条件查询,磁盘存储组织PointValue组织形式基于BKDTree等结构加速范围查询。
参考:
概述:也常被称为反向索引,是一种索引方法,被用来存储在全文搜索下某个词条在一个文档或者一组文档中的存储位置的映射,它是文档检索系统中最常用的数据结构。
采用映射表记录哪些词条出现在哪些文档中,然后实现快速检索。
随着的词条的增多,这个倒排记录表也越来越大,倒排记录也越来越多,每次遍历查找的效率不高肯定不行。而且全部将倒排记录加载进内存也吃不消。
因此Lucene在前面倒排记录表前加了一层,增加一个字典结构索引Term Index,字典结构搜索场景用的比较多,实现方式有很多
以Trie树实现的字典为例,他不存储所有的单词,只存储单词前缀,从Trie树索引树找词条,最后找到词条对应的文档列表。
下图是一个简化的Trie树,真实的Trie实现kv结构实现方式有很多种
Finite State Transducer (FST)是一种计算模型,它基于有限状态自动机(FSM)并添加了输出功能。
主要描述有限个状态(睡觉、玩耍、吃饭、躲藏、猫砂窝)与状态转移动作(提供食物、有大声音等)之间的关系。
比如算法中的动态规划思想就是一种对状态的抽象,核心的就是抽象状态转移方程,比如爬楼梯算法的状态转移方程为 f(n) = f(n-2) + f(n-1)
基于FSM实现的字典FST,不但能共享前缀还能共享后缀(压缩数据)。不但能判断查找的key是否存在,还能给出响应的输出output。 它在时间复杂度和空间复杂度上都做了最大程度的优化,使得Lucene能够将Term Dictionary完全加载到内存,快速的定位Term找到响应的output(posting倒排列表)
普通的Trie和FST对比:
Trie有重复的3个final状态3,8,11. 而8,11都是s转移,是可以合并的。FST可以看做是一个带有度的有向无环图
Lucene从4开始大量使用的数据结构是FST。FST有两个优点:
1)空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间。
2)查询速度快。O(len(str))的查询时间复杂度。
缺点:
1)FST通常不适合频繁的插入和删除操作,因为它的构建和修改开销较大,需要调整有向图边上的度,出现公共前缀、后缀就要调整。
我们可以将FST当做Key-Value数据结构来进行使用,特别在对内存开销要求少的应用场景。FST压缩率一般在3倍~20倍之间,相对于TreeMap/HashMap的膨胀3倍,内存节省就有9倍到60倍!(摘自:把自动机用作 Key-Value 存储)
参考:
倒排索引采用这两种数据结构主要是为了多条件查询,SkipList用于构建Term Dict,BitSet用于对查找到的多个倒排记录指向的docids做交集合。
对于FST字典结构也不是完全映射倒排记录表的,也是做的一个前缀,因为组合实在太多了,实际是类似一个目录,通过FST找出Term Dict的起始指针、结束指针位置。
如单查询过滤条件 name =Alice 的过程就是先利用FST结构从Term Index找到Alice在Term Dict 的大概位置,然后再从Term Dict里利用SkipList精确地找到Alice这个term,然后找到指向的docids
如多条件查询 name=Alice AND gender=女 就是把两个 posting list 做一个“与”的合并。也就是取两个docids的交集合。
如何高效的合并呢?
// 类似木桶原理,如果list1=[1,3,4]遍历到完了,遍历3次,此时list2=[5,6,8,9,29,34,54,545,54545]才读取第一个元素。
// 双指针写法
def block_max_wand_intersection(list1, list2):
result = []
i, j = 0, 0
while i < len(list1) and j < len(list2):
if list1[i] == list2[j]:
# 如果文档ID匹配,将其添加到结果中
result.append(list1[i])
i += 1
j += 1
elif list1[i] < list2[j]:
# 如果第一个列表中的文档ID较小,增加其索引以找到更大的文档ID
i += 1
else:
# 如果第二个列表中的文档ID较小,增加其索引以找到更大的文档ID
j += 1
return result
# 使用示例
name_list = [1, 3, 4]
gender_list = [5, 6, 8, 9, 29, 34, 54, 545, 54545]
result_intersection = block_max_wand_intersection(name_list, gender_list)
print(result_intersection)
比如为什莫要区分Stored Field(行式存储)和 Doc Value(列式存储)?是否可以手动指定?
// Lucene API
// 常规行式存储:document.add(new TextField("age", student.getAge() + "", Field.Store.YES));
document.add(new SortedDocValuesField("age", new BytesRef(student.getAge())));
// ES API
PUT /my_index
{
"mappings": {
"properties": {
"field1": {
"type": "text",
"store": true
},
"field2": {
"type": "text",
"store": false
}
}
}
}
主要是有两方面的原因:性能、存储成本。
代码层面?
参考前面简单实用lucnen的代码,整个过程逻辑,前四层式逻辑调用层,中间层是索引链式处理层
图源:https://zhuanlan.zhihu.com/p/384486147
DefaultIndexingChain是一个非常核心的类,负责对当前文档个建索引的核心操作,它定义了什么时候该写倒排拉链,什么时候写DocValue,什么时候写入StoredField 等。 processDocument 是整个索引链个入口方法,它会负责将整个文档按照Field拆开,分别调用下面的processField方法:
private int processField(IndexableField field, long fieldGen, int fieldCount) throws IOException {
String fieldName = field.name();
IndexableFieldType fieldType = field.fieldType();
PerField fp = null;
if (fieldType.indexOptions() == null) {
throw new NullPointerException("IndexOptions must not be null (field: \"" + field.name() + "\")");
}
// Invert indexed fields:
// 在该Field上面建倒排表
if (fieldType.indexOptions() != IndexOptions.NONE) {
fp = getOrAddField(fieldName, fieldType, true);
boolean first = fp.fieldGen != fieldGen;
fp.invert(field, first);
if (first) {
fields[fieldCount++] = fp;
fp.fieldGen = fieldGen;
}
} else {
verifyUnIndexedFieldType(fieldName, fieldType);
}
// Add stored fields: 存储该field的storedField
if (fieldType.stored()) {
if (fp == null) {
fp = getOrAddField(fieldName, fieldType, false);
}
if (fieldType.stored()) {
String value = field.stringValue();
if (value != null && value.length() > IndexWriter.MAX_STORED_STRING_LENGTH) {
throw new IllegalArgumentException("stored field \"" + field.name() + "\" is too large (" + value.length() + " characters) to store");
}
try {
storedFieldsConsumer.writeField(fp.fieldInfo, field);
} catch (Throwable th) {
docWriter.onAbortingException(th);
throw th;
}
}
}
// 建docValue
DocValuesType dvType = fieldType.docValuesType();
if (dvType == null) {
throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");
}
if (dvType != DocValuesType.NONE) {
if (fp == null) {
fp = getOrAddField(fieldName, fieldType, false);
}
indexDocValue(fp, dvType, field);
}
if (fieldType.pointDataDimensionCount() != 0) {
if (fp == null) {
fp = getOrAddField(fieldName, fieldType, false);
}
indexPoint(fp, field);
}
return fieldCount;
}
数据的落盘Lucene也是采用一些方法如vint(可变长)编码方式压缩数据存储空间。
Lucene倒排索引设计是不可变的,如何进行索引与数据的维护呢?
由于倒排索引的结构特性,在索引建立完成后对其进行修改将会非常复杂。再加上几层索引嵌套,更让索引的更新变成了几乎不可能的动作。所以索性设计成不可改变的:倒排索引被写入磁盘后是不可改变的,它永远不会修改。
不变性有重要的价值:
缺点:
更新数据就要更新索引,Lucene采用一次写多次读(write-once-read-multiple)策略来完成动态更新索引。具体来说就是在数据更新的过程中
通过提交点Commit Point保证崩溃恢复:在写入过程,每次更新数据后会进行一次commit,每当发生一次提交操作,就会创建一个新的提交点,并将Segment cache刷盘。这样可以应对即使系统崩溃或意外关闭引起的Segment cache刷盘失败,最近的索引更改也能够恢复并不会丢失。另外,即使在机器故障或其他问题导致索引文件出现损坏时,Lucene也可通过检测到损坏的提交点来进行相应的修复和恢复工作。
磁盘倒排索引合并问题:为了优化索引性能和空间利用率,Lucene 定期或在需要的时候会将磁盘上的多个小的Segment合并成更大的Segment。该过程可能存在的问题:
举个例子来说明Lucene索引合并导致读取过期数据这种情况:
ES就是封装调用Lucene,提供方便的RESTful API以及高级的一些查询,然后通过分片、副本支持分布式和高可用,也对索引等处理做了一些优化。
在传统的先写后读策略中,索引段会不断地增加,导致查询时需要搜索更多的段,从而影响查询速度。
而且索引段合并需要磁盘IO也需要考虑。针对上述问题,ES主要做了些索引Segment的合并上的优化
通过将索引拆分为多个分片,类似分治的思想,每个分片可以类比一个Lucene。通过ES集群将分片分布在不同的节点上。每个分片都是一个独立的 Lucene 索引包含若干个Segment。搜索操作在每个分片上并行执行,然后合并结果。
分片:分片是底层基础的读写单元,分片的目的是分割巨大索引,分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里不会跨分片存储。 当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。一个分片是一个Lucene索引,一个Lucene又分成很多Segment(每段都是一个倒排索引)。比如有100个indices(数据库),可以拆分片到5台机器,每台20个indices。
分片副本:分片进行副本存储,主分片、从分片分散分布在不同节点,提供高可用。在索引建立的时候就已经确定了主分片数,但是副本分片数可以随时修改。
关于分片数量的一些建议:分片的数量在5.x之前不能修改,在5.x-6.x之后支持一定条件的修改,可以对主分片大小拆分和缩小,分片越小,分的片就越多,应该根据硬件和业务数据量来进行拆分。
1、分片数量不够时,可以考虑重新建立索引,搜索1个50分片的索引和搜索50个1分片的索引效果一样,建议是周期性创建新索引,如website索引index每天创建一个website_时间戳index,然后在website主索引进行软连接,这样删除数据时可以直接删除某个索引,避免以id删除文档不会立即释放空间,删除的document时候只有在Lucene分段(倒排索引)合并时候才会从磁盘删除,手动合并会导致较高的I\O压力的问题。
2、分片数量过多:若是每天一个索引,但是某天数据量很小,可以_shrink API来减少主分片数量,减低集群管理很多分片的负载。
WAL(Write-Ahead Log)是用来保证数据在写入索引之前的持久化机制,以防止数据丢失或损坏。
ES在WAL机制上做了一些升级,通过Translog、异步刷新和分布式复制等措施来提高写入性能、降低IO延迟,并保证数据的持久性。
文档数据更新的流程
refresh异步刷新默认是每秒触发一次,但也可以手动调整该时间间隔。如果在刷新之前发生了节点或进程故障,所有尚未刷入磁盘的数据都可以通过translog文件进行恢复。
ps:操作系统中,磁盘文件其实都有一个操作系统缓存OS Cache,因此Segment file数据写入磁盘文件之前,会先进入操作系统级别的内存缓存OS Cache中成为 segemnt buffer,当translog fsync之后,等待refresh,也就是segemnt buffer fsync,此时的倒排索引Segment就能被搜索到了。
这就是为什么es被称为准实时(NRT,near real-time):因为写入的数据默认每隔1秒refresh一次,也就是数据每隔一秒才能被 es 搜索到,之后才能被看到,所以称为准实时。
translog日志文件的作用是什么?
在你执行commit操作之前,数据要么是停留在buffer中,要么是停留在segment cache中,无论是buffer还是os cache都是内存,一旦这台机器死了,内存中的数据就全丢了。
因此需要将数据对应的操作写入一个专门的日志文件,也就是translog日志文件,一旦此时机器宕机,再次重启的时候,es会自动读取translog日志文件中的数据,恢复到内存buffer和segment cache中去。
ps:ES数据写入之后,要经过一个refresh操作之后,才能够创建索引文件到磁盘,进行查询。但是get查询很特殊,数据实时可查。ES5.0之前translog可以提供实时的CRUD,get查询会首先检查translog中有没有最新的修改,然后再尝试去segment中对id进行查找。5.0之后,为了减少translog设计的复杂性以便于再其他更重要的方面对translog进行优化,所以取消了translog的实时查询功能。get查询的实时性也是一次写多次读,通过每次get查询的时候,如果发现该id还在内存中没有创建索引,那么首先会触发refresh操作,来让id可查。
文档数据更新之文档版本号
删除文档:段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。磁盘上的每个segment都有一个.del文件与它相关联。当发送删除请求时,该文档未被真正删除,而是在.del文件中标记为已删除。此文档可能仍然能被搜索到,但会从结果中过滤掉。当segment合并时,在.del文件中标记为已删除的文档不会被包括在新的segment中,也就是说merge的时候会真正删除被删除的文档。
更新文档:创建新文档时,ES将为该文档分配一个版本号。对文档的每次更改都会产生一个新的版本号。当执行更新时,旧版本doc在.del文件中被标记为已删除,并且新版本doc在新的Segment中更新倒排索引。旧版本可能仍然与搜索查询匹配,但是从结果中将其过滤掉。
使用版本号机制乐观控制并发,每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。 Elasticsearch 使用这个 _version 号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。
// 当前内部版本号version=3,执行
PUT /accounts/_doc/1?version=1
{
...
}
// 执行报错:内部版本号不能并发控制
{
"error": {
"root_cause": [
{
"type": "action_request_validation_exception",
"reason": "Validation Failed: 1: internal versioning can not be used for optimistic concurrency control. Please use `if_seq_no` and `if_primary_term` instead;"
}
],
"type": "action_request_validation_exception",
"reason": "Validation Failed: 1: internal versioning can not be used for optimistic concurrency control. Please use `if_seq_no` and `if_primary_term` instead;"
},
"status": 400
}
// 使用自己定义的外部版本号,设置为5
// 如果当前的_version < 5,那么该操作执行成功,否者失败
PUT /accounts/_doc/1?version=5&version_type=external
{
...
}
参考书籍《ElasticSearch源码解读与深度优化》
ES的架构设计功能实现主要分为8个模块,使用Guice框架进行模块化管理(Guice是Google开发的轻量级的IoC依赖注入框架)
ES单节点启动、关闭流程
分析启动流程中进程如何解析配置,检查环境、初始化内部模块。
1、当执行bin/elasticsearch启动ES时候,脚本通过exec加载Java程序,其中JVM的配置在config/jvm.options指定。
启动脚本后面可以加上参数
2、然后就是Java程序解析配置文件elasticsearch.yml即主要配置文件、log4j2.properties日志配置文件。
3、接着是加载安全配置(敏感信息不适合放在配置文件中的配置)、检查内部环境(Lucene版本防止有人替换不兼容的jar包、检测jar冲突)、检测外部环境(节点实现时候被封装进Node模块,Node.start()就是进行此步骤)主要包括:
4、检查完毕之后就是启动ES的内部子模块(见上文介绍),它们启动方法被封装在Node类,如discovery.start()、clusterService.start()等
5、启动keep-alive线程:线程本身不做具体的工作,主线程执行完启动流程后会退出,keepalive线程是唯一的用户线程,作用是保证进程运行,在Java程序中,至少要有一个用户线程,否则进程就会退出。
关闭流程中,需要按照一定的顺序,综合来看大致为
ES是通过分片支持分布式的,分片创建的副本称之为副分片,因此主、副分片可以分布在不同的机器节点上,共同组成ES集群。
集群主从模式:分布式系统的集群方式分为主从模式和无主模式,ES、HDFS、HBase使用主从模式,主从可以简化系统设计,master作为权威节点,负责管理元信息,缺点是存在单点故障,需要解决灾备问题。从机器的角度看分布式系统,每个机器可以放多个节点,分片数据有规则的和节点对应起来。
集群管理需要考虑数据路由、主副分片数据一致性等问题,因此需要为ES所在的机器节点划分角色。ES集群的机器节点角色
ES集群启动流程:集群启动指的是集群首次启动或者是完全重启的启动过程,期间要经历选举ES主节点、主分片、数据恢复等重要阶段。其过程可能会出现脑裂、无主、恢复慢、丢数据等问题。
在recovery的时候主也是可以接受请求更新数据的,从的全量、增量同步都需要时间?从如何保证这些数据不丢失?
关于recovery阶段从如何应对全量同步阶段主的更新导致数据丢失:前面说从分片第二阶段增量同步的translog快照包含第一阶段以后所有的新的新增操作,如果在第一阶段全量同步还未执行完,主发生数据 lucene commit(将文件系统写缓冲的数据刷盘,并清空translog)呢?这样是不是在第二阶段就拿不到translog快照了呢?在ES2.0之前是阻止刷盘操作,这样可能会导致一直往translog写数据而不刷盘,2.0之后到6.0之前,为了防止期间出现过大的translog,使用translog.view来获取后续所有操作。从6.0之后,引入TranslogDeletingPolicy的概念,他将translog做一个快照保证translog不被清理掉。
关于recovery阶段从如何应对增量量同步阶段主的更新导致数据丢失:在ES2.0之前,副分片恢复过程其实是有三个阶段的,第三阶段会阻塞主的更新数据的操作,传输第二阶段执行期间新增的translog,这个时间很短,在2.0之后第三个阶段就被删除了,恢复期间没有任何写阻塞过程,副重放translog的时候,主在第一阶段和第二阶段的写操作 与 从第二阶段重放translog操作之间的时序错误和冲突,通过写流程中进行异常处理,对比版本号来过滤掉过期操作。遮这样就把正对于某个doc只有最新的一次操作生效,保证了主副分片一致。
ES集群的选主流程
Discovery模块负责发现集群中的节点,以及选取主节点,因为是分布式存储系统,自然要处理一致性问题,一般解决方案
(1)试图避免不一致情况发生CA
(2)发生不一致如何挽救。第二种一般对数据模型有着较高的要求CP
集群的架构可以为主从模式、哈希表模式
选举算法
详细流程
在ES选Master过程相关的重要配置其中之一discovery.zen.minimum_master_nodes 最小主节点数量,值最好设置ES集群总节点数的半数以上,比如共三个节点,最好设置为 3 / 2 + 1 = 2 个,这是防止脑裂、数据丢失及其重要的参数,作为其他几种集群行为的判断依据。详细流程:
1、触发选主:当参选的节点数量大于设置的最小节点数,才能进行选主
2、确定Master,主要分为下面的选出临时Master和确定最终的Master两个步骤,原因上文也有说。
2.1、选出临时Master:通过配置discovry.zen.ping.unicast.hosts指定集群中的节点列表(包含ES进程的ip、port),各节点之间投票,根据Bully选举算法,每个节点计算出一个最小的已知节点ID(可以通过启动时间、网络响应时间等等确定),详细的流程就是
2.2、投票确定最终Master:各节点选出自己确定的临时Master,需要半数该值节点数认同才能成为真正的Master,否则该临时Master就会加入集群,发送投票就是本节点向自己选的临时Master发送加入集群的请求,获得的票数就是该临时Master接收到其他节点的加入集群的请求数量。投票过程中对于莫i个临时Master会存在两种情况:
2.3、Master选取元信息:像有Master资格节点(配置了node.master = true的节点)发请求获取元数据,获取响应数量必须达到最小节点数才会选为元信息。
3、Master发布集群状态
4、集群节点失效检测
选举完成之后集群状态发布,后面集群需要探测到某些节点失效的异常情况,不执行的话可能会造成脑裂(双主、多主),因此需要启动两种失效探测器: