ElasticSearch與Spring整合
SpringBoot微服務項目筆記-08

ElasticSearch

用來執行站內搜索(如商品查詢)的引擎

  • 開源的分散式搜尋引擎,基於Apache Lucene,但是封裝成REST API,方便操作
  • 核心觀念: Inverted Index (反向索引)
  • 是 NoSQL 資料庫的一種,所有的資料都是以 JSON 的方式進行存取
  • 名詞對應:
RDBMS 概念 Elasticsearch
Table Index
Row 一筆資料(例如:張三) Document
Column 屬性(例如:姓名) Field
Schema 架構(例如:資料類型、允許為空等等) Mapping
SQL 操作語句 DSL
  • 以前可能有人會說Index對應database,type才是對應表,但在ES7版後Type已經幾乎被棄用
  • 個人直接把它看作表了,畢竟是概念,不用分太細

安裝

使用docker

  • 教學的他設定掛載位置與插件路徑,並取得權限等等,方便後續操作
mkdir -p /mydata/elasticsearch/config
mkdir -p /mydata/elasticsearch/data
echo "http.host: 0.0.0.0" >/mydata/elasticsearch/config/elasticsearch.yml
chmod -R 777 /mydata/elasticsearch/
docker pull nshou/elasticsearch-kibana

docker run --name elasticsearch -p 5601:5601 -p 9200:9200 -e  "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms64m -Xmx512m" -d nshou/elasticsearch-kibana
  • --name 取名
  • -e "discovery.type=single-node" 單節點模式
  • -e ES_JAVA_OPTS="-Xms64m -Xmx512m" 限制記憶體,不限會占用超多
  • -p 連接阜
    • Elasticsearch @ localhost:9200
    • Kibana @ localhost:5601
  • -d 後台運行並返回id

測試

  • 嘗試看看Kibana介面,訪問: localhost:5601
  • 查看主服務健康: http://localhost:9200/_cat/health

使用

  • 查看索引: http://localhost:9200/_cat/indices
    • 相當於show databases或tables

建立一個Document

  • 使用PUT請求 http://localhost:9200/customer/external/1
    • 並且帶有body資訊

image-20220118235447277

  • 返回的是metadata,後設資料,這些_開頭的就是資料的資料

    • _index:document 所屬的 index 名稱,相當於"表"
    • _type:document 類型 ,通常為_doc
    • _id:document ID,主鍵
    • _source:document 的原始 JSON 資料樣貌
    • _version:版本訊息 (有這欄位就表示 ES 具有版本控管的能力)
    • _score:查詢時的算分結果 (每次的搜尋都會根據 document 對於搜尋內容的相關度進行算分)
  • index 在 ES 中是個邏輯空間的概念,用來儲存 document 的容器,而這些 document 內容都是相似的 (跟其他領域的 index 用法不太一樣)

  • shard 在 ES 中則是個物理空間的的概念,index 中的資料會分散放在不同的 shard 中

PUT與POST更新

  • PUT必須指定ID,如果存在則更新(覆蓋更新)
    • 與REST規則相符,PUT是冪等,即多次操作結果返回相同(每次都會整個刷新的意思)
  • POST不必指定ID,如果不存在就建立,已存在就更新
    • 但是如果資料都沒變動,就不更新,且會返回"noop"

查詢

  • 使用GET請求 http://localhost:9200/customer/external/1

    • 並且不能有body
  • 返回

    "_version": 1,
    "_seq_no": 0,
    "_primary_term": 1,

_seq_no 每次更新都會+1,用來做樂觀鎖

_primary_term 當主分片重新分配,如重啟就會變化

  • 就DELETE,可以指定ID,或整個index全刪
  • 沒辦法刪除type

bulk批次操作

  • 每條語法獨立,其中有誰失敗不影響其他人
  • 範例:
POST customer/external/_bulk
{"index":{"_id":"1"}}
{"name":"John Doe"}
{"index":{"_id":"2"}}
{"name":"John Doe"}

