如果我們對上述實戰(zhàn)問題進行歸類,就都可以歸結(jié)為 Elasticsearch 數(shù)據(jù)建模
問題。
本文將以實戰(zhàn)問題為基準,手把手帶你實踐 Elasticsearch 數(shù)據(jù)建模全流程,重點解析基于業(yè)務(wù)角度、數(shù)據(jù)量角度、Setting 、Mapping ,以及復(fù)雜索引關(guān)聯(lián),這五個層面中涉及的數(shù)據(jù)建模實戰(zhàn)問題,讓你學(xué)完即可應(yīng)用到工作中。
1、為什么要做數(shù)據(jù)建模?我們選型傳統(tǒng)的數(shù)據(jù)庫,這里以 MySQL 為例,做數(shù)據(jù)存儲前需要考慮的問題如下:
以上這些疑問也均是數(shù)據(jù)建模問題。在 MySQL 中我們往往認為建模非常有必要,但反觀 Elasticsearch ,“上手快”這類先入為主的觀念已根植在很多同學(xué)心中,使得大家忽略了 Elasticsearch 數(shù)據(jù)建模的重要性。
接下來,我們基于 MySQL 做數(shù)據(jù)存儲需要考慮的問題,重新審視數(shù)據(jù)建模的定義,內(nèi)容如下。
到這里,相信你已經(jīng)初步明晰了數(shù)據(jù)建模的重要性。但我還想提醒你的是,“一把梭用法,上來就是干”并不是捷徑,尤其到了項目中后期,極易暴露出問題。經(jīng)歷的項目越多,你會發(fā)現(xiàn)建模的時間不能省。
下面我們具體分析一下為什么要數(shù)據(jù)建模?
相比于 MySQL,Elasticsearch 有非??旖莸?/span>優(yōu)勢
:
Elasticsearch 支持動態(tài)類型檢查和匹配。也就是說,當(dāng)我們寫入索引數(shù)據(jù)的時候,可以不提前指定數(shù)據(jù)類型,直接插入數(shù)據(jù)。
以類似天眼查、企查查的工商實戰(zhàn)數(shù)據(jù)為例(已做脫敏處理),如果利用以下語句直接創(chuàng)建索引和寫入一條數(shù)據(jù),豈不是很快?
PUT company_index/_doc/1
{
"regist_id": 1XX1600000000012,
"company_name": "北京XX長江創(chuàng)業(yè)投資有限公司",
"regist_id_new": "191XX160066933968XC",
"legal_representative": "徐X武",
"scope_bussiness": "創(chuàng)業(yè)投資業(yè)務(wù);代理其他創(chuàng)業(yè)投資企業(yè)等機構(gòu)或個人的創(chuàng)業(yè)投資業(yè)務(wù);創(chuàng)業(yè)投資咨詢業(yè)務(wù);為創(chuàng)業(yè)企業(yè)提供管理服務(wù)業(yè)務(wù);參與設(shè)立創(chuàng)業(yè)投資企業(yè)與企業(yè)投資管理顧問機(依法須經(jīng)批準的項目,經(jīng)相關(guān)部門批準后方可開展經(jīng)營活)",
"registration_status": "在營(開業(yè))企業(yè)",
"approval_date": "201X年04月13日",
"registration_number": "191XX160066933968XC",
"establishment_time": "200X年12月03日",
"address": "北京市黃河XX路西首育青小區(qū)",
"register_capital": 3000,
"business_starttime": "20XX年12月03日",
"registration_authority": "北XX工商行政管理局",
"company_type": "其他有限責(zé)任公司",
"enttype": 1190,
"enttypename": "法定代表人:",
"pripid": "1XXX102201305305801X",
"uniscid": "1XXX160066933968XC"
}
相比于 MySQL 中一個字段一個字段地敲定,這樣操作確實節(jié)省了很多時間。但隨著后續(xù)數(shù)據(jù)量激增,副作用便會很快顯現(xiàn)出來。該處理方式的弊端:
首先是極大地浪費了存儲空間,所有字符串類型數(shù)據(jù)都存儲為 text + keyword 組合類型,這種很多業(yè)務(wù)字段都是非必須的;
其次字符串類型默認分詞 standard,無法滿足中文精細化分詞檢索的需求。
接下來,結(jié)合我自己工作中早期系統(tǒng)的一個案例,我們做進一步分析。
5 個數(shù)據(jù)節(jié)點集群(5 個分片,1 個副本),微博數(shù)據(jù)每日增量 5000W+(增量存儲 150GB),核心數(shù)據(jù)磁盤 10TB 左右,很明顯該系統(tǒng)面臨存儲上限問題。
我們當(dāng)時就上述業(yè)務(wù)數(shù)據(jù)規(guī)劃了一個大索引,比如微博數(shù)據(jù)一個索引,微信數(shù)據(jù)一個索引。但微博索引最多只能存儲 20 天左右的數(shù)據(jù),然后就得走刪除索引數(shù)據(jù)的操作。由于 1 個索引只能通過 delete_by_query 刪除部分數(shù)據(jù),而 delete_by_query 的特點是版本號更新的邏輯刪除,實際效果是越刪數(shù)據(jù)量越大,磁盤占用率激增。加上是線上環(huán)境,壓力之大,處理難度之大,經(jīng)歷過你就知道有多苦。
這也是很多大廠在面試候選人的時候,尤其偏愛數(shù)據(jù)建模能力強的工程師的主要原因之一。
比如下圖是美團對大數(shù)據(jù)開發(fā)高級工程師的崗位要求,第一條就是“深入理解業(yè)務(wù),對業(yè)務(wù)服務(wù)流程進行合理的抽象和建模。”
從以上兩個反例,以及這條招聘信息中便可以窺探出數(shù)據(jù)建模的重要性。下面我們具體說說如何做數(shù)據(jù)建模。
2、Elasticsearch 如何數(shù)據(jù)建模?在做數(shù)據(jù)建模之前,會先進行架構(gòu)設(shè)計,架構(gòu)環(huán)節(jié)涉及選型、集群規(guī)劃、節(jié)點角色劃分。
本文涉及的建模傾向于索引層面、數(shù)據(jù)層面的建模。為了讓你學(xué)完即可應(yīng)用到工作中,我會結(jié)合項目實戰(zhàn)進行講解。
Elasticsearch 適用范圍非常廣,包括電商、快遞、日志等各行各業(yè)。涉及索引層面的設(shè)計,和業(yè)務(wù)貼合緊密。
其一:業(yè)務(wù)一定要細分。
分成哪幾類數(shù)據(jù),每類數(shù)據(jù)歸結(jié)為一個索引還是多個索引,這是產(chǎn)品經(jīng)理、架構(gòu)師、項目經(jīng)理要討論敲定的問題。比如大數(shù)據(jù)類的數(shù)據(jù),可以按照業(yè)務(wù)數(shù)據(jù)分為微博索引、微信索引、Twiiter 索引、Facebook 索引等。
其二:多個業(yè)務(wù)類型需不需要跨索引檢索?
跨索引檢索的痛點是字段不統(tǒng)一、不一致,需要寫非常復(fù)雜的 bool 組合查詢語句來實現(xiàn)。為了避免這種情況,最好的方式就是提前建模。每一類業(yè)務(wù)數(shù)據(jù)的相同或者相似字段,采取統(tǒng)一建模的方式。
下面我們舉一個實際的例子加以分析。微博、微信、Twitter、Facebook 都有的字段,可以設(shè)計如下:
字段名稱 | 字段中文含義 | 字段類型 |
---|---|---|
publish_time | 發(fā)布時間 | date |
author | 作者 | keyword |
cont | 正文內(nèi)容 | text |
這樣設(shè)計的好處是:字段統(tǒng)一,寫查詢 DSL 無需特殊處理,非常快捷方便。所以,在設(shè)計階段,多個業(yè)務(wù)索引數(shù)據(jù)要盡可能地“求同存異”。具體來說:
求同指的是相同或者相近含義字段,一定要統(tǒng)一字段名、統(tǒng)一字段類型;
存異指的則是特定業(yè)務(wù)數(shù)據(jù)特有字段類型,可以獨立設(shè)計字段名稱和類型。
比如微博信息來源字段有手機 App 或者網(wǎng)頁等,別的業(yè)務(wù)索引如果沒有,獨立建模就可以。
類似這些建模信息可以統(tǒng)一 Excel 存儲,統(tǒng)一 git 多人協(xié)作管理。
多索引管理一般優(yōu)先推薦使用模板(template)和 別名(alias)結(jié)合的方式。
模板的特點:相同前綴名稱的索引可以歸結(jié)為一大類,一次創(chuàng)建,N 多索引共享,非常方便。
別名的特點:多個索引可以映射到一個別名,方便多索引以相同的名稱統(tǒng)一對外提供服務(wù)。
如本文前面所述,我是吃過單索引激增的虧,所以對于時序性數(shù)據(jù)(日志數(shù)據(jù)、大數(shù)據(jù)類數(shù)據(jù))等,我強烈建議你基于時間切分索引,具體如下圖所示。
當(dāng)然,其他可用的方案非常多,這里我列舉如下,供你選型參考。
由此可見,時序管理數(shù)據(jù)的優(yōu)點非常明顯。
Setting 層面又分為靜態(tài) Setting 和動態(tài) Setting 兩種。
一種是靜態(tài) Settings,一旦設(shè)置后,后續(xù)不可修改。如 number_of_shards
。
另一種是動態(tài) Setting,索引創(chuàng)建后,后面隨時可以更新。如 number_of_replicas
, max_result_window
, refresh_interval
。
僅就建模階段最核心的問題,拆解如下。
這里有個認知前提,就是主分片數(shù)一旦設(shè)置后就不可以修改,副本分片數(shù)可以靈活動態(tài)調(diào)整。
主分片設(shè)計一般會考量總體數(shù)據(jù)量、集群節(jié)點規(guī)模,這點在集群規(guī)劃層面會著重強調(diào)。一般主分片數(shù)要考慮集群未來動態(tài)擴展,通常設(shè)置為數(shù)據(jù)節(jié)點的 1 倍或者 1~3 倍之間的值。
副本分片是保證集群的高可用性,普通業(yè)務(wù)場景建議至少設(shè)置一個副本。
默認值 1s,這意味著在寫入階段,每秒都會生成一個分段。
refresh_interval
的目的是:數(shù)據(jù)由 index buffer
的堆內(nèi)存緩存區(qū)刷新到堆外內(nèi)存區(qū)域,形成 segment
,以使得搜索可見。
在實際業(yè)務(wù)場景里,如果寫入的數(shù)據(jù)不需要近實時搜索可見,可以適當(dāng)?shù)卦谀0濉⑺饕龑用嬲{(diào)大這個值,當(dāng)然也可以動態(tài)調(diào)整,比如調(diào)整為 30s 或者 60s。
這里同樣有個認知前提,就是對于深度翻頁的 from + size 實現(xiàn),越往后翻頁越慢。其實你對比看主流搜索引擎,比如 Google、百度、360、Bing 均不支持一下跳轉(zhuǎn)到最后一頁,這就是最大翻頁上限限制。
其實在基本業(yè)務(wù)層面也很好理解,按照相關(guān)度返回結(jié)果,前面幾頁是最相關(guān)的,越往后相關(guān)度越低。比如默認值 10000,也就是說如果每頁顯示 10 條數(shù)據(jù),可以翻 1000 頁?;緲I(yè)務(wù)場景已經(jīng)足夠了。因此不建議調(diào)大該值。
如果需要向后翻頁查詢,推薦 search_after 查詢方式。如果需要全量遍歷或者全量導(dǎo)出數(shù)據(jù),推薦 scroll 查詢方式。
管道預(yù)處理的好處很多,雖然 5.X 版本就有了這個功能,但實戰(zhàn)環(huán)境用起來還不多。
管道 ingest pipeline
就相當(dāng)于大數(shù)據(jù)的 ETL 抽取、轉(zhuǎn)換、加載的環(huán)節(jié),或者類似 logstash filter
處理環(huán)節(jié)。一些數(shù)據(jù)打標(biāo)簽、字段類型切分、加默認字段、加默認值等的預(yù)處理操作都可以借助 ingest pipelie
實現(xiàn)。
這里給出索引層面 Setting
設(shè)置的簡單模板,供你進一步學(xué)習(xí)參考,如下定義了 indexed_at 缺省的管道,同時在索引 my_index_0001 指定了該缺省管道,這樣做的好處,是每個新增的數(shù)據(jù)都會加了插入時刻的時間戳:indexed_at 字段,無需我們在業(yè)務(wù)層面手動處理,非常靈活和方便。
更多設(shè)置,推薦閱讀官方文檔,地址如下:
https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-modules-settings
PUT _ingest/pipeline/indexed_at
{
"description": "Adds indexed_at timestamp to documents",
"processors": [
{
"set": {
"field": "_source.indexed_at",
"value": "{{_ingest.timestamp}}"
}
}
]
}
PUT my_index_0001
{
"settings": {
"number_of_replicas": 1,
"number_of_shards": 3,
"refresh_interval": "30s",
"index": {
"default_pipeline": "indexed_at"
}
},
"mappings": {
"properties": {
"cont": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
}
}
}
Mapping 層面核心是字段名稱、字段類型、分詞器選型、多字段 multi_fields 選型,以及字段細節(jié)(是否索引、是否存儲等)的敲定。
索引名稱不允許用大寫,字段名稱官方?jīng)]有限制,但是可以參考 Java 編碼規(guī)范。我還真見過學(xué)員用中文或者拼音命名的,非常不專業(yè),大家一定要避免。
要結(jié)合業(yè)務(wù)類型選擇合適的字段類型。比如 integer 能搞定的,就不要用 long、float 或 double。
注意,字符串類型在 5.X 版本之后分為兩種類型:
一種是 keyword,適合精準匹配、排序和聚合操作;
另一種是 text,適合全文檢索。默認值 text & keyword 組合不見得是最優(yōu)的,選型時候要結(jié)合業(yè)務(wù)選擇。比如優(yōu)先選擇 keyword 類型,keyword 走倒排索引更快。
再舉個例子,實戰(zhàn)中情感值介于 0~100 之間,50 代表中性,0~50 代表負面,50~100 代表正面。如果使用 integer 查詢的時候要 range query,而實際存儲可以增加字段:0~50 設(shè)置為 -1,50 設(shè)置為 0,50~100 設(shè)置為 1,三種都是 keyword 類型,檢索時直接走 term 檢索會非??臁?/span>
實戰(zhàn)中中文分詞器用得比較多,中文分詞又分為 ansj,結(jié)巴,IK 等。以 IK 舉例,可以細分為 ik_smart 粗粒度分詞、ik_max_word 細粒度分詞。
在工作中,要結(jié)合業(yè)務(wù)選擇合適的分詞器,分詞器一旦設(shè)定是不可以修改的,除非 reindex。
分詞器選型后,都會有動態(tài)詞典的更新問題。更新的前提是不要僅使用開源插件原生詞典,而是要在平時業(yè)務(wù)中自己多積累特定業(yè)務(wù)數(shù)據(jù)詞典、詞庫。
如果要動態(tài)更新:一般推薦第三方更新插件借助數(shù)據(jù)庫更新實現(xiàn)。如果普通分詞都不能滿足業(yè)務(wù)需要,可以考慮 ngram 自定義分詞方式實現(xiàn)更細粒度分詞。
同一個字段根據(jù)需要可以設(shè)置多種類型。實戰(zhàn)業(yè)務(wù)中,對用特定中文詞明明存在,卻無法召回的情況,采用字詞混合索引的方式得以滿足。
所謂字詞混合,實際就是 standard 分詞器實現(xiàn)單字拆解,以及 ik_max_word 實現(xiàn)中文切詞結(jié)合的方式。檢索的時候 bool 對兩種分詞器結(jié)合,就可以實現(xiàn)相對精準的召回效果。
PUT mix_index
{
"mappings": {
"properties": {
"content": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"standard": {
"type": "text",
"analyzer": "standard"
},
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
POST mix_index/_search
{
"query": {
"bool": {
"should": [
{
"match_phrase": {
"content": "佟大"
}
},
{
"match_phrase": {
"content.standard": "佟大"
}
}
]
}
}
}
為了方便你記憶和使用,這里我把字段細節(jié)總結(jié)在如下這張表格中。
核心參數(shù) | 默認值 | 釋義 |
enabled | true | 僅適用于 Mapping 頂層以及 Object 對象,設(shè)置為 false 后該字段將不再被解析。 |
index | true | 控制是否對字段值進行索引,設(shè)置為 false 的字段不能被查詢。 |
doc_values | true | 正排索引,除了 text 類型外的其他類型默認開啟,用于聚合和排序分析。 |
fielddata | false | 是否為 text 類型啟動 fielddata,實現(xiàn) text 字段排序和聚合分析。 |
store | false | 是否存儲該字段值。 |
coerce | true | 是否開啟自動數(shù)據(jù)類型轉(zhuǎn)換功能,比如 字符串轉(zhuǎn)數(shù)字、浮點轉(zhuǎn)整型。true 代表可以轉(zhuǎn)換,false 代表不可以轉(zhuǎn)換。 |
fields | 根據(jù)業(yè)務(wù)需要而定 | 靈活使用多字段解決多樣的業(yè)務(wù)需求。 |
dynamic | true | 控制 mapping 的動態(tài)自動更新。 |
date_detection | true | 是否自動識別類型。 |
我們再來分析一下數(shù)據(jù)建模的流程,如下圖所示。
數(shù)據(jù)建模的流程圖
首先,根據(jù)業(yè)務(wù)選擇合適的數(shù)據(jù)類型。
注意字符串類型分為兩種 text 和 keyword類型;盡量選擇貼近實際大小的數(shù)據(jù)類型;nested 和 join 復(fù)雜類型需根據(jù)業(yè)務(wù)特點選型,具體會在下一部分詳細闡述。
其次,判定是否需要檢索,如果不需要,index 設(shè)置為 false 即可。
然后,判定是否需要排序和聚合操作,如果不需要可以設(shè)置 doc_values 為 false。
最后,考慮一下是否需要另行存儲,會結(jié)合使用 store 和 _source 字段。
Mapping 層面要強調(diào)的是:盡量不要使用默認的 dynamic 動態(tài)字段類型,強烈建議 strict 嚴格控制字段,避免字段“暴漲”導(dǎo)致不可預(yù)知的風(fēng)險,比如字段數(shù)超過默認 1000 個的上限、磁盤大于預(yù)期的激增等。
要摒棄 MySQL 的多表關(guān)聯(lián)建模思想,因為 MySQL 中的范式思想都不再適用于 Elasticsearch?;仡櫸恼麻_頭的幾個多表關(guān)聯(lián)問題,Elasticsearch 能提供的核心解決方案如下。
這是空間換時間的方案,就是允許部分字段冗余存儲的存儲方式。實戰(zhàn)舉例如下。
用戶索引:user。
博客索引:blogpost。
一個用戶可以發(fā)表多篇博客。按照傳統(tǒng)的 MySQL 建表思想:兩個表建立個用戶外鍵,即可搞定一切。而對于 Elasticsearch,我們更愿意在每篇博文后面都加上用戶信息(這就是寬表存儲的方案),看似存儲量大了,但是一次檢索就能搞定搜索結(jié)果。
PUT user/_doc/1
{
"name": "John Smith",
"email": "john@smith.com",
"dob": "1970/10/24"
}
PUT blogpost/_doc/2
{
"title": "Relationships",
"body": "It's complicated...",
"user": {
"id": 1,
"name": "John Smith"
}
}
GET /blogpost/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "relationships"
}
},
{
"match": {
"user.name": "John"
}
}
]
}
}
}
如果需要索引對象數(shù)組并保持數(shù)組中每個對象的獨立性,則應(yīng)使用嵌套 Nested 數(shù)據(jù)類型而不是對象 Oject 數(shù)據(jù)類型。
nested 文檔的優(yōu)點是可以將父子關(guān)系的兩部分數(shù)據(jù)(如博客+評論)關(guān)聯(lián)起來,我們可以基于nested 類型做任何的查詢。但缺點是查詢速度相對較慢,更新子文檔需要更新整篇文檔。
比如 1 個產(chǎn)品和供應(yīng)商之間就是 1 對 N 的關(guān)聯(lián)關(guān)系。當(dāng)使用父子文檔時,使用 has_child 或者 has_parent 做父子關(guān)聯(lián)查詢。優(yōu)點是父子文檔可獨立更新,但維護 Join 關(guān)系需要占據(jù)部分內(nèi)存,查詢較 Nested 更耗資源。
注意:5.X 之前版本叫父子文檔(多 type 實現(xiàn)),6.X 之后高版本是 join 類型(單 type 類型)。
需通過多次檢索獲取所需的關(guān)鍵字段,業(yè)務(wù)層面自己寫代碼實現(xiàn)。
這里小結(jié)一下,以上四種方式便是 Elasticsearch 能實現(xiàn)的全量多表關(guān)聯(lián)方案。實戰(zhàn)建模階段,一定要結(jié)合自己的業(yè)務(wù)場景,盡量往上靠,先通過 kibana dev tool 模擬實現(xiàn),找到契合自己業(yè)務(wù)的多表關(guān)聯(lián)方案。
此外我還要強調(diào)的是:多表關(guān)聯(lián)都會有性能問題,數(shù)據(jù)量極大且檢索性能要求高的場景需要慎用。這里我摘取了官方文檔對應(yīng)的描述如下,供你參考。
尤其應(yīng)該避免多表關(guān)聯(lián)。Nested 嵌套可以使查詢慢幾倍,而 Join 父子關(guān)系可以使查詢慢數(shù)百倍。
3、總結(jié)最后,我們再來總結(jié)一下建模其他核心考量因素。
盡量空間換時間:能多個字段解決的不要用腳本實現(xiàn)。
盡量前期數(shù)據(jù)預(yù)處理,不要后期腳本。優(yōu)先選擇 ingest process 數(shù)據(jù)預(yù)處理實現(xiàn),盡量不要留到后面 script 腳本實現(xiàn)。
能指定路由的提前指定路由。寫入的時候指定路由,檢索的時候也同樣適用路由。
能前置的盡量前置,讓后面檢索聚合更加清爽。比如 index sorting 前置索引字段排序是非常好的方式。
數(shù)據(jù)建模是 Elasticsearch 開發(fā)實戰(zhàn)中非常重要的一環(huán),也是項目管理角度中的設(shè)計環(huán)節(jié)的重中之重,你一定要重視!千萬不要著急寫業(yè)務(wù)代碼,以“代碼之前,設(shè)計先行”作為行動準繩。
感謝你的時間!有 Elasticsearch 建模問題歡迎留言交流。
本文成文于:2021年8月11日
更短時間更快習(xí)得更多干貨!
和全球 1600+ Elastic 愛好者一起精進!