ElasticSearch IK熱詞自動熱更新原理與Golang實現
- 2021 年 10 月 15 日
- 筆記
- Go, elasticsearch, 百萬級IM架構研究, golang精進
熱更新概述
- 藉助Nginx,在其某個目錄結構下放一個dic.txt,我們只要更新這個文件,不需要重啟ES也能達到熱更新的目的。優點是簡單,無需開發,缺點就是不夠靈活。
- 自己開發一個HTTP介面,返回詞庫。注意:一行代表一個詞,http body中,自己追加\n換行。
熱更新原理
/**
* 監控流程:
* ①向詞庫伺服器發送Head請求
* ②從響應中獲取Last-Modify、ETags欄位值,判斷是否變化
* ③如果未變化,休眠1min,返回第①步
* ④如果有變化,重新載入詞典
* ⑤休眠1min,返回第①步
*/
public void runUnprivileged() {
//超時設置
RequestConfig rc = RequestConfig.custom().setConnectionRequestTimeout(10*1000)
.setConnectTimeout(10*1000).setSocketTimeout(15*1000).build();
HttpHead head = new HttpHead(location);
head.setConfig(rc);
//設置請求頭
if (last_modified != null) {
head.setHeader("If-Modified-Since", last_modified);
}
if (eTags != null) {
head.setHeader("If-None-Match", eTags);
}
CloseableHttpResponse response = null;
try {
response = httpclient.execute(head);
//返回200 才做操作
if(response.getStatusLine().getStatusCode()==200){
if (((response.getLastHeader("Last-Modified")!=null) && !response.getLastHeader("Last-Modified").getValue().equalsIgnoreCase(last_modified))
||((response.getLastHeader("ETag")!=null) && !response.getLastHeader("ETag").getValue().equalsIgnoreCase(eTags))) {
// 遠程詞庫有更新,需要重新載入詞典,並修改last_modified,eTags
Dictionary.getSingleton().reLoadMainDict();
last_modified = response.getLastHeader("Last-Modified")==null?null:response.getLastHeader("Last-Modified").getValue();
eTags = response.getLastHeader("ETag")==null?null:response.getLastHeader("ETag").getValue();
}
}else if (response.getStatusLine().getStatusCode()==304) {
//沒有修改,不做操作
//noop
}else{
logger.info("remote_ext_dict {} return bad code {}" , location , response.getStatusLine().getStatusCode() );
}
} catch (Exception e) {
logger.error("remote_ext_dict {} error!",e , location);
}finally{
try {
if (response != null) {
response.close();
}
} catch (IOException e) {
logger.error(e.getMessage(), e);
}
}
}
- 先發送Http HEAD請求,獲取Last-Modified、ETag(裡面都是字元串)
- 如果其中有一個變化,則繼續發送Get請求,獲取詞庫內容。
HEAD 格式
HEAD方法跟GET方法相同,只不過伺服器響應時不會返回消息體。一個HEAD請求的響應中,HTTP頭中包含的元資訊應該和一個GET請求的響應消息相同。這種方法可以用來獲取請求中隱含的元資訊,而不用傳輸實體本身。也經常用來測試超鏈接的有效性、可用性和最近的修改。
一個HEAD請求的響應可被快取,也就是說,響應中的資訊可能用來更新之前快取的實體。如果當前實體跟快取實體的閾值不同(可通過Content-Length、Content-MD5、ETag或Last-Modified的變化來表明),那麼這個快取就被視為過期了。
$ curl --head //127.0.0.1:9800/es/steelDict HTTP/1.1 200 OK Etag: DefaultTags Last-Modified: 2021-10-15 14:49:35 Date: Fri, 15 Oct 2021 07:23:15 GMT
GET 格式
- 返回詞庫時,Content-Length、charset=UTF-8一定要有。
- Last-Modified和Etag 只需要1個有變化即可。只有當HEAD請求返回時,這2個其中一個欄位的值變了,才會發送GET請求獲取內容,請注意!
- 一行代表一個詞,自己追加\n換行
$ curl -i //127.0.0.1:9800/es/steelDict HTTP/1.1 200 OK Content-Length: 130 Content-Type: text/html;charset=UTF-8 Etag: DefaultTags Last-Modified: 2021-10-15 14:49:35 Date: Fri, 15 Oct 2021 07:37:47 GMT 裝飾管 裝飾板 圓鋼 無縫管 無縫方管 衛生級無縫管 衛生級焊管 熱軋中厚板 熱軋平板 熱軋卷平板
實現
配置ES IK分詞器
# 這裡以centos 7為例,通過rpm安裝 $ vim /usr/share/elasticsearch/plugins/ik/config/IKAnalyzer.cfg.xml # 改這一行,換成我們的地址 <entry key="remote_ext_dict">//10.16.52.52:9800/es/steelDict</entry> $ systemctl restart elasticsearch # 重啟es # 這裡還可以實時看到日誌,比較方便 $ tail -f /var/log/elasticsearch/my-application.log [2021-10-15T15:02:31,448][INFO ][o.w.a.d.Monitor ] [node-1] 獲取遠程詞典成功,總數為:0 [2021-10-15T15:02:31,952][INFO ][o.e.l.LicenseService ] [node-1] license [3ca1dc7b-3722-40e5-916e-3b2093980b75] mode [basic] - valid [2021-10-15T15:02:31,962][INFO ][o.e.g.GatewayService ] [node-1] recovered [1] indices into cluster_state [2021-10-15T15:02:32,812][INFO ][o.e.c.r.a.AllocationService] [node-1] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[steel-category-mapping][2]] ...]). [2021-10-15T15:02:41,630][INFO ][o.w.a.d.Monitor ] [node-1] 重新載入詞典... [2021-10-15T15:02:41,631][INFO ][o.w.a.d.Monitor ] [node-1] try load config from /etc/elasticsearch/analysis-ik/IKAnalyzer.cfg.xml [2021-10-15T15:02:41,631][INFO ][o.w.a.d.Monitor ] [node-1] try load config from /usr/share/elasticsearch/plugins/ik/config/IKAnalyzer.cfg.xml [2021-10-15T15:02:41,886][INFO ][o.w.a.d.Monitor ] [node-1] [Dict Loading] //10.16.52.52:9800/es/steelDict [2021-10-15T15:02:43,958][INFO ][o.w.a.d.Monitor ] [node-1] 獲取遠程詞典成功,總數為:0 [2021-10-15T15:02:43,959][INFO ][o.w.a.d.Monitor ] [node-1] 重新載入詞典完畢...
Golang介面
const (
kUrlSyncESIndex = "/syncESIndex" // 同步鋼材品名、材質、規格、產地、倉庫到ES索引中
kUrlGetSteelHotDict = "/steelDict" // 獲取鋼材字典(品材規產倉)
)
func InitRouter(router *gin.Engine) {
// ...
esRouter := router.Group("es")
// 同一個介面,根據head/get來決定是否返回數據部,避免寬頻浪費
esRouter.HEAD(kUrlGetSteelHotDict, onHttpGetSteelHotDictHead)
esRouter.GET(kUrlGetSteelHotDict, onHttpGetSteelHotDict)
// ...
}
// onHttpGetSteelHotDictHead 處理head請求,只有當Last-Modified 或 ETag 其中1個值改變時,才會出發GET請求獲取詞庫列表
func onHttpGetSteelHotDictHead(ctx *gin.Context) {
t, err := biz.QueryEsLastSyncTime()
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": biz.StatusError,
"msg": "server internal error",
})
logger.Warn(err)
return
}
ctx.Header("Last-Modified", t)
ctx.Header("ETag", kDefaultTags)
}
// onHttpGetSteelHotDict 處理GET請求,返回真正的詞庫,每一行一個詞
func onHttpGetSteelHotDict(ctx *gin.Context) {
// 這裡從mysql查詢詞庫,dic是一個[]string切片
dic, err := biz.QuerySteelHotDic()
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": biz.StatusError,
"msg": "server internal error",
})
logger.Warn(err)
return
}
// 這裡查詢最後一次更新時間,作為判斷詞庫需要更新的標準
t, err := biz.QueryEsLastSyncTime()
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": biz.StatusError,
"msg": "server internal error",
})
logger.Warn(err)
return
}
ctx.Header("Last-Modified", t)
ctx.Header("ETag", kDefaultTags)
body := ""
for _, v := range dic {
if v != "" {
body += v + "\n"
}
}
logger.Infof("%s query steel dict success, count = %d", ctx.Request.URL, len(dic))
buffer := []byte(body)
ctx.Header("Content-Length", strconv.Itoa(len(buffer)))
ctx.Data(http.StatusOK, "text/html;charset=UTF-8", buffer)
}
效果

