帶有ttl的Lru在Rust中的實現及原始碼解析

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

TTL是Time To Live的縮寫,通常意味著元素的生存時間是多長。

應用場景

  • 資料庫:在redis中我們最常見的就是快取我們的資料元素,但是我們又不想其保留太長的時間,因為資料時間越長汙染的可能性就越大,我們又不想在後續的程式中設定刪除,所以我們此時需要設定過期時間來讓資料自動淘汰。

setex now 10000 algorithm-rs

  • 記憶體快取:通常在程式中需要快取一定的資料結果,但是因為記憶體是有限的,需要在記憶體中儲存最有效的資料進行快取,此時需要設定過期時間,以在規定時間內淘汰無用的資料。

帶ttl的Lru演算法的優缺點

  • 優點
    • 可以根據過期時間自動淘汰掉無用的資料。
  • 缺點
    • 需要維護過期時間欄位
    • 需要額外的cpu進行資料對比及可能出現的大量資料淘汰要額外️的進行cpu運算去淘汰資料。

瞭解Rust中的feature

在Rust程式語言中,feature是一個在Cargo.toml檔案中定義的配置項,它允許開發者在構建和依賴項選擇方面進行更細粒度的控制。
feature類似於C/C++中的#ifdef,我們可以根據需求來啟用或者關閉程式碼,這樣子可以有效的達到我們想要的功能。
在此設計中,我們在Cargo.toml定義了ttlfeature來啟用ttl的功能。
在程式碼中我們可以在函式,也可以在某欄位,也可以在某個執行中定義#[cfg(feature = "ttl")],他生效的是下一個欄位或者函式或者語句

結構變化

在每個結點中,新增ttl的feature

pub(crate) struct LruEntry<K, V> {
    /// 頭部節點及尾部結點均未初始化值
    pub key: mem::MaybeUninit<K>,
    /// 頭部節點及尾部結點均未初始化值
    pub val: mem::MaybeUninit<V>,
    pub prev: *mut LruEntry<K, V>,
    pub next: *mut LruEntry<K, V>,
    /// 帶ttl的過期時間,單位秒
    /// 如果為u64::MAX,則表示不過期
    #[cfg(feature = "ttl")]
    pub expire: u64,
}

在此處我們每個結點新增了一個u64的過期時間。

pub struct LruCache<K, V, S> {
    // ...
    #[cfg(feature = "ttl")]
    check_next: u64,
    /// 每次大檢查點的時間間隔,如果不想啟用該特性,可以將該值設成u64::MAX
    #[cfg(feature = "ttl")]
    check_step: u64,
    /// 所有節點中是否存在帶ttl的結點,如果均為普通的元素,則過期的將不進行檢查
    #[cfg(feature = "ttl")]
    has_ttl: bool,
}

函式變化

我們在獲取元素結點時,需要判斷其是否過期再進行返回,如果過期我們將返回空並將該結點進行刪除。

pub(crate) fn get_node<Q>(&mut self, k: &Q) -> Option<*mut LruEntry<K, V>>
    where
        K: Borrow<Q>,
        Q: Hash + Eq + ?Sized,
{
    match self.map.get(KeyWrapper::from_ref(k)) {
        Some(l) => {
            let node = l.as_ptr();
            self.detach(node);
            #[cfg(feature = "ttl")]
            unsafe {
                if self.has_ttl && (*node).is_expire() {
                    self.map.remove(KeyWrapper::from_ref(k));
                    let _ = *Box::from_raw(node);
                    return None;
                }
            }
            
            self.attach(node);
            Some(node)
        }
        None => None,
    }
}

其中is_expire將會獲取系統時間來進行當前是否過期的對比

#[cfg(feature = "ttl")]
#[inline(always)]
pub fn is_expire(&self) -> bool {
    get_timestamp() >= self.expire
}

#[inline(always)]
pub fn get_timestamp() -> u64 {
    SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).expect("ok").as_secs()
}

我們將這種函式程式碼量極少的進行內聯的宣告,以犧牲二進位制包大小來提高執行速度。

插入方法我們額外提供帶ttl的資料插入:

/// 插入帶有生存時間的元素
/// 每次獲取像redis一樣,並不會更新生存時間
/// 如果需要更新則需要手動的進行重新設定
#[inline(always)]
pub fn insert_with_ttl(&mut self, k: K, v: V, ttl: u64) -> Option<V> {
    self.has_ttl = true;
    self._capture_insert_with_ttl(k, v, ttl).map(|(_, v, _)| v)
}