kibana

舒服的可視化操作介面

POST bank/account/_bulk
(去github複製貼到這)

image-20220119003426307

Search Api

兩種基本檢索方式

  • 通過REST request uri 發送搜索參數 (uri +檢索參數)
GET bank/_search?q=*&sort=account_number:asc
  • 通過REST request body 來發送它們(uri+請求體)
    • 只能用在dev tool,一般HTTP不給你發GET帶body的
    • 在kibana dev_tools用ctrl + enter做為執行的快捷鍵
GET /bank/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "account_number": "asc" },
    {"balance":"desc"}
  ]
}
  • 返回的值

    • hits 結果數量
    • _score 得分(字串比對的得分)

補充: SQL方式查詢

  • 我後來才知道ES 6.3以後原生支持用SQL方式來查,例如:
POST /_sql?format=txt
{
  "query": "SELECT account_number,address,age,balance FROM account LIMIT 10"
}

Query DSL

  • Elasticsearch提供了一個可以執行查詢的Json風格的DSL。這個被稱為Query DSL,該查詢語言非常全面。例如:
QUERY_NAME:{
   ARGUMENT:VALUE,
   ARGUMENT:VALUE,...
}

{
  QUERY_NAME:{
     FIELD_NAME:{
       ARGUMENT:VALUE,
       ARGUMENT:VALUE,...
      }
   }
}

GET bank/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0,
  "size": 5,
  "sort": [
    {
      "account_number": {
        "order": "desc"
      }
    }
  ]
}


GET bank/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0,
  "size": 5,
  "sort": [
    {
      "account_number": {
        "order": "desc"
      }
    }
  ],
  "_source": ["balance","firstname"]

}
  • match_all 代表查詢所有,es中可以在query中組合非常多的查詢類型完成複雜查詢
  • from + size 限定返回數量,完成分頁功能
  • sort 排序,可以多重排序(前一個順位相等時)
  • _source 只想返回某些屬性

匹配查詢

match 匹配

  • 例如匹配某值等於多少
GET bank/_search
{
  "query": {
    "match": {
      "account_number": "20"
    }
  }
}
  • 也可以模糊匹配,會進行分詞匹配並依照得分排序返回
    • 會用Aviation查、用Road查,然後最接近Aviation Road的會排在前面
GET bank/_search
{
  "query": {
    "match": {
      "address": "Aviation Road"
    }
  }
}

match_phrase 短句匹配

  • 不能拆分,一定要包含完整的Aviation Road
    • 但是可以更長,例如返回的結果是 2123 Aviation Road xxx
GET bank/_search
{
  "query": {
    "match_phrase": {
      "address": "Aviation Road"
    }
  }
}

multi_match 多字段匹配

  • address或city中包含"mill"的
    • 也能多查詢字,例如"mill road"就會跟前面模糊匹配一樣分詞
GET bank/_search
{
  "query": {
    "multi_match": {
      "query": "mill ",
      "fields": ["address","city"]
    }
  }
}

term 精確匹配

  • 類似match匹配某個屬性,但是term不分詞且須完全符合

    • 例如用term找"Aviation Road",就只會找address完全一致的那個"Aviation Road",太長太短有沒有空格通通out
  • 總之文字檢索用match,其他非文字匹配用term

GET bank/_search
{
  "query": {
    "term": {
      "address": "Aviation Road"
    }
  }
}

複合查詢

可以組合嵌套使用,讓查詢條件千變萬化

bool 條件判斷

  • must 必須達到must所列舉的所有條件

    • 就是and
  • must_not 必須不匹配must_not所列舉的所有條件

    • 就是not
  • should 應該滿足should所列舉的條件

    • 就是加分項,有更好,沒有也行
GET bank/_search
{
   "query":{
        "bool":{
             "must":[
              {"match":{"address":"mill"}},
              {"match":{"gender":"M"}}
             ]
         }
    }
}
  • 在boolean查詢中,must, shouldmust_not 元素都被稱為查詢子句
  • 文檔是否符合每個子句中的標準,決定了文檔的"相關性得分"

