ES分散式搜尋引擎
注意: 在沒有建立庫的時候搜尋,ES會建立一個庫並自動建立該欄位並且設定為String型別也就是text
什麼是elasticsearch?
- 一個開源的分散式搜尋引擎,可以用來實現搜尋、日誌統計、分析、系統監控等功能
什麼是elastic stack(ELK)?
- 是以elasticsearch為核心的技術棧,包括beats、Logstash、kibana、elasticsearch
什麼是Lucene?
- 是Apache的開源搜尋引擎類庫,提供了搜尋引擎的核心API
elasticsearch是一款非常強大的開源搜尋引擎,具備非常多強大功能,可以幫助我們從海量資料中快速找到需要的內容
ELK技術棧
本文只使用了elasticsearch,以及kibana做視覺化介面
elasticsearch結合kibana、Logstash、Beats,也就是elastic stack(ELK)。被廣泛應用在日誌資料分析、實時監控等領域:
而elasticsearch是elastic stack的核心,負責儲存、搜尋、分析資料。
初識elasticsearch
1. elasticsearch背景介紹
elasticsearch底層是基於lucene來實現的。
Lucene是一個Java語言的搜尋引擎類庫,是Apache公司的頂級專案,由DougCutting於1999年研發。官網地址:https://lucene.apache.org/ 。
elasticsearch的發展歷史:
- 2004年Shay Banon基於Lucene開發了Compass
- 2010年Shay Banon 重寫了Compass,取名為Elasticsearch。
2. 倒排索引
倒排索引的概念是基於MySQL這樣的正向索引而言的。
2.1 正向索引
設定了索引的話挺快的,但要是模糊查詢則就很慢!
那麼什麼是正向索引呢?例如給下表(tb_goods)中的id建立索引:
如果是根據id查詢,那麼直接走索引,查詢速度非常快。
但如果是基於title做模糊查詢,只能是逐行掃描資料,流程如下:
1)使用者搜尋資料,條件是title符合"%手機%"
2)逐行獲取資料,比如id為1的資料
3)判斷資料中的title是否符合使用者搜尋條件
4)如果符合則放入結果集,不符合則丟棄。回到步驟1
逐行掃描,也就是全表掃描,隨著資料量增加,其查詢效率也會越來越低。當資料量達到數百萬時,就是一場災難。
2.2 倒排索引
倒排索引中有兩個非常重要的概念:
- 文件(
Document
):用來搜尋的資料,其中的每一條資料就是一個文件。例如一個網頁、一個商品資訊 - 詞條(
Term
):對文件資料或使用者搜尋資料,利用某種演算法分詞,得到的具備含義的詞語就是詞條。例如:我是中國人,就可以分為:我、是、中國人、中國、國人這樣的幾個詞條
建立倒排索引是對正向索引的一種特殊處理,流程如下:
- 將每一個文件的資料利用演算法分詞,得到一個個詞條
- 建立表,每行資料包括詞條、詞條所在文件id、位置等資訊
- 因為詞條唯一性,可以給詞條建立索引,例如hash表結構索引
如圖:
倒排索引的搜尋流程如下(以搜尋"華為手機"為例):
1)使用者輸入條件"華為手機"
進行搜尋。
2)對使用者輸入內容分詞,得到詞條:華為
、手機
。
3)拿著詞條在倒排索引中查詢,可以得到包含詞條的文件id:1、2、3。
4)拿著文件id到正向索引中查詢具體文件。
如圖:
雖然要先查詢倒排索引,再查詢倒排索引,但是無論是詞條、還是文件id都建立了索引,查詢速度非常快!無需全表掃描。
2.3 正向和倒排對比
概念區別:
-
正向索引是最傳統的,根據id索引的方式。但根據詞條查詢時,必須先逐條獲取每個文件,然後判斷文件中是否包含所需要的詞條,是根據文件找詞條的過程。
-
而倒排索引則相反,是先找到使用者要搜尋的詞條,根據詞條得到保護詞條的文件的id,然後根據id獲取文件。是根據詞條找文件的過程。
優缺點:
正向索引:
- 優點:
- 可以給多個欄位建立索引
- 根據索引欄位搜尋、排序速度非常快
- 缺點:
- 根據非索引欄位,或者索引欄位中的部分詞條查詢時,只能全表掃描。
倒排索引:
- 優點:
- 根據詞條搜尋、模糊搜尋時,速度非常快
- 缺點:
- 只能給詞條建立索引,而不是欄位
- 無法根據欄位做排序
3. ES資料庫基本概念
elasticsearch中有很多獨有的概念,與mysql中略有差別,但也有相似之處。
3.1.文件和欄位
一個文件就像資料庫裡的一條資料,欄位就像資料庫裡的列
elasticsearch是面向文件(Document)儲存的,可以是資料庫中的一條商品資料,一個訂單資訊。文件資料會被序列化為json格式後儲存在elasticsearch中:
而Json文件中往往包含很多的欄位(Field),類似於mysql資料庫中的列。
3.2.索引和對映
索引就像資料庫裡的表,對映就像資料庫中定義的表結構
索引(Index),就是相同型別的文件的集合【類似mysql中的表】
例如:
- 所有使用者文件,就可以組織在一起,稱為使用者的索引;
- 所有商品的文件,可以組織在一起,稱為商品的索引;
- 所有訂單的文件,可以組織在一起,稱為訂單的索引;
因此,我們可以把索引當做是資料庫中的表。
資料庫的表會有約束資訊,用來定義表的結構、欄位的名稱、型別等資訊。因此,索引庫中就有對映(mapping),是索引中文件的欄位約束資訊,類似表的結構約束。
3.3.mysql與elasticsearch
各自長處:
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 |
在企業中,往往是兩者結合使用:
- 對安全性要求較高的寫操作,使用mysql實現
- 對查詢效能要求較高的搜尋需求,使用elasticsearch實現
- 兩者再基於某種方式,實現資料的同步,保證一致性
4. 安裝es、kibana、分詞器
分詞器的作用是什麼?
- 建立倒排索引時對文件分詞
- 使用者搜尋時,對輸入的內容分詞
IK分詞器有幾種模式?
- ik_smart:智慧切分,粗粒度
- ik_max_word:最細切分,細粒度
IK分詞器如何擴充詞條?如何停用詞條?
- 利用config目錄的IkAnalyzer.cfg.xml檔案新增擴充詞典和停用詞典
- 在詞典中新增擴充詞條或者停用詞條
4.1 部署單點es
4.1.1.建立網路
因為我們還需要部署kibana容器,因此需要讓es和kibana容器互聯。這裡先建立一個網路:
docker network create es-net
4.1.2.載入映象
這裡我們採用elasticsearch的7.12.1版本的映象,這個映象體積非常大,接近1G。不建議大家自己pull。
課前資料提供了映象的tar包:
大家將其上傳到虛擬機器中,然後執行命令載入即可:
# 匯入資料
docker load -i es.tar
注意:同理還有kibana
的tar包也需要這樣做。
4.1.3.執行
執行docker命令,部署單點es:
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 es-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
命令解釋:
-e "cluster.name=es-docker-cluster"
:設定叢集名稱-e "http.host=0.0.0.0"
:監聽的地址,可以外網訪問-e "ES_JAVA_OPTS=-Xms512m -Xmx512m"
:記憶體大小-e "discovery.type=single-node"
:非叢集模式-v es-data:/usr/share/elasticsearch/data
:掛載邏輯卷,繫結es的資料目錄-v es-logs:/usr/share/elasticsearch/logs
:掛載邏輯卷,繫結es的日誌目錄-v es-plugins:/usr/share/elasticsearch/plugins
:掛載邏輯卷,繫結es的外掛目錄--privileged
:授予邏輯卷訪問權--network es-net
:加入一個名為es-net的網路中-p 9200:9200
:埠對映配置
在瀏覽器中輸入:http://192.168.194.131/:9200 即可看到elasticsearch的響應結果:
4.2.部署kibana
kibana可以給我們提供一個elasticsearch的視覺化介面,便於我們學習。
4.2.1.部署
建立網路後,匯入kibana壓縮包,然後建立並啟動相應容器。【和前面部署單點es一樣做法】
再執行docker命令,部署kibana
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
--network es-net
:加入一個名為es-net的網路中,與elasticsearch在同一個網路中-e ELASTICSEARCH_HOSTS=http://es:9200"
:設定elasticsearch的地址,因為kibana已經與elasticsearch在一個網路,因此可以用容器名直接訪問elasticsearch-p 5601:5601
:埠對映配置
kibana啟動一般比較慢,需要多等待一會,可以透過命令:
docker logs -f kibana
檢視執行日誌,當檢視到下面的日誌,說明成功:
此時,在瀏覽器輸入地址訪問:http://192.168.194.131:5601,即可看到結果如下圖:
kibana左側中提供了一個DevTools介面:
這個介面中可以編寫DSL來操作elasticsearch。並且對DSL語句有自動補全功能。
4.3.安裝IK分詞器
4.3.1.線上安裝ik外掛(較慢)
# 進入容器內部
docker exec -it elasticsearch /bin/bash
# 線上下載並安裝
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
#退出
exit
#重啟容器
docker restart elasticsearch
4.3.2.離線安裝ik外掛(推薦)
1)檢視資料卷目錄
安裝外掛需要知道elasticsearch的plugins目錄位置,而我們用了資料卷掛載,因此需要檢視elasticsearch的資料卷目錄,透過下面命令檢視:
docker volume inspect es-plugins
顯示結果:
[
{
"CreatedAt": "2022-05-06T10:06:34+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
"Name": "es-plugins",
"Options": null,
"Scope": "local"
}
]
說明plugins目錄被掛載到了:/var/lib/docker/volumes/es-plugins/_data
這個目錄中。
2)解壓縮分詞器安裝包
下面我們需要把課前資料中的ik分詞器解壓縮,重新命名為ik
3)上傳到es容器的外掛資料卷中
也就是/var/lib/docker/volumes/es-plugins/_data
:
4)重啟容器
# 4、重啟容器
docker restart es
# 檢視es日誌
docker logs -f es
5)測試:
IK分詞器包含兩種模式:
-
ik_smart
:最少切分 -
ik_max_word
:最細切分
在kibana的Dev tools中輸入以下程式碼:
”analyzer“ 就是選擇分詞器模式
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "黑馬程式設計師學習java太棒了"
}
結果:
{
"tokens" : [
{
"token" : "黑馬",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "程式設計師",
"start_offset" : 2,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "程式",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "員",
"start_offset" : 4,
"end_offset" : 5,
"type" : "CN_CHAR",
"position" : 3
},
{
"token" : "學習",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "java",
"start_offset" : 7,
"end_offset" : 11,
"type" : "ENGLISH",
"position" : 5
},
{
"token" : "太棒了",
"start_offset" : 11,
"end_offset" : 14,
"type" : "CN_WORD",
"position" : 6
},
{
"token" : "太棒",
"start_offset" : 11,
"end_offset" : 13,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "了",
"start_offset" : 13,
"end_offset" : 14,
"type" : "CN_CHAR",
"position" : 8
}
]
}
4.3.3 擴充套件詞詞典
隨著網際網路的發展,“造詞運動”也越發的頻繁。出現了很多新的詞語,在原有的詞彙列表中並不存在。比如:“奧力給”,“白嫖” 等。
所以我們的詞彙也需要不斷的更新,IK分詞器提供了擴充套件詞彙的功能。
1)開啟IK分詞器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)新建一個 ext.dic,可以參考config目錄下複製一個配置檔案進行修改
白嫖
奧力給
4)重啟elasticsearch
docker restart es
# 檢視 日誌
docker logs -f elasticsearch
日誌中已經成功載入ext.dic配置檔案
5)測試效果:
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "傳智播客Java就業超過90%,奧力給!"
}
注意當前檔案的編碼必須是 UTF-8 格式,嚴禁使用Windows記事本編輯
4.3.4 停用詞詞典
在網際網路專案中,在網路間傳輸的速度很快,所以很多語言是不允許在網路上傳遞的,如:關於宗教、政治等敏感詞語,那麼我們在搜尋時也應該忽略當前詞彙。
IK分詞器也提供了強大的停用詞功能,讓我們在索引時就直接忽略當前的停用詞彙表中的內容。
1)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>
<!--使用者可以在這裡配置自己的擴充套件停止詞字典 *** 新增停用詞詞典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
3)在 stopword.dic 新增停用詞
大帥逼
4)重啟elasticsearch
# 重啟服務
docker restart es
docker restart kibana
# 檢視 日誌
docker logs -f elasticsearch
日誌中已經成功載入stopword.dic配置檔案
5)測試效果:
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "我是真的會謝Java就業率超過95%,大帥逼都點贊白嫖,奧力給!"
}
注意當前檔案的編碼必須是 UTF-8 格式,嚴禁使用Windows記事本編輯
索引庫操作
索引庫就類似資料庫表,mapping對映就類似表的結構。
我們要向es中儲存資料,必須先建立“庫”和“表”。
1. Mapping對映屬性
mapping是對索引庫中文件的約束,常見的mapping屬性包括:
-
type:欄位資料型別,常見的簡單型別有:
-
字串:text(可分詞的文字)、keyword(精確值,例如:品牌、國家、ip地址)
keyword型別只能整體搜尋,不支援搜尋部分內容
-
數值:long、integer、short、byte、double、float、
-
布林:boolean
-
日期:date
-
物件:object
-
-
index:是否建立索引,預設為true
-
analyzer:使用哪種分詞器
-
properties:該欄位的子欄位
例如下面的json文件:
{
"age": 21,
"weight": 52.1,
"isMarried": false,
"info": "真相只有一個!",
"email": "zy@itcast.cn",
"score": [99.1, 99.5, 98.9],
"name": {
"firstName": "柯",
"lastName": "南"
}
}
對應的每個欄位對映(mapping):
- age:型別為 integer;參與搜尋,因此需要index為true;無需分詞器
- weight:型別為float;參與搜尋,因此需要index為true;無需分詞器
- isMarried:型別為boolean;參與搜尋,因此需要index為true;無需分詞器
- info:型別為字串,需要分詞,因此是text;參與搜尋,因此需要index為true;分詞器可以用ik_smart
- email:型別為字串,但是不需要分詞,因此是keyword;不參與搜尋,因此需要index為false;無需分詞器
- score:雖然是陣列,但是我們只看元素的型別,型別為float;參與搜尋,因此需要index為true;無需分詞器
- name:型別為object,需要定義多個子屬性
- name.firstName;型別為字串,但是不需要分詞,因此是keyword;參與搜尋,因此需要index為true;無需分詞器
- name.lastName;型別為字串,但是不需要分詞,因此是keyword;參與搜尋,因此需要index為true;無需分詞器
2. 索引庫的CRUD
CRUD簡單描述:
- 建立索引庫:PUT /索引庫名
- 查詢索引庫:GET /索引庫名
- 刪除索引庫:DELETE /索引庫名
- 修改索引庫(新增欄位):PUT /索引庫名/_mapping
這裡統一使用Kibana編寫DSL的方式來演示。
2.1 建立索引庫和對映
基本語法:
- 請求方式:PUT
- 請求路徑:/索引庫名,可以自定義
- 請求引數:mapping對映
格式:
PUT /索引庫名稱
{
"mappings": {
"properties": {
"欄位名":{
"type": "text",
"analyzer": "ik_smart"
},
"欄位名2":{
"type": "keyword",
"index": "false"
},
"欄位名3":{
"properties": {
"子欄位": {
"type": "keyword"
}
}
},
// ...略
}
}
}
示例:
PUT /conan
{
"mappings": {
"properties": {
"column1":{
"type": "text",
"analyzer": "ik_smart"
},
"column2":{
"type": "keyword",
"index": "false"
},
"column3":{
"properties": {
"子欄位1": {
"type": "keyword"
},
"子欄位2": {
"type": "keyword"
}
}
},
// ...略
}
}
}
2.2 查詢索引庫
基本語法:
-
請求方式:GET
-
請求路徑:/索引庫名
-
請求引數:無
格式:
GET /索引庫名
示例:
2.3 修改索引庫
這裡的修改是隻能增加新的欄位到mapping中
倒排索引結構雖然不復雜,但是一旦資料結構改變(比如改變了分詞器),就需要重新建立倒排索引,這簡直是災難。因此索引庫一旦建立,無法修改mapping。
雖然無法修改mapping中已有的欄位,但是卻允許新增新的欄位到mapping中,因為不會對倒排索引產生影響。
語法說明:
PUT /索引庫名/_mapping
{
"properties": {
"新欄位名":{
"type": "integer"
}
}
}
示例:
2.4 刪除索引庫
語法:
-
請求方式:DELETE
-
請求路徑:/索引庫名
-
請求引數:無
格式:
DELETE /索引庫名
在kibana中測試:
文件操作
文件操作有哪些?
- 建立文件:POST /{索引庫名}/_doc/文件id
- 查詢文件:GET /{索引庫名}/_doc/文件id
- 刪除文件:DELETE /{索引庫名}/_doc/文件id
- 修改文件:
- 全量修改:PUT /{索引庫名}/_doc/文件id
- 增量修改:POST /{索引庫名}/_update/文件id { "doc": {欄位}}
1. 文件的CRUD
1.1 新增文件
語法:
POST /索引庫名/_doc/文件id
{
"欄位1": "值1",
"欄位2": "值2",
"欄位3": {
"子屬性1": "值3",
"子屬性2": "值4"
},
// ...
}
示例:
POST /heima/_doc/1
{
"info": "真相只有一個!",
"email": "zy@itcast.cn",
"name": {
"firstName": "柯",
"lastName": "南"
}
}
響應:
1.2 查詢文件
根據rest風格,新增是post,查詢應該是get,不過查詢一般都需要條件,這裡我們把文件id帶上。
語法:
GET /{索引庫名稱}/_doc/{id}
//批次查詢:查詢該索引庫下的全部文件
GET /{索引庫名稱}/_search
透過kibana檢視資料:
GET /heima/_doc/1
檢視結果:
1.3 刪除文件
刪除使用DELETE請求,同樣,需要根據id進行刪除:
語法:
DELETE /{索引庫名}/_doc/id值
示例:
# 根據id刪除資料
DELETE /heima/_doc/1
結果:
1.4 修改文件
修改有兩種方式:
- 全量修改:直接覆蓋原來的文件
- 增量修改:修改文件中的部分欄位
1.4.1 全量修改
全量修改是覆蓋原來的文件,其本質是:
- 根據指定的id刪除文件
- 新增一個相同id的文件
注意:如果根據id刪除時,id不存在,第二步的新增也會執行,也就從修改變成了新增操作了。
語法:
PUT /{索引庫名}/_doc/文件id
{
"欄位1": "值1",
"欄位2": "值2",
// ... 略
}
示例:
PUT /heima/_doc/1
{
"info": "黑馬程式設計師高階Java講師",
"email": "zy@itcast.cn",
"name": {
"firstName": "雲",
"lastName": "趙"
}
}
1.4.2 增量修改
增量修改是隻修改指定id匹配的文件中的部分欄位。
語法:
POST /{索引庫名}/_update/文件id
{
"doc": {
"欄位名": "新的值",
}
}
示例:
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@itcast.cn"
}
}
RestAPI
ES官方提供了各種不同語言的客戶端,用來操作ES。這些客戶端的本質就是組裝DSL語句,透過http請求傳送給ES。官方文件地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
其中的Java Rest Client又包括兩種:
- Java Low Level Rest Client
- Java High Level Rest Client
我們使用的是Java HighLevel Rest Client客戶端API
API操作索引庫
JavaRestClient操作elasticsearch的流程基本類似。核心是client.indices()方法來獲取索引庫的操作物件。
索引庫操作的基本步驟:【可以根據傳送請求那步的第一個引數,發過來判斷需要建立什麼XXXXRequest】
- 初始化RestHighLevelClient
- 建立XxxIndexRequest。XXX是Create、Get、Delete
- 準備DSL( Create時需要,其它是無參)
- 傳送請求。呼叫RestHighLevelClient#indices().xxx()方法,xxx是create、exists、delete
1. mapping對映分析
根據MySQL資料庫表結構(建表語句),去寫索引庫結構JSON。表和索引庫一一對應
注意:地理座標、組合欄位。索引庫裡的地理座標是一個欄位:
座標:維度,精度
。copy_to組合欄位作用是供使用者查詢(輸入關鍵字可以查詢多個欄位)
建立索引庫,最關鍵的是mapping對映,而mapping對映要考慮的資訊包括:
- 欄位名
- 欄位資料型別
- 是否參與搜尋
- 是否需要分詞
- 如果分詞,分詞器是什麼?
其中:
- 欄位名、欄位資料型別,可以參考資料表結構的名稱和型別
- 是否參與搜尋要分析業務來判斷,例如圖片地址,就無需參與搜尋
- 是否分詞呢要看內容,內容如果是一個整體就無需分詞,反之則要分詞
- 分詞器,我們可以統一使用ik_max_word
來看下酒店資料的索引庫結構:
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword",
"copy_to": "all"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
幾個特殊欄位說明:
- location:地理座標,裡面包含精度、緯度
- all:一個組合欄位,其目的是將多欄位的值 利用copy_to合併,提供給使用者搜尋
地理座標說明:
copy_to說明:
2.初始化RestClient
在elasticsearch提供的API中,與elasticsearch一切互動都封裝在一個名為RestHighLevelClient的類中,必須先完成這個物件的初始化,建立與elasticsearch的連線。
分為三步:
1)引入es的RestHighLevelClient依賴:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
2)因為SpringBoot預設的ES版本是7.6.2,所以我們需要覆蓋預設的ES版本:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
3)初始化RestHighLevelClient:這裡一般在啟動類或者配置類裡注入該Bean,用於告訴Java 訪問ES的ip地址
初始化的程式碼如下:
@Bean
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}
這裡為了單元測試方便,我們建立一個測試類HotelIndexTest,然後將初始化的程式碼編寫在@BeforeEach方法中:
package cn.itcast.hotel;
import org.apache.http.HttpHost;
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 HotelIndexTest {
private RestHighLevelClient client;
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}
3. 索引庫CRUD
3.1 建立索引庫
程式碼分為三步:
- 1)建立Request物件。因為是建立索引庫的操作,因此Request是CreateIndexRequest。
- 2)新增請求引數,其實就是DSL的JSON引數部分。因為json字串很長,這裡是定義了靜態字串常量MAPPING_TEMPLATE,讓程式碼看起來更加優雅。
- 3)傳送請求,client.indices()方法的返回值是IndicesClient型別,封裝了所有與索引庫操作有關的方法。
建立索引庫的API如下:
程式碼:
在hotel-demo的cn.itcast.hotel.constants包下,建立一個類,定義mapping對映的JSON字串常量:
package cn.itcast.hotel.constants;
public class HotelConstants {
public static 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" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"address\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"city\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"starName\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"location\":{\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"all\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
在hotel-demo中的HotelIndexTest測試類中,編寫單元測試,實現建立索引:
@Test
void createHotelIndex() throws IOException {
// 1.建立Request物件
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2.準備請求的引數:DSL語句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.傳送請求
client.indices().create(request, RequestOptions.DEFAULT);
}
3.2 刪除索引庫
三步走:
- 1)建立Request物件。這次是DeleteIndexRequest物件
- 2)準備引數。這裡是無參
- 3)傳送請求。改用delete方法
刪除索引庫的DSL語句非常簡單:
DELETE /hotel
在hotel-demo中的HotelIndexTest測試類中,編寫單元測試,實現刪除索引:
@Test
void testDeleteHotelIndex() throws IOException {
// 1.建立Request物件
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
// 2.傳送請求
client.indices().delete(request, RequestOptions.DEFAULT);
}
3.3 查詢索引庫
三步走:
- 1)建立Request物件。這次是GetIndexRequest物件
- 2)準備引數。這裡是無參
- 3)傳送請求。改用exists方法
判斷索引庫是否存在,本質就是查詢,對應的DSL是:
GET /hotel
@Test
void testExistsHotelIndex() throws IOException {
// 1.建立Request物件
GetIndexRequest request = new GetIndexRequest("hotel");
// 2.傳送請求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
// 3.輸出
System.err.println(exists ? "索引庫已經存在!" : "索引庫不存在!");
}
API操作文件
這裡更多的是先讀取Mysql中的資料,然後再存進ES中。
文件操作的基本步驟:【可以根據傳送請求那步的第一個引數,發過來判斷需要建立什麼XXXXRequest】
- 初始化RestHighLevelClient
- 建立XxxRequest。XXX是Index、Get、Update、Delete、Bulk
- 準備引數(Index、Update、Bulk時需要)
- 傳送請求。呼叫RestHighLevelClient#.xxx()方法,xxx是index、get、update、delete、bulk
- 解析結果(Get時需要)
1. 初始化RestClient
在elasticsearch提供的API中,與elasticsearch一切互動都封裝在一個名為RestHighLevelClient的類中,必須先完成這個物件的初始化,建立與elasticsearch的連線。
分為三步:
1)引入es的RestHighLevelClient依賴:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
2)因為SpringBoot預設的ES版本是7.6.2,所以我們需要覆蓋預設的ES版本:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
3)初始化RestHighLevelClient:這裡一般寫在最前面,用於告訴Java 訪問ES的ip地址
初始化的程式碼如下:
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
這裡為了單元測試方便,我們建立一個測試類HotelIndexTest,然後將初始化的程式碼編寫在@BeforeEach方法中:
package cn.itcast.hotel;
import org.apache.http.HttpHost;
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 HotelIndexTest {
private RestHighLevelClient client;
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}
2. 文件CRUD
2.0 批次匯入文件
三步走:
- 1)建立Request物件。這裡是BulkRequest
- 2)準備引數。批處理的引數,就是其它Request物件,這裡就是多個IndexRequest
- 3)發起請求。這裡是批處理,呼叫的方法為client.bulk()方法
案例需求:利用BulkRequest批次將資料庫資料匯入到索引庫中。
步驟如下:
-
利用mybatis-plus查詢酒店資料
-
將查詢到的酒店資料(Hotel)轉換為文件型別資料(HotelDoc)
-
利用JavaRestClient中的BulkRequest批處理,實現批次新增文件
語法說明:
批次處理BulkRequest,其本質就是將多個普通的CRUD請求組合在一起傳送。
其中提供了一個add方法,用來新增其他請求:
可以看到,能新增的請求包括:
- IndexRequest,也就是新增
- UpdateRequest,也就是修改
- DeleteRequest,也就是刪除
因此Bulk中新增了多個IndexRequest,就是批次新增功能了。示例:
我們在匯入酒店資料時,將上述程式碼改造成for迴圈處理即可。
在hotel-demo的HotelDocumentTest測試類中,編寫單元測試:
@Test
void testBulkRequest() throws IOException {
// 批次查詢酒店資料
List<Hotel> hotels = hotelService.list();
// 1.建立Request
BulkRequest request = new BulkRequest();
// 2.準備引數,新增多個新增的Request
for (Hotel hotel : hotels) {
// 2.1.轉換為文件型別HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 2.2.建立新增文件的Request物件
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
// 3.傳送請求
client.bulk(request, RequestOptions.DEFAULT);
}
2.1 批次新增文件
四步走:
- 0)建立索引庫實體類
- 1)建立Request物件
- 2)準備請求引數,也就是DSL中的JSON文件
- 3)傳送請求 (注意:這裡直接使用client.xxx()的API,不再需要client.indices()了)
我們要將資料庫的酒店資料查詢出來,寫入elasticsearch中。
1)建立索引庫實體類
一般實體類裡包含經緯度都需要建立一個新的實體類,將經緯度拼成一個欄位
資料庫查詢後的結果是一個Hotel型別的物件。結構如下:
@Data
@TableName("tb_hotel")
public class Hotel {
@TableId(type = IdType.INPUT)
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String longitude;
private String latitude;
private String pic;
}
與我們的索引庫結構存在差異:
- longitude和latitude需要合併為location
因此,我們需要定義一個新的型別,與索引庫結構吻合:
package cn.itcast.hotel.pojo;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}
2)新增程式碼
新增文件的DSL語句如下:
POST /{索引庫名}/_doc/1
{
"name": "Jack",
"age": 21
}
對應的java程式碼如圖:
我們匯入酒店資料,基本流程一致,但是需要考慮幾點變化:
- 酒店資料來自於資料庫,我們需要先查詢出來,得到hotel物件
- hotel物件需要轉為HotelDoc物件
- HotelDoc需要序列化為json格式
在hotel-demo的HotelDocumentTest測試類中,編寫單元測試:
@Test
void testAddDocument() throws IOException {
// 批次查詢酒店資料
List<Hotel> hotels = hotelService.list();
// 1.建立Request
BulkRequest request = new BulkRequest();
// 2.準備引數,新增多個新增的Request
for (Hotel hotel : hotels) {
// 2.1.轉換為文件型別HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 2.2.建立新增文件的Request物件
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));//實體類轉JSON,指定JSON格式
request.add(new IndexRequest("xxx")...)
}
// 3.傳送請求
client.bulk(request, RequestOptions.DEFAULT);
}
2.2 查詢文件
查詢文件是根據id查詢的,所以沒有批次查詢
三步走:
- 1)準備Request物件。這次是查詢,所以是GetRequest
- 2)傳送請求,得到結果。因為是查詢,這裡呼叫client.get()方法
- 3)解析結果,就是對JSON做反序列化
查詢的DSL語句如下:
GET /hotel/_doc/{id}
非常簡單,因此程式碼大概分兩步:
- 準備Request物件
- 傳送請求
不過查詢的目的是得到結果,解析為HotelDoc,因此難點是結果的解析。完整程式碼如下:
可以看到,結果是一個JSON,其中文件放在一個_source
屬性中,因此解析就是拿到_source
,使用工具反序列化為Java物件即可。
在hotel-demo的HotelDocumentTest測試類中,編寫單元測試:
@Test
void testGetDocumentById() throws IOException {
// 1.準備Request
GetRequest request = new GetRequest("hotel", "61082");
// 2.傳送請求,得到響應
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.解析響應結果
String json = response.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
2.3 批次刪除文件
三步走:
- 1)準備Request物件,因為是刪除,這次是DeleteRequest物件。要指定索引庫名和id
- 2)準備引數,無參
- 3)傳送請求。因為是刪除,所以是client.delete()方法
刪除的DSL為是這樣的:
DELETE /hotel/_doc/{id}
在hotel-demo的HotelDocumentTest測試類中,編寫單元測試:
@Test
void testDeleteDocument() throws IOException {
//0.查詢資料庫中的資料
List<Hotel> list = hotelService.list();
// 1.建立Request
BulkRequest request = new BulkRequest();
//2.批次轉換實體類,順便寫入到ES中
for (Hotel hotel : list) {
//2.1轉換實體類
HotelDoc hotelDoc =new HotelDoc(hotel);
//2.2寫入ES
request.add(new DeleteRequest("hotel")
.id(hotel.getId().toString()));
}
//3.傳送請求
client.bulk(request,RequestOptions.DEFAULT);
}
2.4 批次修改文件
三步走:
- 1)準備Request物件。這次是修改,所以是UpdateRequest
- 2)準備引數。也就是JSON文件,裡面包含要修改的欄位
- 3)更新文件。這裡呼叫client.update()方法
修改有兩種方式:
- 全量修改:本質是先根據id刪除,再新增
- 增量修改:修改文件中的指定欄位值
在RestClient的API中,全量修改與新增的API完全一致,判斷依據是ID:
- 如果新增時,ID已經存在,則修改
- 如果新增時,ID不存在,則新增
只演示增量修改:
程式碼示例如圖:
在hotel-demo的HotelDocumentTest測試類中,編寫單元測試:
@Test
void testUpdateDocument() throws IOException {
//0.查詢資料庫中的資料
List<Hotel> list = hotelService.list();
// 1.建立Request
BulkRequest request = new BulkRequest();
//2.批次轉換實體類,順便寫入到ES中
for (Hotel hotel : list) {
//2.1轉換實體類
HotelDoc hotelDoc =new HotelDoc(hotel);
//2.2寫入ES
request.add(new UpdateRequest("hotel",hotel.getId().toString())
.doc(
"price", "952",
"starName", "四鑽"
));
}
//3.傳送請求
client.bulk(request,RequestOptions.DEFAULT);
}
ES搜尋引擎
elasticsearch的查詢依然是基於JSON風格的DSL來實現的。
1. DSL設定查詢條件
1.1 DSL查詢分類
Elasticsearch提供了基於JSON的DSL(Domain Specific Language)來定義查詢。常見的查詢型別包括:
-
查詢所有:查詢出所有資料,一般測試用。例如:match_all
-
全文檢索(full text)查詢:利用分詞器對使用者輸入內容分詞,然後去倒排索引庫中匹配。例如:
- match_query
- multi_match_query
-
精確查詢:根據精確詞條值查詢資料,一般是查詢keyword、數值、日期、boolean等型別欄位。例如:
- ids
- range
- term
-
地理(geo)查詢:根據經緯度查詢。例如:
- geo_distance
- geo_bounding_box
-
複合(compound)查詢:複合查詢可以將上述各種查詢條件組合起來,合併查詢條件。例如:
- bool
- function_score
查詢的語法基本一致:
GET /indexName/_search
{
"query": {
"查詢型別": {
"查詢條件": "條件值"
}
}
}
我們以查詢所有為例,其中:
- 查詢型別為match_all
- 沒有查詢條件
// 查詢所有
GET /indexName/_search
{
"query": {
"match_all": {
}
}
}
其它查詢無非就是查詢型別、查詢條件的變化。
1.2 全文檢索查詢
match和multi_match的區別是什麼?
- match:根據一個欄位查詢【推薦:使用copy_to構造all欄位】
- multi_match:根據多個欄位查詢,參與查詢欄位越多,查詢效能越差
注:搜尋欄位越多,對查詢效能影響越大,因此建議採用copy_to,然後單欄位查詢的方式。
1.2.1 使用場景
全文檢索查詢的基本流程如下:
- 對使用者搜尋的內容做分詞,得到詞條
- 根據詞條去倒排索引庫中匹配,得到文件id
- 根據文件id找到文件,返回給使用者
比較常用的場景包括:
- 商城的輸入框搜尋
- 百度輸入框搜尋
例如京東:
因為是拿著詞條去匹配,因此參與搜尋的欄位也必須是可分詞的text型別的欄位。
常見的全文檢索查詢包括:
- match查詢:單欄位查詢
- multi_match查詢:多欄位查詢,任意一個欄位符合條件就算符合查詢條件
1.2.2 match查詢
match查詢語法如下:
GET /indexName/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
}
}
match查詢示例:
1.2.3 mulit_match查詢
mulit_match語法如下:
GET /indexName/_search
{
"query": {
"multi_match": {
"query": "TEXT",
"fields": ["FIELD1", " FIELD12"]
}
}
}
multi_match查詢示例:
1.3 精準查詢
精準查詢型別:
- term查詢:根據詞條精確匹配,一般搜尋keyword型別、數值型別、布林型別、日期型別欄位
- range查詢:根據數值範圍查詢,可以是數值、日期的範圍
精確查詢一般是查詢keyword、數值、日期、boolean等型別欄位。所以不會對搜尋條件分詞。常見的有:
- term:根據詞條精確值查詢
- range:根據值的範圍查詢
1.3.1 term查詢
因為精確查詢的欄位搜時不分詞的欄位,因此查詢的條件也必須是不分詞的詞條。查詢時,使用者輸入的內容跟自動值完全匹配時才認為符合條件。如果使用者輸入的內容過多,反而搜尋不到資料。
語法說明:
// term查詢
GET /indexName/_search
{
"query": {
"term": {
"FIELD": {
"value": "VALUE"
}
}
}
}
示例:
當我搜尋的是精確詞條時,能正確查詢出結果:
但是,當我搜尋的內容不是詞條,而是多個詞語形成的短語時,反而搜尋不到:
1.3.2 range查詢
範圍查詢,一般應用在對數值型別做範圍過濾的時候。比如做價格範圍過濾。
基本語法:
// range查詢
GET /indexName/_search
{
"query": {
"range": {
"FIELD": {
"gte": 10, // 這裡的gte代表大於等於,gt則代表大於
"lte": 20 // lte代表小於等於,lt則代表小於
}
}
}
}
示例:
1.4 地理座標查詢
所謂的地理座標查詢,其實就是根據經緯度查詢,官方文件:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
常見的使用場景包括:
- 攜程:搜尋我附近的酒店
- 滴滴:搜尋我附近的計程車
- 微信:搜尋我附近的人
附近的酒店:
附近的車:
1.4.1 矩形範圍查詢
很少有業務有這種需求
矩形範圍查詢,也就是geo_bounding_box查詢,查詢座標落在某個矩形範圍的所有文件:
查詢時,需要指定矩形的左上、右下兩個點的座標,然後畫出一個矩形,落在該矩形內的都是符合條件的點。
語法如下:
// geo_bounding_box查詢
GET /indexName/_search
{
"query": {
"geo_bounding_box": {
"FIELD": {
"top_left": { // 左上點
"lat": 31.1,
"lon": 121.5
},
"bottom_right": { // 右下點
"lat": 30.9,
"lon": 121.7
}
}
}
}
}
1.4.2 附近(圓形)查詢
附近查詢,也叫做距離查詢(geo_distance):查詢到指定中心點小於某個距離值的所有文件。
換句話來說,在地圖上找一個點作為圓心,以指定距離為半徑,畫一個圓,落在圓內的座標都算符合條件:
語法說明:
// geo_distance 查詢
GET /indexName/_search
{
"query": {
"geo_distance": {
"distance": "15km", // 半徑
"FIELD": "31.21,121.5" // 圓心
}
}
}
示例:
我們先搜尋陸家嘴附近15km的酒店:
發現共有47家酒店。
1.5 複合查詢
複合(compound)查詢:複合查詢可以將其它簡單查詢組合起來,實現更復雜的搜尋邏輯。常見的有兩種:
- fuction score:算分函式查詢,可以控制文件相關性算分,控制文件排名
- bool query:布林查詢,利用邏輯關係組合多個其它的查詢,實現複雜搜尋
1.5.0 複合查詢歸納
GET /hotel/_search
{
"query": {
"function_score": {
"query": { // 原始查詢,可以是任意條件
"bool": {
"must": [
{"term": {"city": "上海" }}
],
"should": [
{"term": {"brand": "皇冠假日" }},
{"term": {"brand": "華美達" }}
],
"must_not": [
{ "range": { "price": { "lte": 500 } }}
],
"filter": [
{ "range": {"score": { "gte": 45 } }}
]
}
},
"functions": [ // 算分函式
{
"filter": { // 滿足的條件,品牌必須是如家【品牌是如家的才加分,這裡是加分條件】
"term": {
"brand": "如家"
}
},
"weight": 2 // 算分權重為2
}
],
"boost_mode": "sum" // 加權模式,求和
}
}
}
1.5.1 相關性算分
elasticsearch會根據詞條和文件的相關度做打分,演算法由兩種:
- TF-IDF演算法
- BM25演算法,elasticsearch5.1版本後採用的演算法
當我們利用match查詢時,文件結果會根據與搜尋詞條的關聯度打分(_score),返回結果時按照分值降序排列。
例如,我們搜尋 "虹橋如家",結果如下:
[
{
"_score" : 17.850193,
"_source" : {
"name" : "虹橋如家酒店真不錯",
}
},
{
"_score" : 12.259849,
"_source" : {
"name" : "外灘如家酒店真不錯",
}
},
{
"_score" : 11.91091,
"_source" : {
"name" : "迪士尼如家酒店真不錯",
}
}
]
在elasticsearch中,早期使用的打分演算法是TF-IDF演算法,公式如下:
在後來的5.1版本升級中,elasticsearch將演算法改進為BM25演算法,公式如下:
TF-IDF演算法有一各缺陷,就是詞條頻率越高,文件得分也會越高,單個詞條對文件影響較大。而BM25則會讓單個詞條的算分有一個上限,曲線更加平滑:
1.5.2 算分函式查詢
在搜尋出來的結果的分數基礎上,再手動與指定的數字進行一定運算來改變算分,從而改變結果的排序。
function score query定義的三要素是什麼?
- 過濾條件:哪些文件要加分
- 算分函式:如何計算function score
- 加權方式:function score 與 query score如何運算
根據相關度打分是比較合理的需求,但合理的不一定是產品經理需要的。
以百度為例,你搜尋的結果中,並不是相關度越高排名越靠前,而是誰掏的錢多排名就越靠前。如圖:
要想認為控制相關性算分,就需要利用elasticsearch中的function score 查詢了。
function score 查詢
1)語法說明
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)基於運算模式做運算,得到最終結果,作為相關性算分。
2)舉例
需求:給“如家”這個品牌的酒店排名靠前一些
翻譯一下這個需求,轉換為之前說的四個要點:
- 原始條件:不確定,可以任意變化
- 過濾條件:brand = "如家"
- 算分函式:可以簡單粗暴,直接給固定的算分結果,weight
- 運算模式:比如求和
因此最終的DSL語句如下:
GET /hotel/_search
{
"query": {
"function_score": {
"query": { .... }, // 原始查詢,可以是任意條件
"functions": [ // 算分函式
{
"filter": { // 滿足的條件,品牌必須是如家【品牌是如家的才加分,這裡是加分條件】
"term": {
"brand": "如家"
}
},
"weight": 2 // 算分權重為2
}
],
"boost_mode": "sum" // 加權模式,求和
}
}
}
測試,在未新增算分函式時,如家得分如下:
新增了算分函式後,如家得分就提升了:
1.5.3 布林查詢
布林查詢是一個或多個查詢子句的組合,每一個子句就是一個子查詢。子查詢的組合方式有:
- must:必須匹配每個子查詢,類似“與”
- should:選擇性匹配子查詢,類似“或”
- must_not:必須不匹配,不參與算分,類似“非”
- filter:必須匹配,不參與算分
注意:儘量在篩選的時候多使用不參與算分的must_not和filter,以保證效能良好
比如在搜尋酒店時,除了關鍵字搜尋外,我們還可能根據品牌、價格、城市等欄位做過濾:
每一個不同的欄位,其查詢的條件、方式都不一樣,必須是多個不同的查詢,而要組合這些查詢,就必須用bool查詢了。
需要注意的是,搜尋時,參與打分的欄位越多,查詢的效能也越差。因此這種多條件查詢時,建議這樣做:
- 搜尋框的關鍵字搜尋,是全文檢索查詢,使用must查詢,參與算分
- 其它過濾條件,採用filter查詢。不參與算分
bool查詢
1)語法
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{"term": {"city": "上海" }}
],
"should": [
{"term": {"brand": "皇冠假日" }},
{"term": {"brand": "華美達" }}
],
"must_not": [
{ "range": { "price": { "lte": 500 } }}
],
"filter": [
{ "range": {"score": { "gte": 45 } }}
]
}
}
}
2)示例
需求:搜尋名字包含“如家”,價格不高於400,在座標31.21,121.5周圍10km範圍內的酒店。
分析:
- 名稱搜尋,屬於全文檢索查詢,應該參與算分。放到must中
- 價格不高於400,用range查詢,屬於過濾條件,不參與算分。放到must_not中
- 周圍10km範圍內,用geo_distance查詢,屬於過濾條件,不參與算分。放到filter中
2. 設定搜尋結果
搜尋的結果可以按照使用者指定的方式去處理或展示。
2.0 搜尋結果種類
查詢的DSL是一個大的JSON物件,包含下列屬性:
- query:查詢條件
- from和size:分頁條件
- sort:排序條件
- highlight:高亮條件
- aggs:定義聚合
示例:
2.1 排序
在使用排序後就不會進行算分了,根據排序設定的規則排列
普通欄位是根據字典序排序
地理座標是根據舉例遠近排序
2.1.1普通欄位排序
keyword、數值、日期型別排序的排序語法基本一致。
語法:
排序條件是一個陣列,也就是可以寫多個排序條件。按照宣告的順序,當第一個條件相等時,再按照第二個條件排序,以此類推
(可以參考下面的圖片案例)
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"FIELD": "desc" // 排序欄位、排序方式ASC、DESC
}
]
}
示例:
需求描述:酒店資料按照使用者評價(score)降序排序,評價相同的按照價格(price)升序排序
2.1.2 地理座標排序
地理座標排序略有不同。
語法說明:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance" : {
"FIELD" : "緯度,經度", // 文件中geo_point型別的欄位名、目標座標點
"order" : "asc", // 排序方式
"unit" : "km" // 排序的距離單位
}
}
]
}
這個查詢的含義是:
- 指定一個座標,作為目標點
- 計算每一個文件中,指定欄位(必須是geo_point型別)的座標 到目標點的距離是多少
- 根據距離排序
示例:
需求描述:實現對酒店資料按照到你的位置座標的距離升序排序
提示:獲取你的位置的經緯度的方式:https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-lnglat/
假設我的位置是:31.034661,121.612282,尋找我周圍距離最近的酒店。
2.2 分頁
elasticsearch會禁止from+ size 超過10000的請求
elasticsearch 預設情況下只返回top10的資料。而如果要查詢更多資料就需要修改分頁引數了。elasticsearch中透過修改from、size引數來控制要返回的分頁結果:
- from:從第幾個文件開始
- size:總共查詢幾個文件
類似於mysql中的limit ?, ?
2.2.1 基本分頁
分頁的基本語法如下:
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 0, // 分頁開始的位置,預設為0
"size": 10, // 期望獲取的文件總數
"sort": [
{"price": "asc"}
]
}
2.2.2 深度分頁
原理:elasticsearch內部分頁時,必須先查詢 0~1000條,然後擷取其中的990 ~ 1000的這10條
現在,我要查詢990~1000的資料,查詢邏輯要這麼寫:
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, // 分頁開始的位置,預設為0
"size": 10, // 期望獲取的文件總數
"sort": [
{"price": "asc"}
]
}
這裡是查詢990開始的資料,也就是 第990~第1000條 資料。
叢集情況的深度分頁
針對深度分頁,ES提供了兩種解決方案,官方文件:
- search after:分頁時需要排序,原理是從上一次的排序值開始,查詢下一頁資料。【官方推薦】
- scroll:原理將排序後的文件id形成快照,儲存在記憶體。
不過,elasticsearch內部分頁時,必須先查詢 0~1000條,然後擷取其中的990 ~ 1000的這10條:
查詢TOP1000,如果es是單點模式,這並無太大影響。
但是elasticsearch將來一定是叢集,例如我叢集有5個節點,我要查詢TOP1000的資料,並不是每個節點查詢200條就可以了。
因為節點A的TOP200,在另一個節點可能排到10000名以外了。
因此要想獲取整個叢集的TOP1000,必須先查詢出每個節點的TOP1000,彙總結果後,重新排名,重新擷取TOP1000。
那如果我要查詢9900~10000的資料呢?是不是要先查詢TOP10000呢?那每個節點都要查詢10000條?彙總到記憶體中?
當查詢分頁深度較大時,彙總資料過多,對記憶體和CPU會產生非常大的壓力,因此elasticsearch會禁止from+ size 超過10000的請求。
2.3 高亮
注意:
- 高亮是對關鍵字高亮,因此搜尋條件必須帶有關鍵字,而不能是範圍這樣的查詢。
- 預設情況下,高亮的欄位,必須與搜尋指定的欄位一致,否則無法高亮
- 如果要對非搜尋欄位高亮,則需要新增一個屬性:required_field_match=false
使用場景:在百度等搜尋後,會對結果中出現搜尋欄位的部分進行高亮處理。
高亮原理
高亮顯示的實現分為兩步:
- 1)給文件中的所有關鍵字都新增一個標籤,例如
<em>
標籤 - 2)頁面給
<em>
標籤編寫CSS樣式
實現高亮
1)語法
GET /hotel/_search
{
"query": {
"match": {
"FIELD": "TEXT" // 查詢條件,高亮一定要使用全文檢索查詢
}
},
"highlight": {
"fields": { // 指定要高亮的欄位
"FIELD": { //【要和上面的查詢欄位FIELD一致】
"pre_tags": "<em>", // 用來標記高亮欄位的前置標籤
"post_tags": "</em>" // 用來標記高亮欄位的後置標籤
}
}
}
}
2)示例:組合欄位all的案例
2.4 資料聚合
類似於mysql中的【度量(Metric)聚合】聚合語句實現AVG,MAX,MIN;以及【桶(Bucket)聚合】GroupBy實現分組
聚合(aggregations)可以讓我們極其方便的實現對資料的統計、分析、運算。例如:
- 什麼品牌的手機最受歡迎?
- 這些手機的平均價格、最高價格、最低價格?
- 這些手機每月的銷售情況如何?
實現這些統計功能的比資料庫的sql要方便的多,而且查詢速度非常快,可以實現近實時搜尋效果。
aggs代表聚合,與query同級,此時query的作用是?
- 限定聚合的的文件範圍
聚合必須的三要素:
- 聚合名稱
- 聚合型別
- 聚合欄位
聚合可配置屬性有:
- size:指定聚合結果數量
- order:指定聚合結果排序方式
- field:指定聚合欄位
2.4.1 聚合種類
注意:參加聚合的欄位必須是keyword、日期、數值、布林型別
聚合常見的有三類:
-
桶(Bucket)聚合:用來對文件做分組
- TermAggregation:按照文件欄位值分組,例如按照品牌值分組、按照國家分組
- Date Histogram:按照日期階梯分組,例如一週為一組,或者一月為一組
-
度量(Metric)聚合:用以計算一些值,比如:最大值、最小值、平均值等
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同時求max、min、avg、sum等
-
管道(pipeline)聚合:其它聚合的結果為基礎做聚合
如:用桶聚合實現種類排序,然後使用度量聚合實現各個桶的最大值、最小值、平均值等
2.4.2 桶(Bucket)聚合
以統計酒店品牌種類,並對其進行資料分組
GET /hotel/_search
{
"query": { //限定要聚合的文件範圍,只要新增query條件【一般在沒搜尋關鍵字時不寫query】
"range": {
"price": {
"lte": 200 // 只對200元以下的文件聚合
}
}
},
"size": 0, // 設定size為0,結果中不包含查詢結果文件,只包含聚合結果
"aggs": { // 定義聚合
"brandAgg": { //給聚合起個名字
"terms": { // 聚合的型別,按照品牌值聚合,所以選擇term
"field": "brand", // 參與聚合的欄位
"order": {
"doc_count": "asc" // 對聚合結果按照doc_count升序排列
},
"size": 20 // 希望獲取的聚合結果數量【設定多少就最多隻顯示多少】
}
}
}
}
2.4.3 度量(Metric) and 管道(pipeline)聚合
度量聚合很少單獨使用,一般是和桶聚合一併結合使用
我們對酒店按照品牌分組,形成了一個個桶。現在我們需要對桶內的酒店做運算,獲取每個品牌的使用者評分的min、max、avg等值。
這就要用到Metric聚合了,例如stat聚合:就可以獲取min、max、avg等結果。
語法如下:
這次的score_stats聚合是在brandAgg的聚合內部巢狀的子聚合。因為我們需要在每個桶分別計算。
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"order": {
"scoreAgg.avg": "desc" // 對聚合結果按照指定欄位降序排列
},
"size": 20
},
"aggs": { // 是brands聚合的子聚合,也就是分組後對每組分別計算
"score_stats": { // 聚合名稱
"stats": { // 聚合型別,這裡stats可以計算min、max、avg等
"field": "score" // 聚合欄位,這裡是score
}
}
}
}
}
}
另外,我們還可以給聚合結果做個排序,例如按照每個桶的酒店平均分做排序:
3. RestClient查詢文件
文件的查詢同樣適用昨天學習的 RestHighLevelClient物件,基本步驟包括:
- 1)準備Request物件
- 2)準備請求引數
- 3)發起請求
- 4)解析響應
3.1 快速入門
查詢的基本步驟是:
建立SearchRequest物件
準備Request.source(),也就是DSL。
① QueryBuilders來構建查詢條件
② 傳入Request.source() 的 query() 方法
傳送請求,得到結果
解析結果(參考JSON結果,從外到內,逐層解析)
3.1.1 傳送查詢請求
程式碼解讀:
-
第一步,建立
SearchRequest
物件,指定索引庫名 -
第二步,利用
request.source()
構建DSL,DSL中可以包含查詢、分頁、排序、高亮等query()
:代表查詢條件,利用QueryBuilders.matchAllQuery()
構建一個match_all查詢的DSL
-
第三步,利用client.search()傳送請求,得到響應
這裡關鍵的API有兩個,一個是request.source()
,其中包含了查詢、排序、分頁、高亮等所有功能:
另一個是QueryBuilders
,其中包含match、term、function_score、bool等各種查詢:
3.1.2 解析響應結果
響應結果的解析:
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文件資料
3.1.3 完整程式碼
完整程式碼如下:
@Test
void testMatchAll() throws IOException {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
request.source()
.query(QueryBuilders.matchAllQuery());
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
handleResponse(response);
}
private void handleResponse(SearchResponse response) {
// 4.解析響應
SearchHits searchHits = response.getHits();
// 4.1.獲取總條數
long total = searchHits.getTotalHits().value;
System.out.println("共搜尋到" + total + "條資料");
// 4.2.文件陣列
SearchHit[] hits = searchHits.getHits();
// 4.3.遍歷
for (SearchHit hit : hits) {
// 獲取文件source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}
3.2 設定查詢條件
3.2.1 全文檢索查詢
全文檢索的match和multi_match查詢與match_all的API基本一致。差別是查詢條件,也就是query的部分。
因此,Java程式碼上的差異主要是request.source().query()中的引數了。同樣是利用QueryBuilders提供的方法:
而結果解析程式碼則完全一致,可以抽取並共享。
完整程式碼如下:
@Test
void testMatch() throws IOException {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
request.source()
.query(QueryBuilders.matchQuery("all", "如家"));
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
handleResponse(response);
}
3.2.2 精準查詢
精確查詢主要是兩者:
- term:詞條精確匹配
- range:範圍查詢
與之前的查詢相比,差異同樣在查詢條件,其它都一樣。
查詢條件構造的API如下:
3.2.3 地理查詢
DSL格式
在cn.itcast.hotel.service.impl
的HotelService
的search
方法中,新增一個排序功能:
完整程式碼:
@Override
public PageResult search(RequestParams params) {
try {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
// 2.1.query
buildBasicQuery(params, request);
// 2.2.分頁
int page = params.getPage();
int size = params.getSize();
request.source().from((page - 1) * size).size(size);
// 2.3.排序
String location = params.getLocation();
if (location != null && !location.equals("")) {
request.source().sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS)
);
}
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
3.2.4 布林查詢
布林查詢是用must、must_not、filter等方式組合其它查詢,程式碼示例如下:
可以看到,API與其它查詢的差別同樣是在查詢條件的構建,QueryBuilders,結果解析等其他程式碼完全不變。
完整程式碼如下:
@Test
void testBool() throws IOException {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
// 2.1.準備BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.2.新增term
boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
// 2.3.新增range
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
request.source().query(boolQuery);
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
handleResponse(response);
}
3.2.5 算分函式查詢
java程式碼邏輯:新增一個isAD欄位,在算分函式的filter中判斷
isAD=ture
就進行重新算分
function_score查詢結構如下:
對應的JavaAPI如下:
我們可以將之前寫的boolean查詢作為原始查詢條件放到query中,接下來就是新增過濾條件、算分函式、加權模式了。
// 算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
// 原始查詢,相關性算分的查詢
boolQuery,
// function score的陣列
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
// 其中的一個function score 元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
// 過濾條件
QueryBuilders.termQuery("isAD", true),
// 算分函式
ScoreFunctionBuilders.weightFactorFunction(10)
)
});
//將查詢請求放入查詢
request.source().query(functionScoreQuery);
3.3 設定搜尋結果
3.3.1 排序和分頁
由於這兩個比較簡單,所以一起寫了
搜尋結果的排序和分頁是與query同級的引數,因此同樣是使用request.source()來設定。
對應的API如下:
完整程式碼示例:
@Test
void testPageAndSort() throws IOException {
// 頁碼,每頁大小
int page = 1, size = 5;
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
// 2.1.query
request.source().query(QueryBuilders.matchAllQuery());
// 2.2.排序 sort
request.source().sort("price", SortOrder.ASC);
// 2.3.分頁 from、size
request.source().from((page - 1) * size).size(5);
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
handleResponse(response);
}
3.3.2 高亮
高亮的程式碼與之前程式碼差異較大,有兩點:
- 查詢的DSL:其中除了查詢條件,還需要新增高亮條件,同樣是與query同級。
- 結果解析:結果除了要解析_source文件資料,還要解析高亮結果
1)高亮請求構建
高亮請求的構建API如下:
上述程式碼省略了查詢條件部分,但是大家不要忘了:高亮查詢必須使用全文檢索查詢,並且要有搜尋關鍵字,將來才可以對關鍵字高亮。
完整程式碼如下:
@Test
void testHighlight() throws IOException {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
// 2.1.query
request.source().query(QueryBuilders.matchQuery("all", "如家"));
// 2.2.高亮
request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
// 3.傳送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析響應
handleResponse(response);
}
2)高亮結果解析
高亮的結果與查詢的文件結果預設是分離的,並不在一起。
因此解析高亮的程式碼需要額外處理:
程式碼解讀:
- 第一步:從結果中獲取source。hit.getSourceAsString(),這部分是非高亮結果,json字串。還需要反序列為HotelDoc物件
- 第二步:獲取高亮結果。hit.getHighlightFields(),返回值是一個Map,key是高亮欄位名稱,值是HighlightField物件,代表高亮值
- 第三步:從map中根據高亮欄位名稱,獲取高亮欄位值物件HighlightField
- 第四步:從HighlightField中獲取Fragments,並且轉為字串。這部分就是真正的高亮字串了
- 第五步:用高亮的結果替換HotelDoc中的非高亮結果
完整程式碼如下:
private void handleResponse(SearchResponse response) {
// 4.解析響應
SearchHits searchHits = response.getHits();
// 4.1.獲取總條數
long total = searchHits.getTotalHits().value;
System.out.println("共搜尋到" + total + "條資料");
// 4.2.文件陣列
SearchHit[] hits = searchHits.getHits();
// 4.3.遍歷
for (SearchHit hit : hits) {
// 獲取文件source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 獲取高亮結果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (!CollectionUtils.isEmpty(highlightFields)) {
// 根據欄位名獲取高亮結果
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
// 獲取高亮值
String name = highlightField.getFragments()[0].string();
// 覆蓋非高亮結果
hotelDoc.setName(name);
}
}
System.out.println("hotelDoc = " + hotelDoc);
}
}
3.3.3 聚合
聚合條件與query條件同級別,因此需要使用request.source()來指定聚合條件。
聚合條件的語法:
聚合的結果也與查詢結果不同,API也比較特殊。不過同樣是JSON逐層解析:
舉例:業務程式碼
@Override
public Map<String, List<String>> filters(RequestParams params) {
try {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
// 2.1.query查詢語句
buildBasicQuery(params, request);
// 2.2.設定size
request.source().size(0);
// 2.3.聚合
buildAggregation(request);
// 3.發出請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析結果
Map<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
// 4.1.根據品牌名稱,獲取品牌結果
List<String> brandList = getAggByName(aggregations, "brandAgg");
result.put("品牌", brandList);
// 4.2.根據品牌名稱,獲取品牌結果
List<String> cityList = getAggByName(aggregations, "cityAgg");
result.put("城市", cityList);
// 4.3.根據品牌名稱,獲取品牌結果
List<String> starList = getAggByName(aggregations, "starAgg");
result.put("星級", starList);
return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void buildAggregation(SearchRequest request) {
request.source().aggregation(AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100)
);
request.source().aggregation(AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100)
);
request.source().aggregation(AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100)
);
}
private List<String> getAggByName(Aggregations aggregations, String aggName) {
// 4.1.根據聚合名稱獲取聚合結果
Terms brandTerms = aggregations.get(aggName);
// 4.2.獲取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3.遍歷
List<String> brandList = new ArrayList<>();
for (Terms.Bucket bucket : buckets) {
// 4.4.獲取key
String key = bucket.getKeyAsString();
brandList.add(key);
}
return brandList;
}
自動補全
① 設定建立索引庫(設定一個自動補全欄位,型別為:completion)
② 重新插入資料
③ 查詢(查詢時要設定這個自動補全操作的名稱,並且指定那個型別為completion的欄位)
④ 分解結果(結果也需要根據之前設定這個自動查詢操作的名稱來取)
當使用者在搜尋框輸入字元時,我們應該提示出與該字元有關的搜尋項,如圖:
這種根據使用者輸入的字母,提示完整詞條的功能,就是自動補全了。
1. 拼音分詞器
下載拼音分詞器記得版本要和ES對應,不對應會報錯
要實現根據字母做補全,就必須對文件按照拼音分詞。在GitHub上恰好有elasticsearch的拼音分詞外掛。地址:https://github.com/medcl/elasticsearch-analysis-pinyin
課前資料中也提供了拼音分詞器的安裝包:
安裝方式與IK分詞器一樣,分三步:
①解壓
②上傳到虛擬機器中,elasticsearch的plugin目錄
③重啟elasticsearch
④測試
詳細安裝步驟可以參考IK分詞器的安裝過程。
2. 自定義拼音分詞器
如何使用拼音分詞器?
①下載pinyin分詞器
②解壓並放到elasticsearch的plugin目錄
③重啟即可
如何自定義分詞器?
①建立索引庫時,在settings中配置,可以包含三部分
②character filter
③tokenizer
④filter
拼音分詞器注意事項?
- 為了避免搜尋到同音字,搜尋時不要使用拼音分詞器
預設的拼音分詞器會將每個漢字單獨分為拼音,而我們希望的是每個詞條形成一組拼音,需要對拼音分詞器做個性化定製,形成自定義分詞器。官網文件查詢地址:https://github.com/medcl/elasticsearch-analysis-pinyin
elasticsearch中分詞器(analyzer)的組成包含三部分:
- character filters:在tokenizer之前對文字進行處理。例如刪除字元、替換字元
- tokenizer:將文字按照一定的規則切割成詞條(term)。例如keyword,就是不分詞;還有ik_smart
- tokenizer filter:將tokenizer輸出的詞條做進一步處理。例如大小寫轉換、同義詞處理、拼音處理等
文件分詞時會依次由這三部分來處理文件:
宣告自定義分詞器的語法如下:
PUT /test
{
"settings": {
"analysis": {
"analyzer": { // 自定義分詞器
"my_analyzer": { // 分詞器名稱
"tokenizer": "ik_max_word",
"filter": "py"
}
},
"filter": { // 自定義tokenizer filter
"py": { // 過濾器名稱
"type": "pinyin", // 過濾器型別,這裡是pinyin
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"limit_first_letter_length": 16,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}
測試:
3. 自動補全查詢
三步驟:
① 建立索引庫
② 插入資料
③ 查詢的DSL語句
elasticsearch提供了Completion Suggester查詢來實現自動補全功能。這個查詢會匹配以使用者輸入內容開頭的詞條並返回。為了提高補全查詢的效率,對於文件中欄位的型別有一些約束:
-
參與補全查詢的欄位必須是completion型別。
-
欄位的內容一般是用來補全的多個詞條形成的陣列。
比如,一個這樣的索引庫:
// 建立索引庫
PUT test
{
"mappings": {
"properties": {
"title":{
"type": "completion"
}
}
}
}
然後插入下面的資料:
// 示例資料
POST test/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test/_doc
{
"title": ["Nintendo", "switch"]
}
查詢的DSL語句如下:
// 自動補全查詢
GET /test/_search
{
"suggest": {
"title_suggest": { //設定這個自動查詢操作的名稱
"text": "s", // 關鍵字
"completion": {
"field": "title", // 補全查詢的欄位名
"skip_duplicates": true, // 跳過重複的
"size": 10 // 獲取前10條結果
}
}
}
}
4. 自動補全嵌入專案
4.1 修改索引庫對映結構
重點注意:
① all、name欄位等要 分詞設定為自定義分詞器("analyzer": "text_anlyzer")(一般要分詞,然後再對分詞後的詞語進行拼音處理),查詢設定為最精簡分詞器("search_analyzer": "ik_smart")
② 設定一個自動補全欄位(如 suggestion) 型別必須為:completion,並且使用自定義分詞器(一般不分詞直接對整個詞語進行拼音處理)
先刪除之前的索引庫,再設定如下:
// 酒店資料索引庫
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
"text_anlyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
},
"completion_analyzer": {
"tokenizer": "keyword",
"filter": "py"
}
},
"filter": {
"py": {
"type": "pinyin",
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"limit_first_letter_length": 16,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"id":{
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "text_anlyzer",
"search_analyzer": "ik_smart",
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword",
"copy_to": "all"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "text_anlyzer",
"search_analyzer": "ik_smart"
},
"suggestion":{
"type": "completion",
"analyzer": "completion_analyzer"
}
}
}
}
4.2 修改實體類
型別為completion的欄位需要在改造方法裡做組裝
HotelDoc中要新增一個欄位,用來做自動補全,內容可以是酒店品牌、城市、商圈等資訊。按照自動補全欄位的要求,最好是這些欄位的陣列。
因此我們在HotelDoc中新增一個suggestion欄位,型別為List<String>
,然後將brand、city、business等資訊放到裡面。
程式碼如下:
package cn.itcast.hotel.pojo;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
private Object distance;
private Boolean isAD;
private List<String> suggestion;
public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
// 組裝suggestion
if(this.business.contains("/")){
// business有多個值,需要切割
String[] arr = this.business.split("/");
// 新增元素
this.suggestion = new ArrayList<>();
this.suggestion.add(this.brand);
Collections.addAll(this.suggestion, arr);
}else {
this.suggestion = Arrays.asList(this.brand, this.business);
}
}
}
4.3 重新匯入資料
先刪除資料,再重新執行之前編寫的匯入資料功能,可以看到新的酒店資料中包含了suggestion:
4.4 自動補全的JavaAPI
示例:(這兩幅圖有點亂,看不懂就忽略)
查詢程式碼如下:
解析結果程式碼如下:
1)在cn.itcast.hotel.web
包下的HotelController
中新增新介面,接收新的請求:
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {
return hotelService.getSuggestions(prefix);
}
2)在cn.itcast.hotel.service
包下的IhotelService
中新增方法:
List<String> getSuggestions(String prefix);
3)在cn.itcast.hotel.service.impl.HotelService
中實現該方法:
@Override
public List<String> getSuggestion(String prefix) {
try {
// 1.準備Request
SearchRequest request = new SearchRequest("hotel");
// 2.準備DSL
request.source().suggest(new SuggestBuilder().addSuggestion(
"hotelSuggestion", //設定這個自動補全操作的名稱
SuggestBuilders.completionSuggestion("suggestion") //型別為completion的欄位名
.prefix(prefix)
.skipDuplicates(true)
.size(10)
));
// 3.發起請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析結果
Suggest suggest = response.getSuggest();
// 4.1.根據補全查詢名稱,獲取補全結果(這裡的引數是索引庫裡型別為completion的欄位名)
CompletionSuggestion suggestions = suggest.getSuggestion("hotelSuggestion"); //之前設定的這個自動查詢操作的名稱
// 4.2.獲取options
List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
// 4.3.遍歷
List<String> list = new ArrayList<>(options.size());
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
list.add(text);
}
return list;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
ES與Mysql資料同步
elasticsearch中的酒店資料來自於mysql資料庫,因此mysql資料發生改變時,elasticsearch也必須跟著改變,這個就是elasticsearch與mysql之間的資料同步。
1. 三種方法
常見的資料同步方案有三種:
- 同步呼叫
- 非同步通知
- 監聽binlog
方式一:同步呼叫
- 優點:實現簡單,粗暴
- 缺點:業務耦合度高
方式二:非同步通知【常用】
- 優點:低耦合,實現難度一般
- 缺點:依賴mq的可靠性
方式三:監聽binlog
- 優點:完全解除服務間耦合
- 缺點:開啟binlog增加資料庫負擔、實現複雜度高
1.1.同步呼叫
方案一:同步呼叫
基本步驟如下:
- hotel-demo對外提供介面,用來修改elasticsearch中的資料
- 酒店管理服務在完成資料庫操作後,直接呼叫hotel-demo提供的介面,
1.2.非同步通知
方案二:非同步通知
流程如下:
- hotel-admin對mysql資料庫資料完成增、刪、改後,傳送MQ訊息
- hotel-demo監聽MQ,接收到訊息後完成elasticsearch資料修改
1.3.監聽binlog
方案三:監聽binlog
流程如下:
- 給mysql開啟binlog功能
- mysql完成增、刪、改操作都會記錄在binlog中
- hotel-demo基於canal監聽binlog變化,實時更新elasticsearch中的內容
2. 實現資料同步
當資料發生增、刪、改時,要求對elasticsearch中資料也要完成相同操作。
步驟:
- 單機部署並啟動MQ(單機部署在MQ部分有講)
- 接收者中宣告exchange、queue、RoutingKey
- 在hotel-admin傳送者中的增、刪、改業務中完成訊息傳送
- 在hotel-demo接收者中完成訊息監聽,並更新elasticsearch中資料
- 啟動並測試資料同步功能
2.0 匯入依賴和yaml
對傳送者和消費者都新增依賴和yaml資訊
1)引入依賴
<!--amqp-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2)yaml
spring:
rabbitmq: #MQ配置
host: 192.168.194.131 # 主機名
port: 5672 # 埠
virtual-host: / # 虛擬主機
username: itcast # 使用者名稱
password: 123321 # 密碼
2.1 宣告交換機、佇列
MQ結構如圖:
1)宣告佇列交換機名稱
在hotel-admin傳送者和hotel-demo消費者中的cn.itcast.hotel.constatnts
包下新建一個類MqConstants
:
package cn.itcast.hotel.constatnts;
public class MqConstants {
/**
* 交換機
*/
public final static String HOTEL_EXCHANGE = "hotel.topic";
/**
* 監聽新增和修改的佇列
*/
public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
/**
* 監聽刪除的佇列
*/
public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
/**
* 新增或修改的RoutingKey
*/
public final static String HOTEL_INSERT_KEY = "hotel.insert";
/**
* 刪除的RoutingKey
*/
public final static String HOTEL_DELETE_KEY = "hotel.delete";
}
2)宣告佇列交換機
在hotel-demo消費者中,定義配置類,宣告佇列、交換機:
package cn.itcast.hotel.config;
import cn.itcast.hotel.constants.MqConstants;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MqConfig {
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);
}
@Bean
public Queue insertQueue(){
return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);
}
@Bean
public Queue deleteQueue(){
return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);
}
@Bean
public Binding insertQueueBinding(){
return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);
}
@Bean
public Binding deleteQueueBinding(){
return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);
}
}
2.2 傳送MQ訊息
在hotel-admin傳送者中的增、刪、改業務中分別傳送MQ訊息:
2.3 接收MQ訊息
hotel-demo接收到MQ訊息要做的事情包括:
- 新增訊息:根據傳遞的hotel的id查詢hotel資訊,然後新增一條資料到索引庫
- 刪除訊息:根據傳遞的hotel的id刪除索引庫中的一條資料
1)寫SDL業務
首先在hotel-demo的cn.itcast.hotel.service
包下的IHotelService
中新增新增、刪除業務
void deleteById(Long id);
void insertById(Long id);
給hotel-demo中的cn.itcast.hotel.service.impl
包下的HotelService中實現業務:
@Override
public void deleteById(Long id) {
try {
// 1.準備Request
DeleteRequest request = new DeleteRequest("hotel", id.toString());
// 2.傳送請求
client.delete(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void insertById(Long id) {
try {
// 0.根據id查詢酒店資料
Hotel hotel = getById(id);
// 轉換為文件型別
HotelDoc hotelDoc = new HotelDoc(hotel);
// 1.準備Request物件
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 2.準備Json文件
request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
// 3.傳送請求
client.index(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
2)編寫監聽器
在hotel-demo中的cn.itcast.hotel.mq
包新增一個類:
package cn.itcast.hotel.mq;
import cn.itcast.hotel.constants.MqConstants;
import cn.itcast.hotel.service.IHotelService;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class HotelListener {
@Autowired
private IHotelService hotelService;
/**
* 監聽酒店新增或修改的業務
* @param id 酒店id
*/
@RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
public void listenHotelInsertOrUpdate(Long id){
hotelService.insertById(id);
}
/**
* 監聽酒店刪除的業務
* @param id 酒店id
*/
@RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
public void listenHotelDelete(Long id){
hotelService.deleteById(id);
}
}
2.4 測試
用postman呼叫增加/刪除/修改mysql資料庫的介面,然後去頁面搜尋看看刪除的資料還是否能查到,或者修改/增加的資料能不能查出來
ES叢集
1. 搭建ES叢集
1.1 建立ES叢集
部署es叢集可以直接使用docker-compose來完成,不過要求你的Linux虛擬機器至少有4G的記憶體空間
首先編寫一個docker-compose檔案,內容如下:
version: '2.2'
services:
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data02:/usr/share/elasticsearch/data
networks:
- elastic
es03:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
es執行需要修改一些linux系統許可權,修改/etc/sysctl.conf
檔案
vi /etc/sysctl.conf
新增下面的內容:
vm.max_map_count=262144
然後執行命令,讓配置生效:
sysctl -p
透過docker-compose啟動叢集:
docker-compose up -d
1.2 叢集狀態監控
kibana可以監控es叢集,不過新版本需要依賴es的x-pack 功能,配置比較複雜。
這裡推薦使用cerebro來監控es叢集狀態,官方網址:https://github.com/lmenezes/cerebro
課前資料已經提供了安裝包:
解壓即可使用,非常方便。
解壓好的目錄如下:
進入對應的bin目錄:
雙擊其中的cerebro.bat檔案即可啟動服務。
訪問http://localhost:9000 即可進入管理介面:
輸入你的elasticsearch的任意節點的地址和埠,點選connect即可:
綠色的條,代表叢集處於綠色(健康狀態)。
1.3建立索引庫
建立索引庫的時候需要設定分片數量(其他還有多少個ES服務在該叢集)以及副本數量(本服務的資料複製幾份)
方法一:利用kibana的DevTools建立索引庫
如果沒有啟動ES的視覺化介面Kibana,那就用方法二
在DevTools中輸入指令:
PUT /itcast
{
"settings": {
"number_of_shards": 3, // 分片數量
"number_of_replicas": 1 // 副本數量
},
"mappings": {
"properties": {
// mapping對映定義 ...
}
}
}
方法二:利用cerebro建立索引庫
利用cerebro還可以建立索引庫:
填寫索引庫資訊:
點選右下角的create按鈕:
檢視分片效果
回到首頁,即可檢視索引庫分片效果:
2.叢集腦裂問題
master eligible節點的作用是什麼?
- 參與叢集選主
- 主節點可以管理叢集狀態、管理分片資訊、處理建立和刪除索引庫的請求
data節點的作用是什麼?
- 資料的CRUD
coordinator節點的作用是什麼?
路由請求到其它節點
合併查詢到的結果,返回給使用者
2.1.叢集職責劃分
透過改變配置檔案中的 true——> false 來改變職責。如data資料職責節點就只保留data為true其他為false
注意:每個節點都是路由,這樣可以保證不管哪個節點接收到請求可以分給其他人已經從其他人那接收資訊。
elasticsearch中叢集節點有不同的職責劃分:
預設情況下,叢集中的任何一個節點都同時具備上述四種角色。
但是真實的叢集一定要將叢集職責分離:(因為不同職責對CPU要求不同)
- master節點:對CPU要求高,但是記憶體要求低
- data節點:對CPU和記憶體要求都高
- coordinating節點:對網路頻寬、CPU要求高
職責分離可以讓我們根據不同節點的需求分配不同的硬體去部署。而且避免業務之間的互相干擾。
一個典型的es叢集職責劃分如圖:
2.2.腦裂問題
ES 7.0後預設配置了( eligible節點數量 + 1 )/ 2來解決腦裂問題
腦裂是因為叢集中的節點失聯導致的。
例如一個叢集中,主節點與其它節點失聯:
此時,node2和node3認為node1當機,就會重新選主:
當node3當選後,叢集繼續對外提供服務,node2和node3自成叢集,node1自成叢集,兩個叢集資料不同步,出現資料差異。
當網路恢復後,因為叢集中有兩個master節點,叢集狀態的不一致,出現腦裂的情況:
解決腦裂的方案是,要求選票超過 ( eligible節點數量 + 1 )/ 2 才能當選為主,因此eligible節點數量最好是奇數。對應配置項是discovery.zen.minimum_master_nodes,在es7.0以後,已經成為預設配置,因此一般不會發生腦裂問題
例如:3個節點形成的叢集,選票必須超過 (3 + 1) / 2 ,也就是2票。node3得到node2和node3的選票,當選為主。node1只有自己1票,沒有當選。叢集中依然只有1個主節點,沒有出現腦裂。
3.叢集分散式儲存
當新增文件時,應該儲存到不同分片,保證資料均衡,那麼coordinating node如何確定資料該儲存到哪個分片呢?
3.1.分片儲存測試
插入三條資料:
測試可以看到,三條資料分別在不同分片:
結果:
3.2.分片儲存原理
elasticsearch會透過hash演算法來計算文件應該儲存到哪個分片:
說明:
- _routing預設是文件的id
- 演算法與分片數量有關,因此索引庫一旦建立,分片數量不能修改!
新增文件的流程如下:
解讀:
- 1)新增一個id=1的文件
- 2)對id做hash運算,假如得到的是2,則應該儲存到shard-2
- 3)shard-2的主分片在node3節點,將資料路由到node3
- 4)儲存文件
- 5)同步給shard-2的副本replica-2,在node2節點
- 6)返回結果給coordinating-node節點
4. 叢集分散式查詢
原理:
elasticsearch的查詢分成兩個階段:
-
scatter phase:分散階段,coordinating node會把請求分發到每一個分片
-
gather phase:聚集階段,coordinating node彙總data node的搜尋結果,並處理為最終結果集返回給使用者
5.叢集故障轉移
ES本身已經配置好了有叢集故障轉移,不需要我們再去配置
叢集的master節點會監控叢集中的節點狀態,如果發現有節點當機,會立即將當機節點的分片資料遷移到其它節點,確保資料安全,這個叫做故障轉移。
1)例如一個叢集結構如圖:
現在,node1是主節點,其它兩個節點是從節點。
2)突然,node1發生了故障:
當機後的第一件事,需要重新選主,例如選中了node2:
node2成為主節點後,會檢測叢集監控狀態,發現:shard-1、shard-0沒有副本節點。因此需要將node1上的資料遷移到node2、node3: