<2021SC@SDUSC>博客(8)山东大学软件工程应用与实践Jpress代码分析(7)

2021SC@SDUSC

这一篇博客主要将ElasticSearcher

ElasticSearch介绍

Elasticsearch 是一个实时的分布式存储、搜索、分析的引擎

在学习一项技术之前,必须先要了解为什么要使用这项技术。所以,为什么要使用Elasticsearch呢?我们在日常开发中,数据库也能做到(实时、存储、搜索、分析)。

相对于数据库,Elasticsearch的强大之处就是可以模糊查询。

有的同学可能就会说:我数据库怎么就不能模糊查询了??我反手就给你写一个SQL:

select * from user where name like '%公众号Java3y%'

的确,这样做的确可以。但是要明白的是:name like %Java3y%这类的查询是不走索引的,不走索引意味着:只要你的数据库的量很大(1亿条),你的查询肯定会是秒级别的

而且,即便给你从数据库根据模糊匹配查出相应的记录了,那往往会返回大量的数据给你,往往你需要的数据量并没有这么多,可能50条记录就足够了。

还有一个就是:用户输入的内容往往并没有这么的精确,比如我从Google输入ElastcSeach(打错字),但是Google还是能估算我想输入的是Elasticsearch

而Elasticsearch是专门做搜索的,就是为了解决上面所讲的问题而生的,换句话说:

  • Elasticsearch对模糊搜索非常擅长(搜索速度很快)
  • 从Elasticsearch搜索到的数据可以根据评分过滤掉大部分的,只要返回评分高的给用户就好了(原生就支持排序)
  • 没有那么准确的关键字也能搜出相关的结果(能匹配有相关性的记录)

ElasticSearch的术语与架构

  • Index:Elasticsearch的Index相当于数据库的Table
  • Type:这个在新的Elasticsearch版本已经废除(在以前的Elasticsearch版本,一个Index下支持多个Type–有点类似于消息队列一个topic下多个group的概念)
  • Document:Document相当于数据库的一行记录
  • Field:相当于数据库的Column的概念
  • Mapping:相当于数据库的Schema的概念
  • DSL:相当于数据库的SQL(给我们读取Elasticsearch数据的API)
    一个Elasticsearch集群会有多个Elasticsearch节点,所谓节点实际上就是运行着Elasticsearch进程的机器。

在众多的节点中,其中会有一个Master Node,它主要负责维护索引元数据、负责切换主分片和副本分片身份等工作(后面会讲到分片的概念),如果主节点挂了,会选举出一个新的主节点。

从上面我们也已经得知,Elasticsearch最外层的是Index(相当于数据库 表的概念);一个Index的数据我们可以分发到不同的Node上进行存储,这个操作就叫做分片。

比如现在我集群里边有4个节点,我现在有一个Index,想将这个Index在4个节点上存储,那我们可以设置为4个分片。这4个分片的数据合起来就是Index的数据

为什么要分片?原因也很简单:

如果一个Index的数据量太大,只有一个分片,那只会在一个节点上存储,随着数据量的增长,一个节点未必能把一个Index存储下来。
多个分片,在写入或查询的时候就可以并行操作(从各个节点中读写数据,提高吞吐量)

现在问题来了,如果某个节点挂了,那部分数据就丢了吗?显然Elasticsearch也会想到这个问题,所以分片会有主分片和副本分片之分(为了实现高可用)

数据写入的时候是写到主分片,副本分片会复制主分片的数据,读取的时候主分片和副本分片都可以读。

Index需要分为多少个主分片和副本分片都是可以通过配置设置的

如果某个节点挂了,前面所提高的Master Node就会把对应的副本分片提拔为主分片,这样即便节点挂了,数据就不会丢。

ElasticSearch的源码以及CURD原理

public class ElasticSearcher implements ProductSearcher,JPressOptions.OptionChangeListener {

    private static final Log LOG = Log.getLog(ElasticSearcher.class);


    private String index = "jpress-product-index";
    private String type = "jpress-product-type";


    private RestHighLevelClient client;
    private RestClient restClient;


    public ElasticSearcher() {
        JPressOptions.addListener(this);
    }

    @Override
    public void onChanged(String key, String newValue, String oldValue) {
        if ("product_search_es_host".equals(key) || "product_search_es_port".equals(key)){
            this.client = null;
            this.restClient = null;
        }
    }
}

ElasticSearcher实现了ProductSeacher和OptionChangeLIsnte接口,实现OptionChangeListener是为了能够让JpressOption来监听es。


    @Override
    public void onChanged(String key, String newValue, String oldValue) {
        if ("product_search_es_host".equals(key) || "product_search_es_port".equals(key)){
            this.client = null;
            this.restClient = null;
        }
    }

这段代码的意识是,如果onChanged的参数key等于product_search_es_host or product_search_es_port 就把client和restclient设置为空。
并且可以看到,es支持REST

ES的写入

 @Override
    public void addProduct(Product product) {
        IndexRequest indexRequest = new IndexRequest(index, type, product.getId().toString());
        indexRequest.source(product.toJson(), XContentType.JSON);
        try {
            IndexResponse response = getClient().index(indexRequest, RequestOptions.DEFAULT);
            if (LogKit.isDebugEnabled()) {
                LogKit.debug(response.toString());
            }
        } catch (Exception e) {
            LOG.error(e.toString(), e);
        }
    }

客户端写入一条数据,到Elasticsearch集群里边就是由节点来处理这次请求:

集群上的每个节点都是coordinating node(协调节点),协调节点表明这个节点可以做路由。比如节点1接收到了请求,但发现这个请求的数据应该是由节点2处理(因为主分片在节点2上),所以会把请求转发到节点2上。