filter 結果過濾

  • 不會計算相關性得分,純過濾
    • 用在不想對評分排序有影響的場合,類似between之類
GET bank/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "address": "mill"
          }
        }
      ],
      "filter": {
        "range": {
          "balance": {
            "gte": "10000",
            "lte": "20000"
          }
        }
      }
    }
  }
}

aggregation 執行聚合

  • 類似GROUP BY的概念,可以將查詢的結果依照條件分組打包成buckets
GET bank/_search
{
"aggs": {
  "NAME": {
    "AGG_TYPE": {}
  }
}
}
  • NAME 這次聚合的名字,就像MySQL取別名
  • AGG_TYPE 聚合的類型
    • avg 平均
    • term 精確匹配
    • terms 多個精確匹配

image-20220119152725760

子聚合

  • 基於某個聚合結果來聚合,寫在父聚合之內
    • "size": 0 不想關注每個人的詳情,只想要統計結果
GET bank/_search
{
  "query": {
    "match_all": {}
  },
  "aggs": {
    "年齡分組": {
      "terms": {
        "field": "age",
        "size": 10
      },
      "aggs": {
        "平均薪資": {
          "avg": {
            "field": "balance"
          }
        }
      }
    }
  },
  "size": 0
}

image-20220119154011303

  • 範例2: 查出所有年齡分佈,並且這些年齡段中M的平均薪資和F的平均薪資以及這個年齡段的總體平均薪資
    • .keyword 精確匹配
GET bank/_search
{
  "query": {
    "match_all": {}
  },
  "aggs": {
    "ageAgg": {
      "terms": {
        "field": "age",
        "size": 100
      },
      "aggs": {
        "genderAgg": {
          "terms": {
            "field": "gender.keyword"
          },
          "aggs": {
            "balanceAvg": {
              "avg": {
                "field": "balance"
              }
            }
          }
        },
        "ageBalanceAvg": {
          "avg": {
            "field": "balance"
          }
        }
      }
    }
  },
  "size": 0
}

image-20220119154724229


Mapping

就是建表時指定存在裡面的玩意的規則

  • Maping是用來定義一個文檔(document),以及它所包含的屬性(field)是如何存儲和索引的。比如:

    • 數值格式: 都是json但也能分text、long

    • 哪些字符串屬性應該被看做全文本屬性(full text fields)

    • 哪些屬性包含數字,日期或地理位置

    • 文檔中的所有屬性是否都被索引(就是決定是否會被搜,預設都是true)

    • 日期的格式

    • 自定義映射規則來執行動態添加屬性

  • 查看mapping信息 GET bank/_mapping

指定mapping

  • 要在建立index的時候就指定,不然就是預設的
    • 類似SQL建表的時候指定表的構成
PUT /my_index
{
  "mappings": {
    "properties": {
      "age": {
        "type": "integer"
      },
      "email": {
        "type": "keyword"
      },
      "name": {
        "type": "text"
      }
    }
  }
}
  • Mapping建完就不能改,要改只能像給database改名,複製建新的那樣

分詞

  • 顧名思義將詞拆開成有意義的片段,預設就是用standard標準分詞器,例如:
POST _analyze
{
  "analyzer": "standard",
  "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone."
}
  • tokenizer分詞器,將詞拆成一個一個的token
  • 預設的中文分不好(應該說只會全部拆成一個字),需要火力支援

安裝ik分詞器

./bin/elasticsearch-plugin install xxx插件版本資源網址
  • 直接從docker desktop打開該容器的CLI
  • 因為是2合一鏡像,進了容器還要cd elasticsearch-7.16.2再進到elasticsearch資料夾下,執行:
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.16.2/elasticsearch-analysis-ik-7.16.2.zip

