坑系列 — 時間和空間的平衡

吳YH堅發表於2019-02-13

這是系列的最後一彈了,這篇文章非常長,希望你能看完,要是看完有很酣暢的感覺就最好了。這一篇的主要來說說架構中時間和空間的平衡吧,這裡的時間指代比較廣,可能是開發時間,但大部分指的是執行時間,也就是演算法的時間複雜度了,而空間就是演算法中經常說的空間換時間中的空間了,一個好的系統,設計出來必然是各種時間複雜度空間複雜度平衡出來的結果,架構設計的過程,並不僅僅是模組的堆疊,在走到岔路口的時候,更多的是時間和空間平衡之後選的一個技術方案,這一篇,我會用一個搜尋提示服務設計的實際例子,來說一下架構設計的過程中,時間和空間的各種矛盾,怎麼分析,怎麼選擇,最後淌過這些時空的坑

0. 搜尋提示是什麼

搜尋提示是搜尋引擎的重要組成部分,雖然一般是作為一個單獨的服務來對外提供服務,但在一個搜尋系統中,搜尋提示是非常重要的組成部分,我還沒看到哪個比較成熟的搜尋引擎沒有搜尋提示功能的。

首先,我們看看搜尋提示是什麼,大家肯定都用過,就是下面這些個東西

1. 搜尋提示的場景和目的

搜尋提示一般情況下是為了提高使用者的搜尋體驗,更快的選擇合適的搜尋詞,提高檢索的效率的,但是因為搜尋框的流量實在是太大了,所以搜尋提示也扮演著廣告變現的責任,網際網路嘛,有流量就有變現,比如下面這個圖,明顯就是一個廣告啦。

2. 初步技術選型

2.1 搜尋提示的需求

要實現一個搜尋提示系統,首先需要確定的是需要提示出來什麼東西,有兩種提示方式。

  • 一種是提示出其他的搜尋詞,這也是大部分的搜尋提示所做的,提示出其他使用者的類似搜尋詞。
  • 還有一種是提示出現有的結果集有的東西,這種實現方式比較少見,比如一個生鮮類的電商網站,商品數量比較少,那麼沒必要去提示一些使用者的搜尋詞,直接把商品名稱(比如蘋果,桃子,橘子)提示出來就行了,這種提示方式我們這裡不討論,因為實現起來比較簡單。

    2.2 技術棧

    既然知道需求了,那麼開始選擇技術棧了。

  • 首先,既然有其他使用者的搜尋詞,那麼必然有一個離線的資料收集和處理的系統來完成其他使用者的搜尋日誌處理,生成需要的資料。
  • 其次,需要一個單獨的API服務,來提供搜尋提示的功能,輸入為不完整的搜尋詞,輸出為根據這個搜尋詞提示出來的其他搜尋詞,檢索方式的話,一般都是使用字首匹配的方式了,這個大家都比較認可。
  • 最後,需要前端有個js程式碼來實時呼叫後臺的API,這個不在我們的討論範圍內。

整個系統的結構圖應該是下面這個樣子,離線模組處理完日誌資料以後,推送到API模組中,給前面的前端提供服務。

好了,框框設計好了。也就是架構圖完成了哦,真是牛逼的架構啊,三個框,離線,線上,前端全齊了。

接下來,我們來看看線上API部分的設計吧,我們先假設離線資料都已經準備好了,就是一堆使用者的搜尋詞,如何快速的字首匹配這些詞就成了API設計部分的關鍵了,有這麼幾種實現方式。

  • 粗暴的短平快方式

    用redis儲存所有資訊,每條資訊類似
    {KEY:北 VALUE:北京,北京大學,北大,北京遇上西雅圖}
    {KEY:北京 VALUE:北京,北京大學,北京遇上西雅圖}….
    每次來了請求的話,直接查詢redis給出結果返回,就是佔點空間,最好還需要一臺單獨的伺服器。

  • 優雅點的實現方式

    字首匹配嘛,最先想到的資料結構就是Trie樹了,所以所有的Key可以用Trie樹來儲存和檢索,速度也挺快的,而且空間佔用比較少。

  • 複雜點的實現方式

    既然是檢索嘛,就直接用搜尋引擎的倒排索引技術來實現嘛,速度也夠,而且資料量也可以支援得很大。

3. 時間與空間的平衡一

實際工程應用中,這三種實現方式我都見過,而且有些實現方式是把這三種結合起來使用了,後面的文章我會說到。

