爲自己搭建一個分佈式 IM 系統二【從查找算法聊起】

前言

最近這段時間確實有點忙,這篇的目錄還是在飛機上敲出來了的。

言歸正傳,上週更新了 cim 第一版;沒想到反響熱烈,最高時上了 GitHub Trending Java 版塊的首位,一天收到了 300+ 的 star。

現在總共也有 1.3K+ 的 star,有幾十個朋友參加了測試,非常感謝大家的支持。

在這過程中也收到一些 bug 反饋,feature 建議;因此這段時間我把一些影響較大的 bug 以及需求比較迫切的 feature 調整了,本次更新的 v1.0.1 版本:

  • 客戶端超時自動下線。
  • 新增 AI 模式。
  • 聊天記錄查詢。
  • 在線用戶前綴模糊匹配。

下面談下幾個比較重點的功能。

客戶端超時自動下線 這個功能涉及到客戶端和服務端的心跳設計,比較有意思,也踩了幾個坑;所以準備留到下次單獨來聊。

AI 模式

大家應該還記得這個之前刷爆朋友圈的 估值兩個一個億的 AI 核心代碼

和我這裏的場景再合適不過了。

於是我新增了一個命令用於一鍵開啓 AI 模式,使用情況大概如下。

歡迎大家更新源碼體驗,融資的請私聊我🤣。

聊天記錄

聊天記錄也是一個比較迫切的功能。

使用命令 :q 關鍵字 即可查詢與個人相關的聊天記錄。

這個功能其實比較簡單,只需要在消息發送及接收消息時保存即可。

但要考慮的一點是,這個保存消息是 IO 操作,不可避免的會有耗時;需要儘量避免對消息發送、接收產生影響。

異步寫入消息

因此我把消息寫入的過程異步完成,可以不影響真正的業務。

實現起來也挺簡單,就是一個典型的生產者消費者模式。

主線程收到消息之後直接寫入隊列,另外再有一個線程一直源源不斷的從隊列中取出數據後保存聊天記錄。

大概的代碼如下:



寫入消息的同時會把消費消息的線程打開:

而最終存放消息記錄的策略,考慮後還是以最簡單的方式存放在客戶端,可以降低複雜度。

簡單來說就是根據當前日期+用戶名寫入到磁盤裏。

當客戶端關閉時利用線程中斷的方式停止了消費隊列的線程。


這點的設計其實和 logback 寫日誌的方式比較類似,感興趣的可以去翻翻 logback 的源碼,更加詳細。

回調接口

至於收到其他客戶端發來的消息時則是利用之前預留的消息回調接口來寫入日誌。

收到消息後會執行自定義的回調接口。

於是在這個回調方法中實現寫入邏輯即可,當後續還有其他的消息處理邏輯時也能在這裏直接添加。

當處理邏輯增多時最好是改爲責任鏈模式,更加清晰易維護。

查找算法

接下來是本文着重要討論的一個查找算法,準確的說是一個前綴模糊匹配的算法。

實現的效果如下:

使用命令 :qu prefix 可以按照前綴的方式搜索用戶信息。

當然在命令行中其實意義不大,但是在移動端中確是比較有用的。類似於微信按照用戶名匹配:

因爲後期打算出一個移動端 APP,所以就先把這個功能實現了。

從效果也看得出來:就是按照輸入的前綴匹配字符串(目前只支持英文)。

在沒有任何限制的條件下最快、最簡單的實現方式可以直接把所有的字符串存放在一個容器中 (List、Set),查詢時則挨個遍歷;利用 String.startsWith("prefix") 進行匹配。

但這樣會有幾個問題:

  • 存儲資源比較浪費,不管是 list 還是 Set 都會有額外的損耗。
  • 查詢效率較低,需要遍歷集合後再遍歷字符串的 char 數組(String.startsWith 的實現方式)。

字典樹

基於以上的問題我們可以考慮下:

假設我需要存放 java,javascript,jsp,php 這些字符串時在 ArrayList 中會怎麼存放?