image-20220119161207797

  • 另外附上教學的手動方法,在前面我們已經將elasticsearch容器的“/usr/share/elasticsearch/plugins”目錄,映射到宿主機的“ /mydata/elasticsearch/plugins”目錄下,所以比較方便的做法就是下載“/elasticsearch-analysis-ik-xxx.zip”文件,然後解壓到該文件夾下即可
// 進入es容器內部plugin目錄
docker exec -it elasticsearch /bin/bash

// 下載
# wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.6.2/elasticsearch-analysis-ik-7.6.2.zip

// 解壓縮
unzip elasticsearch-analysis-ik-7.6.2.zip -d ink

// 搬到插件資料夾
mv ik plugins/

// 刪除殘餘
rm -rf elasticsearch-analysis-ik-7.6.2.zip
  • 改完都要重啟容器才能生效!
  • 測試:
POST _analyze
{
  "analyzer": "ik_max_word",
  "text": "《白雪公主》是廣泛流行於歐洲的一篇德國童話,亦是該童話中的主角。其最著名的版本見於德國《格林童話》,講述了白雪公主受到繼母的虐待,逃到森林裡,遇到七個小矮人的故事"
}

image-20220119161834409

自定義詞庫

  • 修改/usr/share/elasticsearch/plugins/ik/config中的IKAnalyzer.cfg.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
	<comment>IK Analyzer 擴展配置</comment>
	<!--用户可以在這裏配置自己的擴展字典 -->
	<entry key="ext_dict"></entry>
	 <!--用户可以在這裏配置自己的擴展停止詞字典-->
	<entry key="ext_stopwords"></entry>
	<!--用户可以在這裏配置遠程擴展字典 -->
	<!-- <entry key="remote_ext_dict">words_location</entry> -->
	<!--用户可以在這裏配置遠程擴展停止詞字典-->
	<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
  • 更新完成後,es只會對於新增的數據用更新分詞。歷史數據是不會重新分詞的。如果想要歷史數據重新分詞,需要執行:
POST my_index/_update_by_query?conflicts=proceed
  • 我用的二合一鏡像是在
/home/elasticsearch/elasticsearch-7.16.2/config/analysis-ik/IKAnalyzer.cfg.xml
  • 將檔案抓出來放到linux宿主/mydata/elasticsearch路徑下
docker cp elasticsearch:/home/elasticsearch/elasticsearch-7.16.2/config/analysis-ik/IKAnalyzer.cfg.xml /mydata/elasticsearch
  • 改一改,塞回去

整合Spring

  • 導依賴,注意版本要等同ELK的版本
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>7.16.2</version>
</dependency>
  • 並且要在項目中指定properties
    <properties>
        ...
        <elasticsearch.version>7.16.2</elasticsearch.version>
    </properties>

測試連線

  • 先造一個設定類
@Configuration
public class ElasticConfig {

    // 設定
    public static final RequestOptions COMMON_OPTIONS;

    static {
        RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
        // builder.addHeader("Authorization", "Bearer " + TOKEN);
        // builder.setHttpAsyncResponseConsumerFactory(
        //         new HttpAsyncResponseConsumerFactory
        //                 .HeapBufferedResponseConsumerFactory(30 * 1024 * 1024 * 1024));
        COMMON_OPTIONS = builder.build();
    }

    // 已經被棄用的創造Client方法
    @Bean
    public RestHighLevelClient restHighLevelClient() {
        // Create the low-level client
        RestClientBuilder httpClientBuilder = RestClient.builder(
                new HttpHost("localhost", 9200)
        );
        // Create the HLRC
        RestHighLevelClient hlrc = new RestHighLevelClient(httpClientBuilder);
        return hlrc;
    }
}
  • 簡單測試,嘗試造一個index與物件
@SpringBootTest
class EsTest {

    @Autowired
    private RestHighLevelClient client;