具體使用哪一種需要看你的實際場景,這三種實現方式差不多正好對應三種場景。

  • 如果你是個小型的電商或者論壇之類的,每天的搜尋量也不是很大,而且在可見的未來也不會變得很大,而且也不差錢,那麼直接第一種,說不定一天就能擼出來,速度還不錯,但是這種有一些缺陷,首先,value值不能太複雜,影響效率,所以可擴充套件性不是很強,而現在的電商搜尋提示中往往還有很多其他資訊需要儲存,redis作為快取伺服器提供高併發服務的前提是資料量比較小,最好在2K以內,這樣的話用redis就有點不合適了。這種方案是個存空間的選擇了,用空間換取了檢索時間和開發時間,多虧有redis這種神器。

  • 如果是個大型的搜尋引擎或者電商,搜尋日誌已經是巨量了,而且搜尋詞多種多樣,那麼第三種倒排索引技術為基礎的實現方式可能是更好的選擇,而且既然是大搜,技術都是現成的,索引分片,叢集都是現成的,直接改了上就是。這種方式用長期的開發時間和檢索速度上稍微的降低換取了記憶體空間,如果從頭開始做的話,時間成本比較高。

  • 大部分時候,第二種實現方式是大家都採用的方式,首先沒有第一種那麼粗暴,並且能完成方案一的所以功能,單機就能達到較好的效果,也不用索引分片,也不用叢集,所以工程複雜性不是很高,也能在較短的時間內實現出來。其次第二種方案可擴充套件性較強,後面掛個倒排檔案就可以變成簡化版的第三方案。這種方式用演算法換取了記憶體空間,用O(n)替代了O(1),換取了記憶體空間,也是標準的計算機領域的時間換空間了。

通過一番分析下來,決定使用第二種實現方式,就是Trie樹的方式了,好了,API的基本選型確定了,那麼開始設計,準備寫程式碼吧。

4. Trie樹的多種結構

既然確定了Trie樹的實現方式,那麼首先要了解一下Trie樹吧,以及Trie樹的各種結構,看看具體用哪個吧。

4.1 基本Trie樹

Trie樹又叫字典樹,本質上是一個多叉樹,每一個節點就是一個多叉的結構,如果是英文的匹配,那麼是一個26叉樹,每個節點一個26長度的陣列,每個節點的資料結構如下

type TrieNode struct{
    flag       bool     //是否是一個完整的詞
    hasNext bool    //是否還有後繼字元
    nexts    [26]*TrieNode
}複製程式碼

Trie樹畫出來就是下面這個樣子。

從畫出來的圖,很直觀的可以看出來這棵樹的構造方法和遍歷方法,如果是純英文的話,每個節點都有一個26長度的陣列,來了一個字元,通過字元的編號直接就可以遍歷到下一個節點,查詢的時候複雜度就是O(K),K表示查詢的字串長度,這種資料結構簡單明瞭,實現起來也很容易。

4.2 優化後的Trie樹

基本Trie樹的資料結構有個問題,就是記憶體使用得太多了,如果是中文查詢的話,需要把所有的中國字都編號到這個陣列中,記憶體就爆了,於是有一種優化方法,就是把陣列變成變長的,這種Trie樹的節點資料結構變成下面的樣子了,節點查詢變成一個順序查詢或者二分查詢了。

type TrieNode struct{
    flag       bool     //是否是一個完整的詞
    hasNext bool    //是否還有後繼字元
    nexts    []*TrieNode //變成變長陣列了
}複製程式碼

4.3 雙陣列Trie樹

所謂雙陣列Trie樹,當然就是通過兩個陣列來實現這棵樹了,這兩個陣列分別叫base陣列check陣列,一個是基礎陣列,一個是檢查陣列。

Trie樹實際上是一種有限狀態機,通過狀態轉移矩陣在各個狀態之間跳轉,雙陣列Trie樹極大的節省了空間,大致就是下面這個樣子,我後面會有一篇專門的文章來說Trie樹實現的,這裡就不詳細展開了,實在等不及的可以自己先搜尋一下相關資料看看雙陣列Trie樹吧。

5. 時間與空間的平衡二

OK,三種Trie樹的實現方式都說了,現在要開始抉擇了,我們先看看這三種資料結構的時間和空間。

第一種空間佔用大,特別是中文的情況,檢索的時間效率為O(n),其中n為每次請求的字串的長度,這種實現方式基本上屬於新人練手的水平,純粹為了瞭解這個資料結構或者大學生做做課程設計,工程化的可能性幾乎為0。

