单机 “5千万以上“ 工业级 LRU cache 实现
文章目錄
- 前言
- 工業(yè)級(jí) LRU Cache
- 1. 基本架構(gòu)
- 2. 基本操作
- 2.1 insert 操作
- 2.2 高并發(fā)下 insert 的一致性/性能 保證
- 2.3 Lookup操作
- 2.4 shard 對(duì) cache Lookup 性能的影響
- 2.4 Erase 操作
- 2.5 內(nèi)存維護(hù)
- 3. 優(yōu)化
前言
近期做了很多 Cache 優(yōu)化相關(guān)的事情,因?yàn)閷?duì)存儲(chǔ)引擎較為熟悉,所以深入研究了 Rocksdb 的Cache實(shí)現(xiàn),可以說(shuō)是樸實(shí)無(wú)華得展示了工業(yè)級(jí)Cache的細(xì)節(jié),非常精彩。
欣賞實(shí)現(xiàn)的同時(shí)做了一些簡(jiǎn)單的代碼驗(yàn)證,同時(shí)將整個(gè) Cache 的實(shí)現(xiàn)做了一番梳理,發(fā)現(xiàn)真實(shí)處處都是設(shè)計(jì),基本的算法實(shí)現(xiàn)只是最基礎(chǔ)的能力,如何設(shè)計(jì)實(shí)現(xiàn)的每一個(gè)細(xì)節(jié) 與 我們的cpu cacheline 運(yùn)行體系綁定,如何實(shí)現(xiàn)insert/lockup 鏈路中的無(wú)鎖,如何不影響性能的基礎(chǔ)上保持對(duì)Cache的內(nèi)存控制,如何讓頻繁的內(nèi)存分配和釋放不是cache的性能瓶頸… 等等都是需要非常多的設(shè)計(jì)。
本篇及下一篇將主體介紹兩種 Rocksdb 實(shí)現(xiàn)的高性能 工業(yè)級(jí) Cache中的LRUCache 和 ClockCache。
相關(guān)的Cache 實(shí)現(xiàn)代碼 以及 通用的cache_bench 工具 已經(jīng)單獨(dú)摘出來(lái),并在其上補(bǔ)充了一些更好展示cache內(nèi)部狀態(tài)的功能,代碼路徑:https://github.com/BaronStack/Cache_Bench。
編譯運(yùn)行:
# 1. 編譯
sh build.sh ;
# 2. 執(zhí)行壓測(cè)
./cache_bench -threads=32 \
-lookup_percent=100 \
-erase_percent=0 \
-insert_percent=0 \
-num_shard_bits=10 \
-cache_size=2147483648 \
-use_clock_cache=false # 使用的是 LRU Cache# 3. 輸出
Number of threads : 32
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 10
Max key : 1073741824
Populate cache : 0
Insert percentage : 0%
Lookup percentage : 100%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 0.670 s; QPS = 57348698
整體介紹整個(gè) Cache 體系實(shí)現(xiàn)之前,我們先思考一下我們的生產(chǎn)環(huán)境對(duì)Cache 的功能/性能 需求:
- Cache的讀性能至關(guān)重要,Cache的存在大多數(shù)是為了彌補(bǔ)我們熱點(diǎn) 低延時(shí)需求的場(chǎng)景下的高昂I/O代價(jià),所以高性能的讀 一定是必要的,良好的數(shù)據(jù)結(jié)構(gòu)的選型是關(guān)鍵,且要優(yōu)于從page-cache讀,不然我們干嘛需要單獨(dú)的cache,直接存在page-cache得了(不考慮內(nèi)存占用問題)。
- 高并發(fā)場(chǎng)景下需要保證Cache的可靠性。比如,我們多線程下insert的時(shí)候需要保證cache數(shù)據(jù)結(jié)構(gòu)更新的原子性。這里的更新包括insert/erase。所以在更新鏈路上可能需要加鎖,如何解決鎖引入的 CPU 上下文切換的代價(jià)?
- 友好的內(nèi)存控制功能。我們的線上機(jī)器往往不是一個(gè)服務(wù)在運(yùn)行,為了防止內(nèi)存無(wú)止境的消耗引發(fā)操作系統(tǒng)的OOM或者其他服務(wù)的swap ,則需要對(duì)當(dāng)前服務(wù)的Cache所占用的內(nèi)存進(jìn)行限制。這個(gè)時(shí)候,怎樣盡可能得保證可能得熱點(diǎn)長(zhǎng)期存在于Cache之中,則需要我們重點(diǎn)關(guān)注。
- 一些邊緣功能的實(shí)現(xiàn)。更高效的分配器選型,cache的insert/erase 會(huì)涉及到非常頻繁的內(nèi)存操作,如何保證在復(fù)雜的workload場(chǎng)景(key-value大小差異非常大,幾個(gè)byte 到 幾 M;頻繁的插入和Erase)能夠?qū)ache拿到的內(nèi)存有一個(gè)高效的管理,在對(duì)分配器底層有足夠了解的情況下 選擇合適的分配器能夠起到加速cache使用 的作用。
綜合來(lái)看,一個(gè)工業(yè)級(jí)高效Cache的實(shí)現(xiàn)是需要 深入理解數(shù)據(jù)結(jié)構(gòu)、操作系統(tǒng)的CPU子系統(tǒng)、內(nèi)存子系統(tǒng)的實(shí)現(xiàn) 才能保證最終的產(chǎn)出是高效合理 滿足工業(yè)級(jí)需求的。
限于作者能力有限,希望能在有限的能力基礎(chǔ)上為大家展示更多的實(shí)現(xiàn)細(xì)節(jié),一起品鑒,如有不足,麻煩一定指正。
工業(yè)級(jí) LRU Cache
介紹 Rocksdb 對(duì)LRU cache的改造之前,先概覽一下 LRU cache,顧名思義,LRU(Least recently used),近期使用最少的緩存單元將會(huì)被優(yōu)先淘汰。
關(guān)于LRU的基本算法實(shí)現(xiàn),網(wǎng)上介紹的太多了,基礎(chǔ)的鏈表就能夠滿足針對(duì)LRU的插入/刪除/查找了,這里就不做過多的展開了。我們直接進(jìn)入正題,也就是Rocksdb 的LRU Cache如何在滿足以上那幾個(gè)需求的過程中針對(duì)LRU 做了哪一些改造。
1. 基本架構(gòu)
先看一下整體的LRU Cache的架構(gòu):
是不是看起來(lái)和 基本的LRU 實(shí)現(xiàn)有很大的差異,先整體介紹一下整個(gè)架構(gòu)中每一個(gè)組件的作用,后續(xù)將詳細(xì)介紹這幾個(gè)組件是為了解決需求中的什么問題而引入的:
- shard。 我們可以看到,整個(gè)LRU cahce 從外部來(lái)看是一個(gè)個(gè)分開的shard,每一個(gè)shard內(nèi)部才是真正的lru cache的底層實(shí)現(xiàn)。上層的用戶請(qǐng)求 在進(jìn)入cache操作之前 會(huì)先經(jīng)過hash映射到預(yù)先創(chuàng)建好的一個(gè)個(gè)shard中,然后才是針對(duì)每個(gè)cache內(nèi)部的操作。
- HashTable。 這個(gè)組件是為了加速shard內(nèi)部 lru cache查找的,將要操作的key進(jìn)行hash生成唯一的一個(gè)hash值,這個(gè)key的操作將落在某一個(gè)hash-bucket之中,通過單鏈表來(lái)解決hash沖突。每次針對(duì)hash表的鏈表插入都會(huì)采用頭插法,保證越新的key將采越靠近bucket頭部。
- High pri pool / Low pri pool。高優(yōu)先級(jí)緩存池和低優(yōu)先級(jí)緩存池 是主體的Cache部分,也就是 LRU 維護(hù)數(shù)據(jù)的主體部分。
lru_是高優(yōu)先級(jí)pool的head,也是整個(gè)cache的head,所有高優(yōu)先級(jí)的entry的插入都先插入到lru頭部。low-pri是低優(yōu)先級(jí)pool的尾部,所有針對(duì)低優(yōu)先級(jí)pool的更新也都會(huì)插入到low-pri的尾部。從上圖可以看到,高優(yōu)先級(jí)pool的尾部和低優(yōu)先級(jí)pool的尾部相接,這樣從整個(gè)lru_鏈表來(lái)看,lru的prev就是整個(gè)lru-cache的最新的元素,lrv的next就是lru-cache的最舊的元素。
2. 基本操作
針對(duì)Cache的幾個(gè)基本操作包括如下幾種:
virtual bool Insert(const Slice& key, uint32_t hash, void* value,size_t charge,void (*deleter)(const Slice& key, void* value),Cache::Handle** handle,Cache::Priority priority) override;virtual Cache::Handle* Lookup(const Slice& key, uint32_t hash) override;virtual bool Ref(Cache::Handle* handle) override;virtual bool Release(Cache::Handle* handle,bool force_erase = false) override;virtual void Erase(const Slice& key, uint32_t hash) override;
更細(xì)粒度的一些數(shù)據(jù)結(jié)構(gòu)的指標(biāo)如下,能夠直接將Cache 擁有的內(nèi)部結(jié)構(gòu)暴漏出來(lái):
每一個(gè)cache entry 維護(hù)了一個(gè) LRUHandle,可以看作是用來(lái)標(biāo)識(shí)一個(gè)key-value
struct LRUHandle {void* value; void (*deleter)(const Slice&, void* value);LRUHandle* next_hash;LRUHandle* next;LRUHandle* prev;size_t charge; // TODO(opt): Only allow uint32_t?size_t key_length;// The hash of key(). Used for fast sharding and comparisons.uint32_t hash;// The number of external refs to this entry. The cache itself is not counted.uint32_t refs;enum Flags : uint8_t {// Whether this entry is referenced by the hash table.IN_CACHE = (1 << 0),// Whether this entry is high priority entry.IS_HIGH_PRI = (1 << 1),// Whether this entry is in high-pri pool.IN_HIGH_PRI_POOL = (1 << 2),// Wwhether this entry has had any lookups (hits).HAS_HIT = (1 << 3),};uint8_t flags;char key_data[1];
其中 主體部分包括:
- key_data: 實(shí)際插入的key數(shù)據(jù)部分
- value: 實(shí)際的value數(shù)據(jù)
- deleter ,是一個(gè)回調(diào)函數(shù),用來(lái)清理ref=0, in-cache=false的key-value
- next_hash,是hashtable 中解決hash沖突的單鏈表
- next/prev: hpri, lrpi 中的雙向鏈表
- charge,表示當(dāng)前handle的大小
- hash,當(dāng)前key的hash值,主要被用做hash-table/ high/low pool 中的key的查找,用來(lái)標(biāo)識(shí)唯一key
- refs, key被引用的次數(shù)
- flags, 其四個(gè)字節(jié)分別被枚舉類型 Flags 中的四個(gè)標(biāo)識(shí)來(lái)用。這四個(gè)標(biāo)識(shí),將用來(lái)決定一個(gè) LRUHandle 應(yīng)該存儲(chǔ)于哪里。
前面提到我們的shard cache 為了加速查找性能,引入了 hashtable, 基本的table 數(shù)據(jù)結(jié)構(gòu)可以參考LRUHandleTable。
每一個(gè) shard 則維護(hù)了這個(gè) Cache 的內(nèi)部信息,通過如下數(shù)據(jù)結(jié)構(gòu)能夠更直觀得看到 Cache 的內(nèi)部情況。
// Initialized before use.size_t capacity_;......LRUHandle lru_;LRUHandle* lru_low_pri_;LRUHandleTable table_;size_t usage_;size_t lru_usage_;......
};
- capacity。 當(dāng)前shard的初始容量,我們外部配置的LRU cache大小會(huì)被均分到多個(gè)cache shard中
- lru_。高優(yōu)先級(jí)pool的雙向循環(huán)鏈表的表頭,也是整個(gè)lru 鏈表的表頭。
- lru_low_pri。低優(yōu)先級(jí)pool,總是指向低優(yōu)先級(jí)pool的鏈表頭。
- table_。 hashtable 。
- usage_。整個(gè)shard 當(dāng)前的使用總?cè)萘?#xff0c;包含lru_usage_的容量。
- lru_usage_。兩個(gè)pool中的鏈表使用容量。
大體的shard 以及 shard內(nèi)部數(shù)據(jù)結(jié)構(gòu)就介紹這么多,下來(lái)我們從一些基礎(chǔ)的操作來(lái)看看整個(gè) shard 內(nèi)部的狀態(tài)。
2.1 insert 操作
了解LRU Cache最直接的辦法就是從Insert 中來(lái)看,insert 會(huì)涉及針對(duì)每一個(gè)組件的操作,同時(shí)會(huì)夾雜著cache 滿之后的數(shù)據(jù)淘汰過程,這也是Cache的算法基礎(chǔ)體現(xiàn)。
最開始的Cache中沒有任何數(shù)據(jù),我們初始化cache的一些配置如下:
ps: 這里的capacity實(shí)際是當(dāng)前shard cache的大小,單位是bytes, 這里的5 是為了后續(xù)說(shuō)明整個(gè)插入過程而用一種有歧義的方式來(lái)表示,可以看作是當(dāng)前shard中能夠保存多少個(gè)key-value的entry個(gè)數(shù)。
那么初始化之后的cache 情況如下:
- hashtable 只有在有key的時(shí)候才會(huì)動(dòng)態(tài)初始化填充。
- high pri pool 根據(jù)初始時(shí)設(shè)置的cache 比例,將會(huì)初始化為capacity 為 4,且對(duì)雙向循環(huán)鏈表進(jìn)行初始化。
- low pri pool 則直接取 high 剩下的容量。
當(dāng)我們插入一條enry的時(shí)候,經(jīng)過對(duì)key的hash 可以知道這個(gè)key將落在哪一個(gè)shard,后續(xù)的操作都將針對(duì)這個(gè)shard來(lái)進(jìn)行,接下來(lái)我們看看一條 key 在shard cache內(nèi)部 的數(shù)據(jù)流:
第一次插入的時(shí)候需會(huì)同步在hashtable 和 high-pool和之中,同時(shí)設(shè)置count 為0.
high pool 有充足的容量之前的插入都會(huì)先插入到highpool之中,如果high pool滿了,會(huì)將high pool中最舊的數(shù)據(jù)淘汰到low pool之中。
則在shard cache中的插入過程如下:
key在選擇指定shard 操作之前會(huì)做一次hash, 帶著這個(gè)hash值操作接下來(lái)的shard。
- 首先是更新hashtable,拿著這個(gè)hash值通過
hash & (length_ - 1)選擇對(duì)應(yīng)的hash-bucket,并采用頭插法插入到這個(gè)bucket后的單鏈表中。 - 其次更新high pool 或者 low pool。判斷初始參數(shù)設(shè)置的high pool的比例是否大于0,以及當(dāng)前pool 是否未滿,如果是則優(yōu)先更新high pool,否則更新low pool。所以會(huì)先采用頭插法,插入到lru_頭部(高優(yōu)先級(jí) pool)。
第二次的插入也是類似的。
后續(xù)的插入針對(duì)hash-table 的更新 以及 lru_ 鏈表的更新都是采用頭插法。
當(dāng)high pool 插滿之后,則需要將high pool最舊的元素插入到 low pool之中,這個(gè)時(shí)候就需要將low_pri的指針放在high pool的末尾了。
Ban 這個(gè)字符串需要插入到high pool的時(shí)候發(fā)現(xiàn)滿了,插入還是會(huì)先插入到high pool的頭部,但是low-pri指針需要需要向后移動(dòng)一次,low-pri的next 直接指向的是high pool的末尾元素。
此時(shí),在high-pool之中,頭指針的lru_.prev指向的是最新的key,lru_.next指向的是最舊的key。
此時(shí)我們可以看到當(dāng)前的shard-cache已經(jīng)滿了,那后續(xù)的insert將是什么樣的行為呢,再插入一條key看看。
主體主要是兩個(gè)步驟:
- 嘗試插入新的key
Ted之前,會(huì)先檢測(cè)插入后的容量是否超過capacity。這里顯然是超過了,會(huì)出發(fā)cache evict,即從cache中移除最舊的一條key,顯然就是lru.next指向的 abc了。現(xiàn)將abc 從hashtable 中移除,同時(shí)在其ref count為0的時(shí)候從lru-list中移除。 - 將新的key
Ted按照之前的方式插入到 hash-table 和 lru-list之中,并將high pool的最舊的keyhsd放在low-pool之中。
到此,基本的插入過程就已經(jīng)很清晰了,我們能夠看到high-pool的頭插法 + low-pool 的尾插法 是能夠完整的維護(hù)LRU Cache的特性。
可以看到,高低優(yōu)先級(jí) pool的功能,是為了盡可能得讓熱點(diǎn)key在cache中駐留的時(shí)間最長(zhǎng)。而hashtable 的能力則是一個(gè)cache-key的全集,能夠在需要lookup的時(shí)候以最快的速度找到目標(biāo)key。
2.2 高并發(fā)下 insert 的一致性/性能 保證
這個(gè)時(shí)候,我們?cè)倩仡^看看我們的工業(yè)級(jí)需求。
可以看到Insert的過程涉及大量的指針更新(針對(duì)high/low/hashtable),所以針對(duì)同一個(gè)shard-cache的更新,如果我們不想出現(xiàn)指針指向的丟失或者指向錯(cuò)誤,那就需要保證每一次更新的原子性了,那就需要引入鎖機(jī)制了。
但是這個(gè)鎖不應(yīng)該影響整個(gè)shard的其他讀取/更新操作,只需要保證當(dāng)前handle的更新是一個(gè)排他鎖,所以LRU-Cache的更新鎖粒度是 key。鎖的范圍是 從 key 在 hashtable 中的更新到 key在lru-list 中的更新。
這把鎖鎖住了當(dāng)前CPU 多次訪存操作,而在高并發(fā)的cahce更新場(chǎng)景性能將非常難看,使用我們的cache-bench 做如下幾個(gè)測(cè)試。
# 單shard 單線程的 純 insert./cache_bench -threads=1 -lookup_percent=0 -erase_percent=0 -insert_percent=100 -num_shard_bits=0 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 1
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 0
Max key : 1073741824
Populate cache : 0
Insert percentage : 100%
Lookup percentage : 0%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 0.919 s; QPS = 1306012# 單shard 16線程的 純 insert
./cache_bench -threads=16 -lookup_percent=0 -erase_percent=0 -insert_percent=100 -num_shard_bits=0 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 16
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 0
Max key : 1073741824
Populate cache : 0
Insert percentage : 100%
Lookup percentage : 0%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 38.139 s; QPS = 503421
可以看到如果我們只維護(hù)一個(gè)整體的cache,那在這種高并發(fā)場(chǎng)景下的性能將巨差,從單線程的130w 降到 16線程的 50w,因?yàn)獒槍?duì)一個(gè)cache - shard 的原子更新讓多核場(chǎng)景的cpu根本無(wú)法發(fā)揮用處,性能顯然會(huì)很差。
雖然鎖 保證了多線程下的Cache一致性, 但是高并發(fā)場(chǎng)景的性能是用戶所不能接受的。而為了提升多核硬件中高并發(fā)下的性能,cache的分shard策略是必然的。
如下測(cè)試,多shard的多線程顯然比單shard 好很多,而這一點(diǎn)在cache的lookup heavy場(chǎng)景體現(xiàn)的更加明顯,后面有詳細(xì)的測(cè)試數(shù)據(jù)。
./cache_bench -threads=16 -lookup_percent=0 -erase_percent=0 -insert_percent=100 -num_shard_bits=5 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 16
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 5
Max key : 1073741824
Populate cache : 0
Insert percentage : 100%
Lookup percentage : 0%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 7.874 s; QPS = 2438406
2.3 Lookup操作
那我們繼續(xù)。
接下來(lái)看看 LRU 的查找操作,查找很簡(jiǎn)單。
查找的大體過程如下:
- 先從hashtable 中查找,如果找不到直接返回。
- 找到了,拿著找到的handle 返回即可。
- 同時(shí),為了提升lookup 命中cache的key的熱度,會(huì)標(biāo)記當(dāng)前key為命中。再次insert 當(dāng)前key時(shí),則會(huì)直接放在高優(yōu)先級(jí)的pool中。
- 判斷其ref是否為0,如果是會(huì)將其從lru鏈表中移除,但因?yàn)樗徊檎疫^,所以還會(huì)在hashtable中保留一份它的key-handle。
如果ref count 為0 時(shí),對(duì)于Cache來(lái)說(shuō)再次進(jìn)行操作就可以進(jìn)行清理了,因?yàn)?目的是為了讓熱點(diǎn)key長(zhǎng)期駐留在cache中。而lookup則表明這個(gè)key是一個(gè)熱點(diǎn)key,所以會(huì)將其保留在HashTable中,因?yàn)橹暗膔ef count 為0,則會(huì)從LRU 中移除,但是會(huì)標(biāo)記hit,在后續(xù)的insert 操作則會(huì)再次添加到lru-cache的high-pool中。
所以,Lookup操作也會(huì)有指針的更新,也就需要鎖的保護(hù)了。
Cache::Handle* LRUCacheShard::Lookup(const Slice& key, uint32_t hash) {MutexLock l(&mutex_);LRUHandle* e = table_.Lookup(key, hash);if (e != nullptr) {assert(e->InCache());if (!e->HasRefs()) {// The entry is in LRU since it's in hash and has no external references// LRU 雙向鏈表中的移除操作。LRU_Remove(e);}e->Ref();e->SetHit();}return reinterpret_cast<Cache::Handle*>(e);
}
所以,因?yàn)楣I(yè)級(jí)cache對(duì)一致性的需求引入了鎖,那我們想要提升性能,分shard仍然有很大的需求了,對(duì)于shard來(lái)說(shuō) 無(wú)非引入了一點(diǎn)內(nèi)存管理的代價(jià)而已,性能上key的按hash映射是位運(yùn)算,可能就幾個(gè)ns,根本沒有什么消耗。
2.4 shard 對(duì) cache Lookup 性能的影響
我們來(lái)測(cè)試一下Lookup性能,保持80% 的lookup和20% 的insert,分別看一下單shard下單線程和多線程的性能 已經(jīng) 多shard下的多線程性能。
# 單線程 單sharda
./cache_bench -threads=1 -lookup_percent=80 -erase_percent=0 -insert_percent=20 -num_shard_bits=0 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 1
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 0
Max key : 1073741824
Populate cache : 0
Insert percentage : 20%
Lookup percentage : 80%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 0.273 s; QPS = 4398988# 多線程 單shard
./cache_bench -threads=16 -lookup_percent=80 -erase_percent=0 -insert_percent=20 -num_shard_bits=0 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 16
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 0
Max key : 1073741824
Populate cache : 0
Insert percentage : 20%
Lookup percentage : 80%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 18.037 s; QPS = 1064451
可以看到,單shard下的單線程有440w /s 的吞吐,而 多線程僅有106w。
再跑一下多shard的場(chǎng)景,我們跑了1024個(gè)shard,可以看到性能能夠達(dá)到 1950w/s (在lookup 比例更高一些的場(chǎng)景,隨著shard個(gè)數(shù)的增加,性能甚至能夠達(dá)到線性,當(dāng)然實(shí)際shard個(gè)數(shù)大于2^20 的時(shí)候性能就開始退化了)。
./cache_bench -threads=16 -lookup_percent=80 -erase_percent=0 -insert_percent=20 -num_shard_bits=10 -cache_size=2147483648 -use_clock_cache=false
Number of threads : 16
Ops per thread : 1200000
Cache size : 2147483648
Num shard bits : 10
Max key : 1073741824
Populate cache : 0
Insert percentage : 20%
Lookup percentage : 80%
Erase percentage : 0%
Test count : 1
----------------------------
1 Test: complete in 0.984 s; QPS = 19504108
所以LRU cache 之上的shard 封裝,真的可以說(shuō)是工業(yè)級(jí)高并發(fā)cache上 設(shè)計(jì)層面的一個(gè) 必備能力了。
2.4 Erase 操作
后面的 Erase操作這里便不再多說(shuō)了,和lookup的操作時(shí)類似的。優(yōu)先從hashtable中清理,如果key-handle的引用計(jì)數(shù)為0,則會(huì)從 high/low pool中的cache中移除,同樣因?yàn)樵O(shè)計(jì)cache的更新,所以還是有鎖的參與來(lái)原子更新cache的雙向鏈表。
void LRUCacheShard::Erase(const Slice& key, uint32_t hash) {LRUHandle* e;bool last_reference = false;{MutexLock l(&mutex_);e = table_.Remove(key, hash);if (e != nullptr) {assert(e->InCache());e->SetInCache(false);if (!e->HasRefs()) {// The entry is in LRU since it's in hash and has no external referencesLRU_Remove(e);usage_ -= e->charge;last_reference = true;}}}// Free the entry here outside of mutex for performance reasons// last_reference will only be true if e != nullptrif (last_reference) {e->Free();}
}
2.5 內(nèi)存維護(hù)
- 外部設(shè)置的Cache大小會(huì)被均分給設(shè)置的多個(gè)shard。
LRUCache::LRUCache(size_t capacity, int num_shard_bits,bool strict_capacity_limit, double high_pri_pool_ratio,std::shared_ptr<MemoryAllocator> allocator,bool use_adaptive_mutex) : ShardedCache(capacity, num_shard_bits, strict_capacity_limit,std::move(allocator)) {// 2^num_shard_bits 個(gè) shardnum_shards_ = 1 << num_shard_bits;shards_ = reinterpret_cast<LRUCacheShard*>(port::cacheline_aligned_alloc(sizeof(LRUCacheShard) * num_shards_));// 均分的總cache大小size_t per_shard = (capacity + (num_shards_ - 1)) / num_shards_;for (int i = 0; i < num_shards_; i++) {new (&shards_[i])LRUCacheShard(per_shard, strict_capacity_limit, high_pri_pool_ratio,use_adaptive_mutex);} } - 每個(gè)Cache內(nèi)部,Lru high/low pool的總內(nèi)存大小 lru_usge_ 是包含在 hashtable 的內(nèi)存占用總大小usage之內(nèi)的。
hashtable的usage 是每一個(gè)handle 都要進(jìn)行更新的,insert/erase 都需要操作hashtable。
如果想要看到更細(xì)粒度的cache內(nèi)部狀態(tài),則可以通過如下命令
編譯運(yùn)行的話進(jìn)入到cd example; make test即可,會(huì)詳細(xì)得打印每個(gè)shard cache內(nèi)部hashtable , lru 雙向鏈表的狀態(tài)。
3. 優(yōu)化
通過以上的一些實(shí)現(xiàn)架構(gòu)和細(xì)節(jié)上的描述,我們能夠總結(jié)出 rocksdb 的工業(yè)級(jí)lru cache 相比于傳統(tǒng)lru-cache 的優(yōu)化點(diǎn):
- 多次提到過的 并且在性能測(cè)試中性能突出的 分shard能力。
- 為了保證Cache一致性的操作鎖。
- 為了提升Cache 讀性能的hashtable 以及 高低優(yōu)先級(jí)pool,hash table用來(lái)加速key的查找,并且盡可能得保證熱點(diǎn)key保存在了 內(nèi)存中。
- 支持指定不同的分配器來(lái)作為L(zhǎng)RUCache的默認(rèn)分配器。(本篇沒有對(duì)不同分配器的性能進(jìn)行測(cè)試,直接使用的是操作系統(tǒng)默認(rèn)的malloc/free)
- 內(nèi)存控制管理能力,超過限制,則按照LRU策略從優(yōu)先從low pool中淘汰數(shù)據(jù)(其本身也是整個(gè)LRU Cache中較舊的數(shù)據(jù),其頭部則是整個(gè)lru 雙向鏈表最舊的數(shù)據(jù))。
相關(guān)代碼 和 benchmark 工具 :https://github.com/BaronStack/Cache_Bench
這個(gè)LRU Cache是作為Rocksdb的默認(rèn)cache,同時(shí)Rocksdb還提供了另外一種可選的Cache : ClockCache。因?yàn)樗x用了tbb:concurrent_hash_map 作為索引,底層選擇deque 作為clock algorighm的實(shí)現(xiàn)。因?yàn)門BB 庫(kù)本身的無(wú)鎖操作,從而在Cache的并發(fā)操作上有了不小的提升。
限于篇幅原因,ClockCache的介紹會(huì)單獨(dú)進(jìn)行,包括基本的架構(gòu)設(shè)計(jì) 以及 tbb 的 無(wú)鎖化(并發(fā)erase) 實(shí)現(xiàn)細(xì)節(jié)。
總結(jié)
以上是生活随笔為你收集整理的单机 “5千万以上“ 工业级 LRU cache 实现的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 妄想山海熊蜂刺怎么获得?
- 下一篇: python 绘图脚本系列简单记录