使用 Rust 構建分散式 Key-Value Store

PingCAP發表於2017-11-20

作者:唐劉

引子

構建一個分散式 Key-Value Store 並不是一件容易的事情,我們需要考慮很多的問題,首先就是我們的系統到底需要提供什麼樣的功能,譬如:

  • 一致性:我們是否需要保證整個系統的線性一致性,還是能容忍短時間的資料不一致,只支援最終一致性。

  • 穩定性:我們能否保證系統 7 x 24 小時穩定執行。系統的可用性是 4 個 9,還有 5 個 9?如果出現了機器損壞等災難情況,系統能否做的自動恢復。

  • 擴充套件性:當資料持續增多,能否通過新增機器就自動做到資料再次平衡,並且不影響外部服務。

  • 分散式事務:是否需要提供分散式事務支援,事務隔離等級需要支援到什麼程度。

上面的問題在系統設計之初,就需要考慮好,作為整個系統的設計目標。為了實現這些特性,我們就需要考慮到底採用哪一種實現方案,取捨各個方面的利弊等。

後面,我將以我們開發的分散式 Key-Value TiKV 作為實際例子,來說明下我們是如何取捨並實現的。

TiKV

TiKV 是一個分散式 Key-Value store,它使用 Rust 開發,採用 Raft 一致性協議保證資料的強一致性,以及穩定性,同時通過 Raft 的 Configuration Change 機制實現了系統的可擴充套件性。

TiKV 提供了基本的 KV API 支援,也就是通常的 Get,Set,Delete,Scan 這樣的 API。TiKV 也提供了支援 ACID 事務的 Transaction API,我們可以使用 Begin 開啟一個事務,在事務裡面對 Key 進行操作,最後再用 Commit 提交一個事務,TiKV 支援 SI 以及 SSI 事務隔離級別,用來滿足使用者的不同業務場景。

Rust

在規劃好 TiKV 的特性之後,我們就要開始進行 TiKV 的開發。這時候,我們面臨的第一個問題就是採用什麼樣的語言進行開發。當時,擺在我們眼前的有幾個選擇:

  • Go,Go 是我們團隊最擅長的一門語言,而且 Go 提供的 goroutine,channel 這些機制,天生的適合大規模分散式系統的開發,但靈活方便的同時也有一些甜蜜的負擔,首先就是 GC,雖然現在 Go 的 GC 越來越完善,但總歸會有短暫的卡頓,另外 goroutine 的排程也會有切換開銷,這些都可能會造成請求的延遲增高。

  • Java,現在世面上面有太多基於 Java 做的分散式系統了,但 Java 一樣有 GC 等開銷問題,同時我們團隊在 Java 上面沒有任何開發經驗,所以沒有采用。

  • C++,C++ 可以認為是開發高效能系統的代名詞,但我們團隊沒有特別多的同學能熟練掌握 C++,所以開發大型 C++ 專案並不是一件非常容易的事情。雖然使用現代 C++ 的程式設計方式能大量減少 data race,dangling pointer 等風險,我們仍然可能犯錯。

當我們排除了上面幾種主流語言之後,我們發現,為了開發 TiKV,我們需要這門語言具有如下特性:

  • 靜態語言,這樣才能最大限度的保證執行效能。

  • 無 GC,完全手動控制記憶體。

  • Memory safe,儘量避免 dangling pointer,memory leak 等問題。

  • Thread safe,不會遇到 data race 等問題。

  • 包管理,我們可以非常方便的使用第三方庫。

  • 高效的 C 繫結,因為我們還可能使用一些 C library,所以跟 C 互動不能有開銷。

綜上,我們決定使用 Rust,Rust 是一門系統程式語言,它提供了我們上面想要的語言特性,但選擇 Rust 對我們來說也是很有風險的,主要有兩點:

  1. 我們團隊沒有任何 Rust 開發經驗,全部都需要花時間學習 Rust,而偏偏 Rust 有一個非常陡峭的學習曲線。

  2. 基礎網路庫的缺失,雖然那個時候 Rust 已經出了 1.0,但我們發現很多基礎庫都沒有,譬如在網路庫上面只有 mio,沒有好用的 RPC 框架,HTTP 也不成熟。