POST //10.0.56.153:9200/_analyze
{
"analyzer": "ik_smart",
"text": "武鋼 Q235B 3*1500*3000 6780 佰隆庫 在途整件出"
}
{
"tokens": [
{
"token": "武鋼",
"start_offset": 0,
"end_offset": 2,
"type": "CN_WORD",
"position": 0
},
{
"token": "q235b",
"start_offset": 3,
"end_offset": 8,
"type": "CN_WORD",
"position": 1
},
{
"token": "3*1500*3000",
"start_offset": 9,
"end_offset": 20,
"type": "ARABIC",
"position": 2
},
{
"token": "6780",
"start_offset": 21,
"end_offset": 25,
"type": "ARABIC",
"position": 3
},
{
"token": "佰隆庫",
"start_offset": 26,
"end_offset": 29,
"type": "CN_WORD",
"position": 4
},
{
"token": "在途",
"start_offset": 30,
"end_offset": 32,
"type": "CN_WORD",
"position": 5
},
{
"token": "整件",
"start_offset": 32,
"end_offset": 34,
"type": "CN_WORD",
"position": 6
},
{
"token": "出",
"start_offset": 34,
"end_offset": 35,
"type": "CN_CHAR",
"position": 7
}
]
}
/**
* 載入遠程擴展詞典到主詞庫表
*/
private void loadRemoteExtDict() {
// ...
for (String theWord : lists) {
if (theWord != null && !"".equals(theWord.trim())) {
// 載入擴展詞典數據到主記憶體詞典中
// 注釋這一行:
// logger.info(theWord);
_MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());
}
}
// ...
}
mvn package
後記
調試介面不生效
/**
* 載入遠程擴展詞典到主詞庫表
*/
private void loadRemoteExtDict() {
List<String> remoteExtDictFiles = getRemoteExtDictionarys();
for (String location : remoteExtDictFiles) {
logger.info("[Dict Loading] " + location);
List<String> lists = getRemoteWords(location);
// 如果找不到擴展的字典,則忽略
if (lists == null) {
logger.error("[Dict Loading] " + location + "載入失敗");
continue;
} else {
logger.info("獲取遠程詞典成功,總數為:" + lists.size());
}
for (String theWord : lists) {
if (theWord != null && !"".equals(theWord.trim())) {
// 載入擴展詞典數據到主記憶體詞典中
logger.info(theWord);
_MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());
}
}
}
}
[2021-10-15T15:02:41,886][INFO ][o.w.a.d.Monitor] [node-1] [Dict Loading] //10.16.52.52:9800/es/steelDict [2021-10-15T15:02:43,958][INFO ][o.w.a.d.Monitor] [node-1] 獲取遠程詞典成功,總數為:0 [2021-10-15T15:02:43,959][INFO ][o.w.a.d.Monitor] [node-1] 重新載入詞典完畢...
public static void main(String[] args) {
List<String> words = getRemoteWordsUnprivileged("//127.0.0.1:9800/es/steelDict");
System.out.println(words.size());
}
數字分詞如何把*號不過濾
/**
* 英文字元及阿拉伯數字子分詞器
*/
class LetterSegmenter implements ISegmenter {
// ...
//鏈接符號(這裡追加*號)
private static final char[] Letter_Connector = new char[]{'#', '&', '+', '-', '.', '@', '_', '*'};
//數字元號(這裡追加*號)
private static final char[] Num_Connector = new char[]{',', '.', '*'};
// ...
}
關於作者
推薦下自己的開源IM,純Golang編寫:
CoffeeChat://github.com/xmcy0011/CoffeeChat
opensource im with server(go) and client(flutter+swift)
參考了TeamTalk、瓜子IM等知名項目,包含服務端(go)和客戶端(flutter+swift),單聊和機器人(小微、圖靈、思知)聊天功能已完成,目前正在研發群聊功能,歡迎對golang感興趣的小夥伴Star加關注。

