KeeWiDB的高性能修鍊之路:架構篇

數據也有冷熱之分,你知道嗎?

根據訪問的頻率的高低可將數據分為熱數據和冷數據,訪問頻率高的則為熱數據,低為冷數據。如果熱、冷數據不區分,一併存儲,顯然不科學。將冷數據也存儲在昂貴的內存中,那麼你想,成本得多高呢?

有趣的是,根據我們實際的觀察,目前很多使用 Redis 的業務就是這樣操作的。

得益於高性能以及豐富的數據結構命令,Redis 成為目前最受歡迎的 KV 內存數據庫。但隨着業務數據量的爆炸增長,Redis 的內存消耗也會隨之爆炸。無論客戶是自建服務器還是雲服務器,內存都是一個必須考慮的成本問題,它不僅貴還要持續購買。

此外 Redis 雖然提供了 AOF 和 RDB 兩種方案來實現數據的持久化,但是使用不當可能會對性能造成影響甚至引發丟數據的問題。

好在,隨着科技的發展,持久化硬件的發展速度也在提升,持久內存的出現進一步縮小了與內存的性能差距。或許,合理利用新型持久化技術會成為一個好的成本解決方案

基於這一思路,為解決 Redis 可能帶來的內存成本、容量限制以及持久化等一系列問題,騰訊雲數據庫團隊推出了新一代分佈式KV存儲數據庫 KeeWiDB。本文將詳細介紹KeeWiDB 的架構設計思路、實現路徑及成效。先簡單總結一下 KeeWiDB 的特性:

  • 友好:完全兼容 Redis 協議,原先使用 Redis 的業務無需修改任何代碼便可以遷移到 KeeWiDB 上;
  • 高性能低延遲:通過創新性的分級存儲架構設計,單節點讀寫能力超過 18 萬 QPS,訪問延遲達到毫秒級;
  • 更低的成本:內核自動區分冷熱數據,冷數據存儲在相對低價的 SSD 上;
  • 更大的容量:節點支持 TB 級別的數據存儲,集群支持 PB 級別的數據存儲;
  • 保證了事務的 ACID (原子性 Atomicity、一致性 Consitency、隔離性 Isolation、持久性 Durability)四大特性;

一、整體架構

KeeWiDB 的架構由代理層和服務層兩個部分構成:
代理層:由多個無狀態的Proxy節點組成,主要功能是負責與客戶端進行交互;
服務層:由多個Server節點組成的集群,負責數據的存儲以及在機器發生故障時可以自動進行故障切換。

file

圖:KeeWiDB整體架構圖

代理層

客戶端通過 Proxy 連接來進行訪問,由於 Proxy 內部維護了後端集群的路由信息,所以 Proxy 可以將客戶端的請求轉發到正確的節點進行處理,從而客戶端無需關心集群的路由變化,用戶可以像使用 Redis 單機版一樣來使用 KeeWiDB。

Proxy 的引入,還帶來了諸多優勢:

  • 客戶端直接和 Proxy 進行交互,後端集群在擴縮容場景不會影響客戶端請求
  • Proxy 內部有自己的連接池和後端 KeeWiDB 進行交互,可大大減少 KeeWiDB 上的連接數量,同時有效避免業務短連接場景下反覆建連斷連對內核造成性能的影響
  • 支持讀寫分離,針對讀多寫少的場景,通過添加副本數量可以有效分攤 KeeWiDB 的訪問壓力
  • 支持命令攔截和審計功能,針對高危命令進行攔截和日誌審計,大幅度提高系統的安全性
  • 由於 Proxy 是無狀態的,負載較高場景下可以通過增加 Proxy 數量來緩解壓力,此外我們的 Proxy 支持熱升級功能,後續 Proxy 添加了新功能或者性能優化,存量 KeeWiDB 實例的 Proxy 都可以進行對客戶端無感知的平滑升級

file

圖:Proxy上的功能