但我們還是決定使用 Rust,對於第一點,我們團隊花了將近一個月的時間來學習 Rust,跟 Rust 編譯器作鬥爭,而對於第二點,我們就完全開始自己寫。

幸運的,當我們越過 Rust 那段陣痛期之後,發現用 Rust 開發 TiKV 異常的高效,這也就是為啥我們能在短時間開發出 TiKV 並在生產環境中上線的原因。

一致性協議

對於分散式系統來說,CAP 是一個不得不考慮的問題,因為 P 也就是 Partition Tolerance 是一定存在的,所以我們就要考慮到底是選擇 C - Consistency 還是 A - Availability。

我們在設計 TiKV 的時候就決定 - 完全保證資料安全性,所以自然就會選擇 C,但其實我們並沒有完全放棄 A,因為多數時候,畢竟斷網,機器停電不會特別頻繁,我們只需要保證 HA - High Availability,也就是 4 個 9 或者 5 個 9 的可用性就可以了。

既然選擇了 C,我們下一個就考慮的是選用哪一種分散式一致性演算法,現在流行的無非就是 Paxos 或者 Raft,而 Raft 因為簡單,容易理解,以及有很多現成的開源庫可以參考,自然就成了我們的首要選擇。

在 Raft 的實現上,我們直接參考的 etcd 的 Raft。etcd 已經被大量的公司在生產環境中使用,所以它的 Raft 庫質量是很有保障的。雖然 etcd 是用 Go 實現的,但它的 Raft library 是類似 C 的實現,所以非常便於我們用 Rust 直接翻譯。在翻譯的過程中,我們也給 etcd 的 Raft fix 了一些 bug,新增了一些功能,讓其變得更加健壯和易用。

現在 Raft 的程式碼仍然在 TiKV 工程裡面,但我們很快會將獨立出去,變成獨立的 library,這樣大家就能在自己的 Rust 專案中使用 Raft 了。

使用 Raft 不光能保證資料的一致性,也可以藉助 Raft 的 Configuration Change 機制實現系統的水平擴充套件,這個我們會在後面的文章中詳細的說明。

儲存引擎

選擇了分散式一致性協議,下一個就要考慮資料儲存的問題了。在 TiKV 裡面,我們會儲存 Raft log,然後也會將 Raft log 裡面實際的客戶請求應用到狀態機裡面。

首先來看狀態機,因為它會存放使用者的實際資料,而這些資料完全可能是隨機的 key - value,為了高效的處理隨機的資料插入,自然我們就考慮使用現在通用的 LSM Tree 模型。而在這種模型下,RocksDB 可以認為是現階段最優的一個選擇。

RocksDB 是 Facebook 團隊在 LevelDB 的基礎上面做的高效能 Key-Value Storage,它提供了很多配置選項,能讓大家根據不同的硬體環境去調優。這裡有一個梗,說的是因為 RocksDB 配置太多,以至於連 RocksDB team 的同學都不清楚所有配置的意義。

關於我們在 TiKV 中如何使用,優化 RocksDB,以及給 RocksDB 新增功能,fix bug 這些,我們會在後面文章中詳細說明。

而對於 Raft Log,因為任意 Log 的 index 是完全單調遞增的,譬如 Log 1,那麼下一個 Log 一定是 Log 2,所以 Log 的插入可以認為是順序插入。這種的,最通常的做法就是自己寫一個 Segment File,但現在我們仍然使用的是 RocksDB,因為 RocksDB 對於順序寫入也有非常高的效能,也能滿足我們的需求。但我們不排除後面使用自己的引擎。

因為 RocksDB 提供了 C API,所以可以直接在 Rust 裡面使用,大家也可以在自己的 Rust 專案裡面通過 rust-rocksdb 這個庫來使用 RocksDB。

分散式事務

要支援分散式事務,首先要解決的就是分散式系統時間的問題,也就是我們用什麼來標識不同事務的順序。通常有幾種做法:

  • TrueTime,TrueTime 是 Google Spanner 使用的方式,不過它需要硬體 GPS + 原子鐘支援,而且 Spanner 並沒有在論文裡面詳細說明硬體環境是如何搭建的,外面要自己實現難度比較大。

  • HLC,HLC 是一種混合邏輯時鐘,它使用 Physical Time 和 Logical Clock 來確定事件的先後順序,HLC 已經在一些應用中使用,但 HLC 依賴 NTP,如果 NTP 精度誤差比較大,很可能會影響 commit wait time。

  • TSO,TSO 是一個全域性授時器,它直接使用一個單點服務來分配時間。TSO 的方式很簡單,但會有單點故障問題,單點也可能會有效能問題。