#[allow(unused_variables)]
fn _capture_insert_with_ttl(&mut self, k: K, mut v: V, ttl: u64) -> Option<(K, V, bool)> {
    #[cfg(feature="ttl")]
    self.clear_expire();

    let key = KeyRef::new(&k);
    match self.map.get_mut(&key) {
        Some(entry) => {
            let entry_ptr = entry.as_ptr();
            unsafe {
                mem::swap(&mut *(*entry_ptr).val.as_mut_ptr(), &mut v);
            }
            #[cfg(feature="ttl")]
            unsafe {
                (*entry_ptr).expire = ttl.saturating_add(get_timestamp());
            }
            self.detach(entry_ptr);
            self.attach(entry_ptr);

            Some((k, v, true))
        }
        None => {
            let (val, entry) = self.replace_or_create_node(k, v);
            let entry_ptr = entry.as_ptr();
            self.attach(entry_ptr);
            #[cfg(feature="ttl")]
            unsafe {
                (*entry_ptr).expire = ttl.saturating_add(get_timestamp());
            }
            unsafe {
                self.map
                    .insert(KeyRef::new((*entry_ptr).key.as_ptr()), entry);
            }
            val.map(|(k, v)| (k, v, false))
        }
    }
}

我們在插入的同時,會將過期時間進行設定,不帶ttl的我們同樣走該方法,只是傳入的ttl引數ttl: u64將不會被使用,我們這裡宣告瞭#[allow(unused_variables)]告訴編譯器,我們這裡變數沒有使用是我們預料之中的,不要進行警告。
我們將會設定節點的過期時間:

#[cfg(feature="ttl")]
unsafe {
    (*entry_ptr).expire = ttl.saturating_add(get_timestamp());
}

清除策略

Redis中過期資料的清除策略主要有三種:惰性刪除、定時刪除和定期刪除。這些策略在Redis中用於平衡記憶體佔用與CPU使用之間的關係,以確保Redis的效能和穩定性。

在這裡我們實現的是惰性刪除及定期刪除策略,但是每次定期刪除可能會遍歷所有的元素,如果資料太大,容易無法在規定的時間內進行資料清理。後續可能需要單次最大遍歷資料數量。

惰性刪除

我們將獲取元素的時候統一進行檢查get_node,所有相關獲取的資料將全部呼叫這裡,這樣子將函式統一化,可以更好的最佳化程式碼。

定期刪除

每次執行會獲取一次系統函式時間。

#[cfg(feature="ttl")]
pub fn clear_expire(&mut self) {
    if !self.has_ttl {
        return;
    }
    let now = get_timestamp();
    if now < self.check_next {
        return;
    }
    self.check_next = now + self.check_step;
    unsafe {
        let mut ptr = self.tail;
        while ptr != self.head {
            if (*ptr).is_little(&now) {
                let next = (*ptr).prev;
                self.detach(ptr);
                self.map.remove(&KeyRef::new(&*(*ptr).key.as_ptr()));
                let _ = *Box::from_raw(ptr);
                ptr = next;
            } else {
                ptr = (*ptr).prev;
            }
        }
    }
}

在清除的時候,需要先將map的資料移除掉,因為map的key只是節點的一個引用,如果先將節點刪除,那麼將出現map中的key指標懸空的情況。

self.map.remove(&KeyRef::new(&*(*ptr).key.as_ptr()));
let _ = *Box::from_raw(ptr);

在上述程式碼中,兩行函式不能被調換,否則將無法正確刪除map中的資料。

其它操作

  • set_ttl 設定元素的生存時間
  • del_ttl 刪除元素的生存時間,表示永不過期
  • get_ttl 獲取元素的生存時間
  • set_check_step 設定當前檢查lru的間隔
  • 其它Lru能進行操作的均能操作
示例

以下示例示範當資料過期時,在獲取元素將為空,演示了惰性刪除。

#[test]
#[cfg(feature="ttl")]
fn test_ttl_cache() {
    let mut lru = LruCache::new(3);
    lru.insert_with_ttl("help", "ok", 1);
    lru.insert_with_ttl("author", "tickbh", 2);
    assert_eq!(lru.len(), 2);
    std::thread::sleep(std::time::Duration::from_secs(1));
    assert_eq!(lru.get("help"), None);
    std::thread::sleep(std::time::Duration::from_secs(1));
    assert_eq!(lru.get("author"), None);
    assert_eq!(lru.len(), 0);
}

以下演示以定時刪除,將在插入及定時到的時候進行刪除資料。

#[test]
#[cfg(feature="ttl")]
fn test_ttl_check_cache() {
    let mut lru = LruCache::new(3);
    lru.set_check_step(1);
    lru.insert_with_ttl("help", "ok", 1);
    lru.insert("now", "algorithm");
    assert_eq!(lru.len(), 2);
    std::thread::sleep(std::time::Duration::from_secs(1));
    assert_eq!(lru.len(), 2);
    lru.insert_with_ttl("author", "tickbh", 3);
    assert_eq!(lru.len(), 2);
    assert_eq!(lru.get("help"), None);
    assert_eq!(lru.len(), 2);
}

完整專案地址

https://github.com/tickbh/algorithm-rs

結語

帶ttl的Lru可以一定程式上補充快取的可用性。更方便的讓您操作快取。將記憶體與命中率進行完美的結合。

相關文章