服務層

KeeWiDB 的後端採用了集群的架構,這是因為集群具有高可用、可擴展性、分佈式、容錯等優質特性;同時,在具體的實現上參考了 Redis 的集群模式。KeeWiDB 集群同樣由若干個分片構成,而每個分片上又存在若干個節點,由這些節點共同組成一主多從的高可用架構;此外每個分片的主節點負責集群中部分 Slot 的數據,並且可以通過動態修改主節點負責 Slot 區間的形式來實現橫向的擴縮容,為客戶提供了容量可彈性伸縮的能力。

二、Server內部模型

在 Server 內部存在一個集群管理模塊,該模塊通過 Gossip 協議與集群中的其他節點進行通信,獲取集群的最新狀態信息;另外 Server 內部存在多個工作線程,KeeWiDB 會將當前 Server 負責的 Slot 區間按一定的規則劃分給各個工作線程進行處理, 並且每個工作線程都有自己獨立的連接管理器,事務模塊以及存儲引擎等重要組件,線程之間不存在資源共享,做到了進程內部的 Shared-Nothing。正是這種 Shared-Nothing 的體系結構,減少了 KeeWiDB 進程內部線程之間由於競爭資源的等待時間,獲得了良好並行處理能力以及可擴展的性能。

file
圖:Server內部模塊

線程模型

KeeWiDB 的設計目的,就是為了解決 Redis 的痛點問題。所以大容量,高性能以及低延遲是 KeeWiDB 追求的目標。

和數據都存放在內存中的 Redis 不同,KeeWiDB 的數據是存儲在 PMem(Persistent Memory)和相對低價的 SSD 上。在用戶執行讀寫訪問請求期間 KeeWiDB 都有可能會涉及到跟硬盤的交互,所以如果還像 Redis 一樣採用單進程單線程方案的話,單節點的性能肯定會大打折扣。因此,KeeWiDB 採取了單進程多線程的方案,一方面可以更好的利用整機資源來提升單節點的性能,另一方面也能降低運維門檻

多線程方案引入的核心思想是通過提高並行度來提升單節點的吞吐量,但是在處理用戶寫請求期間可能會涉及到不同線程操作同一份共享資源的情況,比如存儲引擎內部為了保證事務的原子性和持久性需要寫 WAL,主從之間進行同步需要寫 Binlog,這些日誌文件在寫入的過程中通常會涉及到持久化操作,相對較慢。雖然我們也採取了一系列的優化措施,例如使用組提交策略來降低持久化的頻率,但是優化效果有限。

同時為了保證線程安全,在這類日誌的寫入期間通常都要進行加鎖,這樣一來,一方面雖然上層可以多線程並行的處理用戶請求,但是到了寫日誌期間卻退化成了串行執行;另一方面,申請和釋放鎖通常會涉及到用戶態和內核態的切換,頻繁的申請釋放操作會給 CPU 帶來額外的開銷,顯然會導致性能問題。

file
圖:線程模型

正是由於進程內不同線程訪問同一份共享資源需要加鎖,而大量的鎖衝突無法將多線程的性能發揮到極致,所以我們將節點內部負責的 Slot 區間進行進一步的拆分,每個工作線程負責特定一組 Slot 子區間的讀寫請求,互不衝突;此外每個工作線程都擁有自己獨立的事務模塊以及存儲引擎等重要組件,不再跨線程共享

通過對共享資源進行線程級別的拆分,各個線程在處理用戶請求時都可以快速的獲得所需要的資源,不發生等待事件,這無論是對單個請求延遲的降低還是多個請求並發的提升,都有巨大的好處;此外由於處理用戶請求所需的資源都在線程內部,KeeWiDB 無需再為了線程安全而上鎖,有效規避了由於頻繁上鎖帶來的額外性能開銷。

引入協程