很明顯,會是這樣完整的存放在一個數組中;同時這個數組還可能存在浪費,沒有全部使用完。

但其實仔細觀察這些數據會發現有一些共同特點,比如 java,javascript 有共同的前綴 java;和 jsp 有共同的前綴 j

那是否可以把這些前綴利用起來呢?這樣就可以少存儲一份。

比如寫入 java,javascript 這兩個字符串時存放的結構如下:

當再存入一個 jsp 時:

最後再存入 jsf 時:

相信大家應該已經看明白了,按照這樣的存儲方式可以節省很多內存,同時查詢效率也比較高。

比如查詢以 jav 開頭的數據,只需要從頭結點 j 開始往下查詢,最後會查詢到 ava 以及 script 這兩個個結點,所以整個查詢路徑所經歷的字符拼起來就是查詢到的結果java+javascript

如果以 b 開頭進行查詢,那第一步就會直接返回,這樣比在 list 中的效率高很多。

但這個圖還不完善,因爲不知道查詢到啥時候算是匹配到了一個之前寫入的字符串。

比如在上圖中怎麼知道 j+ava 是一個我們之前寫入的 java 這個字符呢。

因此我們需要對這種是一個完整字符串的數據打上一個標記:

比如這樣,我們將 ava、script、p、f 這幾個節點都換一個顏色表示。表明查詢到這個字符時就算是匹配到了一個結果。

而查到 s 這個字符顏色不對,代表還需要繼續往下查。

比如輸入關鍵字 js 進行匹配時,當它的查詢路徑走到 s 這裏時判斷到 s 的顏色不對,所以不會把 js 作爲一個匹配結果。而是繼續往下查,發現有兩個子節點 p、f 顏色都正確,於是把查詢的路徑 jspjsf 都作爲一個匹配結果。

而只輸入 j,則會把下面所有有色的字符拼起來作爲結果集合。

這其實就一個典型的字典樹。

具體實現

下面則是具體的代碼實現,其實算法不像是實現一個業務功能這樣好用文字分析;具體還是看源碼多調試就明白了。

談下幾個重點的地方吧:

字典樹的節點實現,其中的 isEnd 相當於圖中的上色。

利用一個 Node[] children 來存放子節點。

爲了可以區分大小寫查詢,所以子節點的長度相當於是 26*2

寫入數據

這裏以一個單測爲例,寫入了三個字符串,那最終形成的數據結構如下:

圖中有與上圖有幾點不同:

  • 每個節點都是一個字符,這樣樹的高度最高爲52。
  • 每個節點的子節點都是長度爲 52 的數組;所以可以利用數組的下標表示他代表的字符值。比如 0 就是大 A,26 則是小 a,以此類推。
  • 有點類似於之前提到的布隆過濾器,可以節省內存。

debug 時也能看出符合上圖的數據結構:

所以真正的寫入步驟如下:

  1. 把字符串拆分爲 char 數組,並判斷大小寫計算它所存放在數組中的位置 index
  2. 將當前節點的子節點數組的 index 處新增一個節點。
  3. 如果是最後一個字符就將新增的節點置爲最後一個節點,也就是上文的改變節點顏色。
  4. 最後將當前節點指向下一個節點方便繼續寫入。


查詢總的來說要麻煩一些,其實就是對樹進行深度遍歷;最終的思想看圖就能明白。

所以在 cim 中進行模糊匹配時就用到了這個結構。

字典樹的源碼在此處:

https://github.com/crossoverJie/cim/blob/master/cim-common/src/main/java/com/crossoverjie/cim/common/data/construct/TrieTree.java

其實利用這個結構還能實現判斷某個前綴的單詞是否在某堆數據裏、某個前綴的單詞出現的次數等。

總結

目前 cim 還在火熱內測中(雖然羣裏只有20幾人),感興趣的朋友可以私聊我拉你入夥☺️

再沒有新的 BUG 產生前會着重把這些功能完成了,不出意外下週更新 cim 的心跳重連等機制。

完整源碼:

https://github.com/crossoverJie/cim

如果這篇對你有所幫助還請不吝轉發。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章