Elasticsearch
1、認識和安裝
Elasticsearch的官方網站如下:
https://www.elastic.co/cn/elasticsearch
Elasticsearch是由elastic公司開發的一套搜索引擎技術,它是elastic技術棧中的一部分。完整的技術棧包括:
- Elasticsearch:用于數據存儲、計算和搜索
- Logstash/Beats:用于數據收集
- Kibana:用于數據可視化
整套技術棧被稱為ELK,經常用來做日志收集、系統監控和狀態分析等等:
整套技術棧的核心就是用來存儲、搜索、計算的Elasticsearch,因此我們接下來學習的核心也是Elasticsearch。
我們要安裝的內容包含2部分:
- elasticsearch:存儲、搜索和運算
- kibana:圖形化展示
首先Elasticsearch不用多說,是提供核心的數據存儲、搜索、分析功能的。
然后是Kibana,Elasticsearch對外提供的是Restful風格的API,任何操作都可以通過發送http請求來完成。不過http請求的方式、路徑、還有請求參數的格式都有嚴格的規范。這些規范我們肯定記不住,因此我們要借助于Kibana這個服務。
Kibana是elastic公司提供的用于操作Elasticsearch的可視化控制臺。它的功能非常強大,包括:
- 對Elasticsearch數據的搜索、展示
- 對Elasticsearch數據的統計、聚合,并形成圖形化報表、圖形
- 對Elasticsearch的集群狀態監控
- 它還提供了一個開發控制臺(DevTools),在其中對Elasticsearch的Restful的API接口提供了語法提示
安裝ES
通過下面的Docker命令即可安裝單機版本的elasticsearch:
docker run -d \--name es \-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \-e "discovery.type=single-node" \-v es-data:/usr/share/elasticsearch/data \-v es-plugins:/usr/share/elasticsearch/plugins \--privileged \--network hmall \-p 9200:9200 \-p 9300:9300 \elasticsearch:7.12.1
注意,這里我們采用的是elasticsearch的7.12.1版本,由于8以上版本的JavaAPI變化很大,在企業中應用并不廣泛,企業中應用較多的還是8以下的版本。
安裝完成后,訪問9200端口,即可看到響應的Elasticsearch服務的基本信息:
安裝Kibana
通過下面的Docker命令,即可部署Kibana:
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=hmall \
-p 5601:5601 \
kibana:7.12.1
選擇Explore on my own
之后,進入主頁面:
然后選中Dev tools
,進入開發工具頁面:
2、基礎概念
elasticsearch中有很多獨有的概念,與mysql中略有差別,但也有相似之處。
文檔和字段
elasticsearch是面向**文檔(Document)**存儲的,可以是數據庫中的一條商品數據,一個訂單信息。文檔數據會被序列化為json
格式后存儲在elasticsearch
中:
{"id": 1,"title": "小米手機","price": 3499
}
{"id": 2,"title": "華為手機","price": 4999
}
{"id": 3,"title": "華為小米充電器","price": 49
}
{"id": 4,"title": "小米手環","price": 299
}
因此,原本數據庫中的一行數據就是ES中的一個JSON文檔;而數據庫中每行數據都包含很多列,這些列就轉換為JSON文檔中的字段(Field)。
索引和映射
隨著業務發展,需要在es中存儲的文檔也會越來越多,比如有商品的文檔、用戶的文檔、訂單文檔等等:
elasticsearch之所以有如此高性能的搜索表現,正是得益于底層的倒排索引技術。那么什么是倒排索引呢?
倒排索引的概念是基于MySQL這樣的正向索引而言的。
所有文檔都散亂存放顯然非常混亂,也不方便管理。
因此,我們要將類型相同的文檔集中在一起管理,稱為索引(Index)。例如:
商品索引
{"id": 1,"title": "小米手機","price": 3499
}{"id": 2,"title": "華為手機","price": 4999
}{"id": 3,"title": "三星手機","price": 3999
}
用戶索引
{"id": 101,"name": "張三","age": 21
}{"id": 102,"name": "李四","age": 24
}{"id": 103,"name": "麻子","age": 18
}
訂單索引
{"id": 10,"userId": 101,"goodsId": 1,"totalFee": 294
}{"id": 11,"userId": 102,"goodsId": 2,"totalFee": 328
}
- 所有用戶文檔,就可以組織在一起,稱為用戶的索引;
- 所有商品的文檔,可以組織在一起,稱為商品的索引;
- 所有訂單的文檔,可以組織在一起,稱為訂單的索引;
因此,我們可以把索引當做是數據庫中的表。
數據庫的表會有約束信息,用來定義表的結構、字段的名稱、類型等信息。因此,索引庫中就有映射(mapping),是索引中文檔的字段約束信息,類似表的結構約束
mysql與elasticsearch
我們統一的把mysql與elasticsearch的概念做一下對比:
MySQL | Elasticsearch | 說明 |
---|---|---|
Table | Index | 索引(index),就是文檔的集合,類似數據庫的表(table) |
Row | Document | 文檔(Document),就是一條條的數據,類似數據庫中的行(Row),文檔都是JSON格式 |
Column | Field | 字段(Field),就是JSON文檔中的字段,類似數據庫中的列(Column) |
Schema | Mapping | Mapping(映射)是索引中文檔的約束,例如字段類型約束。類似數據庫的表結構(Schema) |
SQL | DSL | DSL是elasticsearch提供的JSON風格的請求語句,用來操作elasticsearch,實現CRUD |
如圖:
那是不是說,我們學習了elasticsearch就不再需要mysql了呢?
并不是如此,兩者各自有自己的擅長之處:
- Mysql:擅長事務類型操作,可以確保數據的安全和一致性
- Elasticsearch:擅長海量數據的搜索、分析、計算
因此在企業中,往往是兩者結合使用:
- 對安全性要求較高的寫操作,使用mysql實現
- 對查詢性能要求較高的搜索需求,使用elasticsearch實現
- 兩者再基于某種方式,實現數據的同步,保證一致性
3、倒排索引
正向索引
我們先來回顧一下正向索引。
例如有一張名為tb_goods
的表:
id | title | price |
---|---|---|
1 | 小米手機 | 3499 |
2 | 華為手機 | 4999 |
3 | 華為小米充電器 | 49 |
4 | 小米手環 | 49 |
… | … | … |
其中的id
字段已經創建了索引,由于索引底層采用了B+樹結構,因此我們根據id搜索的速度會非常快。但是其他字段例如title
,只在葉子節點上存在。
因此要根據title
搜索的時候只能遍歷樹中的每一個葉子節點,判斷title數據是否符合要求。
比如用戶的SQL語句為:
select * from tb_goods where title like '%手機%';
那搜索的大概流程如圖:
綜上,根據id精確匹配時,可以走索引,查詢效率較高。而當搜索條件為模糊匹配時,由于索引無法生效,導致從索引查詢退化為全表掃描,效率很差。
因此,正向索引適合于根據索引字段的精確搜索,不適合基于部分詞條的模糊匹配。
而倒排索引恰好解決的就是根據部分詞條模糊匹配的問題。
倒排索引
倒排索引中有兩個非常重要的概念:
- 文檔(
Document
):用來搜索的數據,其中的每一條數據就是一個文檔。例如一個網頁、一個商品信息 - 詞條(
Term
):對文檔數據或用戶搜索數據,利用某種算法分詞,得到的具備含義的詞語就是詞條。例如:我是中國人,就可以分為:我、是、中國人、中國、國人這樣的幾個詞條
創建倒排索引是對正向索引的一種特殊處理和應用,流程如下:
- 將每一個文檔的數據利用分詞算法根據語義拆分,得到一個個詞條
- 創建表,每行數據包括詞條、詞條所在文檔id、位置等信息
- 因為詞條唯一性,可以給詞條創建正向索引
此時形成的這張以詞條為索引的表,就是倒排索引表,兩者對比如下:
正向索引
id(索引) | title | price |
---|---|---|
1 | 小米手機 | 3499 |
2 | 華為手機 | 4999 |
3 | 華為小米充電器 | 49 |
4 | 小米手環 | 49 |
… | … | … |
倒排索引
詞條(索引) | 文檔id |
---|---|
小米 | 1,3,4 |
手機 | 1,2 |
華為 | 2,3 |
充電器 | 3 |
手環 | 4 |
倒排索引的搜索流程如下(以搜索"華為手機"為例),如圖:
4、IK分詞器
Elasticsearch的關鍵就是倒排索引,而倒排索引依賴于對文檔內容的分詞,而分詞則需要高效、精準的分詞算法,IK分詞器就是這樣一個中文分詞算法。
安裝IK分詞器
方案一:在線安裝
運行一個命令即可:
docker exec -it es ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
然后重啟es容器:
docker restart es
方案二:離線安裝
如果網速較差,也可以選擇離線安裝。
首先,查看之前安裝的Elasticsearch容器的plugins數據卷目錄:
docker volume inspect es-plugins
結果如下:
[{"CreatedAt": "2024-11-06T10:06:34+08:00","Driver": "local","Labels": null,"Mountpoint": "/var/lib/docker/volumes/es-plugins/_data","Name": "es-plugins","Options": null,"Scope": "local"}
]
可以看到elasticsearch的插件掛載到了/var/lib/docker/volumes/es-plugins/_data
這個目錄。我們需要把IK分詞器上傳至這個目錄。
然后重啟es
使用IK分詞器
IK分詞器包含兩種模式:
ik_smart
:智能語義切分ik_max_word
:最細粒度切分
我們在Kibana的DevTools上來測試分詞器,首先測試Elasticsearch官方提供的標準分詞器:
POST /_analyze
{"analyzer": "standard","text": "你他娘的真是個天才"
}
{"tokens" : [{"token" : "你","start_offset" : 0,"end_offset" : 1,"type" : "<IDEOGRAPHIC>","position" : 0},{"token" : "他","start_offset" : 1,"end_offset" : 2,"type" : "<IDEOGRAPHIC>","position" : 1},{"token" : "娘","start_offset" : 2,"end_offset" : 3,"type" : "<IDEOGRAPHIC>","position" : 2},{"token" : "的","start_offset" : 3,"end_offset" : 4,"type" : "<IDEOGRAPHIC>","position" : 3},{"token" : "真","start_offset" : 4,"end_offset" : 5,"type" : "<IDEOGRAPHIC>","position" : 4},{"token" : "是","start_offset" : 5,"end_offset" : 6,"type" : "<IDEOGRAPHIC>","position" : 5},{"token" : "個","start_offset" : 6,"end_offset" : 7,"type" : "<IDEOGRAPHIC>","position" : 6},{"token" : "天","start_offset" : 7,"end_offset" : 8,"type" : "<IDEOGRAPHIC>","position" : 7},{"token" : "才","start_offset" : 8,"end_offset" : 9,"type" : "<IDEOGRAPHIC>","position" : 8}]
}
可以看到原本的分詞器對中文分詞是不太友好的
我們使用Ikun分詞器
看看
POST /_analyze
{"analyzer": "ik_smart","text": "你他娘的真是個天才"
}
執行結果如下:
可以看到明顯的差別,更符合我們國內自己人的使用
拓展詞典
隨著互聯網的發展,“造詞運動”也越發的頻繁。出現了很多新的詞語,在原有的詞匯列表中并不存在。比如:“Ikun”,“雞你太美” 等。
IK分詞器無法對這些詞匯分詞,測試一下:
POST /_analyze
{"analyzer": "ik_max_word","text": "中分頭背帶褲,我是Ikun你記住,雞你太美"
}
可以看到我們后面暗藏玄坤的詞語并沒有被分到一起。
所以要想正確分詞,IK分詞器的詞庫也需要不斷的更新,IK分詞器提供了擴展詞匯的功能。
1)打開IK分詞器config目錄:
注意,如果采用在線安裝的通過,默認是沒有config目錄的
2)在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">ext.dic</entry>
</properties>
3)在IK分詞器的config目錄新建一個 ext.dic
,可以參考config目錄下復制一個配置文件進行修改
Ikun
雞你太美
4)重啟elasticsearch
docker restart es# 查看 日志
docker logs -f elasticsearch
次測試,可以發現Ikun
和雞你太美
都正確分詞了:
總結
分詞器的作用是什么?
- 創建倒排索引時,對文檔分詞
- 用戶搜索時,對輸入的內容分詞
IK分詞器有幾種模式?
ik_smart
:智能切分,粗粒度ik_max_word
:最細切分,細粒度
IK分詞器如何拓展詞條?如何停用詞條?
- 利用config目錄的
IkAnalyzer.cfg.xml
文件添加拓展詞典和停用詞典 - 在詞典中添加拓展詞條或者停用詞條
5、索引庫操作
Index就類似數據庫表,Mapping映射就類似表的結構。我們要向es中存儲數據,必須先創建Index和Mapping
Mapping映射屬性
Mapping是對索引庫中文檔的約束,常見的Mapping屬性包括:
type
:字段數據類型,常見的簡單類型有:- 字符串:
text
(可分詞的文本)、keyword
(精確值,例如:品牌、國家、ip地址) - 數值:
long
、integer
、short
、byte
、double
、float
、 - 布爾:
boolean
- 日期:
date
- 對象:
object
- 字符串:
index
:是否創建索引,默認為true
analyzer
:使用哪種分詞器properties
:該字段的子字段
{"age": 21,"weight": 52.1,"isMarried": false,"info": "中分頭背帶褲","email": "zy@123.cn","score": [99.1, 99.5, 98.9],"name": {"firstName": "徐坤","lastName": "蔡"}
}
對應的每個字段映射(Mapping):
字段名 | 字段類型 | 類型說明 | 是否****參與搜索 | 是否****參與分詞 | 分詞器 | |
---|---|---|---|---|---|---|
age | integer | 整數 | —— | |||
weight | float | 浮點數 | —— | |||
isMarried | boolean | 布爾 | —— | |||
info | text | 字符串,但需要分詞 | IK | |||
keyword | 字符串,但是不分詞 | —— | ||||
score | float | 只看數組中元素類型 | —— | |||
name | firstName | keyword | 字符串,但是不分詞 | —— | ||
lastName | keyword | 字符串,但是不分詞 | —— |
索引庫的CRUD
創建索引庫和映射
基本語法:
- 請求方式:
PUT
- 請求路徑:
/索引庫名
,可以自定義 - 請求參數:
mapping
映射
格式:
PUT /索引庫名稱
{"mappings": {"properties": {"字段名":{"type": "text","analyzer": "ik_smart"},"字段名2":{"type": "keyword","index": "false"},"字段名3":{"properties": {"子字段": {"type": "keyword"}}},// ...略}}
}
查詢索引庫
基本語法:
- 請求方式:GET
- 請求路徑:/索引庫名
- 請求參數:無
格式:
GET /索引庫名
刪除索引庫
語法:
- 請求方式:DELETE
- 請求路徑:/索引庫名
- 請求參數:無
格式:
DELETE /索引庫名
修改索引庫
倒排索引結構雖然不復雜,但是一旦數據結構改變(比如改變了分詞器),就需要重新創建倒排索引,這簡直是災難。因此索引庫一旦創建,無法修改mapping。
雖然無法修改mapping中已有的字段
,但是卻允許添加新的字段
到mapping中,因為不會對倒排索引產生影響。因此修改索引庫能做的就是向索引庫中添加新字段,或者更新索引庫的基礎屬性。
PUT /索引庫名/_mapping
{"properties": {"新字段名":{"type": "integer"}}
}
- 錯誤操作
那我們先來試一試錯誤的操作,直接修改已有的字段看看是否會報錯
可以看到提示我們不能將firstName字段 keyword 修改成text
- 正確操作
6、文檔操作
文檔的CRUD
新增文檔
語法:
POST /索引庫名/_doc/文檔id
{"字段1": "值1","字段2": "值2","字段3": {"子屬性1": "值3","子屬性2": "值4"},
}
查詢文檔
根據rest風格,新增是post,查詢應該是get,不過查詢一般都需要條件,這里我們把文檔id帶上。
語法:
GET /{索引庫名稱}/_doc/{id}
刪除文檔
刪除使用DELETE請求,同樣,需要根據id進行刪除:
語法:
DELETE /{索引庫名}/_doc/id值
修改文檔
修改有兩種方式:
- 全量修改:直接覆蓋原來的文檔
- 局部修改:修改文檔中的部分字段
全量修改
全量修改是覆蓋原來的文檔,其本質是兩步操作:
- 根據指定的id刪除文檔
- 新增一個相同id的文檔
注意:如果根據id刪除時,id不存在,第二步的新增也會執行,也就從修改變成了新增操作了。
語法:
PUT /{索引庫名}/_doc/文檔id
{"字段1": "值1","字段2": "值2",// ... 略
}
由于id
為1
的文檔已經被刪除,所以第一次執行時,得到的反饋是created
:
所以如果執行第2次時,得到的反饋則是updated
:
局部修改
局部修改是只修改指定id匹配的文檔中的部分字段。
語法:
POST /{索引庫名}/_update/文檔id
{"doc": {"字段名": "新的值",}
}
批處理
批處理采用POST請求,基本語法如下:
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }
其中:
index
代表新增操作_index
:指定索引庫名_id
指定要操作的文檔id{ "field1" : "value1" }
:則是要新增的文檔內容
delete
代表刪除操作_index
:指定索引庫名_id
指定要操作的文檔id
update
代表更新操作_index
:指定索引庫名_id
指定要操作的文檔id{ "doc" : {"field2" : "value2"} }
:要更新的文檔字段
示例,批量新增:
# 批處理 新增
POST _bulk
{"index" : { "_index" : "ikun","_id":"3"}}
{"info":"雞你太美2","age":"11","name":{"firstName": "只因","lastName": "蔡"}}
{"index" : { "_index" : "ikun","_id":"4"}}
{"info":"雞你太美","age":"11","name":{"firstName": "只因","lastName": "蔡"}}
批量刪除:
POST /_bulk
{"delete":{"_index":"ikun", "_id": "3"}}
{"delete":{"_index":"ikun", "_id": "4"}}
7、RestAPI
ES官方提供了各種不同語言的客戶端,用來操作ES。這些客戶端的本質就是組裝DSL語句,通過http請求發送給ES。
官方文檔地址:
https://www.elastic.co/guide/en/elasticsearch/client/index.html
初始化RestClient
在elasticsearch提供的API中,與elasticsearch一切交互都封裝在一個名為RestHighLevelClient
的類中,必須先完成這個對象的初始化,建立與elasticsearch的連接。
分為三步:
1)在item-service
模塊中引入es
的RestHighLevelClient
依賴:
<dependency><groupId>org.elasticsearch.client</groupId><artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
2)因為SpringBoot默認的ES版本是7.17.10
,所以我們需要覆蓋默認的ES版本:
<properties><maven.compiler.source>11</maven.compiler.source><maven.compiler.target>11</maven.compiler.target><elasticsearch.version>7.12.1</elasticsearch.version></properties>
3)初始化RestHighLevelClient:
初始化的代碼如下:
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(HttpHost.create("http://192.168.150.101:9200")
));
這里為了單元測試方便,我們創建一個測試類IndexTest
,然后將初始化的代碼編寫在@BeforeEach
方法中:
package com.hmall.item.es;import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;import java.io.IOException;public class IndexTest {private RestHighLevelClient client;@BeforeEachvoid setUp() {this.client = new RestHighLevelClient(RestClient.builder(HttpHost.create("http://192.168.150.101:9200")));}@Testvoid testConnect() {System.out.println(client);}@AfterEachvoid tearDown() throws IOException {this.client.close();}
}
創建索引庫
由于要實現對商品搜索,所以我們需要將商品添加到Elasticsearch中,不過需要根據搜索業務的需求來設定索引庫結構,而不是一股腦的把MySQL數據寫入Elasticsearch.
Mapping映射
搜索頁面的效果如圖所示:
實現搜索功能需要的字段包括三大部分:
- 搜索過濾字段
- 分類
- 品牌
- 價格
- 排序字段
- 默認:按照更新時間降序排序
- 銷量
- 價格
- 展示字段
- 商品id:用于點擊后跳轉
- 圖片地址
- 是否是廣告推廣商品
- 名稱
- 價格
- 評價數量
- 銷量
對應的商品表結構如下,索引庫無關字段已經劃掉:
結合數據庫表結構,以上字段對應的mapping映射屬性如下:
字段名 | 字段類型 | 類型說明 | 是否****參與搜索 | 是否****參與分詞 | 分詞器 |
---|---|---|---|---|---|
id | long | 長整數 | 1 | —— | |
name | text | 字符串,參與分詞搜索 | 1 | 1 | IK |
price | integer | 以分為單位,所以是整數 | 1 | —— | |
stock | integer | 字符串,但需要分詞 | 1 | —— | |
image | keyword | 字符串,但是不分詞 | —— | ||
category | keyword | 字符串,但是不分詞 | 1 | —— | |
brand | keyword | 字符串,但是不分詞 | 1 | —— | |
sold | integer | 銷量,整數 | 1 | —— | |
commentCount | integer | 評價,整數 | —— | ||
isAD | boolean | 布爾類型 | 1 | —— | |
updateTime | Date | 更新時間 | 1 | —— |
因此,最終我們的索引庫文檔結構應該是這樣:
PUT /items
{"mappings": {"properties": {"id": {"type": "keyword"},"name":{"type": "text","analyzer": "ik_max_word"},"price":{"type": "integer"},"stock":{"type": "integer"},"image":{"type": "keyword","index": false},"category":{"type": "keyword"},"brand":{"type": "keyword"},"sold":{"type": "integer"},"commentCount":{"type": "integer","index": false},"isAD":{"type": "boolean"},"updateTime":{"type": "date"}}}
}
創建索引
創建索引庫的API如下:
代碼分為三步:
- 1)創建Request對象。
- 因為是創建索引庫的操作,因此Request是
CreateIndexRequest
。
- 因為是創建索引庫的操作,因此Request是
- 2)添加請求參數
- 其實就是Json格式的Mapping映射參數。因為json字符串很長,這里是定義了靜態字符串常量
MAPPING_TEMPLATE
,讓代碼看起來更加優雅。
- 其實就是Json格式的Mapping映射參數。因為json字符串很長,這里是定義了靜態字符串常量
- 3)發送請求
client.indices()
方法的返回值是IndicesClient
類型,封裝了所有與索引庫操作有關的方法。例如創建索引、刪除索引、判斷索引是否存在等
那么我們看試一下是否能創建成功索引庫
@Testvoid testCreateIndex() throws IOException {CreateIndexRequest request = new CreateIndexRequest("items");// 2.準備請求參數request.source(MAPPING_TEMPLATE, XContentType.JSON);client.indices().create(request, RequestOptions.DEFAULT);}private final String MAPPING_TEMPLATE = "{\n" +" \"mappings\": {\n" +" \"properties\": {\n" +" \"id\": {\n" +" \"type\": \"keyword\"\n" +" },\n" +" \"name\":{\n" +" \"type\": \"text\",\n" +" \"analyzer\": \"ik_max_word\"\n" +" },\n" +" \"price\":{\n" +" \"type\": \"integer\"\n" +" },\n" +" \"stock\":{\n" +" \"type\": \"integer\"\n" +" },\n" +" \"image\":{\n" +" \"type\": \"keyword\",\n" +" \"index\": false\n" +" },\n" +" \"category\":{\n" +" \"type\": \"keyword\"\n" +" },\n" +" \"brand\":{\n" +" \"type\": \"keyword\"\n" +" },\n" +" \"sold\":{\n" +" \"type\": \"integer\"\n" +" },\n" +" \"commentCount\":{\n" +" \"type\": \"integer\",\n" +" \"index\": false\n" +" },\n" +" \"isAD\":{\n" +" \"type\": \"boolean\"\n" +" },\n" +" \"updateTime\":{\n" +" \"type\": \"date\"\n" +" }\n" +" }\n" +" }\n" +"}";
這里的MAPPING_TEMPLATE
代表了你索引庫的映射,這里我們用代碼和圖形化分別查一下
代碼查詢
@Testvoid testGetIndex() throws IOException {GetIndexRequest request = new GetIndexRequest("items");client.indices().get(request,RequestOptions.DEFAULT);}
刪除索引庫
刪除索引庫的請求非常簡單:
DELETE /hotel
與創建索引庫相比:
- 請求方式從PUT變為DELTE
- 請求路徑不變
- 無請求參數
所以代碼的差異,注意體現在Request對象上。流程如下:
- 1)創建Request對象。這次是DeleteIndexRequest對象
- 2)準備參數。這里是無參,因此省略
- 3)發送請求。改用delete方法
在item-service
中的IndexTest
測試類中,編寫單元測試,實現刪除索引:
@Test
void testDeleteIndex() throws IOException {// 1.創建Request對象DeleteIndexRequest request = new DeleteIndexRequest("items");// 2.發送請求client.indices().delete(request, RequestOptions.DEFAULT);
}
判斷索引庫是否存在
判斷索引庫是否存在,本質就是查詢,對應的請求語句是:
GET /hotel
因此與刪除的Java代碼流程是類似的,流程如下:
- 1)創建Request對象。這次是GetIndexRequest對象
- 2)準備參數。這里是無參,直接省略
- 3)發送請求。改用exists方法
@Test
void testExistsIndex() throws IOException {// 1.創建Request對象GetIndexRequest request = new GetIndexRequest("items");// 2.發送請求boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);// 3.輸出System.err.println(exists ? "索引庫已經存在!" : "索引庫不存在!");
}
8.RestClient操作文檔
索引庫準備好以后,就可以操作文檔了。為了與索引庫操作分離,我們再次創建一個測試類,做兩件事情:
- 初始化RestHighLevelClient
- 我們的商品數據在數據庫,需要利用IHotelService去查詢,所以注入這個接口
package com.hmall.item.domain.po;import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;import java.time.LocalDateTime;@Data
@ApiModel(description = "索引庫實體")
public class ItemDoc{@ApiModelProperty("商品id")private String id;@ApiModelProperty("商品名稱")private String name;@ApiModelProperty("價格(分)")private Integer price;@ApiModelProperty("商品圖片")private String image;@ApiModelProperty("類目名稱")private String category;@ApiModelProperty("品牌名稱")private String brand;@ApiModelProperty("銷量")private Integer sold;@ApiModelProperty("評論數")private Integer commentCount;@ApiModelProperty("是否是推廣廣告,true/false")private Boolean isAD;@ApiModelProperty("更新時間")private LocalDateTime updateTime;
}
查詢文檔
我們以根據id查詢文檔為例
我們導入商品數據,除了參考API模板“三步走”以外,還需要做幾點準備工作:
- 商品數據來自于數據庫,我們需要先查詢出來,得到
Item
對象 Item
對象需要轉為ItemDoc
對象ItemDTO
需要序列化為json
格式
因此,代碼整體步驟如下:
- 1)根據id查詢商品數據
Item
- 2)將
Item
封裝為ItemDoc
- 3)將
ItemDoc
序列化為JSON - 4)創建IndexRequest,指定索引庫名和id
- 5)準備請求參數,也就是JSON文檔
- 6)發送請求
在item-service
的DocumentTest
測試類中,編寫單元測試:
@Test
void testAddDocument() throws IOException {// 1.根據id查詢商品數據Item item = itemService.getById(100002644680L);// 2.轉換為文檔類型ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);// 3.將ItemDTO轉jsonString doc = JSONUtil.toJsonStr(itemDoc);// 1.準備Request對象IndexRequest request = new IndexRequest("items").id(itemDoc.getId());// 2.準備Json文檔request.source(doc, XContentType.JSON);// 3.發送請求client.index(request, RequestOptions.DEFAULT);
}
語法說明
查詢的請求語句如下:
GET /{索引庫名}/_doc/{id}
刪除文檔
刪除的請求語句如下:
DELETE /hotel/_doc/{id}
與查詢相比,僅僅是請求方式從DELETE
變成GET
,可以想象Java代碼應該依然是2步走:
- 1)準備Request對象,因為是刪除,這次是
DeleteRequest
對象。要指定索引庫名和id - 2)準備參數,無參,直接省略
- 3)發送請求。因為是刪除,所以是
client.delete()
方法
在item-service
的DocumentTest
測試類中,編寫單元測試:
@Test
void testDeleteDocument() throws IOException {// 1.準備Request,兩個參數,第一個是索引庫名,第二個是文檔idDeleteRequest request = new DeleteRequest("item", "100002644680");// 2.發送請求client.delete(request, RequestOptions.DEFAULT);
}
修改文檔
修改我們講過兩種方式:
- 全量修改:本質是先根據id刪除,再新增
- 局部修改:修改文檔中的指定字段值
在RestClient的API中,全量修改與新增的API完全一致,判斷依據是ID:
- 如果新增時,ID已經存在,則修改
- 如果新增時,ID不存在,則新增
這里不再贅述,我們主要關注局部修改的API即可。
語法說明
局部修改的請求語法如下:
POST /{索引庫名}/_update/{id}
{"doc": {"字段名": "字段值","字段名": "字段值"}
}
代碼示例如圖:
與之前類似,也是三步走:
- 1)準備
Request
對象。這次是修改,所以是UpdateRequest
- 2)準備參數。也就是JSON文檔,里面包含要修改的字段
- 3)更新文檔。這里調用
client.update()
方法
完整代碼
在item-service
的DocumentTest
測試類中,編寫單元測試:
@Test
void testUpdateDocument() throws IOException {// 1.準備RequestUpdateRequest request = new UpdateRequest("items", "100002644680");// 2.準備請求參數request.doc("price", 58800,"commentCount", 1);// 3.發送請求client.update(request, RequestOptions.DEFAULT);
}
批量導入文檔
在之前的案例中,我們都是操作單個文檔。而數據庫中的商品數據實際會達到數十萬條,某些項目中可能達到數百萬條。
我們如果要將這些數據導入索引庫,肯定不能逐條導入,而是采用批處理方案。常見的方案有:
- 利用Logstash批量導入
- 需要安裝Logstash
- 對數據的再加工能力較弱
- 無需編碼,但要學習編寫Logstash導入配置
- 利用JavaAPI批量導入
- 需要編碼,但基于JavaAPI,學習成本低
- 更加靈活,可以任意對數據做再加工處理后寫入索引庫
接下來,我們就學習下如何利用JavaAPI實現批量文檔導入。
語法說明
批處理與前面講的文檔的CRUD步驟基本一致:
- 創建Request,但這次用的是
BulkRequest
- 準備請求參數
- 發送請求,這次要用到
client.bulk()
方法
BulkRequest
本身其實并沒有請求參數,其本質就是將多個普通的CRUD請求組合在一起發送。例如:
- 批量新增文檔,就是給每個文檔創建一個
IndexRequest
請求,然后封裝到BulkRequest
中,一起發出。 - 批量刪除,就是創建N個
DeleteRequest
請求,然后封裝到BulkRequest
,一起發出
因此BulkRequest
中提供了add
方法,用以添加其它CRUD的請求:
可以看到,能添加的請求有:
IndexRequest
,也就是新增UpdateRequest
,也就是修改DeleteRequest
,也就是刪除
因此Bulk中添加了多個IndexRequest
,就是批量新增功能了。示例:
@Test
void testBulk() throws IOException {// 1.創建RequestBulkRequest request = new BulkRequest();// 2.準備請求參數request.add(new IndexRequest("items").id("1").source("json doc1", XContentType.JSON));request.add(new IndexRequest("items").id("2").source("json doc2", XContentType.JSON));// 3.發送請求client.bulk(request, RequestOptions.DEFAULT);
}
完整代碼
當我們要導入商品數據時,由于商品數量達到數十萬,因此不可能一次性全部導入。建議采用循環遍歷方式,每次導入1000條左右的數據。
item-service
的DocumentTest
測試類中,編寫單元測試:
@Test
void testLoadItemDocs() throws IOException {// 分頁查詢商品數據int pageNo = 1;int size = 1000;while (true) {Page<Item> page = itemService.lambdaQuery().eq(Item::getStatus, 1).page(new Page<Item>(pageNo, size));// 非空校驗List<Item> items = page.getRecords();if (CollUtils.isEmpty(items)) {return;}log.info("加載第{}頁數據,共{}條", pageNo, items.size());// 1.創建RequestBulkRequest request = new BulkRequest("items");// 2.準備參數,添加多個新增的Requestfor (Item item : items) {// 2.1.轉換為文檔類型ItemDTOItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);// 2.2.創建新增文檔的Request對象request.add(new IndexRequest().id(itemDoc.getId()).source(JSONUtil.toJsonStr(itemDoc), XContentType.JSON));}// 3.發送請求client.bulk(request, RequestOptions.DEFAULT);// 翻頁pageNo++;}
}
9.DSL查詢
Elasticsearch提供了基于JSON的DSL(Domain Specific Language)語句來定義查詢條件,其JavaAPI就是在組織DSL條件。
Elasticsearch的查詢可以分為兩大類:
- 葉子查詢(Leaf query clauses):一般是在特定的字段里查詢特定值,屬于簡單查詢,很少單獨使用。
- 復合查詢(Compound query clauses):以邏輯方式組合多個葉子查詢或者更改葉子查詢的行為方式。
在查詢以后,還可以對查詢的結果做處理
包括:
排序
:按照1個或多個字段值做排序分頁
:根據from和size做分頁,類似MySQL高亮
:對搜索結果中的關鍵字添加特殊樣式,使其更加醒目聚合
:對搜索結果做數據統計以形成報表
快速入門
我們依然在Kibana的DevTools中學習查詢的DSL語法。首先來看查詢的語法結構:
GET /{索引庫名}/_search
{"query": {"查詢類型": {// .. 查詢條件}}
}
說明:
GET /{索引庫名}/_search
:其中的_search
是固定路徑,不能修改
例如,我們以最簡單的無條件查詢為例,無條件查詢的類型是:match_all,因此其查詢語句如下:
GET /items/_search
{"query": {"match_all": {}}
}
由于match_all無條件,所以條件位置不寫即可。
執行結果如下:
你會發現雖然是match_all,但是響應結果中并不會包含索引庫中的所有文檔,而是僅有10條。這是因為處于安全考慮,elasticsearch設置了默認的查詢頁數。
葉子查詢
葉子查詢的類型也可以做進一步細分,詳情大家可以查看官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl.html
如圖:
這里列舉一些常見的,例如:
- 全文檢索查詢(Full Text Queries):利用分詞器對用戶輸入搜索條件先分詞,得到詞條,然后再利用倒排索引搜索詞條。例如:
match
:multi_match
- 精確查詢(Term-level queries):不對用戶輸入搜索條件分詞,根據字段內容精確值匹配。但只能查找keyword、數值、日期、boolean類型的字段。例如:
ids
term
range
- **地理坐標查詢:**用于搜索地理位置,搜索方式很多,例如:
geo_bounding_box
:按矩形搜索geo_distance
:按點和半徑搜索
- …略
全文檢索查詢
全文檢索的種類也很多,詳情可以參考官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/full-text-queries.html
以全文檢索中的match
為例,語法如下:
GET /{索引庫名}/_search
{"query": {"match": {"字段名": "搜索條件"}}
}
示例:
與match
類似的還有multi_match
,區別在于可以同時對多個字段搜索,而且多個字段都要滿足,語法示例:
GET /{索引庫名}/_search
{"query": {"multi_match": {"query": "搜索條件","fields": ["字段1", "字段2"]}}
}
示例:
精確查詢
精確查詢,英文是Term-level query
,顧名思義,詞條級別的查詢。也就是說不會對用戶輸入的搜索條件再分詞,而是作為一個詞條,與搜索的字段內容精確值匹配。因此推薦查找keyword
、數值、日期、boolean
類型的字段。例如:
- id
- price
- 城市
- 地名
- 人名
等等,作為一個整體才有含義的字段。
詳情可以查看官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/term-level-queries.html
以term
查詢為例,其語法如下:
GET /{索引庫名}/_search
{"query": {"term": {"字段名": {"value": "搜索條件"}}}
}
示例:
復合查詢
復合查詢大致可以分為兩類:
- 第一類:基于邏輯運算組合葉子查詢,實現組合條件,例如
- bool
- 第二類:基于某種算法修改查詢時的文檔相關性算分,從而改變文檔排名。例如:
- function_score
- dis_max
其它復合查詢及相關語法可以參考官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/compound-queries.html
算分函數查詢
當我們利用match查詢時,文檔結果會根據與搜索詞條的關聯度打分(_score),返回結果時按照分值降序排列。
例如,我們搜索 “拉桿箱”,結果如下:
從elasticsearch5.1開始,采用的相關性打分算法是BM25算法,公式如下:
基于這套公式,就可以判斷出某個文檔與用戶搜索的關鍵字之間的關聯度,還是比較準確的。但是,在實際業務需求中,常常會有競價排名的功能。不是相關度越高排名越靠前,而是掏的錢多的排名靠前。
例如在百度中搜索Java培訓,排名靠前的就是廣告推廣:
要想認為控制相關性算分,就需要利用elasticsearch中的function score 查詢了。
基本語法:
function score 查詢中包含四部分內容:
- 原始查詢條件:query部分,基于這個條件搜索文檔,并且基于BM25算法給文檔打分,原始算分(query score)
- 過濾條件:filter部分,符合該條件的文檔才會重新算分
- 算分函數:符合filter條件的文檔要根據這個函數做運算,得到的函數算分(function score),有四種函數
- weight:函數結果是常量
- field_value_factor:以文檔中的某個字段值作為函數結果
- random_score:以隨機數作為函數結果
- script_score:自定義算分函數算法
- 運算模式:算分函數的結果、原始查詢的相關性算分,兩者之間的運算方式,包括:
- multiply:相乘
- replace:用function score替換query score
- 其它,例如:sum、avg、max、min
function score的運行流程如下:
- 1)根據原始條件查詢搜索文檔,并且計算相關性算分,稱為原始算分(query score)
- 2)根據過濾條件,過濾文檔
- 3)符合過濾條件的文檔,基于算分函數運算,得到函數算分(function score)
- 4)將原始算分(query score)和函數算分(function score)基于運算模式做運算,得到最終結果,作為相關性算分。
因此,其中的關鍵點是:
- 過濾條件:決定哪些文檔的算分被修改
- 算分函數:決定函數算分的算法
- 運算模式:決定最終算分結果
示例:給IPhone這個品牌的手機算分提高十倍,分析如下:
- 過濾條件:品牌必須為IPhone
- 算分函數:常量weight,值為10
- 算分模式:相乘multiply
對應代碼如下:
示例
需求:給“SCOGOLF”這個品牌的行李箱排名靠前一些
翻譯一下這個需求,轉換為之前說的四個要點:
- 原始條件:不確定,可以任意變化
- 過濾條件:brand = “SCOGOLF”
- 算分函數:可以簡單粗暴,直接給固定的算分結果,weight
- 運算模式:比如求和
完整代碼
GET /items/_search
{"query": {"function_score": {"query": {"match": {"name": "行李箱"}},"functions": [{"filter": {"term": {"brand": "拉桿箱"}},"weight": 10}],"boost_mode": "sum"}}
}
測試,在未添加算分函數時,SCOGOLF得分如下:
添加了算分函數后,SCOGOLF得分就提升了:
bool查詢
bool查詢,即布爾查詢。就是利用邏輯運算來組合一個或多個查詢子句的組合。bool查詢支持的邏輯運算有:
- must:必須匹配每個子查詢,類似“與”
- should:選擇性匹配子查詢,類似“或”
- must_not:必須不匹配,不參與算分,類似“非”
- filter:必須匹配,不參與算分
bool查詢的語法如下:
GET /items/_search
{"query": {"bool": {"must": [{"match": {"name": "手機"}}],"should": [{"term": {"brand": { "value": "vivo" }}},{"term": {"brand": { "value": "小米" }}}],"must_not": [{"range": {"price": {"gte": 2500}}}],"filter": [{"range": {"price": {"lte": 1000}}}]}}
}
出于性能考慮,與搜索關鍵字無關的查詢盡量采用must_not或filter邏輯運算,避免參與相關性算分。
例如黑馬商城的搜索頁面:
其中輸入框的搜索條件肯定要參與相關性算分,可以采用match。但是價格范圍過濾、品牌過濾、分類過濾等盡量采用filter,不要參與相關性算分。
比如,我們要搜索手機
,但品牌必須是華為
,價格必須是900~1599
,那么可以這樣寫:
GET /items/_search
{"query": {"bool": {"must": [{"match": {"name": "手機"}}],"filter": [{"term": {"brand": { "value": "華為" }}},{"range": {"price": {"gte": 90000, "lt": 159900}}}]}}
}
排序
elasticsearch默認是根據相關度算分(_score
)來排序,但是也支持自定義方式對搜索結果排序。不過分詞字段無法排序,能參與排序字段類型有:keyword
類型、數值類型、地理坐標類型、日期類型等。
詳細說明可以參考官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html
語法說明:
GET /indexName/_search
{"query": {"match_all": {}},"sort": [{"排序字段": {"order": "排序方式asc和desc"}}]
}
示例,我們按照商品價格排序:
GET /items/_search
{"query": {"match_all": {}},"sort": [{"price": {"order": "desc"}}]
}
分頁
elasticsearch 默認情況下只返回top10的數據。而如果要查詢更多數據就需要修改分頁參數了。
基礎分頁
elasticsearch中通過修改from
、size
參數來控制要返回的分頁結果:
from
:從第幾個文檔開始size
:總共查詢幾個文檔
類似于mysql中的limit ?, ?
官方文檔如下:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
語法如下:
GET /items/_search
{"query": {"match_all": {}},"from": 0, // 分頁開始的位置,默認為0"size": 10, // 每頁文檔數量,默認10"sort": [{"price": {"order": "desc"}}]
}
深度分頁
elasticsearch的數據一般會采用分片存儲,也就是把一個索引中的數據分成N份,存儲到不同節點上。這種存儲方式比較有利于數據擴展,但給分頁帶來了一些麻煩。
比如一個索引庫中有100000條數據,分別存儲到4個分片,每個分片25000條數據。現在每頁查詢10條,查詢第99頁。那么分頁查詢的條件如下:
GET /items/_search
{"from": 990, // 從第990條開始查詢"size": 10, // 每頁查詢10條"sort": [{"price": "asc"}]
}
從語句來分析,要查詢第990~1000名的數據。
從實現思路來分析,肯定是將所有數據排序,找出前1000名,截取其中的990~1000的部分。但問題來了,我們如何才能找到所有數據中的前1000名呢?
要知道每一片的數據都不一樣,第1片上的第9001000,在另1個節點上并不一定依然是9001000名。所以我們只能在每一個分片上都找出排名前1000的數據,然后匯總到一起,重新排序,才能找出整個索引庫中真正的前1000名,此時截取990~1000的數據即可。
如圖:
試想一下,假如我們現在要查詢的是第999頁數據呢,是不是要找第9990~10000的數據,那豈不是需要把每個分片中的前10000名數據都查詢出來,匯總在一起,在內存中排序?如果查詢的分頁深度更深呢,需要一次檢索的數據豈不是更多?
由此可知,當查詢分頁深度較大時,匯總數據過多,對內存和CPU會產生非常大的壓力。
因此elasticsearch會禁止from+ size
超過10000的請求。
針對深度分頁,elasticsearch提供了兩種解決方案:
search after
:分頁時需要排序,原理是從上一次的排序值開始,查詢下一頁數據。官方推薦使用的方式。scroll
:原理將排序后的文檔id形成快照,保存下來,基于快照做分頁。官方已經不推薦使用。
詳情見文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
總結:
大多數情況下,我們采用普通分頁就可以了。查看百度、京東等網站,會發現其分頁都有限制。例如百度最多支持77頁,每頁不足20條。京東最多100頁,每頁最多60條。
因此,一般我們采用限制分頁深度的方式即可,無需實現深度分頁。
高亮
高亮原理
什么是高亮顯示呢?
我們在百度,京東搜索時,關鍵字會變成紅色,比較醒目,這叫高亮顯示:
觀察頁面源碼,你會發現兩件事情:
- 高亮詞條都被加了
<em>
標簽 <em>
標簽都添加了紅色樣式
css樣式肯定是前端實現頁面的時候寫好的,但是前端編寫頁面的時候是不知道頁面要展示什么數據的,不可能給數據加標簽。而服務端實現搜索功能,要是有elasticsearch
做分詞搜索,是知道哪些詞條需要高亮的。
因此詞條的高亮標簽肯定是由服務端提供數據的時候已經加上的。
因此實現高亮的思路就是:
- 用戶輸入搜索關鍵字搜索數據
- 服務端根據搜索關鍵字到elasticsearch搜索,并給搜索結果中的關鍵字詞條添加
html
標簽 - 前端提前給約定好的
html
標簽添加CSS
樣式
實現高亮
事實上elasticsearch已經提供了給搜索關鍵字加標簽的語法,無需我們自己編碼。
基本語法如下:
GET /{索引庫名}/_search
{"query": {"match": {"搜索字段": "搜索關鍵字"}},"highlight": {"fields": {"高亮字段名稱": {"pre_tags": "<em>","post_tags": "</em>"}}}
}
注意:
- 搜索必須有查詢條件,而且是全文檢索類型的查詢條件,例如
match
- 參與高亮的字段必須是
text
類型的字段- 默認情況下參與高亮的字段要與搜索字段一致,除非添加:
required_field_match=false
示例
10.RestClient查詢
文檔的查詢依然使用昨天學習的 RestHighLevelClient
對象,查詢的基本步驟如下:
- 1)創建
request
對象,這次是搜索,所以是SearchRequest
- 2)準備請求參數,也就是查詢DSL對應的JSON參數
- 3)發起請求
- 4)解析響應,響應結果相對復雜,需要逐層解析
快速入門
文檔搜索的基本步驟是:
- 創建
SearchRequest
對象 - 準備
request.source()
,也就是DSL。QueryBuilders
來構建查詢條件- 傳入
request.source()
的query()
方法
- 發送請求,得到結果
- 解析結果(參考JSON結果,從外到內,逐層解析)
之前說過,由于Elasticsearch對外暴露的接口都是Restful風格的接口,因此JavaAPI調用就是在發送Http請求。而我們核心要做的就是利用利用Java代碼組織請求參數,解析響應結果。
這個參數的格式完全參考DSL查詢語句的JSON結構,因此我們在學習的過程中,會不斷的把JavaAPI與DSL語句對比。大家在學習記憶的過程中,也應該這樣對比學習。
發送請求
首先以match_all
查詢為例,其DSL和JavaAPI的對比如圖:
代碼解讀:
- 第一步,創建
SearchRequest
對象,指定索引庫名 - 第二步,利用
request.source()
構建DSL,DSL中可以包含查詢、分頁、排序、高亮等 query()
:代表查詢條件,利用QueryBuilders.matchAllQuery()
構建一個match_all
查詢的DSL- 第三步,利用
client.search()
發送請求,得到響應
這里關鍵的API有兩個,一個是request.source()
,它構建的就是DSL中的完整JSON參數。其中包含了query
、sort
、from
、size
、highlight
等所有功能:
另一個是QueryBuilders
,其中包含了我們學習過的各種葉子查詢、復合查詢等:
解析響應結果
在發送請求以后,得到了響應結果SearchResponse
,這個類的結構與我們在kibana中看到的響應結果JSON結構完全一致:
{"took" : 0,"timed_out" : false,"hits" : {"total" : {"value" : 2,"relation" : "eq"},"max_score" : 1.0,"hits" : [{"_index" : "heima","_type" : "_doc","_id" : "1","_score" : 1.0,"_source" : {"info" : "Java講師","name" : "趙云"}}]}
}
因此,我們解析SearchResponse
的代碼就是在解析這個JSON結果,對比如下:
代碼解讀:
elasticsearch返回的結果是一個JSON字符串,結構包含:
hits
:命中的結果total
:總條數,其中的value是具體的總條數值max_score
:所有結果中得分最高的文檔的相關性算分hits
:搜索結果的文檔數組,其中的每個文檔都是一個json對象_source
:文檔中的原始數據,也是json對象
因此,我們解析響應結果,就是逐層解析JSON字符串,流程如下:
SearchHits
:通過response.getHits()
獲取,就是JSON中的最外層的hits
,代表命中的結果SearchHits#getTotalHits().value
:獲取總條數信息SearchHits#getHits()
:獲取SearchHit
數組,也就是文檔數組SearchHit#getSourceAsString()
:獲取文檔結果中的_source
,也就是原始的json
文檔數據
完整代碼如下:
@Test
void testMatchAll() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數request.source().query(QueryBuilders.matchAllQuery());// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}private void handleResponse(SearchResponse response) {SearchHits searchHits = response.getHits();// 1.獲取總條數long total = searchHits.getTotalHits().value;System.out.println("共搜索到" + total + "條數據");// 2.遍歷結果數組SearchHit[] hits = searchHits.getHits();for (SearchHit hit : hits) {// 3.得到_source,也就是原始json文檔String source = hit.getSourceAsString();// 4.反序列化并打印ItemDoc item = JSONUtil.toBean(source, ItemDoc.class);System.out.println(item);}
}
葉子查詢
所有的查詢條件都是由QueryBuilders來構建的,葉子查詢也不例外。因此整套代碼中變化的部分僅僅是query條件構造的方式,其它不動。
例如match
查詢:
@Test
void testMatch() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數request.source().query(QueryBuilders.matchQuery("name", "行李箱"));// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
再比如multi_match
查詢:
@Test
void testMultiMatch() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數request.source().query(QueryBuilders.multiMatchQuery("脫脂牛奶", "name", "category"));// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
還有range
查詢:
@Test
void testRange() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數request.source().query(QueryBuilders.rangeQuery("price").gte(10000).lte(30000));// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
還有term
查詢:
@Test
void testTerm() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數request.source().query(QueryBuilders.termQuery("brand", "華為"));// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
復合查詢
復合查詢也是由QueryBuilders
來構建,我們以bool
查詢為例,DSL和JavaAPI的對比如圖:
完整代碼如下:
@Test
void testBool() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數// 2.1.準備bool查詢BoolQueryBuilder bool = QueryBuilders.boolQuery();// 2.2.關鍵字搜索bool.must(QueryBuilders.matchQuery("name", "脫脂牛奶"));// 2.3.品牌過濾bool.filter(QueryBuilders.termQuery("brand", "德亞"));// 2.4.價格過濾bool.filter(QueryBuilders.rangeQuery("price").lte(30000));request.source().query(bool);// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
排序和分頁
之前說過,requeset.source()
就是整個請求JSON參數,所以排序、分頁都是基于這個來設置,其DSL和JavaAPI的對比如下:
完整示例代碼:
@Test
void testPageAndSort() throws IOException {int pageNo = 1, pageSize = 5;// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數// 2.1.搜索條件參數request.source().query(QueryBuilders.matchQuery("name", "脫脂牛奶"));// 2.2.排序參數request.source().sort("price", SortOrder.ASC);// 2.3.分頁參數request.source().from((pageNo - 1) * pageSize).size(pageSize);// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
高亮
高亮查詢與前面的查詢有兩點不同:
- 條件同樣是在
request.source()
中指定,只不過高亮條件要基于HighlightBuilder
來構造 - 高亮響應結果與搜索的文檔結果不在一起,需要單獨解析
首先來看高亮條件構造,其DSL和JavaAPI的對比如圖:
示例代碼如下:
@Test
void testHighlight() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.組織請求參數// 2.1.query條件request.source().query(QueryBuilders.matchQuery("name", "脫脂牛奶"));// 2.2.高亮條件request.source().highlighter(SearchSourceBuilder.highlight().field("name").preTags("<em>").postTags("</em>"));// 3.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 4.解析響應handleResponse(response);
}
再來看結果解析,文檔解析的部分不變,主要是高亮內容需要單獨解析出來,其DSL和JavaAPI的對比如圖:
代碼解讀:
- 第
3、4
步:從結果中獲取_source
。hit.getSourceAsString()
,這部分是非高亮結果,json字符串。還需要反序列為ItemDoc
對象 - 第
5
步:獲取高亮結果。hit.getHighlightFields()
,返回值是一個Map
,key是高亮字段名稱,值是HighlightField
對象,代表高亮值 - 第
5.1
步:從Map
中根據高亮字段名稱,獲取高亮字段值對象HighlightField
- 第
5.2
步:從HighlightField
中獲取Fragments
,并且轉為字符串。這部分就是真正的高亮字符串了 - 最后:用高亮的結果替換
ItemDoc
中的非高亮結果
完整代碼如下:
private void handleResponse(SearchResponse response) {SearchHits searchHits = response.getHits();// 1.獲取總條數long total = searchHits.getTotalHits().value;System.out.println("共搜索到" + total + "條數據");// 2.遍歷結果數組SearchHit[] hits = searchHits.getHits();for (SearchHit hit : hits) {// 3.得到_source,也就是原始json文檔String source = hit.getSourceAsString();// 4.反序列化ItemDoc item = JSONUtil.toBean(source, ItemDoc.class);// 5.獲取高亮結果Map<String, HighlightField> hfs = hit.getHighlightFields();if (CollUtils.isNotEmpty(hfs)) {// 5.1.有高亮結果,獲取name的高亮結果HighlightField hf = hfs.get("name");if (hf != null) {// 5.2.獲取第一個高亮結果片段,就是商品名稱的高亮值String hfName = hf.getFragments()[0].string();item.setName(hfName);}}System.out.println(item);}
}
11.數據聚合
聚合(aggregations
)可以讓我們極其方便的實現對數據的統計、分析、運算。例如:
- 什么品牌的手機最受歡迎?
- 這些手機的平均價格、最高價格、最低價格?
- 這些手機每月的銷售情況如何?
實現這些統計功能的比數據庫的sql要方便的多,而且查詢速度非常快,可以實現近實時搜索效果。
官方文檔:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations.html
聚合常見的有三類:
- **桶(
Bucket
)**聚合:用來對文檔做分組 TermAggregation
:按照文檔字段值分組,例如按照品牌值分組、按照國家分組Date Histogram
:按照日期階梯分組,例如一周為一組,或者一月為一組- **度量(
Metric
)**聚合:用以計算一些值,比如:最大值、最小值、平均值等 Avg
:求平均值Max
:求最大值Min
:求最小值Stats
:同時求max
、min
、avg
、sum
等- **管道(
pipeline
)**聚合:其它聚合的結果為基礎做進一步運算
**注意:**參加聚合的字段必須是keyword、日期、數值、布爾類型
DSL實現聚合
與之前的搜索功能類似,我們依然先學習DSL的語法,再學習JavaAPI.
Bucket聚合
例如我們要統計所有商品中共有哪些商品分類,其實就是以分類(category)字段對數據分組。category值一樣的放在同一組,屬于Bucket
聚合中的Term
聚合。
基本語法如下:
GET /items/_search
{"size": 0, "aggs": {"category_agg": {"terms": {"field": "category","size": 20}}}
}
語法說明:
size
:設置size
為0,就是每頁查0條,則結果中就不包含文檔,只包含聚合aggs
:定義聚合category_agg
:聚合名稱,自定義,但不能重復terms
:聚合的類型,按分類聚合,所以用term
field
:參與聚合的字段名稱size
:希望返回的聚合結果的最大數量
來看下查詢的結果:
帶條件聚合
默認情況下,Bucket聚合是對索引庫的所有文檔做聚合,例如我們統計商品中所有的品牌,結果如下:
可以看到統計出的品牌非常多。
但真實場景下,用戶會輸入搜索條件,因此聚合必須是對搜索結果聚合。那么聚合必須添加限定條件。
例如,我想知道價格高于3000元的手機品牌有哪些,該怎么統計呢?
我們需要從需求中分析出搜索查詢的條件和聚合的目標:
- 搜索查詢條件:
- 價格高于3000
- 必須是手機
- 聚合目標:統計的是品牌,肯定是對brand字段做term聚合
語法如下:
GET /items/_search
{"query": {"bool": {"filter": [{"term": {"category": "手機"}},{"range": {"price": {"gte": 300000}}}]}}, "size": 0, "aggs": {"brand_agg": {"terms": {"field": "brand","size": 20}}}
}
聚合結果如下:
可以看到,結果中只剩下3個品牌了。
Metric聚合
上節課,我們統計了價格高于3000的手機品牌,形成了一個個桶。現在我們需要對桶內的商品做運算,獲取每個品牌價格的最小值、最大值、平均值。
這就要用到Metric
聚合了,例如stat
聚合,就可以同時獲取min
、max
、avg
等結果。
語法如下:
GET /items/_search
{"query": {"bool": {"filter": [{"term": {"category": "手機"}},{"range": {"price": {"gte": 300000}}}]}}, "size": 0, "aggs": {"brand_agg": {"terms": {"field": "brand","size": 20},"aggs": {"stats_meric": {"stats": {"field": "price"}}}}}
}
query
部分就不說了,我們重點解讀聚合部分語法。
可以看到我們在brand_agg
聚合的內部,我們新加了一個aggs
參數。這個聚合就是brand_agg
的子聚合,會對brand_agg
形成的每個桶中的文檔分別統計。
stats_meric
:聚合名稱stats
:聚合類型,stats是metric
聚合的一種field
:聚合字段,這里選擇price
,統計價格
由于stats是對brand_agg形成的每個品牌桶內文檔分別做統計,因此每個品牌都會統計出自己的價格最小、最大、平均值。
結果如下:
另外,我們還可以讓聚合按照每個品牌的價格平均值排序:
總結
aggs代表聚合,與query同級,此時query的作用是?
- 限定聚合的的文檔范圍
聚合必須的三要素:
- 聚合名稱
- 聚合類型
- 聚合字段
聚合可配置屬性有:
- size:指定聚合結果數量
- order:指定聚合結果排序方式
- field:指定聚合字段
RestClient實現聚合
可以看到在DSL中,aggs
聚合條件與query
條件是同一級別,都屬于查詢JSON參數。因此依然是利用request.source()
方法來設置。
不過聚合條件的要利用AggregationBuilders
這個工具類來構造。DSL與JavaAPI的語法對比如下:
聚合結果與搜索文檔同一級別,因此需要單獨獲取和解析。具體解析語法如下:
完整代碼
@Test
void testAgg() throws IOException {// 1.創建RequestSearchRequest request = new SearchRequest("items");// 2.準備請求參數BoolQueryBuilder bool = QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("category", "手機")).filter(QueryBuilders.rangeQuery("price").gte(300000));request.source().query(bool).size(0);// 3.聚合參數request.source().aggregation(AggregationBuilders.terms("brand_agg").field("brand").size(5));// 4.發送請求SearchResponse response = client.search(request, RequestOptions.DEFAULT);// 5.解析聚合結果Aggregations aggregations = response.getAggregations();// 5.1.獲取品牌聚合Terms brandTerms = aggregations.get("brand_agg");// 5.2.獲取聚合中的桶List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();// 5.3.遍歷桶內數據for (Terms.Bucket bucket : buckets) {// 5.4.獲取桶內keyString brand = bucket.getKeyAsString();System.out.print("brand = " + brand);long count = bucket.getDocCount();System.out.println("; count = " + count);}
}