    @Data
    class User {
        private String name;
        private int age;

    }
    @Test
    public void test() throws IOException {
        // 建立一個測試的物件
        User user = new User();
        user.setName("Tom");
        user.setAge(30);
        String s = JSONUtil.toJsonStr(user);

        // 創立index
        IndexRequest indexRequest = new IndexRequest("users");
        // 指定id
        indexRequest.id("1");
        // 放進去的東西,如果是字串必須聲明是XContentType.JSON,否則要放map
        indexRequest.source(s, XContentType.JSON);
        // 執行,第2個參數是使用一般設定
        IndexResponse response = client.index(indexRequest, ElasticConfig.COMMON_OPTIONS);
        System.out.println("response = " + response);
    }
}
  • 測試2
    @Test
    public void searchData() throws IOException {
        GetRequest getRequest = new GetRequest(
                "users",
                "_-2vAHIB0nzmLJLkxKWk");

        GetResponse getResponse = client.get(getRequest, RequestOptions.DEFAULT);
        System.out.println(getResponse);
        String index = getResponse.getIndex();
        System.out.println(index);
        String id = getResponse.getId();
        System.out.println(id);
        if (getResponse.isExists()) {
            long version = getResponse.getVersion();
            System.out.println(version);
            String sourceAsString = getResponse.getSourceAsString();
            System.out.println(sourceAsString);
            Map<String, Object> sourceAsMap = getResponse.getSourceAsMap();
            System.out.println(sourceAsMap);
            byte[] sourceAsBytes = getResponse.getSourceAsBytes();
        } else {

        }
    }

檢索範例

DSL有的這邊都有,翻著官方文檔寫吧

/**
     * 複雜檢索:在bank中搜索address中包含mill的所有人的年齡分佈以及平均年齡,平均薪資
     * @throws IOException
     */
@Test
public void searchData() throws IOException {
    //1. 創建檢索請求
    SearchRequest searchRequest = new SearchRequest();

    //1.1)指定索引
    searchRequest.indices("bank");
    //1.2)構造檢索條件
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    sourceBuilder.query(QueryBuilders.matchQuery("address","Mill"));

    //1.2.1)按照年齡分佈進行聚合
    TermsAggregationBuilder ageAgg=AggregationBuilders.terms("ageAgg").field("age").size(10);
    sourceBuilder.aggregation(ageAgg);

    //1.2.2)計算平均年齡
    AvgAggregationBuilder ageAvg = AggregationBuilders.avg("ageAvg").field("age");
    sourceBuilder.aggregation(ageAvg);
    //1.2.3)計算平均薪資
    AvgAggregationBuilder balanceAvg = AggregationBuilders.avg("balanceAvg").field("balance");
    sourceBuilder.aggregation(balanceAvg);

    System.out.println("檢索條件:"+sourceBuilder);
    searchRequest.source(sourceBuilder);
    //2. 執行檢索
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    System.out.println("檢索結果:"+searchResponse);

    //3. 將檢索結果封裝為Bean
    SearchHits hits = searchResponse.getHits();
    SearchHit[] searchHits = hits.getHits();
    for (SearchHit searchHit : searchHits) {
        String sourceAsString = searchHit.getSourceAsString();
        Account account = JSON.parseObject(sourceAsString, Account.class);
        System.out.println(account);
    }

    //4. 獲取聚合信息
    Aggregations aggregations = searchResponse.getAggregations();

    Terms ageAgg1 = aggregations.get("ageAgg");

    for (Terms.Bucket bucket : ageAgg1.getBuckets()) {
        String keyAsString = bucket.getKeyAsString();
        System.out.println("年齡:"+keyAsString+" ==> "+bucket.getDocCount());
    }
    Avg ageAvg1 = aggregations.get("ageAvg");
    System.out.println("平均年齡:"+ageAvg1.getValue());

    Avg balanceAvg1 = aggregations.get("balanceAvg");
    System.out.println("平均薪資:"+balanceAvg1.getValue());
}
  • 步驟整理:
    • 指定index
    • 創建sourceBuilder,設定查詢條件
    • 將source封裝到SearchRequest
    • 用client對ES伺服端發起SearchRequest
    • 接收Hits並解析

上次修改於 2022-01-25