ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记

前言

QBM、MFS的试题检索、试题查重、公式转换映射等业务场景以及XOP题库广泛使用搜索中间件,业务场景有着数据量大、对内容搜索性能要求高等特点,其中XOP题库数据量更是接近1亿,对检索性能以及召回率要求高。目前QBM、MFS使用的搜索中间件是Solr,后续需要升级为ES。

看的书是《ElasticSearch源码解读与优化实战》的前半部分(与这篇博客部分内容重合),主要是ES的一些工程模块,分布式集群的一些理论知识。Lucene的部分知识主要来源一些写的比较全面的博客,Lucene涉及的数据结构与算法比较复杂,其中涉及的如FST前缀字典、列式存储数据压缩、ES相关的分布式Paxos算法细节等都是很复杂,还是值得思考研究下。

//TODO 该博客主要使用ES、Lucene过程一些小计以及一些原理分析,初学原理涉及的深度难免不够,不过后续随着学习内容持续更新ing…

目录

  • 概述
  • 实践
  • 原理分析
    • 搜索引擎流程
    • Lucene相关原理
    • ES相关原理

一、概述

ES是什么?

非关系型、搜索引擎、近实时搜索与分析、高可用、天然分布式、横向可扩展。

ElasticSearch是一款非常强大的、基于Lucene的开源搜索及分析引擎;它是一个实时的分布式搜索分析引擎,它能让你以前所未有的速度和规模,去探索你的数据。属于NoSQL文档性DB的一种,内容检索性能是最大的优势。

实时搜索:实时搜索(Real-time Search)很好理解,对于一个数据库系统,执行插入以后立刻就能搜索到刚刚插入到数据。而近实时(Near Real-time),所谓“近”也就是说比实时要慢一点点。像常用的MySQL等关系型数据库不能称之为实时搜索数据库,MySQL可以配置为提供较低的延迟和更高的实时性能。但是,MySQL的实时性取决于多个因素,包括硬件性能、数据库设计、查询优化和负载等因素。

全文搜索属于最常见的需求,开源的 Elasticsearch (以下简称 Elastic)是目前全文搜索引擎的首选。
它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github 都采用它。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第1张图片

图源:https://db-engines.com/en/ranking

主要功能:
1)海量数据的分布式存储以及集群管理,达到了服务与数据的高可用以及水平扩展;
2)近实时搜索,性能卓越。对结构化、全文、地理位置等类型数据的处理;
3)海量数据的近实时分析(聚合功能)

应用场景:
1)网站搜索、垂直搜索、代码搜索
2)日志管理与分析、安全指标监控、应用性能监控

常用非结构化数据存储中间件区别?ES、Solr、MongoDB都属于NoSQL的家族的一员

  • ES和MongoDB区别:它们有不同的设计目标和用例,因此在许多方面存在区别:MongoDB 在某些查询场景下表现很好,但对于全文搜索和实时分析来说,性能通常不如ES。如果你需要处理大量文本数据并进行实时搜索和分析,ES 可能更适合。如果你的应用需要存储和查询半结构化或非结构化的文档数据,MongoDB可能更合适。有时候,这两个数据库也可以组合使用,以满足不同方面的需求。
  • ES和Solr的区别:都是建立在Lucene库上的,提供RESTful的API用于CRUD以及拓展其他高级特性,语法类似。 ES 通常更适合用于实时搜索、日志和指标分析、全文搜索等需要高度动态性和实时性能的应用。Solr 更适合处理传统的文档检索和结构化数据分析,例如图书馆目录、商品搜索等。 ES 在分布式性能方面表现出色,天生支持分片和复制,易于横向扩展。Solr 也支持分布式部署,但需要更多的手动配置和管理。Solr需要配合Zookeeper使用,ES自身带有分布式系统管理功能。

参考:

  • ES和MongoDB对比:https://leriou.github.io/2019-01-09-mongodb-compareto-elasticsearch/
  • ES和Solr对比:https://zhuanlan.zhihu.com/p/85362497
  • ChatGPT

二、实践

1、安装ES和Kibana

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关闭)

2、ES的数据模型

方便理解,类比关系型数据库的数据模型,ES的数据模型分为

  • index(索引):类比一张表,代表文档数据的集合,文档指的是ES中存储的一条数据。
  • type(文档类型):在新版的Elasticsearch中,已经不使用文档类型了,在ES6.x版本中1个索引indices只能创建对应一个types,因为不同types下的字段不能冲突,删除types也不会释放空间,推荐需要多个types时候直接创建多个indices。在ES7.x版本中直接删除掉了type的概念。在Elasticsearch老的版本中文档类型,代表一类文档的集合,index(索引)类似mysql的数据库、文档类型类似MySQL的表。既然新的版本文档类型没什么作用了,那么index(索引)就类似mysql的表的概念,ES没有数据库的概念了。
  • Document(文档):类比一行数据,Elasticsearch是面向文档的数据库,文档是最基本的存储单元,文档类似mysql表中的一行数据。简单的说在ES中,文档指的就是一条JSON数据,JSON数据的字段可以是任意的,这些Documents属于一个index。
  • Field(文档字段):类比一个字段,文档由多个字段(Field)组成。
  • Mapping(映射):类比元数据,映射定义了文档中每个字段的数据类型、分析器和索引选项。映射是用于索引和搜索的关键元素,它决定了如何存储和检索文档中的数据。

