資料也有冷熱之分,你知道嗎?
根據存取的頻率的高低可將資料分為熱資料和冷資料,存取頻率高的則為熱資料,低為冷資料。如果熱、冷資料不區分,一併儲存,顯然不科學。將冷資料也儲存在昂貴的記憶體中,那麼你想,成本得多高呢?
有趣的是,根據我們實際的觀察,目前很多使用 Redis 的業務就是這樣操作的。
得益於高效能以及豐富的資料結構命令,Redis 成為目前最受歡迎的 KV 記憶體資料庫。但隨著業務資料量的爆炸增長,Redis 的記憶體消耗也會隨之爆炸。無論客戶是自建伺服器還是雲伺服器,記憶體都是一個必須考慮的成本問題,它不僅貴還要持續購買。
此外 Redis 雖然提供了 AOF 和 RDB 兩種方案來實現資料的持久化,但是使用不當可能會對效能造成影響甚至引發丟資料的問題。
好在,隨著科技的發展,持久化硬體的發展速度也在提升,持久記憶體的出現進一步縮小了與記憶體的效能差距。或許,合理利用新型持久化技術會成為一個好的成本解決方案。
基於這一思路,為解決 Redis 可能帶來的記憶體成本、容量限制以及持久化等一系列問題,騰訊雲資料庫團隊推出了新一代分散式KV儲存資料庫 KeeWiDB。本文將詳細介紹KeeWiDB 的架構設計思路、實現路徑及成效。先簡單總結一下 KeeWiDB 的特性:
KeeWiDB 的架構由代理層和服務層兩個部分構成:
代理層:由多個無狀態的Proxy節點組成,主要功能是負責與使用者端進行互動;
服務層:由多個Server節點組成的叢集,負責資料的儲存以及在機器發生故障時可以自動進行故障切換。
圖:KeeWiDB整體架構圖
代理層
使用者端通過 Proxy 連線來進行存取,由於 Proxy 內部維護了後端叢集的路由資訊,所以 Proxy 可以將使用者端的請求轉發到正確的節點進行處理,從而使用者端無需關心叢集的路由變化,使用者可以像使用 Redis 單機版一樣來使用 KeeWiDB。
Proxy 的引入,還帶來了諸多優勢:
圖:Proxy上的功能
服務層
KeeWiDB 的後端採用了叢集的架構,這是因為叢集具有高可用、可延伸性、分散式、容錯等優質特性;同時,在具體的實現上參考了 Redis 的叢集模式。KeeWiDB 叢集同樣由若干個分片構成,而每個分片上又存在若干個節點,由這些節點共同組成一主多從的高可用架構;此外每個分片的主節點負責叢集中部分 Slot 的資料,並且可以通過動態修改主節點負責 Slot 區間的形式來實現橫向的擴縮容,為客戶提供了容量可彈性伸縮的能力。
在 Server 內部存在一個叢集管理模組,該模組通過 Gossip 協定與叢集中的其他節點進行通訊,獲取叢集的最新狀態資訊;另外 Server 內部存在多個工作執行緒,KeeWiDB 會將當前 Server 負責的 Slot 區間按一定的規則劃分給各個工作執行緒進行處理, 並且每個工作執行緒都有自己獨立的連線管理器,事務模組以及儲存引擎等重要元件,執行緒之間不存在資源共用,做到了程序內部的 Shared-Nothing。正是這種 Shared-Nothing 的體系結構,減少了 KeeWiDB 程序內部執行緒之間由於競爭資源的等待時間,獲得了良好並行處理能力以及可延伸的效能。
圖:Server內部模組
執行緒模型
KeeWiDB 的設計目的,就是為了解決 Redis 的痛點問題。所以大容量,高效能以及低延遲是 KeeWiDB 追求的目標。
和資料都存放在記憶體中的 Redis 不同,KeeWiDB 的資料是儲存在 PMem(Persistent Memory)和相對低價的 SSD 上。在使用者執行讀寫存取請求期間 KeeWiDB 都有可能會涉及到跟硬碟的互動,所以如果還像 Redis 一樣採用單程序單執行緒方案的話,單節點的效能肯定會大打折扣。因此,KeeWiDB 採取了單程序多執行緒的方案,一方面可以更好的利用整機資源來提升單節點的效能,另一方面也能降低運維門檻。
多執行緒方案引入的核心思想是通過提高並行度來提升單節點的吞吐量,但是在處理使用者寫請求期間可能會涉及到不同執行緒操作同一份共用資源的情況,比如儲存引擎內部為了保證事務的原子性和永續性需要寫 WAL,主從之間進行同步需要寫 Binlog,這些紀錄檔檔案在寫入的過程中通常會涉及到持久化操作,相對較慢。雖然我們也採取了一系列的優化措施,例如使用組提交策略來降低持久化的頻率,但是優化效果有限。
同時為了保證執行緒安全,在這類紀錄檔的寫入期間通常都要進行加鎖,這樣一來,一方面雖然上層可以多執行緒並行的處理使用者請求,但是到了寫紀錄檔期間卻退化成了序列執行;另一方面,申請和釋放鎖通常會涉及到使用者態和核心態的切換,頻繁的申請釋放操作會給 CPU 帶來額外的開銷,顯然會導致效能問題。
圖:執行緒模型
正是由於程序內不同執行緒存取同一份共用資源需要加鎖,而大量的鎖衝突無法將多執行緒的效能發揮到極致,所以我們將節點內部負責的 Slot 區間進行進一步的拆分,每個工作執行緒負責特定一組 Slot 子區間的讀寫請求,互不衝突;此外每個工作執行緒都擁有自己獨立的事務模組以及儲存引擎等重要元件,不再跨執行緒共用。
通過對共用資源進行執行緒級別的拆分,各個執行緒在處理使用者請求時都可以快速的獲得所需要的資源,不發生等待事件,這無論是對單個請求延遲的降低還是多個請求並行的提升,都有巨大的好處;此外由於處理使用者請求所需的資源都線上程內部,KeeWiDB 無需再為了執行緒安全而上鎖,有效規避了由於頻繁上鎖帶來的額外效能開銷。
引入協程
通過上面的章節介紹,KeeWiDB 通過程序內部 Shared-Nothing 的體系結構減少了執行緒之間由於競爭共用資源花費的等待時間,提升了程序內部的並行度。此時我們再將視角轉移到執行緒內部,在業務高峰時期工作執行緒也需要負責處理大量的使用者端請求,由於每次請求操作都有可能會涉及到和磁碟的互動,此時如果再採用同步 IO 的形式和磁碟進行互動的話,由於一個使用者端請求執行的 I/O 操作就會阻塞當前執行緒,此時後面所有的使用者端請求需要排隊等待處理, 顯然並行度會大打折扣。
這時候也許有讀者會提出按照 KeeWiDB 目前這套程序內部 Shared-Nothing 的體系結構,執行緒之間不存在共用資源競爭了,是不是可以通過增加執行緒數來緩解這個問題?想法不錯,但是大量的執行緒引入可能會帶來另外一些問題:
為了讓單個執行緒的效能能夠發揮到極致,不把時間浪費在等待磁碟 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:https://en.cppreference.com/w/cpp/language/coroutines
協程實際上就是一個可以掛起(suspend)和恢復(resume)的函數,我們可以暫停協程的執行,去做其他事情,然後在適當的時候恢復到之前暫停的位置繼續執行接下來的邏輯。總而言之,協程可以讓我們使用同步方式編寫非同步程式碼。
圖:協程切換示意圖
KeeWiDB 為每一個使用者端連線都建立了一個協程,以上圖為例,在工作執行緒內服務三個使用者端連線,就建立三個協程。在[T0,T1)階段協程0正在執行邏輯程式碼,但是到了T1時刻協程0發現需要執行磁碟 I/O 操作獲取資料,於是讓出執行權並且等待 I/O 操作完成,此時協程2獲取到執行權,並且在[T1,T2)時間段內執行邏輯程式碼,到了T2時刻協程2讓出執行權,並且此時協程0的 I/O 事件正好完成了,於是執行權又回到協程0手中繼續執行。
可以看得出來,通過引入協程,我們有效解決了由於同步 I/O 操作導致執行緒阻塞的問題,使執行緒儘可能的繁忙起來,提高了執行緒內的並行;另外由於協程切換隻涉及基本的 CPU 上下文切換,並且完全在使用者空間進行,而執行緒切換涉及特權模式切換,需要在核心空間完成,所以協程切換比執行緒切換開銷更小,效能更優。
在文章開頭有提到在持久記憶體出現後,進一步縮小了與記憶體的效能差距,持久記憶體是一種新的儲存技術,它結合了 DRAM 的效能和位元組定址能力以及像 SSD 等傳統儲存裝置的可持久化特性,正是這些特性使得持久記憶體非常有前景,並且也非常適合用於資料庫系統。
圖: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:https://en.wikipedia.org/wiki/Page_cache
和大多數磁碟資料庫一樣,KeeWiDB 將 Page 作為儲存引擎磁碟管理的最小單位,將資料檔案內部劃分成若干個 Page,每個 Page 的大小為 4K,用於儲存使用者資料和一些我們儲存引擎內部的元資訊。從大容量低成本的角度出發,KeeWiDB 將資料檔案存放在 SSD 上。
圖:資料頁的升溫和落冷
此外,得益於 PMem 接近於 DRAM 的讀寫速度以及支援位元組定址的能力,KeeWiDB在 PMem 上實現了儲存引擎的 Cache 模組,在服務執行期間存放業務熱資料的資料頁會被載入到 PMem 上,KeeWiDB 在處理使用者請求期間不再直接操作 SSD 上的資料頁,而是操作讀寫延遲更低的 PMem,使得 KeeWiDB 的效能以及吞吐量得到了進一步的提升;
同時為了能夠合理高效的利用 PMem 上的空間,KeeWiDB 內部實現了高效的 LRU 淘汰演演算法,並且通過非同步刷髒的方式,將 PMem 中長時間沒有存取的資料頁寫回到 SSD 上的資料檔案中。
文章開頭的架構圖有提到 KeeWiDB 叢集由多個分片構成,每個分片內部有多個節點,這些節點共同組成一主多從的高可用架構。和 Redis 類似,使用者的請求會根據 Key 被路由到對應分片的主節點,主節點執行完後再將請求轉化為 Binlog Record 寫入原生的紀錄檔檔案並轉發給從節點,從節點通過應用紀錄檔檔案完成資料的複製。
在 Redis 的 Replication 實現中,從節點每接收一個請求都立即執行,然後再繼續處理下一個請求,如此往復。依賴其全記憶體的實現,單個請求的執行耗時非常短,從節點的回放相當於是單連線的 pipeline 寫入,其回放速度足以跟上主節點的執行速度。但這種方式卻不適合 KeeWiDB 這樣的儲存型資料庫,主要原因如下:
為了提升從節點回放的速度,避免在主庫高負載寫入場景下,出現從庫追不上主庫的問題,KeeWiDB 的 Replication 機制做了以下兩點改進:
圖:從庫並行回放
所謂的邏輯時鐘,對應到 KeeWiDB 的具體實現裡,就是我們在每一條 BinLog record 中新增了 seqnum 和 parent 兩個欄位:
從節點回放 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 正在公測階段(連結:https://cloud.tencent.com/product/keewidb ),現已在內外部已經接下了不少業務,其中不乏有一些超大規模以及百萬 QPS 級的業務,線上服務均穩定執行中。
後臺回覆「KeeWiDB」,試試看,有驚喜。
關於作者
吳顯堅,騰訊雲資料庫高階工程師。負責過開源專案Pika的核心研發工作,對資料庫、分散式儲存有一定了解,現從事騰訊雲Redis核心以及KeeWiDB的研發工作。