分布式索引設計實驗 in Go

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

作為一個 Go 語言門外漢,這段時間剛剛使用 Go 實現了一個分布式索引系統的模擬實驗,這篇文章就來總結一下實現過程和經驗。

分布式儲存的索引技術是分布式儲存的一個技術重點,為了驗證一種索引的設計,自然要設計一個模擬測試來驗證各項效能指標是否令人滿意。

在實現系統之前,我對 Go 語言的認知水平還很初級,選擇並不熟悉的 Go 語言作為實現語言的原因主要由以下幾條:

  1. Go 語言有比較方便的包管理方案,譬如使用go get命令和第三方的 godep包來實現依賴管理非常方便。在實驗中因為要使用第三方的 B+ Tree 實現,因此 Go語言成為了一個很好的選擇
  2. Go 語言有出色的編譯和執行速度。作為一種編譯執行的語言,既能像指令碼語言那樣獲得這麼好的編譯速度,又可以獲得較好的執行效率,這對於一個要填充較大資料量的模擬實驗來說是一個相當迷人的特性。
  3. Go 語言的文法相對簡潔又不失強大。雖然最初接觸時,感覺 Go 語言的文法比較糾結。但是相比起來要比 C++ 簡潔很多,功能反過來又比 C 更為豐富。帶有記憶體回收的特性使其最終脫穎而出。

下面詳細介紹系統的設計。

Problem Description

在介紹我設計的系統之前,先介紹一下問題以及對應的需求。同時,在這一步還會儘可能地將問題簡化。

假定我們有一個由 $n$ 個儲存節點群組成的分布式儲存系統,每個節點分別儲存了總體資料的一部分。隨後有一段連續的查詢請求,這些查詢請求可能隨機訪問系統中的任何一個節點。如果被請求的節點當中不包含這個資料,那麼它要負責到對應的節點中去尋找資料並返回給用戶端。

一個簡單的雙層索引的設計是:每個節點都有一個 Local Index 和一個 Global Index。在接收到查詢之後,先在 Local Index 當中尋找,尋找失敗之後,再在 Global Index 中尋找可能包含目標資料的節點。此外我們還希望,如果一個查詢多對應的目標資料在整個系統中都不存在,那麼應該儘可能早的發現,從而避免轉寄查詢這個成本較高的操作。所以在雙層索引中,我們希望在尋找 Global Index 的時候,就可以儘可能確定查詢的目標資料是否存在。

一般用 False Positive 這個指標來衡量上述需求。所謂 False Positive,簡單來說可以認為是一個系統中不存在的資料,在 Global Index 當中查詢的時候認為他是存在的。Global Index 最直接的設計就是把每個節點的 Local Index原封不動地放在一起。這種方法可以保證沒有 False Positive,但是卻要佔用較大的空間,必須要進行一定的 Trade Off,使得在空間可以接受的範圍內實現儘可能低的 False Positive 值。

此外還要求查詢 Global Index 的查詢成本要遠少於 Local Index,在這種情況下,我們可以把雙層的索引模型改為總是先尋找 Global Index,決定所在節點之後,再尋找 Local Index。

為了簡化問題,整個測試的系統中儲存的資料看成是靜態。也就是說,實驗的步驟是先將所有的測試資料插入系統,再執行測試查詢。測試過程中,也不考慮為資料建立冗餘備份等問題。

Model

在我設計的系統中,使用的三個重要的模型和資料結構分別是:

  1. Fat Tree
  2. B+ Tree
  3. Bloom Filter

下面分別介紹這幾個模型及其作用。

Fat Tree

Fat Tree 並不是什麼儲存資料的資料結構,而是一種常見的網路拓撲模型。為了計算搜尋請求從一個伺服器轉寄到另一個伺服器的時間消耗,就可以使用 Fat Tree 這種結構。

將 Fat Tree 稱為樹其實有點不準確,他其實更像是一種星型的網路。一個三層的 Fat Tree 結構包含核心層、彙總層和邊緣層三個層次,都有路由器構成。設 Fat Tree 中的每個路由有 $k$ 個連接埠,我們把邊緣層的每個路由的連接埠一半用來串連主機,一半用來連結彙總層。同時把$\frac{k}{2}$ 個邊緣層的路由與 $\frac{k}{2}$ 個彙總層的節點放在一起,構成一個完全二分圖,稱之為一個 Pod。每個彙總層的路由和 $\frac{k}{2}$ 個核心層的節點連結,同一個 Pod 中不同的彙總層路由串連的核心層路由是不重複的。顯而易見,我們需要 $\frac{k^2}{4}$ 個核心層路由,可以串連的主機總數是 $\frac{k^3}{4}$ 。

