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