Rust 實現緩存
在這篇文章中,我將描述如何在 Rust 中實現緩存,它的靈感來自於我最近在 nearcore 項目上的兩個重構。根據這種經驗,似乎錯誤地實現緩存是相當容易的,在這裏的錯誤有 “溢出” 的風險,並稍微破壞應用程序的整體架構。
讓我們從一個假想的應用程序開始,這個應用程序需要一些配置和數據庫:
struct App {
config: Config,
db: Db,
}
數據庫是一個無固定類型的鍵值存儲:
impl Db {
pub fn load(&self, key: &[u8]) -> io::Result<Option<Vec<u8>>> {
...
}
}
應用程序封裝了數據庫,並提供了根據 key 訪問數據庫值的方法:
#[derive(serde::Serialize, serde::Deserialize)]
struct Widget {
title: String,
}
impl App {
pub fn get_widget(
&self,
id: u32,
) -> io::Result<Option<Widget>> {
let key = id.to_be_bytes();
let value = match self.db.load(&key)? {
None => return Ok(None),
Some(it) => it,
};
let widget: Widget =
bincode::deserialize(&value).map_err(|it| {
io::Error::new(io::ErrorKind::InvalidData, it)
})?;
Ok(Some(widget))
}
}
現在,爲了便於討論,我們假設數據庫訪問和隨後的反序列化是相當耗時的,所以我們希望在數據庫前面添加 Widgets 緩存。
我們將使用簡單的 HashMap 作爲緩存:
struct App {
config: Config,
db: Db,
cache: HashMap<u32, Widget>,
}
我們需要修改 get_widget 方法來從緩存中返回值,如果有的話:
impl App {
pub fn get_widget(
&mut self,
id: u32,
) -> io::Result<Option<&Widget>> {
if self.cache.contains_key(&id) {
let widget = self.cache.get(&id).unwrap();
return Ok(Some(widget));
}
let key = id.to_be_bytes();
let value = match self.db.load(&key)? {
None => return Ok(None),
Some(it) => it,
};
let widget: Widget =
bincode::deserialize(&value).map_err(|it| {
io::Error::new(io::ErrorKind::InvalidData, it)
})?;
self.cache.insert(id, widget);
let widget = self.cache.get(&id).unwrap();
Ok(Some(widget))
}
}
最大的變化是 & mut self 。因爲我們需要修改緩存,而獲得該功能的最簡單方法是要求一個獨佔的可變引用。
像如下的方式定義方法會有很多問題:
fn get(&mut self) -> &Widget
首先,這些方法彼此衝突。例如,下面的代碼不能工作,因爲我們將嘗試獨佔地使用可變借用兩次:
let app: &mut App = ...;
let w1 = app.get_widget(1)?;
let w2 = app.get_widget(2)?;
其次,&mut method 與 & method 衝突。由於 get_widget 返回一個共享引用,我們應該能夠調用 & method。所以,我們可以期待如下的代碼正常工作:
let w: &Widget = app.get_widget(1)?.unwrap();
let c: &Color = &app.config.main_color;
唉,它不能工作。Rust 借用檢查器沒有區分 mut 和非 mut 的生存期。所以,儘管 w 只是 & Widget,但它的生命期和 & mut 本身是一樣的,所以當 widget 存在時,應用程序仍然是可變的。
再次,也許是最重要的一點,&mut 本身變得像病毒一樣——程序中的大多數函數開始需要 & mut,並且失去了類型系統定義的只讀和讀寫操作之間的區別。"這個函數只能修改緩存" 和 "這個函數可以修改所有東西" 之間沒有區別。
讓我們看看如何更好地解決這個問題!
這類問題的一般思路是思考所有權和借用的場景應該是什麼,並努力實現它,而不是僅僅遵循編譯器的建議。
讓我們從一個簡化的例子開始。假設只需要處理一個 Widget。在這種情況下,我們想要這樣的代碼如下:
struct App {
...
cache: Option<Widget>,
}
impl App {
fn get_widget(&self) -> &Widget {
if let Some(widget) = &self.cache {
return widget;
}
self.cache = Some(create_widget());
self.cache.as_ref().unwrap()
}
}
這不能工作——修改緩存需要 & mut,這是我們非常希望避免的。然而,考慮到這個模式,感覺它應該是有效的 - 我們在運行時強制緩存的內容永遠不會被覆蓋。也就是說,我們實際上在運行到 11 行時,對緩存有獨佔訪問權,我們只是不能向類型系統解釋這一點。但我們可以使用 unsafe。更重要的是,Rust 的類型系統足夠強大,可以將 unsafe 的使用封裝到一個安全且通常可重用的 API 中。讓我們看看 once_cell crate:
struct App {
...
cache: once_cell::sync::OnceCell<Widget>,
}
impl App {
fn get_widget(&self) -> &Widget {
self.cache.get_or_init(create_widget)
}
}
回到最初的 HashMap 示例,我們可以在這裏應用相同的邏輯:只要不覆蓋、刪除或移動值,就可以安全地返回對它們的引用。這次使用 elsa crate:
struct App {
config: Config,
db: Db,
cache: elsa::map::FrozenMap<u32, Box<Widget>>,
}
impl App {
pub fn get_widget(
&self,
id: u32,
) -> io::Result<Option<&Widget>> {
if let Some(widget) = self.cache.get(&id) {
return Ok(Some(widget));
}
let key = id.to_be_bytes();
let value = match self.db.load(&key)? {
None => return Ok(None),
Some(it) => it,
};
let widget: Widget =
bincode::deserialize(&value).map_err(|it| {
io::Error::new(io::ErrorKind::InvalidData, it)
})?;
let widget = self.cache.insert(id, Box::new(widget));
Ok(Some(widget))
}
}
第三種情況是有界緩存。如果緩存的用戶獲得了一個 & T,而對應的條目被移除,那麼引用就會懸空。在這種情況下,我們希望緩存的客戶端共同擁有該值。這使用 Rc 很容易處理這種情況:
struct App {
config: Config,
db: Db,
cache: RefCell<lru::LruCache<u32, Rc<Widget>>>,
}
impl App {
pub fn get_widget(
&self,
id: u32,
) -> io::Result<Option<Rc<Widget>>> {
{
let mut cache = self.cache.borrow_mut();
if let Some(widget) = cache.get(&id) {
return Ok(Some(Rc::clone(widget)));
}
}
let key = id.to_be_bytes();
let value = match self.db.load(&key)? {
None => return Ok(None),
Some(it) => it,
};
let widget: Widget =
bincode::deserialize(&value).map_err(|it| {
io::Error::new(io::ErrorKind::InvalidData, it)
})?;
let widget = Rc::new(widget);
{
let mut cache = self.cache.borrow_mut();
cache.put(id, Rc::clone(&widget));
}
Ok(Some(widget))
}
}
總結
在實現緩存時,最容易實現的方式是這樣的方法簽名:
fn get(&mut self) -> &T
這通常會導致後續的問題。通常更好的方法是利用一些內部的可變性,得到下面這兩個方法中的任何一個:
fn get(&self) -> &T
fn get(&self) -> T
本文翻譯自:
https://matklad.github.io/2022/06/11/caches-in-rust.html
coding 到燈火闌珊 專注於技術分享,包括 Rust、Golang、分佈式架構、雲原生等。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/WkyrsIynayvxMkllNmg7vA