TiKV 採用了 TSO 的方式進行全域性授時,主要是為了簡單。至於單點故障問題,我們通過 Raft 做到了自動 fallover 處理。而對於單點效能問題,TiKV 主要針對的是 PB 以及 PB 以下級別的中小規模叢集,所以在效能上面只要能保證每秒百萬級別的時間分配就可以了,而網路延遲上面,TiKV 並沒有全球跨 IDC 的需求,在單 IDC 或者同城 IDC 情況下,網路速度都很快,即使是異地 IDC,也因為有專線不會有太大的延遲。

解決了時間問題,下一個問題就是我們採用何種的分散式事務演算法,最通常的就是使用 2 PC,但通常的 2 PC 演算法在一些極端情況下面會有問題,所以業界要不通過 Paxos,要不就是使用 3 PC 等演算法。在這裡,TiKV 參考 Percolator,使用了另一種增強版的 2 PC 演算法。

這裡先簡單介紹下 Percolator 的分散式事務演算法,Percolator 使用了樂觀鎖,也就是會先快取事務要修改的資料,然後在 Commit 提交的時候,對要更改的資料進行加鎖處理,然後再更新。採用樂觀鎖的好處在於對於很多場景能提高整個系統的併發處理能力,但在衝突嚴重的情況下反而沒有悲觀鎖高效。

對於要修改的一行資料,Percolator 會有三個欄位與之對應,Lock,Write 和 Data:

  • Lock,就是要修改資料的實際 lock,在一個 Percolator 事務裡面,有一個 primary key,還有其它 secondary keys, 只有 primary key 先加鎖成功,我們才會再去嘗試加鎖後續的 secondary keys。

  • Write,儲存的是資料實際提交寫入的 commit timestamp,當一個事務提交成功之後,我們就會將對應的修改行的 commit timestamp 寫入到 Write 上面。

  • Data,儲存實際行的資料。

當事務開始的時候,我們會首先得到一個 start timestamp,然後再去獲取要修改行的資料,在 Get 的時候,如果這行資料上面已經有 Lock 了,那麼就可能終止當前事務,或者嘗試清理 Lock。

當我們要提交事務的時候,先得到 commit timestamp,會有兩個階段:

  1. Prewrite:先嚐試給 primary key 加鎖,然後嘗試給 second keys 加鎖。如果對應 key 上面已經有 Lock,或者在 start timestamp 之後,Write 上面已經有新的寫入,Prewrite 就會失敗,我們就會終止這次事務。在加鎖的時候,我們也會順帶將資料寫入到 Data 上面。

  2. Commit:當所有涉及的資料都加鎖成功之後,我們就可以提交 primay key,這時候會先判斷之前加的 Lock 是否還在,如果還在,則刪掉 Lock,將 commit timestamp 寫入到 Write。當 primary key 提交成功之後,我們就可以非同步提交 second keys,我們不用在乎 primary keys 是否能提交成功,即使失敗了,也有機制能保證資料被正常提交。

在 TiKV 裡面,事務的實現主要包括兩塊,一個是整合在 TiDB 中的 tikv client,而另一個則是在 TiKV 中的 storage mod 裡面,後面我們會詳細的介紹。

RPC 框架

RPC 應該是分散式系統裡面常用的一種網路互動方式,但實現一個簡單易用並且高效的 RPC 框架並不是一件容易的事情,幸運的是,現在有很多可以供我們進行選擇。

TiKV 從最開始設計的時候,就希望使用 gRPC,但 Rust 當時並沒有能在生產環境中可用的 gRPC 實現,我們只能先基於 mio 自己做了一個 RPC 框架,但隨著業務的複雜,這套 RPC 框架開始不能滿足需求,於是我們決定,直接使用 Rust 封裝 Google 官方的 C gRPC,這樣就有了 grpc-rs