coodinate(协调)节点通过hash算法可以计算出是在哪个主分片上,然后路由到对应的节点
shard = hash(document_id) % (num_of_primary_shards)

路由到对应的节点以及对应的主分片时,会做以下的事:

  • 将数据写到内存缓存区
  • 然后将数据写到translog缓存区
  • 每隔1s数据从buffer中refresh到FileSystemCache中,生成segment文件,一旦生成segment文件,就能通过索引查询到了
  • refresh完,memory buffer就清空了。
  • 每隔5s中,translog 从buffer flush到磁盘中
  • 定期/定量从FileSystemCache中,结合translog内容flush index到磁盘中。

Es的删除与更新

  @Override
    public void deleteProduct(Object id) {

        DeleteRequest request = new DeleteRequest(index, type, id.toString());
        try {
            DeleteResponse response = getClient().delete(request, RequestOptions.DEFAULT);
            if (LogKit.isDebugEnabled()) {
                LogKit.debug(response.toString());
            }
        } catch (Exception e) {
            LOG.error(e.toString(), e);
        }

    }

    @Override
    public void updateProduct(Product product) {
        UpdateRequest updateRequest = new UpdateRequest(index, type, product.getId().toString());
        Map<String, Object> map = new HashMap<>();
        map.putAll(CPI.getAttrs(product));
        updateRequest.doc(map);

        try {
            UpdateResponse response = getClient().update(updateRequest, RequestOptions.DEFAULT);
            if (LogKit.isDebugEnabled()) {
                LogKit.debug(response.toString());
            }
        } catch (Exception e) {
            LOG.error(e.toString(), e);
        }
    }

Elasticsearch的更新和删除操作流程:

给对应的doc记录打上.del标识,如果是删除操作就打上delete状态,如果是更新操作就把原来的doc标志为delete,然后重新新写入一条数据

前面提到了,每隔1s会生成一个segement 文件,那segement文件会越来越多越来越多。Elasticsearch会有一个merge任务,会将多个segement文件合并成一个segement文件。

在合并的过程中,会把带有delete状态的doc给物理删除掉。

Es的查询

 @Override
    public Page<Product> search(String keyword, int pageNum, int pageSize) {

        BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
        boolQueryBuilder.should(new MatchQueryBuilder("title", keyword));
        boolQueryBuilder.should(new MatchQueryBuilder("content", keyword));


        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        sourceBuilder.from(pageNum * pageSize - pageSize);
        sourceBuilder.size(pageSize);
        sourceBuilder.query(boolQueryBuilder);

        SearchRequest searchRequest = new SearchRequest();
        searchRequest.source(sourceBuilder);
        searchRequest.indices(index);
        searchRequest.types(type);

        try {
            SearchResponse response = getClient().search(searchRequest, RequestOptions.DEFAULT);
            if (response == null || response.getHits() == null || response.getHits().getTotalHits().value <= 0) {
                return null;
            }

            int total = (int) response.getHits().getTotalHits().value;

            List<Product> products = new ArrayList<>();
            response.getHits().forEach(hit -> {
                Product product = new Product();
                product.put(hit.getSourceAsMap());
                products.add(product);
            });

            return new Page<>(products, pageNum, pageSize, total / pageSize, total);

        } catch (Exception e) {
            LOG.error(e.toString(), e);
        }
        return null;
    }

查询我们最简单的方式可以分为两种:

根据ID查询doc
根据query(搜索词)去查询匹配的doc

根据ID去查询具体的doc的流程是:

检索内存的Translog文件
检索硬盘的Translog文件
检索硬盘的Segement文件

根据query去匹配doc的流程是:

同时去查询内存和硬盘的Segement文件

从上面所讲的写入流程,我们就可以知道:Get(通过ID去查Doc是实时的),Query(通过query去匹配Doc是近实时的)

因为segement文件是每隔一秒才生成一次的

Elasticsearch查询又分可以为三个阶段:

QUERY_AND_FETCH(查询完就返回整个Doc内容)
QUERY_THEN_FETCH(先查询出对应的Doc id ,然后再根据Doc id 匹配去对应的文档)
DFS_QUERY_THEN_FETCH(先算分,再查询)
「这里的分指的是 词频率和文档的频率(Term Frequency、Document Frequency)众所周知,出现频率越高,相关性就更强」

一般我们用得最多的就是QUERY_THEN_FETCH,第一种查询完就返回整个Doc内容(QUERY_AND_FETCH)只适合于只需要查一个分片的请求。

QUERY_THEN_FETCH总体的流程流程大概是:

客户端请求发送到集群的某个节点上。集群上的每个节点都是coordinate node(协调节点)
然后协调节点将搜索的请求转发到所有分片上(主分片和副本分片都行)
每个分片将自己搜索出的结果(doc id)返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果。
接着由协调节点根据 doc id 去各个节点上拉取实际的 document 数据,最终返回给客户端。

Query Phase阶段时节点做的事:

协调节点向目标分片发送查询的命令(转发请求到主分片或者副本分片上)
数据节点(在每个分片内做过滤、排序等等操作),返回doc id给协调节点

Fetch Phase阶段时节点做的是:

协调节点得到数据节点返回的doc id,对这些doc id做聚合,然后将目标数据分片发送抓取命令(希望拿到整个Doc记录)
数据节点按协调节点发送的doc id,拉取实际需要的数据返回给协调节点

上一篇:[转]Android事件分发机制完全解析,带你从源码的角度彻底理解(上)


下一篇:bash——curl