通過上面的章節介紹,KeeWiDB 通過進程內部 Shared-Nothing 的體系結構減少了線程之間由於競爭共享資源花費的等待時間,提升了進程內部的並發度。此時我們再將視角轉移到線程內部,在業務高峰時期工作線程也需要負責處理大量的客戶端請求,由於每次請求操作都有可能會涉及到和磁盤的交互,此時如果再採用同步 IO 的形式和磁盤進行交互的話,由於一個客戶端請求執行的 I/O 操作就會阻塞當前線程,此時後面所有的客戶端請求需要排隊等待處理, 顯然並發度會大打折扣。

這時候也許有讀者會提出按照 KeeWiDB 目前這套進程內部 Shared-Nothing 的體系結構,線程之間不存在共享資源競爭了,是不是可以通過增加線程數來緩解這個問題?想法不錯,但是大量的線程引入可能會帶來另外一些問題:

  • 開啟過多的線程會耗費大量的系統資源, 包括內存;
  • 線程的上下文切換涉及到用戶空間和內核空間的切換, 大量線程的上下文切換同樣會給 CPU 帶來額外的開銷;

為了讓單個線程的性能能夠發揮到極致,不把時間浪費在等待磁盤 I/O 上,KeeWiDB 首先考慮的是採取異步 I/O 的方案(應用層觸發 I/O 操作後立即返回,線程可以繼續處理其他事件,而當 I/O 操作已經完成的時候會得到 I/O 完成的通知)。

很明顯,使用異步 I/O 來編寫程序性能會遠遠高於同步 I/O,但是異步 I/O 的缺點是編程模型複雜。我們常規的編碼方式是自上而下的,但是異步 I/O 編程模型大多採用異步回調的方式。

隨着項目工程的複雜度增加,由於採用異步回調編寫的代碼和常規編碼思維相悖,尤其是回調嵌套多層的時候,不僅開發維護成本指數級上升,出錯的幾率以及排查問題的難度也大幅度增加。

正是由於異步 I/O 編程模型有上面提到的種種缺點, 我們經過一系列調研工作之後,決定引入協程來解決我們的痛點, 下面先來看一下cppreference中對協程的描述:

A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller and the data that is required to resume execution is stored separately from the stack. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks).

from//en.cppreference.com/w/cpp/language/coroutines

協程實際上就是一個可以掛起(suspend)恢復(resume)的函數,我們可以暫停協程的執行,去做其他事情,然後在適當的時候恢復到之前暫停的位置繼續執行接下來的邏輯。總而言之,協程可以讓我們使用同步方式編寫異步代碼

file
圖:協程切換示意圖

KeeWiDB 為每一個客戶端連接都創建了一個協程,以上圖為例,在工作線程內服務三個客戶端連接,就創建三個協程。在[T0,T1)階段協程0正在執行邏輯代碼,但是到了T1時刻協程0發現需要執行磁盤 I/O 操作獲取數據,於是讓出執行權並且等待 I/O 操作完成,此時協程2獲取到執行權,並且在[T1,T2)時間段內執行邏輯代碼,到了T2時刻協程2讓出執行權,並且此時協程0的 I/O 事件正好完成了,於是執行權又回到協程0手中繼續執行。

可以看得出來,通過引入協程,我們有效解決了由於同步 I/O 操作導致線程阻塞的問題,使線程儘可能的繁忙起來,提高了線程內的並發;另外由於協程切換隻涉及基本的 CPU 上下文切換,並且完全在用戶空間進行,而線程切換涉及特權模式切換,需要在內核空間完成,所以協程切換比線程切換開銷更小,性能更優

三、數據存儲

在文章開頭有提到在持久內存出現後,進一步縮小了與內存的性能差距,持久內存是一種新的存儲技術,它結合了 DRAM 的性能和位元組尋址能力以及像 SSD 等傳統存儲設備的可持久化特性,正是這些特性使得持久內存非常有前景,並且也非常適合用於數據庫系統。

file
圖:Dram和PMem以及SSD的性能比較

