選擇 TiKV 來搭建 Key-Value Service

Jay Chung
Dcard Tech Blog
Published in
10 min readSep 28, 2023

前言

Key-Value 資料庫是非關聯式資料庫的一種,主要的特點是使用 Key-Value 來儲存數據,相較於關聯式資料庫,雖然功能簡單,但也因此有較高的效能。大家比較常聽到的像是 Redis 和 Memcached,都屬於 Key-Value 資料庫的一種。

在 Dcard,我們有許多情境中會使用 Key-Value 的方式來儲存數據,並依據實際需求來部署不同的資料庫。其中我們最常使用的是 Redis,而流量最大的則是 ScyllaDB。

然而,隨著 Dcard 功能的逐漸增加,我們需要管理的資料庫數量也越來越多。這不僅提高了管理的複雜度,在未來如果想要遷移到其他資料庫也蠻麻煩的。因此,我們抽象出一層 KV Service,藉此固定 protocol,讓後端工程師能更專注在開發上。

Dcard office

找個既有服務來套用 KV Service

去年我們有發佈一篇文章-Dcard Backend Team 如何讓工程師能更專注在列表排序與組合的演算法?,說明 Dcard Backend Team 是如何讓 ML 與後端工程師能夠專注在製作列表,並且快速執行 A/B Testing 實驗來確認成效,不用太過煩惱其他問題。有別於傳統的資料庫分頁,我們做了一個內部的列表系統,以 Key-Value 的形式,把列表都用簡單的 Linked List 來表示。

Fig1. Linked lists insertion flow

在文章的最後有提到為了能支撐更多的寫入量,我們將單台 PostgreSQL 改成多台可水平擴展的方案,而這個水平擴展方案其實就是 ScyllaDB,搭配上 GCP Local SSD 可以承受每秒更新超過 3600 個列表,每秒新增超過 20000 個分頁。雖然效能很好,但我們在使用過程中發現一些問題。

GCP Local SSD 存在丟失資料的風險

GCP Local SSD 是一種暫時性硬碟,透過物理方式掛載到虛擬機上,提供卓越的性能、極高的每秒輸入/輸出操作數 (IOPS),以及極低的延遲。缺點是犧牲了 availability、durability 和 flexibility,在下列幾種情況下資料可能會不見:

  1. 虛擬機被停用
  2. 設定錯誤導致虛擬機無法連到 Local SSD
  3. 虛擬機發生主機錯誤且 Compute Engine 在特定時間內無法重新連上

目前是將 ScyllaDB 的節點放在不同的 GCP Zone 上,並透過 ScyllaDB 本身的 replication 機制來提高可用性。即便如此,在全部的節點都下線的情況下還是會丟失資料。

成本考量

因為成本考量沒有購買 ScyllaDB License,至多只容許開出五個節點,這在不久的將來可能會是一大限制。

維運難點

因為 Local SSD 的易揮發性,使得備份與復原額外重要。ScyllaDB 的備份方式是將 SST files 輸出到 blob storage(例如 GCS)上,作為特定時間點的快照。當需要恢復資料時,再輸入 SST files。但因為資料量龐大,復原得花上不少時間。再來是 scylla-operator 在我們導入時尚未成熟,所以 fork 回來做了許多調整,這使得升版的流程變得複雜。

為了解決上述問題,我們預計替換掉 ScyllaDB 並改用 GCP Persistent Disk,不管虛擬機發生什麼問題硬碟都能留著。此外,我們也可以利用 GCP 的功能對硬碟進行快照,這將大幅縮短資料恢復的時間,並降低資料遺失的風險。雖說如此,移除了 Local SSD 效能勢必會有所下降。為了解決這個問題,我們將需要進行水平擴展,增加更多節點來分攤流量。

換言之我們的需求是要確保資料安全性、沒有水平擴展上限、好維運以及能夠承載列表系統既有的流量,也因為使用情境單純,不會用到複雜的查詢語言,僅僅需要 Get、Put、Delete、BatchGet、BatchPut 等 API。

適合的資料庫

在釐清需求後,我們開始到 CNCF Landscape 尋找適合的解決方案,考慮到 RAM 比 Disk 昂貴,這邊過濾掉以 RAM 為儲存裝置的資料庫,剩下的是 Apache Cassandra,但考慮到 GC 會導致 Stop the World,最終得到的結果是 TiKV。

下面將說明我們如何用 TiKV 搭配 GCP Persistent Disk 來打造內部用的 KV service。

TiKV

TiKV 如其名,是一個 Key-Value Store,提供了高擴展性、低延遲以及容易使用的特性,透過 Raft 來做 replication,並將資料儲存在 RocksDB 中。

RocksDB 於 2012 年從 Google 的 LevelDB 中分叉出來,做了許多調整與新增功能,其中最主要的是優化使用 SSD 時的效能以及多線程的應用,這些改進使得 RocksDB 更適合於處理大規模、高效能的數據儲存需求。

Fig2. TiKV architecture

效能調校

