瑣碎資料快取在 Rust 中

https://medium.com/@ellysam/idiomatic-caching-in-rust-133784ed1147

Rust 中的慣用快取

快取是軟體開發中的一個關鍵技術,它通過臨時存儲昂貴的檢索或計算數據來改進應用程序的效率和性能。在 Rust 中,以慣用方式實現快取涉及利用語言的功能,例如所有權、並行性和安全性。

在此部落格中,我將描述如何在 Rust 中實現快取,靈感來自我為 Redis 協議處理的後端伺服器 redis-proto 項目建立的最近的快取系統。有效的快取對性能優化至關重要,而 Rust 的功能為創建有效且安全的快取系統提供了堅實的基礎。根據這個經驗,實現快取錯誤非常容易,而在那裡犯錯誤會冒著對整個應用程式架構的風險。

為了爭辯起見,假設資料庫訪問和後續反序列化是昂貴的,我們想在資料庫前添加一個 Widget 快取。以資料導向思維,迫使我們排除反序列化步驟,但這次我們不會追求該想法。

我們將使用一個簡單的 HashMap 來進行快取:

“`rust
struct App {
config: Config,
db: Db,
cache: HashMap,
}
“`

我們需要修改 get_widget 方法,如果有的話,從快取中返回值:

“`rust
impl App {
pub fn get_widget(
&mut self,
id: u32,
) -> io::Result> {
// 檢查 Widget 是否已經在快取中
if self.cache.contains_key(&id) {
let widget = self.cache.get(&id).unwrap();
return Ok(Some(widget));
}

// 如果在快取中沒有這個 Widget,就從資料庫載入
let key = id.to_be_bytes();
let value = match self.db.load(&key)? {
None => return Ok(None),
Some(it) => it,
};

// 反序列化 Widget
let widget: Widget =
bincode::deserialize(&value).map_err(|it| {
io::Error::new(io::ErrorKind::InvalidData, it)
})?;

// 將 Widget 插入快取
self.cache.insert(id, widget);
let widget = self.cache.get(&id).unwrap();

Ok(Some(widget))
}
}
“`

最大的改變是 &mut self。即使在讀取 Widget 時,我們也需要修改快取,最簡單的辦法是要求獲得一個獨佔引用。

我想爭辯說這條最大阻力的路不會帶我們到一個好的地方。下列形狀的方法存在許多問題:

“`rust
fn get(&mut self) -> &Widget
“`

首先,這樣的方法會互相衝突。例如,下面的代碼就無法運作,因為我們會嘗試兩次專有地借用 app。

“`rust
let app: &mut App = …;
let w1 = app.get_widget(1)?;
let w2 = app.get_widget(2)?;
“`

其次,&mut 方法甚至與 & 方法衝突。一開始,看起來似乎 get_widget 返回了一個共享引用,我們應該能夠調用 & 方法。因此,人們可以預期類似這樣的東西運作:

“`rust
let w: &Widget = app.get_widget(1)?.unwrap();
let c: &Color = &app.config.main_color;
“`

然而,它並不運作。Rust 借用檢查器不區分 mut 和非 mut 生存期(出於一個很好的原因:這樣做是不安全的)。因此,雖然 w 只是 &Widget,但正在那裡的生存期與 &mut self 上的生存期相同,因此當 Widget 存在時,應用仍然被直接借用。

第三,也許是最重要的一點,&mut self 幾乎讓程式中的大部分函數開始要求 &mut,你失去了唯讀和讀寫操作之間的類型系統區分。沒有區別了 “這個函數只能修改快取” 和 “這個函數可以修改絕大部分東西”。

最後,即使實現 get_widget 也不是令人愉快的。你們中有經驗豐富的 Rustaceans 可能會對不必要重復的 hashmap 查找感到不快。但試圖用 entry-API 擺脫這些所遇到的當前借用檢查器限制。

讓我們看看我們如何更好地解決這個問題!

對於這類問題的一般想法是思考該擁有權和借用狀況應該是怎樣,並嘗試實現這樣做,而不僅僅是按照編譯器的建議。也就是說,大部分時間只是使用 &mut 和 & 就像編譯器指導你的那樣,這是一條通向成功的路徑,因為,事實證明,大多數程式碼自然地遵循簡單的別名規則。但也有例外情況,重要的是要將它們識別為例外情況並使用內部可變性來實現具有意義的別名結構。

讓我們從一個簡化的情況開始。假設我們只有一個 Widget 要處理。在這種情況下,我們想要像這樣的東西:

“`rust
struct App {

cache: Option,
}

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,我們很願意避免這種情況。不過,考慮到這個模式,感覺應該是有效的,我們在運行時強制快取的內容不會被覆蓋。也就是說,我們實際上在第一行的 self.cache 上有對快取的專屬存取權,只是我們無法向類型系統解釋這一點。但我們可以利用不安全來實現。而且 Rust 的類型系統足夠強大,可以將該不安全性包裹為安全且通常可重複使用的 API。因此,讓我們使用 once_cell crate:

“`rust
struct App {

cache: once_cell::sync::OnceCell,
}

impl App {
fn get_widget(&self) -> &Widget {
self.cache.get_or_init(create_widget)
}
}
“`

回到原始的哈希地圖的例子,我們可以在此應用相同的邏輯:只要我們永遠不覆蓋、刪除或移動值,我們可以安全地返回對它們的引用。elsa crate 處理這種情況:

“`rust
struct App {
config: Config,
db: Db,
cache: elsa::map::FrozenMap>,
}

impl App {
pub fn get_widget(
&self,
id: u32,
) -> io::Result> {
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 處理:

“`rust
struct App {
config: Config,
db: Db,
cache: RefCell>>,
}

impl App {
pub fn get_widget(
&self,
id: u32,
) -> io::Result>> {
{
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))
}
}
“`

總而言之:當實現快取時,阻力最小的路徑是得到這樣的簽名:

“`rust
fn get(&mut self) -> &T
“`

這通常會導致以後出現問題。通常最好使用一些內部可變性,而獲得以下任一個更好:

“`rust
fn get(&self) -> &T
fn get(&self) -> T
“`

這是更一般效果的一個實例:儘管 “可變性” 術語,但 Rust 引用跟蹤的不是可變性,而是別名。可變性和專屬存取是相關的,但並不完全相同。重要的是要識別需要使用內部可變性的情況,它們通常在架構上很有趣。

要了解該別名和可變性之間的關係更多資訊,我建議閱讀下列兩篇文章:

[Rust: A unique perspective](https://smallcultfollowing.com/babysteps/blog/2018/11/27/rust-a-unique-perspective/)
[Accurate mental model for Rust’s reference types](https://boats.gitlab.io/blog/post/rust-refs/)
最後,我應該補充說明了 “借用檢查器” 的限制 (有著高超和幽默),這在此文件中有解釋:

[Polonius the Crab](https://rust-lang.github.io/rfcs/2094-polonius.html)

這就是全部了!

via Rust on Medium

July 4, 2024 at 02:52AM

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *