本文同時發佈在個人我的博客javascript
以前嘗試了用mysql作大規模數據的檢索優化,能夠看到單字段檢索的狀況下,是能夠經過各類手段作到各類類型索引快速檢索的,那是一種相對簡單的場景。html
可是實際應用每每會複雜一些 —— 各種索引(關鍵詞匹配、全文檢索、時間範圍)混合使用,還有排序的需求。這種狀況下mysql就有點力不從心了,複雜的索引類型,在多索引檢索的時候對每一個字段單獨建索引於事無補,而聯合索引沒法在如此複雜的索引類型下建起來。java
用ElasticSearch來解決這個場景的問題就要簡單的多了。那麼如何用elastic來解決這個問題呢? 仍是帶着業務需求來實踐一遍吧:node
①檢索字段有7個,4個關鍵詞匹配,1個特殊要求的a=b&c=d
的分段全文檢索,1箇中文全文檢索,1個時間範圍mysql
②數據量很大,須要支持3個月數據的檢索,最好能按月建索引,方便冷備、恢復git
爲了快速學習elasticsearch的api,能夠在本地快速搭建一個demo環境github
step1.安裝jdk1.8sql
https://www.oracle.com/technetwork/java/javase/downloads/index.html 官網下載安裝、配置好環境變量便可apache
step2.安裝elasticsearchjson
https://www.elastic.co/cn/downloads/elasticsearch 一樣的,官網下載對應平臺的包,這個甚至不須要,直接加壓,就能夠在bin目錄下看到服務的啓動文件
我使用的是windows平臺版本的,運行bin目錄下elasticsearch.bat,稍等片刻,訪問 http://localhost:9200
看到此截圖說明elasticsearch demo server啓動成功。
step3.安裝中文分詞器
文章開頭的需求中提到,有須要中文分詞全文索引的字段,因此須要額外安裝一下中文分詞器。
https://github.com/medcl/elasticsearch-analysis-ik/tree/v7.0.0 上官網下載對應elasticsearch版本tag的ik源碼包,好比我使用的最新版本7.0.0,ik也須要下載對應版本的。
elasticsearh是用java寫的,須要安裝maven以編譯此項目。http://maven.apache.org/download.cgi官網下載對應平臺的安裝包,編譯或解壓,配置好環境變量。
解壓ik代碼壓縮包,在其根目錄運行mvn clean && mvn compile && mvn package
,編譯打包
將target/releasa下生成的編譯好的文件,解壓到elasticsearch/plugin/ik
目錄下,重啓elasticsearch,啓動成功則說明安裝成功(或者直接在github下載對應的release版本)。ik分詞器沒辦法直接測試,須要先建好index,再在index下的分詞器中測試,在後文進行。
elasticsearch server以http協議接口的方式提供服務,官方提供了客戶端的nodejs sdk :https://github.com/elastic/elasticsearch-js,文檔在這裏https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#_index
用法這裏先不贅述。
正如官網所說,elasticsearch是一個簡單的引擎,同時也是一個複雜的引擎,提供不一樣級別的配置以實現不一樣複雜度的需求。網上elastic入門的文章,大多以比較簡單的方式介紹入門級別的使用,可是真正用到產品中的時候,仍是要思考一些問題: 如何配置索引字段、如何發起檢索請求、如何添加額外配置。帶着這些問題通讀一遍官網文檔,再來真正使用它,相對來講是比較好一點的。
寫入數據以前,首先得考慮如何建立各類不一樣類型的index,以知足分詞、檢索、排序、統計的需求,官方文檔對這塊的描述在這裏:https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html,真正開始建立index以前,推薦把mapping的文檔通讀一遍,這樣才知道如何選擇合適的type。
首先是4個關鍵詞索引的字段,比較簡單,直接建立keyword類型的property就能夠了:
const { Client } = require('@elastic/elasticsearch') const client = new Client({ node: 'http://localhost:9200' }) client.indices.create({ index: "asrtest1", include_type_name: false, body:{ "mappings" : { "properties" : { "id" : { "type" : "keyword" }, "app_key" : { "type" : "keyword" }, "guid" : { "type" : "keyword" }, "one_shot" : { "type" : "keyword" } } } } }).then((data)=>{ console.log("index create success:") }).catch((err)=>{ console.error("index create error:", err) })
而後是1個日期字段,這裏日期的格式是格式化後的 2019-04-15 14:54:01
或者毫秒數,參考mapping文檔中date類型字段的說明,向上面建立的index中插入date類型的字段,並指定字段的格式化方法:
client.indices.putMapping({ index: "asrtest1", include_type_name: false, body:{ "properties" : { "log_time" : { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" } } } }).then((data)=>{ console.log("add success:", data) }).catch((err)=>{ console.error("add error:", err) })
而後是1個特殊要求的a=b&c=d
的分段全文檢索, 爲了達到這個目標,咱們須要使用一個分詞器,僅使用&
分詞。
這裏須要參考兩處文檔,一處是mapping文檔中text類型字段說明,另外一處是整個analysis部分的文檔(描述了分詞器的組成部分、工做原理、如何自定義分詞器等)
一個analyzer由Character Filters
、Tokenizers
、Token Filters
三部分組成,咱們能夠本身實現一個自定義分詞器toenizer用於此需求,也能夠直接使用內置的pattern analyzer,二者沒啥區別,這裏圖簡單就用一下內置的Pattern Analyzer:
//1. 關閉asrtest1index client.indices.close({index:"asrtest1"}).then(function () { //2. 爲index添加pattern analyzer return client.indices.putSettings({ index: "asrtest1", body:{ "analysis": { "analyzer": { "qua_analyzer": { "type": "pattern", "pattern": "&" } } } } }) }).then(()=>{ //3. 爲index添加text類型的qua字段,並應用pattern analyzer return client.indices.putMapping({ index: "asrtest1", include_type_name: false, body:{ "properties" : { "qua" : { "type": "text", "analyzer": "qua_analyzer", "search_analyzer":"qua_analyzer" } } } }) }).then(()=>{ //4. 打開asrtest1 return client.indices.open({index:"asrtest1"}) }).then(()=>{ console.log("add qua success") }).catch((err)=>{ console.error("add error:", err.message, err.meta.body) })
這裏有個小插曲,直接putSettings的時候,報這個錯,因此上面的代碼中先關閉index,再添加,再open
至此自定義分詞的qua字段添加完畢,好了,測試一下:
client.indices.analyze({ "index":"asrtest1", "body":{ "analyzer" : "qua_analyzer", "text" : "a=b&c=d&e=f&g=h" } }).then((data)=>{ console.log("analyzer run success:", data.body.tokens) }).catch((err)=>{ console.error("analyzer run error:", err) })
[{token:'a=b',start_offset:0,end_offset:3,type:'word',position:0}, {token:'c=d',start_offset:4,end_offset:7,type:'word',position:1}, {token:'e=f',start_offset:8,end_offset:11,type:'word',position:2}, {token:'g=h',start_offset:12,end_offset:15,type:'word',position:3}]
結果是符合預期的。
elasticsearch經過插件的形式來支持中文分詞,在1.1小節中,咱們已經安裝了elasticsearch提供的中文分詞插件,如今來應用並測試一下。
添加text中文分詞字段:
client.indices.putMapping({ index: "asrtest1", include_type_name: false, body:{ "properties" : { "text" : { "type": "text", "analyzer": "ik_max_word", "search_analyzer":"ik_max_word" } } } }).then((data)=>{ console.log("add success:", data) }).catch((err)=>{ console.error("add error:", err) })
測試中文分詞引擎:
client.indices.analyze({ "index":"asrtest1", "body":{ "analyzer" : "ik_max_word", "text" : "中韓漁警衝突調查:韓警平均天天扣1艘中國漁船" } }).then((data)=>{ console.log("analyzer run success:", data.body.tokens) }).catch((err)=>{ console.error("analyzer run error:", err) })
[{token:'中韓',start_offset:0,end_offset:2,type:'CN_WORD',position:0}, {token:'漁',start_offset:2,end_offset:3,type:'CN_CHAR',position:1}, {token:'警',start_offset:3,end_offset:4,type:'CN_CHAR',position:2}, {token:'衝突',start_offset:4,end_offset:6,type:'CN_WORD',position:3}, {token:'調查',start_offset:6,end_offset:8,type:'CN_WORD',position:4}, {token:'韓',start_offset:9,end_offset:10,type:'CN_CHAR',position:5}, {token:'警',start_offset:10,end_offset:11,type:'CN_CHAR',position:6}, {token:'平均',start_offset:11,end_offset:13,type:'CN_WORD',position:7}, {token:'天天',start_offset:13,end_offset:15,type:'CN_WORD',position:8}, {token:'扣',start_offset:15,end_offset:16,type:'CN_CHAR',position:9}, {token:'1',start_offset:16,end_offset:17,type:'ARABIC',position:10}, {token:'艘',start_offset:17,end_offset:18,type:'COUNT',position:11}, {token:'中國',start_offset:18,end_offset:20,type:'CN_WORD',position:12}, {token:'漁船',start_offset:20,end_offset:22,type:'CN_WORD',position:13}]
結果是符合預期的。
2.1小節中分步驟分析了每一個字段應該如何創建索引,而業務場景下有個需求是按月建索引。能夠選擇跑個定時腳本,每月去自動建立下一個月的index,也有更簡單的選擇 —— 插入數據的時候,若是發現索引名稱不存在,則自動建立索引,elasticserch提供了這樣的功能。爲了實現這個目標,須要看兩個部分的文檔: 集羣的自動index建立配置、index模板。
首先建立一個index模板:
client.indices.putTemplate({ "name": "asrtemp", "include_type_name": false, "body":{ "index_patterns" : ["asr*"], "settings": { "analysis": { "analyzer": { "qua_analyzer": { "type": "pattern", "pattern": "&" } } } }, "mappings": { "properties": { "id" : { "type" : "keyword" }, "app_key" : { "type" : "keyword" }, "guid" : { "type" : "keyword" }, "one_shot" : { "type" : "keyword" }, "log_time" : { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" }, "qua" : { "type": "text", "analyzer": "qua_analyzer", "search_analyzer":"qua_analyzer" }, "text" : { "type": "text", "analyzer": "ik_max_word", "search_analyzer":"ik_max_word" } } } } }).then((data)=>{ console.log("add template success:", data) }).catch((err)=>{ console.error("add template error:", err) })
修改集羣配置,設置自動建立索引時只應用此模板(也能夠修改此配置,默認是應用全部知足pattern的模板):
client.cluster.putSettings({ body:{ "persistent": { "action.auto_create_index": "asrtemp" } } })
寫入一個document,指定一個不存在可是知足template中index_patterns的index:
client.create({ id:"testid1", index:"asrtest2", body:{ "id":"testid1", "app_key":"dc6aca3e-bc9f-45ae-afa5-39cc2ca49158", "guid":"4ccaee22-5ce3-11e9-9191-1b9bd38b79e0", "one_shot":"0", "log_time":"2019-05-14 09:13:20", "qua":"key1=asd.asda.asf&key2=2.0.20.1&key3=val3&testk=testv", "text":"明天武漢的天氣好很差啊" } }).then((data)=>{ console.log("create success:", data) }).catch((err)=>{ console.error("create error:", err) })
寫入成功,查詢一下新生成的index的信息:
client.indices.get({ index: "asrtest2" }).then((data)=>{ console.log("get success:", data.body.asrtest2.mappings,data.body.asrtest2.settings) }).catch((err)=>{ console.error("get error:", err) })
{ properties: { app_key: { type: 'keyword' }, guid: { type: 'keyword' }, id: { type: 'keyword' }, log_time: { type: 'date', format: 'yyyy-MM-dd HH:mm:ss||epoch_millis' }, one_shot: { type: 'keyword' }, qua: { type: 'text', analyzer: 'qua_analyzer' }, text: { type: 'text', analyzer: 'ik_max_word' } } } { index: { number_of_shards: '1', provided_name: 'asrtest2', creation_date: '1555319547412', analysis: { analyzer: [Object] }, number_of_replicas: '1', uuid: '_GcwsE4vSBCDW0Pv35w0uA', version: { created: '7000099' } } }
與模板是一致的,自動建立成功。
請求時,須要作到:①各類字段交叉組合檢索 ②支持分頁統計count、offset ③支持按時間排序 ④延時不能太長。下面首先插入幾百萬條模擬數據,而後實踐一下上面的三個檢索需求。
爲了快速大批量插入數據,應該使用elasticsearch提供的bulk api來進行數據插入的操做,關鍵代碼:
function mockIndex(index){ var indexName = "asrtest2" if(index >= 4000000){ console.log(`index mock done!`) return; } if(index % 10000 == 0){ console.log(`current num:${index}`) } var mockDataList = [] for(var i=0;i<500;i++){ var mock = getOneRandomData(index++) mockDataList.push({ "index" : { "_index" : indexName, "_id" : mock.id } }) //mock: {app_key,log_time,guid,qua,id, text, one_shot"} mockDataList.push(mock) } client.bulk({ index:indexName, body: mockDataList }).then(()=>{ mockIndex(index) }).catch((err)=>{ console.error("bulk error:",err.message) }) } mockIndex(0)
模擬數據灌滿後,測試一下多索引聯合檢索,並設置排序字段、獲取count、觀測性能、驗證結果的正確性。此處建議通讀elasticsearch文檔Search APIs、 Query DSL(elastic本身造的一種抽象語法樹)。
在這些部分能夠看到,各個類型的字段應該如何生成檢索條件
這些部分能夠看到如何將各個字段的檢索條件合理組成一個複合檢索參數
這些部分能夠看到如何使用字段排序、如何設置返回結果數量、偏移量。
最後,根據文檔的說明,寫一個測試檢索代碼:
client.search({ index: "asrtest1", from: 0, size: 10, sort: "log_time:asc", body:{ "query": { "bool": { //測試query和filter //"must": [ // { "match_phrase": { "qua": "SDK=39.60.17160906"}}, // { "match_phrase": { "text": "舞麟" }} //], "filter": [ { "match_phrase": { "qua": "SDK=39.60.17160906"}}, { "match_phrase": { "text": "舞麟" }}, //id和guid是unique字段 //{ "term": { "id": "c2e86a41-5f73-11e9-b3d0-45c4efcbf90f" }}, //{ "term": { "guid": "2081359c-5f72-11e9-b3d0-45c4efcbf90f" }}, { "term": { "app_key": "faf1e695-9a97-4e8f-9339-bdce91d4848a" }}, { "term": { "one_shot": "1" }}, { "range": { "log_time": { "gte": "2019-04-15 08:00:00" }}} ] } } } }).then((data)=>{ console.log("timecost:", data.body.took) console.log("total:", data.body.hits.total) console.log("hits:", data.body.hits.hits) }).catch((err)=>{ console.log("error:", err) })
變換各類條件查詢,條件查詢、排序、返回條數、偏移、總數等都是符合預期的。
若是有細心看上面的代碼,能夠發現query條件中有註釋掉的must
部分,這是由於我面臨的業務場景下不須要對document進行score計算,只須要過濾結果,因此將全部的條件塞到filter中,elastic內部會有一些緩存策略,提升效率。經測試,將兩個match_phase
條件放到must中,400W條數據檢索平均耗時在30—40ms,而放到filter中後,平均僅爲7—8ms。
減小條件,只保留時間限制,發現:
至少應該有兩百多萬條結果,這裏total只有10000條。
能夠經過修改index的settings,index.max_result_window屬性,來修改這個數量。
可是!文檔中提到" Search requests take heap memory and time proportional to from + size and this limits that memory",還有這篇文檔,這裏能夠看到es就不適合用於大規模數據的徹底遍歷!想要使用es完美解決全部問題,得一口老血噴在屏幕上!
這裏雖然沒辦法直接查詢到大offset數據,可是能夠經過Count API查詢到真實總數,而後經過其它的search方法來達到分頁的目標,好在elasticsearch也是考慮了這一點,提供了Search After API來應對這種場景。
說白了就是使用了另外一種分頁模式,須要業務本身維護上下文,經過傳入上一次查詢的最後一個結果做爲起點,再日後面去查詢結果。
修改查詢代碼:
client.search({ index: "asrtest1", size: 10, sort: "log_time:desc,id:desc", body:{ "query": { "bool": { "filter": [ { "range": { "log_time": { "gte": "2019-04-15 08:20:00" }}} ] } } } }).then((data)=>{ console.log("first 10 result:", data.body.hits.hits) let last = data.body.hits.hits.slice(-1)[0].sort return client.search({ index: "asrtest1", size: 10, sort: "log_time:desc,id:desc", body:{ "query": { "bool": { "filter": [ { "range": { "log_time": { "gte": "2019-04-15 08:20:00" }}} ] } }, "search_after": last, } }) }).then((data)=>{ console.log("second 10 result:", data.body.hits.hits) }).catch((err)=>{ console.log("error:", err) })
這樣,就能夠查到任意多的結果,又不會把集羣搞死了。
本文基於一個簡單的業務場景大體實踐了一遍elasticsearch的使用,而實際上集羣的搭建、運維,是一個很是複雜的工做,而不少雲服務上都提供了包裝好的PAAS服務,如騰訊雲ElasticSearch Service,直接購買接入便可。