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
定義了ttl
的feature
來啟用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可以一定程式上補充快取的可用性。更方便的讓您操作快取。將記憶體與命中率進行完美的結合。