Elasticsearch 支持如下简单域类型:

  • 字符串: text(string在5.4版本被text替代)、keyword
    • 当一个字段需要用于全文搜索(会被分词), 比如产品名称、产品描述信息, 就应该使用text类型,不能排序,很少用于聚合。
    • 当一个字段需要按照精确值进行过滤、排序、聚合等操作时, 就应该使用keyword类型.不会被分词。
  • 整数 : byte, short, integer, long
  • 浮点数: float, double
  • 布尔型: boolean
  • 日期: date

其他还有object、array、geo、binary

3、ES的领域特定查询语言(Query DSL)

ES如何查询参考

  • https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html
  • ES权威指南DSL语法:https://www.elastic.co/guide/cn/elasticsearch/guide/current/empty-search.html
  • 博客总结命令:https://www.cnblogs.com/machangwei-8/p/14979956.html#_label1
3.1、简单基本查询

以下是 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"
          }
        }
      }
    }
  }
}


3.2、复合查询

多种条件组合的查询,在ES中叫做复合查询,ES提供5种复合查询方式。

  • bool query(布尔查询)
  • boosting query(提高查询)
  • constant_score(固定分数查询)
  • dis_max(最佳匹配查询)
  • function_score(函数查询)

具体的用法直接看官网文档。

4、ES全文查询搜索

4.1、match相关查询和term查询的区别

match以及相关的match_phrase、match_phrase_prefix 查询本质上是term查询的组合。

match查询和term查询是Elasticsearch中两种常用的查询类型,它们在处理方式上略有不同。需要注意的是,在Elasticsearch中,text字段通常适合使用Match查询,而keyword字段适合使用Term查询,这取决于你想要实现的具体需求和查询场景。

Match查询

  • Match查询是一种全文搜索查询,它会将查询字符串分析成词项,并根据相关性来评分。默认情况下,它会尝试将查询字符串与目标字段中的所有词项进行匹配。
  • Match查询还支持布尔操作符(AND、OR和NOT)以及短语搜索等高级功能。
例如,对于一个名为"title"的字段,使用Match查询可以执行如下查询:
{
  "query": {
    "match": {
      "title": "quick brown fox"
    }
  }
}

Term查询

  • Term查询是一种精确匹配查询,它会将查询字符串作为整体进行匹配,而不会对其进行分析或拆解为词项。
  • 默认情况下,Term查询是区分大小写的。
例如,对于一个名为"user.keyword"的关键字字段,使用Term查询可以执行如下查询:
{
  "query": {
    "term": {
      "user.keyword": {
        "value": "john smith"
      }
    }
  }
}

match查询的步骤

  • 检查文档字段类型:检查文档的字段类型是否是全文检索字段
  • 分析查询字符串:查询字符串本身也需要分词,如果是单个词,执行一个词的term查询。如果是多个词,那么会被分词,执行多次term查询,然后结果合并。
  • 查找匹配文档:倒排索引(后面以及往期文章会详细介绍)找到文档
  • 为每个文档评分:用 term 查询计算每个文档相关度评分 _score ,主要依据词频(查询词在某文档出现的频率)、反向文档频率(查询词在所有文档中出现的频率)、字段内容的长度 相结合计算得出。

下面查询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 最小匹配参数,这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。
我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量,查询56结果也是等价的。
# 查询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 
    }
  }
}
4.2、match_phrase查询match_phrase_prefix的区别

match_phrase本质上是多个有序term查询。

前面说match如果涉及多个词会被拆分为多个term查询,而且多个term是按照or查询的。
如果想查询某个段落,可以使用match_pharse、match_phrase_prefix。关于两者是有差别的,match_phrase往往会被认为是查询字符串不被分词,直接去文档检索,这是错误的,其实match_phrase也是会对查询字符串进行分词的,只不过相比match那种方式,分词之后的顺序是保证的。而match_phrase_prefix对应的是上述情况。


ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第2张图片

match_phrase本质是连续的term的查询,所以f并不是一个分词,不满足term查询,所以最终查不出任何内容了。
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第3张图片

而match_phrase_prefix可以查到。另外还有一个match_bool_prefix,本质上可以转化为

GET /test-dsl-match/_search
{
  "query": {
    "bool" : {
      "should": [
        { "term": { "title": "quick" }},
        { "term": { "title": "brown" }},
        { "prefix": { "title": "f"}}
      ]
    }
  }
}
4.3、其他查询

类似match的查询

  • multi_match:对多个字段同时查询
  • query_string:支持多内容通过运算符组合
  • interval:查找的内容字符串在原文档中需要保持顺序

对于term查询 // TODO

5、使用

使用方式很多,官网推荐使用Java API Client,Spring Data Elasticsearch是高级封装,随着依赖的升级以及ES的升级,可能后续不是很容易维护。

  • TransportClient:TransportClient 在 Elasticsearch 7.0.0 中已被弃用,取而代之的是 Java High Level REST Client,并将在 Elasticsearch 8.0中删除。在项目中不再建议使用,详见官方链接:https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/java-api.html#java-api
  • Java High Level REST Client 在 Elasticsearch 7.15.0 中已弃用,取而代之的是 Java API Client。在项目中不再建议使用,详见官方链接:https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/index.html
  • Java API Client:官方推荐使用的方式。详见官方链接:https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/index.html
  • Spring Data Elasticsearch:关键功能领域是一个以 POJO 为中心的模型,用于与 Elastichsearch 文档进行交互,并轻松编写存储库数据访问层。类似MyBatis那种方式,有着Repository、Service等抽象。springboot、spring-data-elasticsearch、elasticsearch版本对应https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/#preface.requirements