在 TiKV 中,線程池主要由 gRPC、Scheduler、UnifyReadPool、Raftstore、StoreWriter、Apply、RocksDB 以及其他一些佔用 CPU 不多的定時任務與檢測元件組成。因此,TiKV 可以調整的參數非常多,如果要一個一個嘗試恐怕會佔用太多時間,所以我們必須先了解 TiKV 在寫入、讀取時的流程。

Fig3. TiKV read/write flow

上圖是 TiKV 官方提供的系統流程圖,每次 Async Write 可以簡單地理解成兩部分:

Raft Replication

Raftstore 負責的是把寫入請求轉成 raft log 寫到 Disk,接著 leader 會把這些 log 送到其他 follower。這邊 Raft 的 max-replicas 設定為 3,也就是每個 raft group 有一個 leader,兩個 follower,寫入時會確保多數(1 leader + 1 follower)成功才返回。

這邊優化的方法是啟用額外的 StoreWriter 線程池來負責 raft log 的寫入,可以有效降低寫入延遲,其中差別最大的是 propose wait 的部分。

RocksDB Persistence

Apply 線程接收到 raft log 後會將它寫到 RocksDB 的 memtable,在 memtable 達到一定條件後會轉成 immutable memtable,接著組成 SST (sorted string table) 寫入硬碟,這時候資料就是真的落地了。除了 Flush 以外,RocksDB 還會在背景做 Compaction,將多個有重疊的 SST file 重新排列組合成新的 SST file,過程中也會把舊版本和 tombstone 資料移除。

Compaction 其實是非常消耗 CPU & I/O 的,尤其 Persistent Disk 的 latency 比較高且不穩定,在執行期間常常會影響到前景的 write & read request。這邊我們的做法是降低 job 的數量以及設定 rate limiter,也就是在 compaction pending bytes 和效能中取得一個平衡,盡量彌平 background job 造成的影響。

透過 Dual-Write 進行資料遷移

我們在列表系統上採取 Dual-Write 的策略進行資料轉移,好處是邏輯簡單,不需要太多額外的開發。

Fig4. Dual-Write strategy

寫入的部分透過 async 模式進行,主要目的是確保 TiKV 能承受相同的流量,另外為了避免 go routine 暴增,在 secondary 寫入後進行一個毫秒等級的等待。

func Put(data any) error {
if dualWrite {
go func() {
if err := secondary.Put(data); err != nil {
// logging
}
}()
time.Sleep(delay)
}

return primary.Put(data)
}

讀取則是需要驗證內容正確性,在寫入一段時間後會打開檢查機制對比 primary 和 secondary 的資料是否一樣,如果不一樣會送 metric 到 Prometheus,隨後可以透過 Grafana 了解情況。

func Get(key string) (any, error) {
data, err := primary.Get(key)
if err != nil {
return nil, err
}

if readCheck {
go func() {
if data2, err := secondary.Get(key); err != nil {
// logging
} else if mismatched(data, data2) {
// send metric
}
}()
}

return data, nil
}

如此一來便可以在不影響生產環境的情況下進行資料遷移。

結論

在目前的配置下,KV Service 的 P95 latency 大概如下:

Fig5. KV request latency (p95)

Put 相比 ScyllaDB (with Local SSD) 高出許多,主要是 Persistent Disk 和 Local SSD 的 throughput 和 latency 有一定的差距,但這也是預期中的就是了。

總結來說,抽象化一層 KV Service 增加了我們的彈性,讓工程師可以專注在開發上,哪天有更適合的資料庫也方便資料遷移。而 TiKV 提供高擴展性、高可用性以及可接受的延遲,搭配 Persistent Disk 可以降低維運的負擔,像是升級 cluster 或是 GCP 不明的錯誤不用擔心資料消失。

接下來呢?

除了探索不同的資料庫以外,我們也會持續改進這個系統,可能的方向有這三個:

Key-Value 分離

RocksDB 有提供 Key-Value 分離的功能,讓 Compaction 時不用重寫 value,大大減少 I/O,缺點是空間放大,但儲存空間理論上會越來越便宜。

離峰時段 Compaction

另一個 Key-Value 資料庫 kvrocks 支援排程功能,能在離峰時間執行 Compaction,降低其對正常業務的影響。

不同類型的硬碟

Discord 有提出 Super-Disk 的概念,把 Local SSD 做 RAID0 再將其和 Persistent Disk 做 RAID1,享有硬碟效能的同時也確保資料安全性。

對了,如果你有興趣一起做更多有趣挑戰,不斷整並改善現有的架構,提供順暢的服務給千萬使用者的話,團隊正在尋找以下職位:

  • Senior Backend Developer — 你將負責根據不同的功能搭配適合的架構、DB 與第三方服務來完成大流量的功能。除了業務需求之外,你也會跟據維運情況,進行架構優化並評估導入新的技術、執行 data migration 與解決技術債。這個職位將有機會碰到 Dcard 的社群、電商產品或廣告產品。
  • Architect — 這個角色將根據系統預期的發展,事先進行架構優化並評估導入新的技術等。分享各種技術知識,協助 Design Review 幫助團隊提升整體的技術水平。

如果想加入我們的話,歡迎隨時跟我們聊聊!

--

--