這裡先說一下為什麼我們決定使用 gRPC,主要有如下原因:

  • gRPC 應用廣泛,很多知名的開源專案都使用了,譬如 Kubernetes,etcd 等。

  • gRPC 有多種語言支援,我們只要定義好協議,其他語言都能直接對接。

  • gRPC 有豐富的介面,譬如支援 unary,client streaming,server streaming 以及 duplex streaming。

  • gRPC 使用 protocol buffer,能高效的處理訊息的編解碼操作。

  • gRPC 基於 HTTP/2,一些 HTTP/2 的特性,譬如 duplexing,flow control 等。

最開始開發 rust gRPC 的時候,我們先準備嘗試基於一個 rust 的版本來開發,但無奈遇到了太多的 panic,果斷放棄,於是就將目光放到了 Google gRPC 官方的庫上面。Google gRPC 庫提供了多種語言支援,譬如 C++,C#,Python,這些語言都是基於一個核心的 C gRPC 來做的,所以我們自然選擇在 Rust 裡面直接使用 C gRPC。

因為 Google 的 C gRPC 是一個非同步模型,為了簡化在 rust 裡面非同步程式碼編寫的難度,我們使用 rust Future 庫將其重新包裝,提供了 Future API,這樣就能按照 Future 的方式簡單使用了。

關於 gRPC 的詳細介紹以及 rust gRPC 的設計還有使用,我們會在後面的文章中詳細介紹。

監控

很難想象一個沒有監控的分散式系統是如何能穩定執行的。如果我們只有一臺機器,可能時不時看下這臺機器上面的服務還在不在,CPU 有沒有問題這些可能就夠了,但如果我們有成百上千臺機器,那麼勢必要依賴監控了。

TiKV 使用的是 Prometheus,一個非常強大的監控系統。Prometheus 主要有如下特性:

  • 基於時序的多維資料模型,對於一個 metric,我們可以用多種 tag 進行多維區分。

  • 自定義的報警機制。

  • 豐富的資料型別,提供了 Counter,Guage,Histogram 還有 Summary 支援。

  • 強大的查詢語言支援。

  • 提供 pull 和 push 兩種模式支援。

  • 支援服務的動態發現和靜態配置。

  • 能跟 Grafana 深度整合。

因為 Prometheus 並沒有 Rust 的客戶端,於是我們開發了 rust-prometheus。Rust Prometheus 在設計上面參考了 Go Prometehus 的 API,但我們只支援了 最常用的 Counter,Guage 和 Histogram,並沒有實現 Summary。

後面,我們會詳細介紹 Prometheus 的使用,以及不同的資料型別的使用場景等。

測試

要做好一個分散式的 Key-Value Store,測試是非常重要的一環。 只有經過了最嚴格的測試,我們才能有信心去保證整個系統是可以穩定執行的。

從最開始開發 TiKV 的時候,我們就將測試擺在了最重要的位置,除了常規的 unit test,我們還做了更多,譬如:

  • Stability test,我們專門寫了一個 stability test,隨機的干擾整個系統,同時執行我們的測試程式,看結果的正確性。

  • Jepsen,我們使用 Jepsen 來驗證 TiKV 的線性一致性。

  • Namazu,我們使用 Namazu 來干擾檔案系統以及 TiKV 執行緒排程。

  • Failpoint,我們在 TiKV 很多關鍵邏輯上面注入了 fail point,然後在外面去觸發這些 fail,在驗證即使出現了這些異常情況,資料仍然是正確的。

上面僅僅是我們的一些測試案例,當程式碼 merge 到 master 之後,我們的 CI 系統在構建好版本之後,就會觸發所有的 test 執行,只有當所有的 test 都完全跑過,我們才會放出最新的版本。

在 Rust 這邊,我們根據 FreeBSD 的 Failpoint 開發了 fail-rs,並已經在 TiKV 的 Raft 中注入了很多 fail,後面還會在更多地方注入。我們也會基於 Rust 開發更多的 test 工具,用來測試整個系統。

小結

上面僅僅列出了我們用 Rust 開發 TiKV 的過程中,一些核心模組的設計思路。這篇文章只是一個簡單的介紹,後面我們會針對每一個模組詳細的進行說明。還有一些功能我們現在是沒有做的,譬如 open tracing,這些後面都會慢慢開始完善。

我們的目標是通過 TiKV,在分散式系統領域,提供一套 Rust 解決方案,形成一個 Rust ecosystem。這個目標很遠大,歡迎任何感興趣的同學加入。

相關文章