Lfu快取在Rust中的實現及原始碼解析

问蒙服务框架發表於2024-06-27

一個 lfu(least frequently used/最不經常使用頁置換演算法 ) 快取的實現,其核心思想是淘汰一段時間內被訪問次數最少的資料項。與LRU(最近最少使用)演算法不同,LFU更側重於資料的訪問頻率而非訪問的新鮮度。

LFU的原理與實現機制

  1. 普通佇列:LFU演算法透過記錄資料項的訪問頻次來工作。當快取容量達到上限時,系統將會淘汰訪問頻次最低的資料項。這種方法基於一個假設,即在一段時間內被訪問頻次較少的資料,未來被訪問的機率同樣較小。
  2. 資料結構選擇:為實現O(1)的時間複雜度,這裡LFU通常使用雜湊表(儲存key與節點資料)和雙向連結串列(儲存次數與key結構關係)結合的方式來實現。雜湊表用於快速查詢節點是否存在,而雙向連結串列則用於根據訪問頻次組織資料項。此處雙向連結串列用一個無限長度的LruCache代替。在remove或者改變頻次的時候可以用O(1)的複雜度進行操作。一開始用HashSet<Key>來設計,因為在Rust中HashSet並不存在pop函式,在資料大量觸發替代的時候隨機選擇一個元素效率太低。
  3. 節點管理:每個節點除了儲存鍵值之外,還需附帶訪問頻次資訊。每次資料項被訪問時,其對應的節點頻次會增加;當需要淘汰時,尋找頻次最低的節點進行移除或替換。

LFU與LRU的對比及使用場景

  • 演算法側重點差異:LRU側重於資料的訪問新鮮度,即最近未被訪問的資料更容易被淘汰;而LFU更關注資料項的總訪問頻次,不頻繁訪問的資料被認為是低優先順序的。
  • 適用場景的不同:LRU適合應對具有時間區域性性的資料訪問模式,例如某些順序讀取的場景;LFU則更適合資料訪問模式較為平穩,且各個資料項訪問頻率差異明顯的環境。
  • 實現複雜性對比:LRU的實現相對簡單,通常只需要維護一個按照時間順序排列的連結串列即可;而LFU需要同時考慮訪問頻次和時間兩個維度,因此實現上更為複雜。

LFU演算法的實際案例

  • 快取系統中的應用:許多現代快取系統中,如Redis,都實現了LFU作為快取逐出策略之一,允許使用者根據具體需求選擇合適的淘汰演算法。在資料負載高的時候可以允許配置maxmemory-policyvolatile-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_freqmax_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_freqmax_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_reqmax_req做遍歷,並將最小的頻次的pop掉最後一個元素。此處如果我們不需護min_reqmax_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的實現較為複雜,需要綜合考慮效率和公平性。在實際應用中,應當根據具體的資料訪問模式和系統需求,靈活選擇和調整快取演算法,以達到最優的效能表現。

相關文章