由以上方式構造出的網路,有一個特點是每個路由的 $k$ 個連接埠都被利用了。整個網路中,任意兩個主機之間通訊,經過的邊數只有三種可能:

  1. 同一個邊緣層路由所串連的主機之間需經過 2 條邊
  2. 同一個 Pod 不同邊緣層路由所串連的主機之間需經過 4 條邊
  3. 不同 Pod 中的主機之間需經過 6 條邊

一個 $k=4$ 的 Fat Tree 的例子如所示:

將主機從左至右編號,給定 $k$ 、通訊發起節點 a 和目標節點 b,用 Go 編寫的計算跳轉次數的函數如下。因在我們的系統中,b 要把尋找結果返回給 a,因此所有的路由次數都乘了二。

1
2
3
4
5
6
7
8
9
10
11
func TransferCost(k, a, b int) int {
hk := k / 2
switch {
case a/hk == b/hk:
return 4
case a/k == b/k:
return 8
default:
return 12
}
}

B+ Tree

關於 B+ Tree 的內容不用贅述了,它是一種常見的索引結構。它用於儲存 $n$ 個元素的空間複雜度是 $O(n)$,插入、尋找和刪除的時間複雜度都是 $O(\log_b n)$,是一種非常有效率的索引方式。

前面說過,之所以選擇 Go 語言來編寫這個實驗,一個重要的原因就在於 Go 方便的依賴管理機制,你可以直接使用託管在 Github 等處的代碼,只需要使用go get命令將代碼抓取過來即可。在這裡,我使用了 cznic/b 這個第三方庫。

1
go get github.com/cznic/b/

在本文中只使用了一個依賴關係,但是對於有多個依賴的項目,我們可能需要一個類似於 Python 的 pip 或者 ruby 的 gem這樣的工具。更近一步,為了隔離不同項目的環境,一個類似 ruby bundle 的工具將會極大地提高生產力。gpm 和 gvp 搭檔使用是一個比較好的解決方案。

我使用 B+ Tree 作為實驗中每個節點的 Local Index,為了計算查詢 B+ Tree 的計算成本,可以充分利用 Go 語言提供的函數式編程的能力,使用閉包獲得上下文環境來統計比較次數。cznic/b這個 B+ Tree 實現允許傳入一個函數作為比較 Key 大小的函數。我使用了下面的結構體定義一個 Node 。

1
2
3
4
5
6
7
8
9
type Node struct {
id int
bloomSize int
hashCount int
cmpCount int // a field to count comparing on this node
bplusTree *b.Tree
bloomFilter []uint64
itemCount int
}

用下面的方法來初始化一個 Node 及其 B+ Tree。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
n := new(Node)
n.bplusTree = b.TreeNew(func(a, b interface{}) int {
na := a.(uint64)
nb := b.(uint64)
n.cmpCount++ // count comparing
switch {
case na > nb:
return 1
case na == nb:
return 0
default:
return -1
}
})

得益於 Go 支援匿名函數以及閉包,我們能夠比較優雅的實現這個功能。

Bloom Filter

在 Global Index 這裡,我選擇了一個非常簡單的解決方案:Bloom Filter。簡單來說, Bloom Filter 就是在插入資料時使用 $k$ 個不同的雜湊函數,把一個 Key 映射到一個整型數組上的不同的位置,並將對應的位置標記為1。在查詢的時候,對請求的鍵使用相同的雜湊函數進行雜湊,檢查對應的 $k$ 個位置是否都為 1。如果是的話,鍵對應的就值很可能存在,否則一定不存在。

為了節省空間的,我們以單個二進位位為單位進行標記,設數組中的所有Int元素共有 $m$ 個位元位,儲存的資料共有 $n$ 個,那麼理論上對 Bloom Filter 查詢的 False Positive 機率的估計公式為:

$$\left(1-e^{-kn/m} \right)^k$$

從上面的公式可以看出,Bloom Filter 雖然有實現簡單、佔用空間小的優點,但是儲存的資料量越大,False Positive 的機率越高,過濾的效果也越差。同時,Bloom Filter 對於刪除元素的操作沒有很方便的處理方法,在刪除時維護 Bloom Filter 的複雜度比較高。