第二種空間基本不浪費,但檢索的時間效率如果按照二分進行每個節點的查詢的話,每個節點的查詢時間變成了O(lg(n)),整體的查詢時間變成K*O(log(n)),同樣插入效率也變低了。

第三種情況空間不浪費,時間效率也為O(n)。

初看,肯定選第三種了,但是!!第三種實現方式有個致命的缺陷,就是無法向下遍歷(具體可以自己看看雙陣列的實現方式),也就是說我輸入北京,找不到北京大學,北京愛上西雅圖,因為它已經不是一個樹型結構了,無法向下遍歷了。所以如果不對第三種結構進行改造的話,是無法滿足我們的功能的。

要改造,最簡單的辦法就是在每個詞後面掛一個連結串列,表示這個詞的後繼詞都是什麼,像下圖這樣。

如果按上圖那麼來的話,需要輔助的空間來儲存後繼詞,那麼問題又來了,又是一次時間和空間的抉擇了,是選擇K*O(log(n))的第二種方案,然後後繼詞實時遍歷樹來獲取(又要耗費一定的時間),還是選擇選擇第三種方案,用空間換取時間呢?

好,既然這樣,我們來仔細算算這個賬,我們以每個節點都存一箇中文來算,雖然常用的漢字大概2500個,但其中最常用的才500左右。

先看第二種方案,那麼我們大概估算出,每個節點的平均陣列長度大概600(實際上除了第一層的節點,後面的節點陣列長度完全達不到這個量級,用600屬於極限估算了),600的二分查詢大約需要7到8次,取個平均值4次,那麼每次查詢的時間就是4*K(K是字串的長度),如果我們定好最長的提示詞不超過8個字(太長也沒意義),那麼首先這個樹的高度就是8了,如果50萬的詞量的話,使用多少記憶體大概能算出來,然後每次遍歷下級節點的時間就是600^(8-K)(如果陣列的每個元素都有值),我去,這麼大,嚇死了,好,我們即便假設每個節點的陣列長度平均為60,要遍歷完也要60^(8-K),也嚇尿了,所以實時遍歷所有子節點的方式不可取,而且後繼詞最多也就提示出10個,遍歷出這麼多詞還要排序,遍歷全部節點實在是沒有必要,所以,第二種方案要麼放棄,要麼也要改造,如何改造呢?

因為詞基本上都是離線算好的,稍微把節點的資料結構優化一下,在節點中加一個欄位,表示哪個子節點有需要的資料(排序前10的詞),這樣往下遍歷的時候就直接遍歷相應的下標就可以了,就能把60^(8-K)這種遍歷減少到幾十次,從而找到10個提示詞,我們把這個結構叫二次優化的Trie樹

這一輪的時間和空間的比拼,第三個方案感覺就要勝利了,但第二個方案的優化版貌似也還能接受,一個耗費空間,查詢速度快,一個節省空間,查詢速度慢點。

這裡多說一下,其實上面只是預估的辦法比較搓,這麼寫是為了說預估的技能,最直接的就是拿著日誌統計一遍,得到一堆不超過8位長度的搜尋詞,同時也能演算法兩個方案的記憶體使用規模和大概的查詢效率,這樣的預估辦法最準確,但是在大部分時候我們並沒有這麼多資料,所以只能做一些基本的預估。

6. 離線資料處理

好了,我們先把檢索部分放一放,來看看離線資料處理部分吧。我們先要確定一下什麼東西需要在離線部分算好,什麼東西需要線上處理?

  • 首先,日誌的清洗肯定是離線部分了,我們先要把沒有搜尋結果的詞去掉,然後去掉太長的詞(假定超過8的都不要),然後保留有一定熱度的詞(比如每天搜尋量超過10次的詞),等等一些規則以後,假如剩下了50萬的詞,那這50萬就是我們的基礎資料了。
  • 其次,Trie樹的構建是離線構建好還是實時往服務推送由服務端去構建呢?
  • 還有,排序的時候是離線給每個搜尋詞打個分,然後實時排序呢?還是離線把序都排好,服務端直接使用結果呢?

    7. 時間與空間的平衡三

    雖然是離線處理,但一樣有時間和空間的選擇。

我們先來看構建部分,Trie樹的構建是離線構建好還是實時往服務推送由服務端去構建,首先我們需要確定的是這個搜尋提示服務需不需要實時更新,一般情況下,搜尋提示沒有那麼強的實時性要求,一般一天或者兩天更新一次體驗也不會太差,所以做實時更新的搜尋提示,要不就是你實在是太蛋疼了,要不就是遇到了一個特別讓人蛋疼的產品經理(臥槽,黑了一下產品經理啊)。所以我們使用離線構建的方式構建好兩個陣列和輔助的資料結構,都存在磁碟上,服務端啟動的時候讀取檔案就行了,這是用離線時間換取的服務端的時間,是很划得來的。

