一個 lfu(least frequently used/最不經常使用頁置換演算法 ) 快取的實現,其核心思想是淘汰一段時間內被訪問次數最少的資料項。與LRU(最近最少使用)演算法不同,LFU更側重於資料的訪問頻率而非訪問的新鮮度。
LFU的原理與實現機制
- 普通佇列:LFU演算法透過記錄資料項的訪問頻次來工作。當快取容量達到上限時,系統將會淘汰訪問頻次最低的資料項。這種方法基於一個假設,即在一段時間內被訪問頻次較少的資料,未來被訪問的機率同樣較小。
- 資料結構選擇:為實現O(1)的時間複雜度,這裡LFU通常使用雜湊表(儲存key與節點資料)和雙向連結串列(儲存次數與key結構關係)結合的方式來實現。雜湊表用於快速查詢節點是否存在,而雙向連結串列則用於根據訪問頻次組織資料項。此處雙向連結串列用一個無限長度的
LruCache
代替。在remove
或者改變頻次的時候可以用O(1)的複雜度進行操作。一開始用HashSet<Key>
來設計,因為在Rust中HashSet並不存在pop
函式,在資料大量觸發替代的時候隨機選擇一個元素效率太低。 - 節點管理:每個節點除了儲存鍵值之外,還需附帶訪問頻次資訊。每次資料項被訪問時,其對應的節點頻次會增加;當需要淘汰時,尋找頻次最低的節點進行移除或替換。
LFU與LRU的對比及使用場景
- 演算法側重點差異:LRU側重於資料的訪問新鮮度,即最近未被訪問的資料更容易被淘汰;而LFU更關注資料項的總訪問頻次,不頻繁訪問的資料被認為是低優先順序的。
- 適用場景的不同:LRU適合應對具有時間區域性性的資料訪問模式,例如某些順序讀取的場景;LFU則更適合資料訪問模式較為平穩,且各個資料項訪問頻率差異明顯的環境。
- 實現複雜性對比:LRU的實現相對簡單,通常只需要維護一個按照時間順序排列的連結串列即可;而LFU需要同時考慮訪問頻次和時間兩個維度,因此實現上更為複雜。
LFU演算法的實際案例
- 快取系統中的應用:許多現代快取系統中,如Redis,都實現了LFU作為快取逐出策略之一,允許使用者根據具體需求選擇合適的淘汰演算法。在資料負載高的時候可以允許配置
maxmemory-policy
為volatile-lru|allkeys-lru|volatile-random|allkeys-random|volatile-ttl|volatile-lfu|allkeys-lfu|noeviction
- 負載均衡演算法:在分散式系統中,LFU也可以作為一種簡單的負載均衡策略,將請求分散到不同的伺服器上,避免單點過載。
- 資料庫查詢最佳化:資料庫管理系統中,LFU可以用來最佳化查詢計劃的快取,減少磁碟I/O次數,提高重複查詢的效能。
結構設計
與Lru的結構類似,K與V均用指標方式儲存,避免在使用過程中出現Copy或者Clone的可能,提高效能。
注:該方法用了指標會相應的出現許多unsafe
的程式碼,因為在Rsut中,訪問指標都被認為是unsafe
。我們也可以使用陣列座標模擬指標的方式來模擬。
節點設計
相對普通的Lru節點,我們需要額外儲存次數資料。
/// Lfu節點資料
pub(crate) struct LfuEntry<K, V> {
pub key: mem::MaybeUninit<K>,
pub val: mem::MaybeUninit<V>,
/// 訪問總頻次
pub counter: usize,
/// 帶ttl的過期時間,單位秒
/// 如果為u64::MAX,則表示不過期
#[cfg(feature = "ttl")]
pub expire: u64,
}
類設計
Lfu相對複雜度會比較高,這裡維護了最大及最小的訪問頻次,方便遍歷的時候高效
pub struct LfuCache<K, V, S> {
map: HashMap<KeyRef<K>, NonNull<LfuEntry<K, V>>, S>,
/// 因為HashSet的pop耗時太長, 所以取LfuCache暫時做為平替
times_map: HashMap<u8, LruCache<KeyRef<K>, (), DefaultHasher>>,
cap: usize,
/// 最大的訪問頻次
max_freq: u8,
/// 最小的訪問頻次
min_freq: u8,
/// 總的訪問次數
visit_count: usize,
/// 初始的訪問次數
default_count: usize,
/// 每多少次訪問進行一次衰減
reduce_count: usize,
/// 下一次檢查的時間點,如果大於該時間點則全部檢查是否過期
#[cfg(feature = "ttl")]
check_next: u64,
/// 每次大檢查點的時間間隔,如果不想啟用該特性,可以將該值設成u64::MAX
#[cfg(feature = "ttl")]
check_step: u64,
/// 所有節點中是否存在帶ttl的結點,如果均為普通的元素,則過期的將不進行檢查
#[cfg(feature = "ttl")]
has_ttl: bool,
}
頻次的設計
這此處頻次我們設計成了一個u8型別,但是實際上我們訪問次數肯定遠遠超過u8::MAX
即255的數值。因為此處將訪問總次數與頻次做了一個對映,防止資料碎片太高及變動頻次太頻繁。
比如初始操作比較頻繁的0-10
分別對映成0-6
如2或者3均對映到2,10-40對映到7-10
。其本質的原理就是越高的訪問頻次越不容易被淘汰,相對來說4次或者5次很明顯,但是100次和101次其實沒多少差別。
這樣子我們就可以將很高的梯度對映成一顆比較小的樹,減少碎片化的操作。
/// 避免hash表爆炸, 次數與頻次對映
fn get_freq_by_times(times: usize) -> u8 {
lazy_static! {
static ref CACHE_ARR: Vec<u8> = {
let vec = vec![
(0, 0, 0u8),
(1, 1, 1u8),
(2, 3, 2u8),
(4, 4, 3u8),
(5, 5, 4u8),
(6, 7, 5u8),
(8, 9, 6u8),
(10, 12, 7u8),
(13, 16, 8u8),
(16, 21, 9u8),
(22, 40, 10u8),
(41, 79, 11u8),
(80, 159, 12u8),
(160, 499, 13u8),
(500, 999, 14u8),
(999, 1999, 15u8),
];
let mut cache = vec![0;2000];
for v in vec {
for i in v.0..=v.1 {
cache[i] = v.2;
}
}
cache
};
static ref CACHE_LEN: usize = CACHE_ARR.len();
};
if times < *CACHE_LEN {
return CACHE_ARR[times];
}
if times < 10000 {
return 16;
} else if times < 100000 {
return 17;
} else if times < 1000000 {
return 18;
} else {
return 19;
}
}
這裡用懶初始化,只有該函式第一次被呼叫的時候才會初始化這static程式碼,且只會初始化一次,增加訪問的速度。
reduce_count
的設計
假設一段時間內某個元素訪問特別多,如algorithm-rs
訪問了100000次,接下來很長的一段時間內他都沒有出現過,如果普通的Lfu的淘汰規則,那麼他將永遠的保持在訪問頻次100000次,基本上屬於很難淘汰。那麼他將長久的佔用了我們的資料空間。
針對這種情況此處設計了降權的模式,假設reduce_count=100000
,那麼就每10w次訪問,將對歷史的存量資料訪問次數進行降權即新次數=原次數/2,那麼在第一次降權後,algorithm-rs
將變成了50000
,其的權重將被削減。在一定訪問的之後如果都沒有該元素的訪問最後他將會被淘汰。
visit_count
將當前訪問的次數進行記錄,一旦大於reduce_count
將進行一輪降權並重新計算。
default_count
的設計
由於存在降權的,那麼歷史的資料次數可能會更低的次數。那麼我們將插入的每個元素賦予初始次數,以防止資料在剛插入的時候就被淘汰。此處預設的訪問次數為5。如果歷史經歷了降權,那麼將會有可能存在資料比5小的資料,將優先被淘汰。
初始化
首先初始化物件,初始化map及空的雙向連結串列:
impl<K, V, S> LfuCache<K, V, S> {
/// 提供hash函式
pub fn with_hasher(cap: usize, hash_builder: S) -> LfuCache<K, V, S> {
let cap = cap.max(1);
let map = HashMap::with_capacity_and_hasher(cap, hash_builder);
Self {
map,
times_map: HashMap::new(),
visit_count: 0,
max_freq: 0,
min_freq: u8::MAX,
reduce_count: cap.saturating_mul(100),
default_count: 4,
cap,
#[cfg(feature = "ttl")]
check_step: DEFAULT_CHECK_STEP,
#[cfg(feature = "ttl")]
check_next: get_milltimestamp()+DEFAULT_CHECK_STEP * 1000,
#[cfg(feature = "ttl")]
has_ttl: false,
}
}
}
此處min_freq > max_freq
在迴圈的時候將不會進行任何迴圈,表示沒有任何元素。
元素插入及刪除
插入物件,分已在快取內和不在快取內與Lru的類似,此處主要存在可能操作的列表變化問題
fn try_fix_entry(&mut self, entry: *mut LfuEntry<K, V>) {
unsafe {
if get_freq_by_times((*entry).counter) == get_freq_by_times((*entry).counter + 1) {
self.visit_count += 1;
(*entry).counter += 1;
} else {
self.detach(entry);
self.attach(entry);
}
}
}
假如訪問次數從10次->變成11次,但是他的對映頻次並沒有發生變化,此處我們僅僅需要將元素的次數+1即可,不用移動元素的位置。
attach 其中附到節點上:
fn attach(&mut self, entry: *mut LfuEntry<K, V>) {
unsafe {
self.visit_count += 1;
(*entry).counter += 1;
let freq = get_freq_by_times((*entry).counter);
self.max_freq = self.max_freq.max(freq);
self.min_freq = self.min_freq.min(freq);
self.times_map
.entry(freq)
.or_default()
.reserve(1)
.insert((*entry).key_ref(), ());
self.check_reduce();
}
}
附到節點時我們將會改變min_freq
,max_freq
,並將該元素放入到對應的頻次裡預留足夠的空間reserve(1)
。並在最後檢測是否降權self.check_reduce()
detach 從佇列中節點剝離
/// 從佇列中節點剝離
fn detach(&mut self, entry: *mut LfuEntry<K, V>) {
unsafe {
let freq = get_freq_by_times((*entry).counter);
self.times_map.entry(freq).and_modify(|v| {
v.remove(&(*entry).key_ref());
});
}
}
此處我們僅僅移除節點key資訊,這裡使用的是LruCache,移除也是O(1)的時間複雜度。但是此處我們不維護min_freq
及max_freq
因為不確定是否當前是否維一,此處維護帶來的收益較低,故不做維護。
check_reduce 降權
/// 從佇列中節點剝離
fn check_reduce(&mut self) {
if self.visit_count >= self.reduce_count {
let mut max = 0;
let mut min = u8::MAX;
for (k, v) in self.map.iter() {
unsafe {
let node = v.as_ptr();
let freq = get_freq_by_times((*node).counter);
(*node).counter /= 2;
let next = get_freq_by_times((*node).counter);
max = max.max(next);
min = min.min(next);
if freq != next {
self.times_map.entry(freq).and_modify(|v| {
v.remove(k);
});
self.times_map
.entry(next)
.or_default()
.reserve(1)
.insert((*node).key_ref(), ());
}
}
}
self.max_freq = max;
self.min_freq = min;
self.visit_count = 0;
}
}
當前降權後將重新初始化min_freq
及max_freq
,將當前的所有的頻次/2,此演算法的複雜度為O(n)。
replace_or_create_node 替換節點
fn replace_or_create_node(&mut self, k: K, v: V) -> (Option<(K, V)>, NonNull<LfuEntry<K, V>>) {
if self.len() == self.cap {
for i in self.min_freq..=self.max_freq {
if let Some(val) = self.times_map.get_mut(&i) {
if val.is_empty() {
continue;
}
let key = val.pop_unusual().unwrap().0;
let old_node = self.map.remove(&key).unwrap();
let node_ptr: *mut LfuEntry<K, V> = old_node.as_ptr();
let replaced = unsafe {
(
mem::replace(&mut (*node_ptr).key, mem::MaybeUninit::new(k))
.assume_init(),
mem::replace(&mut (*node_ptr).val, mem::MaybeUninit::new(v))
.assume_init(),
)
};
unsafe {
(*node_ptr).counter = self.default_count;
}
return (Some(replaced), old_node);
}
}
unreachable!()
} else {
(None, unsafe {
NonNull::new_unchecked(Box::into_raw(Box::new(LfuEntry::new_counter(
k,
v,
self.default_count,
))))
})
}
}
當元素資料滿時,我們將做淘汰演算法,此處我們將從min_req
到max_req
做遍歷,並將最小的頻次的pop掉最後一個元素。此處如果我們不需護min_req
與max_req
那麼將會最壞的情況為0-255,即256次迴圈。
其它操作
pop
移除棧頂上的資料,最近使用的pop_last
移除棧尾上的資料,最久未被使用的contains_key
判斷是否包含key值raw_get
直接獲取key的值,不會觸發雙向連結串列的維護get
獲取key的值,並維護雙向連結串列get_mut
獲取key的值,並可以根據需要改變val的值retain
根據函式保留符合條件的元素get_or_insert_default
獲取或者插入預設引數get_or_insert_mut
獲取或者插入物件,可變資料set_ttl
設定元素的生存時間del_ttl
刪除元素的生存時間,表示永不過期get_ttl
獲取元素的生存時間set_check_step
設定當前檢查lru的間隔
如何使用
在cargo.toml中新增
[dependencies]
algorithm = "0.1"
示例
use algorithm::LfuCache;
fn main() {
let mut lru = LfuCache::new(3);
lru.insert("hello", "algorithm");
lru.insert("this", "lru");
lru.set_reduce_count(100);
assert!(lru.get_visit(&"hello") == Some(5));
assert!(lru.get_visit(&"this") == Some(5));
for _ in 0..98 {
let _ = lru.get("this");
}
lru.insert("hello", "new");
assert!(lru.get_visit(&"this") == Some(51));
assert!(lru.get_visit(&"hello") == Some(3));
let mut keys = lru.keys();
assert!(keys.next()==Some(&"this"));
assert!(keys.next()==Some(&"hello"));
assert!(keys.next() == None);
}
完整專案地址
https://github.com/tickbh/algorithm-rs
結語
綜上所述,LFU演算法透過跟蹤資料項的訪問頻次來決定淘汰物件,適用於資料訪問頻率差異較大的場景。與LRU相比,LFU更能抵禦偶發性的大量訪問請求對快取的衝擊。然而,LFU的實現較為複雜,需要綜合考慮效率和公平性。在實際應用中,應當根據具體的資料訪問模式和系統需求,靈活選擇和調整快取演算法,以達到最優的效能表現。