通過上圖可以看到,PMem(Persistent Memory)相對於 DRAM 有着更大的容量,但是相對於 SSD 有着更大的帶寬和更低的讀寫延遲,正是因為如此,它非常適合存儲引擎中的大容量Cache和高性能 WAL 日誌。

前面有提到過日誌文件在寫入的過程中涉及到的持久化操作有可能會成為整個系統的瓶頸,我們通過將 WAL 存放在 PMem 上,日誌持久化操作耗時大幅降低,提升了服務整體的性能;此外由於 PMem 的讀寫速度比 SSD 要快1~2個數量級,在故障恢復期間,回放 WAL 的時間也大幅度的縮短,整個系統的可用性得到了大幅度的提升。

考慮到 KeeWiDB 作為高性能低延遲的數據庫,我們不僅需要做到平均延遲低,更要做到長尾延遲可控。

雖然在涉及到文件操作的場景下,利用 Page Cache 技術能夠大幅提升文件的讀寫速度,但是由於 Page Cache 默認由操作系統調度分配,存在一定的不確定性(內核總是積極地將所有空閑內存都用作 Page Cache 和 Buffer Cache,當內存不夠用時就會使用 LRU 等算法淘汰緩存頁, 此時有可能造成文件讀寫操作有時延抖動),在一些極端場景下可能會直接影響客戶實例的P99,P100。所以** KeeWiDB 採用了 Direct I/O的方式來繞過操作系統的 Page Cache 並自行維護一份應用層數據的 Cache,讓磁盤的 IO 更加可控**。

使用 Page cache 能夠大大加速文件的讀寫速度,那什麼是頁面緩存(Page Cache)呢?

In computing, a page cache, sometimes also called disk cache, is a transparent cache for the pages originating from a secondary storage device such as a hard disk drive (HDD) or a solid-state drive (SSD). The operating system keeps a page cache in otherwise unused portions of the main memory (RAM), resulting in quicker access to the contents of cached pages and overall performance improvements. A page cache is implemented in kernels with the paging memory management, and is mostly transparent to applications.

from//en.wikipedia.org/wiki/Page_cache#cite_note-1

和大多數磁盤數據庫一樣,KeeWiDB 將 Page 作為存儲引擎磁盤管理的最小單位,將數據文件內部劃分成若干個 Page,每個 Page 的大小為 4K,用於存儲用戶數據和一些我們存儲引擎內部的元信息。從大容量低成本的角度出發,KeeWiDB 將數據文件存放在 SSD 上。

file
圖:數據頁的升溫和落冷

此外,得益於 PMem 接近於 DRAM 的讀寫速度以及支持位元組尋址的能力,KeeWiDB在 PMem 上實現了存儲引擎的 Cache 模塊,在服務運行期間存放業務熱數據的數據頁會被加載到 PMem 上,KeeWiDB 在處理用戶請求期間不再直接操作 SSD 上的數據頁,而是操作讀寫延遲更低的 PMem,使得 KeeWiDB 的性能以及吞吐量得到了進一步的提升;

同時為了能夠合理高效的利用 PMem 上的空間,KeeWiDB 內部實現了高效的 LRU 淘汰算法,並且通過異步刷髒的方式,將 PMem 中長時間沒有訪問的數據頁寫回到 SSD 上的數據文件中。

四、主從同步

文章開頭的架構圖有提到 KeeWiDB 集群由多個分片構成,每個分片內部有多個節點,這些節點共同組成一主多從的高可用架構。和 Redis 類似,用戶的請求會根據 Key 被路由到對應分片的主節點,主節點執行完後再將請求轉化為 Binlog Record 寫入本地的日誌文件並轉發給從節點,從節點通過應用日誌文件完成數據的複製。

