在这篇文章中,我将描述如何在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