参考:

  • http://masikkk.com/article/Elasticsearch/
  • https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/installation.html

三、原理分析

1、搜索引擎流程

全文检索可以分为索引、搜索返回两个阶段,过程主要分为四个部分

  • 查询分析:搜索内容自然语言处理如敏感词过滤,错别字纠正等。
  • 分词技术:搜索内容拆成词条,以下是一些常用的 Lucene 分词器
    • StandardAnalyzer:标准分词器,它基于 Unicode 文本字符边界来划分词条,并去除一些停用词,如介词、连词等。
    • SimpleAnalyzer:简单分词器,将文本转换为小写,并根据非字母字符进行划分
    • WhitespaceAnalyzer:空格分词器,通过空格字符进行划分。
    • KeywordAnalyzer:关键字分词器,将整个输入当作一个词条,不进行进一步划分。
    • StopAnalyzer:停用词分词器,类似于标准分词器,但还会移除一些自定义的停用词。此外,还有很多其他特殊用途的分词器可供选择,
    • IKAnalyzer、SmartChineseAnalyzer 等,比如中文领域的。
  • 关键词检索:在倒排索引库索引,搜索找到具体的文档数据。
  • 搜索排序返回:对多个文档进行相关度计算,排序,返回数据。

2、Lucene相关原理

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-core.jar:包含了Lucene的核心功能,如倒排索引、分词器、查询解析器等。
  • lucene-analyzers-common.jar:包含了一系列常用的分析器,用于将文本进行分词和标准化处理。
  • lucene-queryparser.jar:包含了用于解析用户输入的搜索查询字符串并生成相应查询对象的工具。
  • lucene-highlighter.jar:包含了实现搜索结果高亮显示的组件。
  • lucene-suggest.jar:包含了构建自动补全和建议功能的相关类和接口。
  • lucene-grouping.jar:包含了根据指定字段对搜索结果进行分组的功能。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第4张图片

使用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特有的

  1. PostingList 倒排表,也就是term->[doc1, doc3, doc5]这种倒排索引数据 。真实的倒排记录也并非一个链表,而是采用了SkipList、BitSet等结构。
  2. TermDict, 从term和PostingList的映射关系,这种映射一般都用FST这种数据结构来表示,这种数据结构其实是一种有向图,类似于前缀树,所以Lucene这里就叫BlockTree, 其实我更习惯叫它TermDict。
  3. StoredField 存进去的原始信息 (行式组织数据存储),通常用于存储需要在搜索结果中返回的field,以便在检索文档时能够获取原始字段值。
  4. DocValue 键值数据,通常用于存储需要在搜索结果中需要聚合、排序、筛选的field,这种数据主要是用来对于高级查询加速对字段的排序、筛选的。(列式组织数据存储
  5. TermVector,词向量信息,主要记一个不同term的全局出现频率等信息。
  6. Norms,用来存储Normalisation信息,分词后的文本进行规范化处理(小写转换,删除特殊字符)然后存储,以提高搜索和匹配的准确性。
  7. PointValue 用来加速 range Query的信息,实现基于BKDTree。

针对不同的数据结构采用不同的字典索引,倒排索引基于字典树使用了FST模型压缩索引,使用SkipList和BitSet加速多条件查询,磁盘存储组织PointValue组织形式基于BKDTree等结构加速范围查询。

参考:

  • Lucene源码剖析
  • 理解ElasticSearch工作原理
  • 使用Java调用Lucene实现简单demo
  • 倒排索引:ES倒排索引底层原理及FST算法的实现过程
  • Lucene BKD树-动态磁盘优化BSP树
2.1、倒排索引之FST(Finite State Transducers)

概述:也常被称为反向索引,是一种索引方法,被用来存储在全文搜索下某个词条在一个文档或者一组文档中的存储位置的映射,它是文档检索系统中最常用的数据结构。
采用映射表记录哪些词条出现在哪些文档中,然后实现快速检索。

举例
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第5张图片

随着的词条的增多,这个倒排记录表也越来越大,倒排记录也越来越多,每次遍历查找的效率不高肯定不行。而且全部将倒排记录加载进内存也吃不消。

因此Lucene在前面倒排记录表前加了一层,增加一个字典结构索引Term Index,字典结构搜索场景用的比较多,实现方式有很多
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第6张图片

以Trie树实现的字典为例,他不存储所有的单词,只存储单词前缀,从Trie树索引树找词条,最后找到词条对应的文档列表。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第7张图片

下图是一个简化的Trie树,真实的Trie实现kv结构实现方式有很多种

  • 树节点为固定长度的数组,数组的元素需要包含两个域,一个是k,一个是next指向下一节点。一般一个节点需要固定长度,如存储英文字母的节点长度为26,缺点:对于英文做前缀还能接受,中文字符集太多,太占内存。时间复杂度O(1),空间复杂度O(N)。
  • 树节点为一个链表,链表节点需要包含三个域,一个是k,一个是next指向下一个链表节点,另外一个child指向下一个树木节点。缺点:每次都需要从链表头节点开始访问。时间复杂度O(N),空间复杂度O(1)
  • 树节点为哈希表。时间复杂度为上述两种方式之间。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第8张图片

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第9张图片

Finite State Transducer (FST)是一种计算模型,它基于有限状态自动机(FSM)并添加了输出功能。
主要描述有限个状态(睡觉、玩耍、吃饭、躲藏、猫砂窝)与状态转移动作(提供食物、有大声音等)之间的关系。
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第10张图片
比如算法中的动态规划思想就是一种对状态的抽象,核心的就是抽象状态转移方程,比如爬楼梯算法的状态转移方程为 f(n) = f(n-2) + f(n-1)

基于FSM实现的字典FST,不但能共享前缀还能共享后缀(压缩数据)。不但能判断查找的key是否存在,还能给出响应的输出output。 它在时间复杂度和空间复杂度上都做了最大程度的优化,使得Lucene能够将Term Dictionary完全加载到内存,快速的定位Term找到响应的output(posting倒排列表)

普通的Trie和FST对比:

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第11张图片

Trie有重复的3个final状态3,8,11. 而8,11都是s转移,是可以合并的。FST可以看做是一个带有度的有向无环图
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第12张图片

Lucene从4开始大量使用的数据结构是FST。FST有两个优点:
1)空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间。
2)查询速度快。O(len(str))的查询时间复杂度。
缺点:
1)FST通常不适合频繁的插入和删除操作,因为它的构建和修改开销较大,需要调整有向图边上的度,出现公共前缀、后缀就要调整。

