基于持久内存的 单机上亿(128B)QPS -- 持久化 k/v 存储引擎
文章目錄
- 性能數據
- 設計背景
- 設計架構
- Hash 索引結構 及 PMEM空間管理形態
- 基本API 及 實現
- API
- 初始化流程
- 寫流程
- 讀流程
- 刪除流程
- PMEM Allocator設計
- 主要組件
- 空間分配流程
- 空間釋放
- 圖數據庫 on KVDK 性能
性能數據
這個kv 存儲引擎是持久化的存儲引擎,存儲介質是PMEM,也就是intel 的傲騰系列持久化內存。
先來看一組這個存儲引擎的性能數據:
以下測試均為單 numa下的性能數據,整個單機需要乘2
readrandom read:
read ops 73660400, write ops 0writerandom write:
read ops 0, write ops 48483400read50% write 50%:
read ops 62263100, write ops 4447500
可以看到在128B 下的讀 整個單機能到1.4億,寫也接近1億,讀寫混合場景 的讀仍然能保持一億 qps的情況下寫能接近千萬;最重要的是這個性能是極為穩定的(波峰波谷抖動5%以內),意味著長尾是可控的。
這個數據是在 4 * 128G pmem 上跑的, 硬件本身的性能量級大概是 寫帶寬能到 7.5G/s,讀帶寬在512B下能到11.4G/s,在4K 下能到20.4G/s。
為了更直觀得對比性能,將最為通用的rocksdb 引擎 用最優的參數放在pmem上直接跑(128B disable wal):
- 寫性能 上限也就400w/s,且跑一段時間之后一定會write-stall(后臺compaction消耗了太多的帶寬)
- 讀性能 上限也就300w/s
感興趣的同學可以私信要參數。
綜合來看 在PMEM 存儲介質上的持久化引擎 使用rocksdb 是完全無法發揮硬件本身的性能,這個kv引擎單純性能數據來看,同樣提供持久化存儲能力的情況下,不論是讀還是寫 都超過rocksdb 峰值吞吐10x 以上,且長尾優于rocksdb 接近2個量級。
rocksdb on pmem的 瓶頸主要是在:
- rocksdb 的運行需要走內核協議棧(xfs-dax/ext4-dax),這一部分開銷相比于直接通過 pmdk走 libpmem 驅動 消耗了更多的cpu。
- rocksdb 為了維護本身的全序能力而引入的后臺線程 compaction 在數據流極速增加的情況下消耗了太多的cpu以及io 資源。
項目地址:https://github.com/pmem/kvdk
設計背景
隨著高性能存儲硬件的極速發展,傳統的存儲架構逐漸無法發揮新硬件的性能,從而降低了整體的TCO 收益。
對于PMEM這樣的接近內存性能的持久化存儲,其必然有著相比于普通nvme-ssd 來說更高的成本,所以如何在成本上升的情況下保持較高的TCO,那傳統引擎的架構則需要在PMEM上重新調整。
同時,對于各個存儲項目來說 選擇 rocksdb 作為自己的 單機k/v 存儲引擎 也是無奈之舉:
- rocksdb 擁有完善且活躍的社區來持續推動
- rocksdb 的易用性和可擴展性 能夠支持 調整不同的workload
但是對于當前高速發展的一些存儲方向來說,rocksdb的部分功能確有一些冗余:
- 非全序需求的存儲場景:像是圖數據庫 以及 支持redis協議的持久化分布式存儲中的hset/hmset等主流命令 其實都不需要全序能力。
- 為sata-ssd設計的 lsm-tree 存儲引擎 將隨機寫變成順序寫 來提升寫性能,這個場景在PMEM 上顯然不存在(底層沒有block粒度的GC,且讀寫之間全雙工,互不影響),順序寫和隨機寫性能對PMEM來說基本一樣。
所以,我們和Intel 一起共建了 on PMEM 的工業級持久化 hash 結構的 kv引擎,來提升應用在PMEM 上的TCO收益。
設計架構
Hash 索引結構 及 PMEM空間管理形態
雖然PMEM 能夠支持64B 粒度的存取,但是如果想要hash 索引持久化(防止內存放不下的情況)則最后上層用戶的一次讀寫必然會出現對磁盤的多次更新,這對性能來說是一個非常大的損失。
所以設計上,主體架構還是讓整個Hash 索引放在內存中,內存會保存實際的value on pmem 的偏移。因為pmem 是插在DIMM 插槽上,距離cpu 足夠近,所以從DRAM 讀取value 所在的偏移之后 cpu 再去PMEM 上加載實際的數據,延時和訪存是一個量級。
同時,索引如果全放在內存,如果需要rehash,那整個索引的性能和復雜度都會較高,所以為了設計架構的簡潔,在初始化的時候直接分配好索引對應的內存結構,實際寫入生成新的索引時再具體分配對應的存儲空間(當然,也支持直接分配好對應的索引空間,這樣寫入的時候性能會更好一些)。
先看看整體的設計架構,我們再來描述一下設計細節:
DRAM中:
- 預先分配好的Hash索引部分,按照slot粒度進行劃分。每個slot 中包含一個或者多個bucket,整個內存中會創建2^27 個bucket。默認每個slot 中放置一個bucket,也就是會有2^27 個slot。具體每個slot 最多存放的bucket 數量是可以配置,bucket數量足夠多,完全隨機場景下每個k/v 會被均勻打散到不同的bucket下。
同時,每個slot中會放置一個spinklock 來減少當前slot內部bucket 更新的競爭代價。后續也可以按照slot粒度來配置一些 cache和bloom filter 來加速讀。 - 每一個bucket 則保存實際存儲 索引到pmem 數據 的HashEntry。包括按照key的prefix_len 生成的hash值,當前k/v的類型(新的數據類型 還是 刪除類型 以及 支持prefix range的skiplit類型),在pmem上的存儲偏移,和當前k/v的狀態信息。每一個hashEntry 定長的,總共16B。
每個bucket 的 HashEntry 會持續追加到bucket 后面,默認一個bucket 最多放置 8個HashEnry,也就是一個bucket大小是128B,也是cacheline 對齊的。
同時,除了Hash 索引部分會放在內存中,也會創建一些管理pmem 存儲空間的 Allocator數據結構,用來管理pmem 空間的分配和釋放,這個待會會細說。
每個Allocator 會對應一個寫線程,初始化Engine 的時候會默認啟動48個后臺寫線程(性能最好的一個數量),每個寫線程專門負責請求的寫入處理。
PMEM中:
- pmem 的空間組織是按照block粒度進行劃分的,每一個block大小是64B,一個kv可能會分配多個block,默認key大小上限是64K,value大小上限是64M。
- block 中包含16B的k/v元數據,包括8B 的DataHeader ,用來存放當前k/v 的checksum和總大小;還有一個 8B 的DataMeta,存放當前kv的 timestamp, type 以及 k_size 和 value_size,再之后就是 key和value的實際數據了。
基本API 及 實現
API
KVDK 支持 基本的k/v接口 以及 構造prefix-range 友好的 skiplist hash接口(索引部分中的bucket 從之前的數組 變更為跳表),并且會持久化最后一層跳表節點,設計上會更復雜一些,以上架構圖中并沒有體現。
基本API如下:
#define FOREACH_ENUM(GEN) \GEN(Ok) \GEN(NotFound) \GEN(MemoryOverflow) \GEN(PmemOverflow) \GEN(NotSupported) \GEN(MapError) \GEN(BatchOverflow)\GEN(TooManyWriteThreads) \GEN(InvalidDataSize) \GEN(IOError) \GEN(InvalidConfiguration) \GEN(Abort)
#define GENERATE_ENUM(ENUM) ENUM,
#define GENERATE_STRING(STRING) #STRING,typedef enum {FOREACH_ENUM(GENERATE_ENUM)
} KVDKStatus;class Engine {
public:// Open a new KVDK instance or restore a existing KVDK instance with the// specified "name". The "name" indicates the dir path that persist the// instance.//// Stores a pointer to the instance in *engine_ptr on success, write logs// during runtime to log_file if it's not null.//// To close the instance, just delete *engine_ptr.static Status Open(const std::string &name, Engine **engine_ptr,const Configs &configs, FILE *log_file = stdout);// Insert a STRING-type KV to set "key" to hold "value", return Ok on// successful persistence, return non-Ok on any error.virtual Status Set(const pmem::obj::string_view key,const pmem::obj::string_view value) = 0;virtual Status BatchWrite(const WriteBatch &write_batch) = 0;// Search the STRING-type KV of "key" and store the corresponding value to// *value on success. If the "key" does not exist, return NotFound.virtual Status Get(const pmem::obj::string_view key, std::string *value) = 0;// Remove STRING-type KV of "key".// Return Ok on success or the "key" did not exist, return non-Ok on any// error.virtual Status Delete(const pmem::obj::string_view key) = 0;...
}
初始化流程
主要是Open 流程:
-
如果配置了 devdax模式,則會做一些devdax的檢查;如果不是,則檢查傳入的pmem 路徑是否是一個合規的pmem設備。
devdax 模式 是一種pmem 的namespace模式,這個模式下的pmem namespace空間是一個字符設備形態,可以直接通過pmdk來訪問而不需要像 fsdax這樣構造一個文件系統來進行訪問。
相關介紹可以參考:https://github.com/pmem/kvdk/pull/93 -
在指定的fsdax 設備路徑 利用 pmem_map_file 映射一個大小的pmem空間 或者 devdax 模式下直接映射 字符設備形態的pmem空間。
-
持久化一些 option 配置 以及 創建writebatch 需要的目錄
-
初始化 Pmem Allocator 、管理寫線程的 ThreadManager、以及 內存HashTable
-
嘗試 Recovery 已有的 Pmem數據:
a. 處理之前WriteBatch 未完成的請求。
b. 多線程 以 segment 為粒度 遍歷PMEM 上的 data entry.
c. 根據checksum 校驗數據是否完整
d. 根據讀到的DataEntry類型來決定如何構造內存索引(默認我們會使用stringDataRecord,還有 SortedDataRecord 和 DlistDataRecord類型)。假如是stringDataRecord,則會先搜索hashtable 查看是否已經存在相同的key,并根據timestamp 判斷哪一個data entry 是最新版本。
e. 將完整的新版本 DataEntry 插入到 HashTable中,將不完整的舊的DataEntry 占用的空間放入到 FreeList中。
f. 重復以 c-e 步驟,知道各個線程完成自己的 segmemt 數據重放 -
啟動后臺定期合并 freelist 空閑塊的線程,來提升pmem空間的利用率
寫流程
主要是Set API的實現。
- 初始化一個寫線程,上限是48個。
MaybeInitWriteThread();。 - 計算當前key 的64 bits hash值,按照hash值獲取到該key 所屬的bucket 以及 slot.
KeyHashHint GetHint(const pmem::obj::string_view &key) {KeyHashHint hint;hint.key_hash_value = hash_str(key.data(), key.size());hint.bucket = get_bucket_num(hint.key_hash_value);hint.slot = get_slot_num(hint.bucket);hint.spin = &slots_[hint.slot].spin;return hint;} - 對當前slot 進行加鎖,保證PMEM 的寫入 以及 對應bucket 的更新是原子的。因為bucket 以及 slot數量足夠多,隨機場景下的鎖沖突概率較低。
- 從該key 對應的bucket中搜索是否有已存在的的 hash entry,找到了則標記當前HashEntry的status為update,并返回HashEntry的地址。沒有找到則標記status為initial,返回bucket中最后一個 hashentry的結束地址 作為當前hashEntry的起始地址。
- 向pmem 中寫入 DataEntry。先嘗試從 Segment 末尾追加寫入,如果空間不足,則嘗試用best-fit 算法從freelist中分配空間。如果freelist 中還沒有可用的空閑空間,則直接向PMEM 申請一塊新的segment。寫入數據。
- 更新hash Entry。按照第二步查找的結果進行更新,如果是update 標識之前已經有這個k/v了, 則直接更新value的offset即可。否則,追加到所屬bucket最后一個hashEntry之后。
- 回收Pmem空間。如果索引的 status 是 update,表示舊的pmem空間已經不可用了,需要被回收。會將當前空間放入到free list中。
寫入過程 帶spinlock 查 hash索引 以及 在 pmem上的 更新,完全隨機場景下spinlock的 沖突極小 且 因為是Hash索引的更新, 接近 O(1) 的時間 以及 訪問延時和內存一個量級的落盤 延時 以及 CPU的消耗都會被降到最低(NUMA 架構下需要綁定numa才行,不然跨核訪問的延時還是比較高的,這對內存來說也是一樣的)。
讀流程
主要是 Get API的實現。
- 計算key 的64 bits hash值,并根據 hash后綴得到所屬的bucket和slot.
- 從 bucket 中查找該key 的 HashEntry,找到,則拿著offset 從 pmem上讀取對應的 DataEntry;否則返回 NotFound。
- Double check 讀到的數據是否正確( entry-type, checksum的校驗)。
需要注意的是Get 操作是無需對 slot加鎖的,因為讀 bucket內部的 HashEntry時 不論是讀 HashHeader 還是 offset 都是8B字節,對內存來說都是可以原子訪問的,這也是將 HashEntry 設計為 8B HashHeader 以及 8B offset 的原因。
刪除流程
刪除操作和 LSM-tree 類似,也是寫入一個 delete record。
需要注意的是這個Delete record的處理 和普通的 data record的處理有一些差異。因為Delete record 的空間被回收,但是PMEM上還存在更老版本的data entry,那么在recovery 也就是open 的過程中會 讀到這個更老的版本,這樣就存在數據不一致的情況。
所以,針對Delete record 的數據不會立即回收,而是保留其 delete record 以及 內存中的 HashEntry,當再次有該key 的更新時會直接將 delete record的 空間加入到free list中。否則,為了防止 recovery時數據不一致的情況,當且僅當該key 的所有老版本 data entry 均被復用之后才能復用 delete record 及其 HashEntry占用的空間。
PMEM Allocator設計
前面頻繁提到 freelist 以及 pmem alloctor 的空間管理,這一部分時除了 Hash 以及 Sorted 索引 之外 內存中最重要的一個組件了,其分配空間的高效性 以及 空間利用率 直接關系到這個存儲引擎的性能 以及 易用性。
貼一張已有多的設計架構:
這里的設計架構和 tcmalloc 比較接近TCMalloc 實現原理,能稍微簡單一些,當然,也有一些功能還不夠全面(內部stats展示什么的)。
總體上就是:
- 維護了 thread-cache 和 thread-freelist來緩存一部分存儲空間,因為更靠近cpu,所以空間的分配和釋放都是非常方便的。
- 還有一個共享的內存池,功能類似于tcmalloc 的 transfercache 以及 central-freelist,用來為thread-cache 提供空間分配 以及 空閑free-list 的合并,來提升pmem空間的利用率。
- 同樣,為了保證空閑空間的合并效率以及大空間的分配效率,不論是在thread-cache 還是在 共享的內存中都做了按 size區分,類似tcmalloc 的size-class,這樣同一range 大小的空間管理都在一套數據結構之中,對性能和空間利用率都比較友好。
主要組件
- PMEM Space: 從PMEM map出的一塊空間,分為若干segment,每個segment又分成若干blocks,block是allocator的最小分配單元
- Thread caches:為前臺線程cache一些可用空間,避免線程競爭。包括一段segment和一個free list。Free list管理被釋放的空閑空間,是一個鏈表的數組,每個鏈表結點是一個指向空閑空間的指針,鏈表的數組下標表示結點指針指向的空閑空間的大小,即包含多少個block。
- Pool:為了均衡各thread cache的資源,由一個后臺線程周期地將thread cache中的free list以及segment移動到后臺的pool中,pool中的資源由所有前臺線程共享。后臺線程還會周期性地將pool中的相鄰碎片空間合并為大塊空間“Merged Space”。
空間分配流程
- 線程查看cached PMem space是否有足夠空間,若無,則嘗試從pool中fetch一塊merged space作為新的cache,然后從cached PMem space尾部分配空間
- 若pool中無可用Merged space,則嘗試從free list中分配空間。首先查看cache中的free list,若無可用空間,則從pool中拿取另一段free list。
- 若從free list分配空間仍然失敗,則從PMEM Space中fetch一段新的segment
空間釋放
就是將將free的空間指針加入 thread-cache中 對應大小的free list鏈表
圖數據庫 on KVDK 性能
KVDK 模擬了簡單的graph workload 圖 workload on kvdk 性能 PR。
圖數據庫 在現有的互聯網生態下還是有較大的發展前景,無數的個人終端(手機/PC/Paid)都會作為互聯網中的一個節點,各個APP/互聯網應用 希望能夠利用這一些節點 以及他們的行為作足夠深入的研究和分析,來生產一些利于他們 也 利于自己的流量或者產品。這個過程就頻繁得探索不同節點之間的關系,在以億為頂點的單位 以 萬億 為頂點屬性的單位 構成的超大規模網絡中,利用傳統關系型數據庫來做數據分析,探索頂點之間的關系類型,產生的頻繁的join 操作性能必然不會很好。
所以,nebula, dGraph, TigerGraph,UDB 等這樣的 圖數據庫 才會出現,超大規模的社交網絡數據 對任何企業都是財富,而利用這一些財富快速創造出更多的財富 才是 互聯網企業在當今內卷的社會 立足的根本方法。
圖數據庫因為其本身存儲的就是 關系類型的數據,不過不是按照表形態,而是點邊形態。
所以,在KVDK 下模擬了圖數據庫多的基本形態,來展示KVDK 在圖存儲場景下的極致性能(沒有LDBC 標準,畢竟不是專業的生產圖數據庫)。
包括以下基本特性:
-
基本的點和邊的構造過程(圖數據的加載)。
圖數據庫的存儲形態是 將無數的點+邊 構成的圖網絡轉化為能被存儲引擎識別到的k/v 形態。
這里是在快手場景的圖存儲編碼。
a. 對于一個頂點來說,將一個頂點編碼為k/v 的過程 是 key: 頂點的id, value : 頂點的屬性。比如:快手/抖音 的一個用戶作為頂點,會為這個用戶生成一個唯一標識,同時這個用戶的個人信息/喜好等等 都會作為info中的一種存儲下來。
b. 邊 包含兩個頂點,這個時候社交網絡 或者 電商推薦系統 應該都是有向圖,則會將該邊 編碼為兩個k/v。其中一個存儲 key: src --> dst的關系,另一個kv 存儲 key: dst <-- src的關系。 也就是 key : src_vertex(整個頂點編碼) 或者 dst_vertex; value : 上圖中的edge 編碼 之后的數據。
c. 邊列表 和邊的存儲一樣,只不過value 就是 邊列表,一個頂點有多個出邊,則這一些邊都會編碼為一個value,作為這個頂點的value。 -
基本的圖算法
這一些 圖算法 是需要跑在 點邊構成的圖網絡中的。包括基本的 TopN(擁有關注人數最多的前十個大V),廣度優先遍歷 N 度好友關系等。這一些算法的性能 直接關系到從圖數據庫中提煉 用戶特征的性能,更直接一些的話就是 能分析出當前APP 用戶的喜好甚至 未來可能的喜好(深度學習),從而直接創造收益。 -
支持不同引擎 的性能對比(memory/rocksdb/leveldb/kvdk)
因為圖場景下沒有辦法用qps 直接對比性能,所以測試的過程就是使用不同的存儲引擎 跑在相同的硬件環境下(相同的CPU,內存,PMEM) 用相同的 workload ,看總共的運行時間,當然每一種workload 會跑多輪,結果取平均時間。
主要對比持久化存儲性能,rocksdb和kvdk。因為leveldb 很多優化沒有rocksdb細致,這里就沒有進行對比了。
最終的測試結果很清晰:
- 構造圖數據的場景(頻繁的寫入和讀取),kvdk 的性能優于rocksdb 接近20被
- 廣度優先 ,查找4度好友關系(查找A 關注的好友算第一度,查找A關注的好友關注的好友 算第二度。。。),大量的從存儲引擎上的讀,kvdk 也優于rocksdb 8倍。
- TopN 其實涉及大量的CPU計算,先從存儲引擎讀數據,再利用 容量有限的最小堆 進行計算。這里kvdk 性能優于rocksdb 8倍。
更詳細的測試方式可以在這個PR中嘗試: https://github.com/pmem/kvdk/pull/118。
顯然,在新型存儲介質下,KVDK 的性能相比于 rocksdb 擁有顯著的優勢。
當然,想要在 通用存儲引擎的道路上走的更遠,還需要更多的功能才能作為一個生產級別的存儲引擎 – 和rocksdb 功能對標(單機事務能力,備份,測試/運維系統)。
本文在 Hash 索引部分的介紹僅僅介紹了基本的Hash結構,因為 Hash 結構對 scan 性能并不友好,所以kvdk 還提供了 高性能的prefix-scan 的能力,索引部分的bucket 變更成為了跳表,并且會持久化最后一層跳表結構,高層指針仍然保存在DRAM 中。
歡迎感興趣的同學試用 討論 https://github.com/pmem/kvdk。
總結
以上是生活随笔為你收集整理的基于持久内存的 单机上亿(128B)QPS -- 持久化 k/v 存储引擎的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: “绿冻杨枝折”上一句是什么
- 下一篇: Rocksdb的事务(二):完整事务体系