再來看看排序的部分,很明顯,排序離線做好也比較合適,排序的位置基本不會有太大的變化,但是如果排序離線做好的話,那麼輔助的資料結構就會比較大了,因為每個字首後面跟著的10個詞都要排好序放在輔助結構中,但如果我們只是把每個詞打個分(比如就按熱度給個分),然後用第二個方案(優化的Trie樹)的儲存方式,線上的時候去排序,那麼輔助結構就會小很多,兩種情況的結構大概就是下面這樣的區別。

左邊的是全排序好了的,直接使用,雙陣列Trie樹+輔助結構方式;右邊的是隻是打了分的,優化的Trie樹,遍歷出結果以後實時排序的。

離線排序的空間佔用大,即便優化一下,把詞都放一個地方單獨存著,輔助結構中只儲存詞的編號,一樣也比較佔地方,但是查詢速度快啊。線上排序的方式不怎麼佔地方,就是每個節點多了一個分數的欄位,需要實時排序一下,雖然是實時排序,但個數就10個,不管是快排還是堆排,都很快的,所以時間效率也慢不到哪去。

8. 整體的時空平衡

綜合衡量一看,我個人覺得兩種方式都能接受,具體選哪一個就仁者見仁了。

  • 如果搜尋詞的量比較穩定,不會有太大的變化,那麼使用雙陣列Trie樹+輔助資料結構+離線構建Trie樹+離線排序的方式更合適。
  • 如果搜尋詞雖然現在是50萬,但很可能會增加得比較多,或者像下圖一樣,搜尋提示的頁面還會承載很多其他的資料的話,那麼使用二次優化的Trie樹+離線構建Trie樹+離線打分+實時排序的實現方式更合適,因為能節省更多的記憶體給後續擴充詞語用或者給其他資料用。
  • 還有如果對速度要求苛刻,那麼就第一種,如果沒那麼苛刻,那就第二種

架構設計沒有好壞,只有合適不合適。

9. 總結

上面分析了這麼一大堆,淌過三個的時間與空間的坑,終於基本確定了技術方案了,這其實也是系統架構設計中經常會要遇到的選擇了,架構師們把這些選擇做完以後,可以開始細分模組設計開發了,所以,一個小小的系統就這麼多選擇,各種空間和時間的平衡,你說架構師哪那麼好當?呵呵,你以為就畫完這篇文章的第一圖就架構結束了啊。

這裡只是用搜尋提示作為一個例子來說明系統設計的時候需要時時刻刻關注時間空間這兩個因素的平衡,現在很多人設計系統的時候基本上不太關注時間,因為高配的伺服器,幾十上百GB的記憶體隨便用,所以大多數都把設計往空間上去靠,用更多的空間來換取執行效率,這本身並沒有什麼問題,誰不希望更快啊,但是有時候預估一下,有可能雖然犧牲了一點時間效率,但是換來了不少的空間,這樣的系統在資料量變大時有更多的可擴充套件空間,我覺得是非常值得的交換。

再有,對資料結構和演算法的瞭解以及預估算能力其實是平衡時間和空間的重要技能,也是架構設計中避坑的基本技能,所以有公司的面試題會出現請你估算一下黃河出海口的面積這類估算題,因為預估算能力也是重要的架構技能吧。

10. 更深入一下

上面只是這個系統的一小部分,搜尋提示需要做的遠不止如此,想想下面幾個場景,如果是你,你要如何設計呢?如何平衡時間和空間呢?歡迎討論哈:)

  • 需要拼音支援,就像這樣

  • 需要拼音首字母支援

  • 某些搜尋提示需要更加詳細的資訊

  • 需要對每個使用者的搜尋歷史進行搜尋提示【這個比較難點】

    11. 後記

    這個系列算是結束了,現在我正在做一些推薦廣告相關的工作,後續也會分享一些相關的東西給大家,搜尋部分也不會停,後面還有分詞,相關搜尋,分散式的東西會依次出來,歡迎關注哈。


如果你覺得不錯,歡迎轉發給更多人看到,也歡迎關注我的公眾號,主要聊聊搜尋,推薦,廣告技術,還有瞎扯。。文章會在這裡首先發出來:)掃描或者搜尋微訊號XJJ267或者搜尋西加加語言就行

相關文章