我们可以将FST当做Key-Value数据结构来进行使用,特别在对内存开销要求少的应用场景。FST压缩率一般在3倍~20倍之间,相对于TreeMap/HashMap的膨胀3倍,内存节省就有9倍到60倍!(摘自:把自动机用作 Key-Value 存储)

参考:

  • 字典数据结构-FST(Finite State Transducers)
  • FSM有限状态自动机-维基百科
  • 关于Lucene的词典FST深入剖析
2.2、倒排索引之SkipList、BitSet

倒排索引采用这两种数据结构主要是为了多条件查询,SkipList用于构建Term Dict,BitSet用于对查找到的多个倒排记录指向的docids做交集合。

对于FST字典结构也不是完全映射倒排记录表的,也是做的一个前缀,因为组合实在太多了,实际是类似一个目录,通过FST找出Term Dict的起始指针、结束指针位置。

如单查询过滤条件 name =Alice 的过程就是先利用FST结构从Term Index找到Alice在Term Dict 的大概位置,然后再从Term Dict里利用SkipList精确地找到Alice这个term,然后找到指向的docids
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第13张图片
如多条件查询 name=Alice AND gender=女 就是把两个 posting list 做一个“与”的合并。也就是取两个docids的交集合。

如何高效的合并呢?

  • 如果查询的filter条件缓存到了内存中(以BitSet的形式),那么合并就是两个BitSet的AND,举例如[1,3,4]压缩进位图中就是[1,0,1,1]。Redis中的BitMap就是这个原理,将大量数据压缩进位图如应用布隆过滤器。
  • 如果查询的filter没有缓存,那么就用SkipList的方式去遍历两个on disk的posting list。找一个最短的post list先遍历,Block Max WAND(块最大WAND)策略是一种用于提高布尔查询性能的优化技术,特别是对于AND操作(逻辑与)的查询,使用最小的IO成本过滤掉那些不匹配的docid,比如name=Alice对应的是[1,3,4],gender = 女对应的是[5,6,8,9,29,34,54,545,54545]
// 类似木桶原理,如果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)

2.3、Stored Field存储方式

比如为什莫要区分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
      }
    }
  }
}

主要是有两方面的原因:性能存储成本

  • 性能考虑:行式存储document数据可以方便一次获取全部需要查询展示的fields数据,按照列式存储field可以方便排序、统计、筛选。
  • 存储考虑:Doc Values通常用于存储静态字段值,例如文档的ID、日期、标签或其他结构化数据。这些字段值是文档的一部分,但它们不依赖于查询条件或文档匹配度,因此适合使用Doc Values进行列式存储。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第14张图片

代码层面?
参考前面简单实用lucnen的代码,整个过程逻辑,前四层式逻辑调用层,中间层是索引链式处理层

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第15张图片

图源: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;
}

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第16张图片

数据的落盘Lucene也是采用一些方法如vint(可变长)编码方式压缩数据存储空间。

2.4、倒排索引之不可变性

Lucene倒排索引设计是不可变的,如何进行索引与数据的维护呢?