在不考慮刪除元素的情況下, Bloom Filter 還是很好的一個選擇。而且 Go 語言的標準庫中,已經提供了 MD5、SHA1、ADLER32 以及 CRC64 等雜湊演算法的實現,只需 import 進來即可使用,非常方便:

1
2
3
4
5
6
import (
"crypto/md5"
"crypto/sha1"
"hash/adler32"
"hash/crc64"
)

測試資料的產生及檔案讀取

為了測試我們設計的系統的效能,需要產生一些特定分布的測試資料以及對應的查詢資料。兩種比較常用的分布是均勻分布和 Zipf 分布。特別值得一提的是 Zipf 分布,包括英語中單詞的出現頻率在內,很多重要的資料都服從這一分布。因此在搜尋引擎使用的關鍵詞索引系統中就應該特別重視這種分布。

為了簡化問題,這裡採取了事先產生一批兩種分布的測試資料,在測試的時候依次讀出並插入索引的方案。測試資料都是整型數字,並且作為鍵插入到 B+ Tree 中。使用 Python 中的 numpy 庫產生特定分布的隨機資料的方法如下:

1
2
3
4
import numpy as np

np.random.normal(0, 1280000, 100000).astype(int) # 產生 100000 個 0~1280000 之內的均勻分布隨機數
np.random.zipf(2, 100000) # 產生參數 a=2 的 100000 個 Zipf 分布的隨機數

將產生的資料儲存成文字檔,接下來只要在 Go 程式裡讀取出來就好了。作為一個 Python 重度使用者,在這裡我很想使用類似 Generator 那樣的文法,讓函數每次輸出一個檔案中的數字。 Go 語言雖然沒有yield那樣的文法,但是可以通過 channel 和 goroutine 來實現相近的功能。寫出來是像下面這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func iterFile(filepath string) chan uint64 {
ch := make(chan uint64)
go func() {
fi, err := os.Open(filepath)
if err != nil {
panic(err)
}
defer fi.Close()
var i uint64
for {
_, err := fmt.Fscanf(fi, "%d", &i)
if err != nil {
if err == io.EOF {
break
}
panic(err)
}
ch <- i
}
close(ch)
}()
return ch
}

值得注意的是defer fi.Close()這行,defer關鍵字產生的指令會在當前 goroutine結束的時候執行,避免忘記釋放檔案的問題,是一個很優雅的文法。更方便是,我們還可以可以使用for迴圈來不斷從 channel 中取數值。

1
for i := range iterFile("somefile.txt") {    // do somthing ...}

在研究 channel 的時候,我發現儘管在函數中可以同時返回多個值,但 Go 語言中並沒有元組這樣的類型。所以也就不能建立一次傳輸多個值的 channel (除非使用interface{}),這也算關於 Go 語言的一個小細節吧。

進行模擬實驗

為了均衡各個伺服器儲存的資料量,可以先對要插入的鍵進行雜湊處理,再根據雜湊過的值決定存放在哪個節點。這樣可以很好地將 Zipf 這樣密度分布不平衡的資料均勻的分散開。接下來就可以進行模擬實驗了。

對均勻分布和 Zipf 分布的資料進行 100000 次查詢的模擬結果如下:

1
* Testing Uniform Distribution Sparse SetInserting Keys ...Keys Inserted:               235195Testing Point Search ...Average Comparing:           2.18Average OK Comparing:        10.02Average Fail Comparing:      10.90Average Transfer Cost:       2.50False Positive Proportion:   3.92%* Testing Zipf Distribution Sparse SetInserting Keys ...Keys Inserted:               230581Testing Point Search ...Average Comparing:           8.06Average OK Comparing:        9.58Average Fail Comparing:      10.92Average Transfer Cost:       9.78False Positive Proportion:   3.42%

總結

這篇文章總結了我最近實現的一個簡單的分布式索引模擬測試的程式。當前的系統設計其實過於簡單了,譬如沒有考慮到資料的冗餘備份等問題。但是總體來看對於兩種分布,系統的表現還是令人滿意的。

在實現程式的過程中,我對 Go 語言的一些方面有了更多的瞭解。在我看來, Go 語言是一種很有前景的語言,也許在一些場合下仍然無法取代 C,但是相比起來 C++ 似乎不再有競爭力。當然,Go 現在還缺乏一些 GUI 庫、科學計算庫等等,不過我相信隨著時間的流逝它會展現出越來越強的生命力。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.