在 Redis 的 Replication 實現中,從節點每接收一個請求都立即執行,然後再繼續處理下一個請求,如此往複。依賴其全內存的實現,單個請求的執行耗時非常短,從節點的回放相當於是單連接的 pipeline 寫入,其回放速度足以跟上主節點的執行速度。但這種方式卻不適合 KeeWiDB 這樣的存儲型數據庫,主要原因如下:

  • 存儲型數據庫的請求執行過程中涉及到磁盤 IO,單個請求的執行耗時本身就比較長;
  • 主節點同時服務多個客戶端連接,不同連接的請求並發執行,發揮了協程異步 IO 的優勢,節點整體 QPS 有保障;
  • 主從同步只有一個連接,由於從庫順序回放請求,無法並發,回放的 QPS 遠遠跟不上主節點處理用戶請求的 QPS;

為了提升從節點回放的速度,避免在主庫高負載寫入場景下,出現從庫追不上主庫的問題,KeeWiDB 的 Replication 機製做了以下兩點改進:

  • 在從節點增加 RelayLog 作為中繼,將從節點的命令接收和回放兩個過程拆開,避免回放過程拖慢命令的接收速度;
  • 在主節點記錄 Binlog 的時候增加邏輯時鐘信息,回放的時候根據邏輯時鐘確定依賴關係,將互相之間沒有依賴的命令一起放進回放的協程池,並發完成這批命令的回放,提升從節點整體的回放 QPS;

file
圖:從庫並發回放

所謂的邏輯時鐘,對應到 KeeWiDB 的具體實現里,就是我們在每一條 BinLog record 中添加了 seqnumparent 兩個字段:

  • seqnum 是主節點事務 commit 的序列號,每次有新的事務 commit,當前 seqnum 賦給當前事務,全局 seqnum 自增1;
  • parent 由主節點在每個事務開始執行前的 prepare 階段獲取,記錄此時已經 commit 的最大 seqnum,記為 max,說明當前事務是在 max 對應事務 commit 之後才開始執行,二者在主節點端有邏輯上的先後關係;

從節點回放 RelayLog 中的 Binlog Record 時,我們只需要簡單地將它的 parent 和 seqnum 看作一個區間,簡記為(P,S),如果它的(P,S)區間和當前正在回放的其它Record 的(P,S)區間有交集,說明他們在主節點端 Prepare 階段沒有衝突,可以把這條 Record 放進去一塊並發地回放,反之,則這條 Record 需要阻塞等待,等待當前正在回放的這批 Binlog Record 全部結束後再繼續。

通過在 Binlog 中添加 seqnum 和 parent 兩個字段,我們在保證數據正確性的前提下實現了從庫的並發回放,確保了主庫在高負載寫入場景下,從庫依舊可以輕鬆的追上主庫,為我們整個系統的高可用提供了保障。

五、總結

本篇文章先從整體架構介紹了 KeeWiDB 的各個組件,然後深入 Server 內部分析了在線程模型選擇時的一些思考以及面臨的挑戰,最後介紹了存儲引擎層面的數據文件以及相關日誌在不同存儲介質上的分佈情況,以及 KeeWiDB 是如何解決從庫回放 Binlog 低效的問題。通過本文,相信不少讀者對 KeeWiDB 又有了進一步的了解。那麼,在接下來的文章中我們還會深入到 KeeWiDB 自研存儲引擎內部,向讀者介紹 KeeWiDB 在存儲引擎層面如何實現高效的數據存儲和索引,敬請期待。

目前,KeeWiDB 正在公測階段(鏈接://cloud.tencent.com/product/keewidb ),現已在內外部已經接下了不少業務,其中不乏有一些超大規模以及百萬 QPS 級的業務,線上服務均穩定運行中。

後台回復「KeeWiDB」,試試看,有驚喜。

關於作者
吳顯堅,騰訊雲數據庫高級工程師。負責過開源項目Pika的核心研發工作,對數據庫、分佈式存儲有一定了解,現從事騰訊雲Redis內核以及KeeWiDB的研發工作。