由于倒排索引的结构特性,在索引建立完成后对其进行修改将会非常复杂。再加上几层索引嵌套,更让索引的更新变成了几乎不可能的动作。所以索性设计成不可改变的:倒排索引被写入磁盘后是不可改变的,它永远不会修改。
不变性有重要的价值:

  • 并发安全:不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题,并发锁征用问题也是很耗时间的。
  • 缓存不用长期更新:一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。其它缓存(像filter缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  • 允许数据压缩:写入单个大的倒排索引允许数据压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量。

缺点:

  • 主要事实是它是不可变的,你不能修改它。如果你需要让一个新的文档 可被搜索,你需要重建整个索引达到动态更新索引,这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
2.5、倒排索引之动态更新索引

更新数据就要更新索引,Lucene采用一次写多次读(write-once-read-multiple)策略来完成动态更新索引。具体来说就是在数据更新的过程中

  1. 写入:新增或更新的文档首先被写入内存缓冲区,随着时间的推移或达到一定大小阈值,缓冲区的内容将被刷新到磁盘上的一个新的Segment中,同时记录一个Commit Point文件日志,表示一次成功更新数据,类似MySQL的redolog。
  2. 读取:在查询时,Lucene 首先从内存缓冲区中查找匹配的记录,如果未找到,则继续在磁盘上的Segemts中进行查找。尽量保证搜索实时性。

通过提交点Commit Point保证崩溃恢复:在写入过程,每次更新数据后会进行一次commit,每当发生一次提交操作,就会创建一个新的提交点,并将Segment cache刷盘。这样可以应对即使系统崩溃或意外关闭引起的Segment cache刷盘失败,最近的索引更改也能够恢复并不会丢失。另外,即使在机器故障或其他问题导致索引文件出现损坏时,Lucene也可通过检测到损坏的提交点来进行相应的修复和恢复工作。

磁盘倒排索引合并问题:为了优化索引性能和空间利用率,Lucene 定期或在需要的时候会将磁盘上的多个小的Segment合并成更大的Segment。该过程可能存在的问题:

  1. 索引维护开销:由于频繁的段合并和磁盘写入操作,索引维护需要消耗一定的时间和资源。特别是当索引较为庞大或者更新频率很高时,索引维护可能成为性能瓶颈。在传统的先写后读策略中,索引段会不断地增加,导致查询时需要搜索更多的段,从而影响查询速度。
  2. 空间浪费:在段合并过程中,旧的段并不会立即删除,而是会等待所有正在读取它们的查询完成后再进行删除。因此,对于那些很少访问但占用大量磁盘空间的段,存在着空间浪费的问题。
  3. 读取过期数据:由于段合并操作只在某些条件满足时才会触发,并且需要一定的时间来完成,因此,可能会出现在段合并过程中,读取到已经过期的数据的情况。

举个例子来说明Lucene索引合并导致读取过期数据这种情况:

  1. 假设初始状态下磁盘有三个段 A、B、C,其中段 B 是最新生成的。
  2. 在合并过程中,Lucene 将段 A 和段 B 合并成一个更大的段 AB。
  3. 此时,如果还有查询任务正在读取段 A 中的数据,但由于段合并的过程中还没有完成,导致部分查询仍然读取到了段 A 中的数据。这些数据属于已经过期的信息,因为它们已经被包含在了新的段 AB 中。
2.6、BKD磁盘树 // TODO

3、ES相关原理

ES就是封装调用Lucene,提供方便的RESTful API以及高级的一些查询,然后通过分片、副本支持分布式和高可用,也对索引等处理做了一些优化。

3.1、倒排索引之ES索引合并优化

在传统的先写后读策略中,索引段会不断地增加,导致查询时需要搜索更多的段,从而影响查询速度。
而且索引段合并需要磁盘IO也需要考虑。针对上述问题,ES主要做了些索引Segment的合并上的优化

  1. 慢速合并策略:ES采用了一个名为“慢速合并”(slow merge)的机制来减少磁盘上的段数目和整理碎片,从而提高查询性能。它通过将较小的段合并成更大的段来优化索引结构。
  2. 多线程执行合并操作:ES利用多线程执行合并操作,可以在后台异步地进行索引段的合并过程,不会阻塞正常的读写操作。这样可以保证合并过程对用户的影响最小化,并且提高了合并的效率。
  3. 增量式索引合并(主要优化Segment的数量):ES引入了增量式索引合并(incremental index merging)策略。当有新的索引段生成时,ES会尝试将其与已经存在的段进行合并,以减少索引中的段数目。也就是防止大量索引合并堆积在一个时间。这种增量式的合并方式相比传统的全局合并方式,在处理大量数据时具有更好的效率和性能。
3.2、倒排索引之ES分布式优化

通过将索引拆分为多个分片,类似分治的思想,每个分片可以类比一个Lucene。通过ES集群将分片分布在不同的节点上。每个分片都是一个独立的 Lucene 索引包含若干个Segment。搜索操作在每个分片上并行执行,然后合并结果。

分片:分片是底层基础的读写单元,分片的目的是分割巨大索引,分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里不会跨分片存储。 当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。一个分片是一个Lucene索引,一个Lucene又分成很多Segment(每段都是一个倒排索引)。比如有100个indices(数据库),可以拆分片到5台机器,每台20个indices。

  • 分治减少动态更新索引成本。
  • 分片便于水平拓展。

分片副本:分片进行副本存储,主分片、从分片分散分布在不同节点,提供高可用。在索引建立的时候就已经确定了主分片数,但是副本分片数可以随时修改。

  • 系统高可用:索引以及数据拆分的part1、part2等等。每一份如part1也会存在副本放在其他机器上,简单说就是part1在机器1,那么part1副本需要在其他机器上也存一份。
  • 并发更新,分为主、从分片,及先写主分片,再写从分片。读写请求会落在不同的分片上,不同的机器上,做到读写分离。

ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第17张图片

关于分片数量的一些建议:分片的数量在5.x之前不能修改,在5.x-6.x之后支持一定条件的修改,可以对主分片大小拆分和缩小,分片越小,分的片就越多,应该根据硬件和业务数据量来进行拆分。
1、分片数量不够时,可以考虑重新建立索引,搜索1个50分片的索引和搜索50个1分片的索引效果一样,建议是周期性创建新索引,如website索引index每天创建一个website_时间戳index,然后在website主索引进行软连接,这样删除数据时可以直接删除某个索引,避免以id删除文档不会立即释放空间,删除的document时候只有在Lucene分段(倒排索引)合并时候才会从磁盘删除,手动合并会导致较高的I\O压力的问题。
2、分片数量过多:若是每天一个索引,但是某天数据量很小,可以_shrink API来减少主分片数量,减低集群管理很多分片的负载。

3.3、WAL机制优化

WAL(Write-Ahead Log)是用来保证数据在写入索引之前的持久化机制,以防止数据丢失或损坏。
ES在WAL机制上做了一些升级,通过Translog、异步刷新和分布式复制等措施来提高写入性能、降低IO延迟,并保证数据的持久性。

  • ES引入了Translog(Transaction Log)作为WAL的实现方式。Translog是一个高效的、顺序写的日志文件,记录了索引更新操作。当文档被索引时,它们首先会被写入Translog,然后再批量刷新到内存中的索引结构中。通过这种方式,ES能够提供更快的写入性能,而不必每次将更新操作立即写入磁盘。
  • ES还支持异步刷新(Async Refresh)操作。当新的文档被索引后,在默认情况下,ES会将这些文档添加到内存中的索引结构并应答客户端请求。然后,ES会异步地将这些内存中的变更刷新到磁盘上的Segment中,从而降低了IO延迟和硬盘写入压力。

文档数据更新的流程

  1. write buffer:当更新文档数据时,它首先会将数据写入内存缓冲区 buffer 中,然后在新的segment buffer更新索引信息。
  2. write transog:同时,数据还会被追加到一个称为translog(transaction log,事务日志)的文件中。这个文件位于每个分片的本地磁盘上。只要写入translog成功,那么就意味着一次commit,数据这个时候就能被搜索到。
  3. refresh:默认情况下,ES使用异步刷新机制定期将内存缓冲区 segemnt buffer 中的数据写入磁盘。刷新操作会将数据持久化到segment文件中,并清空内存缓冲区以便接收新的写入请求。

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参数,如果文档的最新版本不是这个版本号,那么操作会失败,这个版本号是ES内部自动生成的,每次操作之后都会递增一。如操作 PUT /website/blog/1?version=1 表示文档版本不是1就会操作失败。
  • 使用外部版本号:ES默认采用递增的整数作为版本号,也可以通过外部自定义整数(long类型)作为版本号,例如时间戳。通过添加参数version_type=external,可以使用自定义版本号。内部版本号使用的时候,更新或者删除操作需要携带ES索引当前最新的版本号,匹配上了才能成功操作。外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同, ES 不是检查当前 _version 和请求中指定的版本号是否相同, 而是检查当前 _version 是否 小于 指定的版本号(版本号更大的操作才能执行成功)。 如果请求成功,外部的版本号作为文档的新 _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
{
    ...
}

3.4、ES各模块与启动流程

参考书籍《ElasticSearch源码解读与深度优化》

ES的架构设计功能实现主要分为8个模块,使用Guice框架进行模块化管理(Guice是Google开发的轻量级的IoC依赖注入框架)

  • Cluster:主节点执行集群管理封装实现(在各个节点迁移分片,保持数据平衡),管理集群状态(将新生成的集群状态发布到各个节点),维护集群层面的配置信息,调用Allocation模块进行分片分配。
  • Allocation:封装了分片分配的功能和策略,包括主分片的分配和副分片的分配,由主节点调用,集群完全重启,创建新索引都需要分片分配的过程。
  • Discovery:发现模块负责发现集群中的节点,以及选取主节点,可以类比Zookeeper,选主节点并管理集群拓扑。
  • gateway:负责收到Master广播下来的集群状态数据持久化存储,并在集群完全重启的时候恢复它们。
  • Indices:管理全局级的索引设置,不包括索引级的设置(索引设置分为全局级别、索引级别),还封装了数据恢复的功能。
  • HTTP:该模块允许通过JSON over HTTP的方式访问ES的API,该模块完全是异步的,没有阻塞线程等待,使用异步通信进行HTTP的好处是解决C10k问题(10k量级的并发连接)。
  • Transport:传输模块负责集群各个节点之间的通信,从一个节点到另外一个节点的每个请求都是使用传输模块,本质上也是使用异步的。使用TCP通信,节点之间维持长连接。
  • Engine:封装了对Lucene的操作以及translog的调用,他是对一个分片读写操作的最终提供者。

ES单节点启动、关闭流程
分析启动流程中进程如何解析配置,检查环境、初始化内部模块。
1、当执行bin/elasticsearch启动ES时候,脚本通过exec加载Java程序,其中JVM的配置在config/jvm.options指定。
启动脚本后面可以加上参数

  • E:设置某项配置项,如-E “cluster.name = my_cluster”
  • V:打印版本号信息
  • d:后台运行
  • p:启动时候在指定路径创建一个pid文件,其中保存了当前进程的pid。之后可以通过查看这个pid文件来关闭进程。
  • q:关闭控制台的标准输出和标准错误输出。
  • s:终端输出最小信息
  • v:终端输出详细信息

2、然后就是Java程序解析配置文件elasticsearch.yml即主要配置文件、log4j2.properties日志配置文件。

3、接着是加载安全配置(敏感信息不适合放在配置文件中的配置)、检查内部环境(Lucene版本防止有人替换不兼容的jar包、检测jar冲突)、检测外部环境(节点实现时候被封装进Node模块,Node.start()就是进行此步骤)主要包括:

  • 1、堆大小检查
  • 2、文件描述符检查
  • 3、内存锁定检查
  • 4、最大线程数检查
  • 5、最大虚拟内存检查
  • 6、最大文件大小检查
  • 7、虚拟内存区域最大数量检查
  • 8、JVM Client模式检查
  • 9、串行收集器检查
  • 10、系统调用过滤器检查
  • 11、OnError和OOM检查
  • 12、Early-access检查
  • 13、G1GC检查

4、检查完毕之后就是启动ES的内部子模块(见上文介绍),它们启动方法被封装在Node类,如discovery.start()、clusterService.start()等

5、启动keep-alive线程:线程本身不做具体的工作,主线程执行完启动流程后会退出,keepalive线程是唯一的用户线程,作用是保证进程运行,在Java程序中,至少要有一个用户线程,否则进程就会退出。

关闭流程中,需要按照一定的顺序,综合来看大致为

  1. 关闭快照和HTTP Server ,不再相应用户REST请求
  2. 关闭集群拓扑管理,不在响应ping请求
  3. 关闭网络模块,让节点离线
  4. 执行各个插件的关闭流程
  5. 关闭IndicesService,最后关闭因为耗时最长
3.5、ES集群与启动流程分析

ES是通过分片支持分布式的,分片创建的副本称之为副分片,因此主、副分片可以分布在不同的机器节点上,共同组成ES集群。

集群主从模式:分布式系统的集群方式分为主从模式和无主模式,ES、HDFS、HBase使用主从模式,主从可以简化系统设计,master作为权威节点,负责管理元信息,缺点是存在单点故障,需要解决灾备问题。从机器的角度看分布式系统,每个机器可以放多个节点,分片数据有规则的和节点对应起来。

集群管理需要考虑数据路由主副分片数据一致性等问题,因此需要为ES所在的机器节点划分角色。ES集群的机器节点角色

  • Master节点:设置可以作为主节点资格后,可以被选举(需要各节点投票),主节点是全局唯一的,主节点也可以作为数据节点,但是数据量不要太多,为了防止数据丢失,有主节点资格的节点需要知道有资格成为主节点的节点数量,默认为1
  • Data节点:CRUD数据,对cpu和内存、I\O要求较高。
  • 预处理节点:5.0后引入的概念,允许在索引文档之前,写入数据之前,通过事先定义好的一系列processors和pipeline,对数据进行处理,富化。
  • 协调节点:处理客户端请求,每个节点都知道任意文档所处的位置,然后转发这些请求到数据节点,收集数据合并返回给客户端。
  • 部落节点:5.0之前有个处理请求的客户端节点,可以理解为负载均衡,在5.0之后被协调节点取代。

ES集群启动流程:集群启动指的是集群首次启动或者是完全重启的启动过程,期间要经历选举ES主节点、主分片、数据恢复等重要阶段。其过程可能会出现脑裂、无主、恢复慢、丢数据等问题。

主要分为以下四个阶段
ElasticSearch学习篇6_ES实践与Lucene对比及原理分析技术分享小记_第18张图片

  • selectmaster:集群启动,从众多ES节点(ES进程)选取一个主节点,选举算法是Bully算法的改进,每个节点都有节点ID,然后每个节点都会对当前已知活跃排序,理论上取ID最大的为主节点,但是会存在由于网络分区或者节点启动速度相差太大的时候,会导致节点最大ID统计不同一,如1节点统计1,2,3,4,但是2节点统计2,3,4,5,此时就会不一致,因此此节点会先半数选举一个临时主节点,然后半数投票才确认最终的主节点。选举完成后若有节点下线,需要判断存活节点数是否大于当前检测到存活的一半节点数,达不到就要放弃master,重新设置集群,假如5台机器网络出现故障分区,1、2一组,3、4、5一组,产生分区前,master位于1或2,此时三台一组的节点会重新并成功选取master,产生双主,俗称脑裂。
  • gateway:确定最新的集群元信息,被选举的master的节ES点存储的集群元信息不一定是最新的,需要将其他节点元信息发过来,根据版本号来确定最新的元信息。然后把这个元信息广播,更新其他节点,称为集群元信息的选举。
  • allocation:分片分配至集群各节点,构建路由表,ES节点分配分片(全部index需要均分分片到对应ES节点),构建路由表,1、先要选出主分片,所有分配工作由master节点来做。开始所有的分片信息都处于unassigned状态,ES中通过分配过程决定哪个分片位于哪个节点,因此首先需要选出主分片。首先询问所有节点依次索要part1分片、part2分片…的元信息,询问量 = 节点数 * 分片数(part1、2、3、4…),由此可以看出效率受分片数量影响,所以最好是控制分片数量。现在拿到了所有的分片信息,5.x之前是将所有的从分片元信息汇总比较,选出版本号最大的作为主分片,但是存在分片所在机器启动慢问题,5.x之后给每个分片设置一个uuid,然后再集群的元信息记录那个shared是最新的。2、选取从分片从众多收集的分片信息选取一个作为从分片。
  • recovery:数据恢复,保持主从分片数据一致,分片分配到节点后,开始统一各主、副分片数据,主分片有可能写的数据还没刷盘,主分片recovery不会等到副分片分配成功才开始,但是副分片recovery需要等到主分片recovery之后(因为主写副读,主的数据副分片有可能还没统一)。一次Lucene倒排索引的提交,就会一次写缓冲区fsync刷盘过程。
    • 主的recovery就是将最后一次提交之后的translog进行重放。
    • 副的recovery会分为两个阶段,为了不影响读,1、全量同步:获取主的translog锁,保证不会受主的fsync改变translog,然后备份主分片快照,直接更新副分片。此阶段完成前,通知完副分片启动engine,然后可以接受读写请求了。2、增量同步:主分片在上面过程中可能写入新的数据和translog,因此副需要增量将translog新增的索引重放恢复,增量translog数据指的是对translog从加锁开始到副分片复制完主分片的快照的时刻产生的新增数据,可以对主的translog做一个快照,发送到副就能找到差异数据。

在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
集群的架构可以为主从模式、哈希表模式

  • 哈希表模式:每小时可支持数千个节点的加入和离开,其可以在不了解底层网络拓扑的情况下,查询相应很快,如Cassandra就是这种模式
  • 主从模式:在网络相对稳定的情况下较为适合,当集群没那么多节点的时候,通常节点的数量远远小于单个节点能够维持的连接数,也就是连接多,并且节点不经常变动,因此es选择这种模式。

选举算法

  • Bully算法:选举Leader的基本算法之一,假设每个节点都有一个唯一的ID,然后根据ID排序,任何时候选取最大ID对应的节点为Leader,这种方式是实现比较简单,不足是容易产生脑裂,比如A节点之前为Leader,但是后来由于负载过重出现假死,这个时候排名第二的节点B被选为Leader,然后A节点又突然恢复正常了,造成脑裂效应。
  • Paxos算法:选举更灵活、简单,但是实现起来比较复杂。参考:https://zhuanlan.zhihu.com/p/31780743、https://www.cnblogs.com/linbingdong/p/6253479.html

详细流程
在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(可以通过启动时间、网络响应时间等等确定),详细的流程就是

  • (1)每个节点Ping所有的集群节点,获取可到达的节点列表加入到fullPingResponses中,然后把自己也加入列表
  • (2)构建两个列表,activeMasters列表存储当前活跃的允许被选为Master的节点列表,这个列表的数据来自遍历fullPingResponses每个节点,根据每个节点选出的ID最小的加入activeMasters列表(不包括自身节点,其中配置了discovery.zen.master_election.ignore_non_master_pings为true的节点并且配置不具备Master也不会被加入)。另外一个是masterCandidates列表是master的候选者列表,如果activeMasters为空那么从这个列表选取。
  • (3)从activeMasters列表中选取一个做为自己认为的临时Master,比较方法是选出一个列表中ID最小值的节点。

2.2、投票确定最终Master:各节点选出自己确定的临时Master,需要半数该值节点数认同才能成为真正的Master,否则该临时Master就会加入集群,发送投票就是本节点向自己选的临时Master发送加入集群的请求,获得的票数就是该临时Master接收到其他节点的加入集群的请求数量。投票过程中对于莫i个临时Master会存在两种情况:

  • (1)被选上:等待其他具备Master资格的节点加入集群即投票达到法定人数,默认30s超时未达到法定人数则选举失败,选举成功的话发布集群状态clusterState。
  • (2)其他临时Master被选上:当前临时Master不在接受投票信息,向被确定为Master的节点发送加入请求,并默认等待1min,超时会重试三次。最终确定的Master会先发布集群状态,然后在确认加入请求。

2.3、Master选取元信息:像有Master资格节点(配置了node.master = true的节点)发请求获取元数据,获取响应数量必须达到最小节点数才会选为元信息。
3、Master发布集群状态

4、集群节点失效检测
选举完成之后集群状态发布,后面集群需要探测到某些节点失效的异常情况,不执行的话可能会造成脑裂(双主、多主),因此需要启动两种失效探测器:

  • 在Master节点:启动NodesFaultDetection,简称NodesFD,定期探测加入集群的节点是否活跃。检查下当前集群存活节点是否达到法定节点数(半数以上),如果不足则会放弃Master,重新加入集群。
  • 在其他节点:启动MasterFaultDetection,简称MasterFD,定期探测Master节点是否活跃。尝试重新加入集群,发送加入申请。

你可能感兴趣的:(#,ElasticSearch,elasticsearch,学习,lucene)