Введение: Почему кэширование в Rust — это вызов и преимущество
В высоконагруженных системах на Rust кэширование перестаёт быть простым добавлением библиотеки. Из-за строгой системы владения, отсутствия сборщика мусора и требований к потокобезопасности, традиционные подходы требуют переосмысления. Однако Rust даёт уникальные возможности: предсказуемое время отклика, нулевые накладные расходы на GC и полный контроль над памятью. В этой статье мы разберём продвинутые техники кэширования: от распределённых решений (Redis, Memcached) до встроенных кэшей (moka, lru) и их комбинации.
1. Асинхронная работа с Redis через deadpool-redis
1.1. Почему не redis-rs напрямую?
Клиент redis-rs синхронный. Для асинхронных приложений на tokio требуется пул соединений. deadpool-redis предоставляет асинхронный пул, который управляет подключениями и автоматически переподключается при сбоях.
1.2. Пример: кэширование с TTL и сериализацией
use deadpool_redis::{Config, Runtime, Pool};
use serde::{Serialize, Deserialize};
use tokio::sync::OnceCell;
#[derive(Serialize, Deserialize, Clone)]
struct User {
id: u64,
name: String,
email: String,
}
async fn get_pool() -> Pool {
static POOL: OnceCell<Pool> = OnceCell::const_new();
POOL.get_or_init(|| async {
let cfg = Config::from_url("redis://127.0.0.1/");
cfg.create_pool(Some(Runtime::Tokio1)).unwrap()
}).await.clone()
}
async fn get_user_cached(user_id: u64) -> Option<User> {
let pool = get_pool().await;
let mut conn = pool.get().await.unwrap();
let key = format!("user:{}", user_id);
// Попытка прочитать из кэша
if let Ok(Some(data)) = redis::cmd("GET").arg(&key).query_async::<Option<Vec<u8>>>(&mut conn).await {
if let Ok(user) = bincode::deserialize::<User>(&data) {
return Some(user);
}
}
None
}
async fn set_user_cache(user: &User) {
let pool = get_pool().await;
let mut conn = pool.get().await.unwrap();
let key = format!("user:{}", user.id);
let data = bincode::serialize(user).unwrap();
// TTL 300 секунд
let _: () = redis::cmd("SETEX")
.arg(&key)
.arg(300)
.arg(&data)
.query_async(&mut conn)
.await
.unwrap();
}
1.3. Продвинутая стратегия: Cache-Aside с блокировками
При cache stampede (когда множество запросов одновременно пытаются перезаполнить кэш) используйте мьютекс на уровне приложения:
use tokio::sync::Mutex;
use std::collections::HashMap;
struct CacheLock {
locks: Mutex<HashMap<String, tokio::sync::Semaphore>>,
}
impl CacheLock {
async fn get_or_compute<F, Fut, T>(&self, key: &str, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
let sem = {
let mut map = self.locks.lock().await;
map.entry(key.to_string())
.or_insert_with(|| tokio::sync::Semaphore::new(1))
.clone()
};
let _permit = sem.acquire().await.unwrap();
f().await
}
}
2. Memcached: лёгкость и скорость с memcache-async
2.1. Особенности Memcached для Rust
Memcached проще Redis: нет персистентности и сложных структур данных, но он быстрее для простых key-value операций. Крейт memcache-async предоставляет асинхронный клиент на tokio.
2.2. Пример: пакетная загрузка и конвейеризация
use memcache_async::{Client, Protocol, Value};
use bytes::Bytes;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::connect_async("memcache://127.0.0.1:11211").await?;
// Пакетная установка
let items = vec![
("key1", b"value1" as &[u8]),
("key2", b"value2"),
("key3", b"value3"),
];
for (key, val) in &items {
client.set(key, val, 300).await?; // TTL 300 сек
}
// Конвейеризация через get_multi
let keys = vec!["key1", "key2", "key3"];
let results: Vec<Option<Value>> = client.get_multi(&keys).await?;
for (key, val) in keys.iter().zip(results.iter()) {
if let Some(v) = val {
println!("{}: {:?}", key, String::from_utf8_lossy(&v.0));
}
}
Ok(())
}
2.3. Когда выбирать Memcached вместо Redis?
- Чистые key-value с TTL
- Максимальная производительность чтения (меньше накладных расходов на парсинг протокола)
- Отсутствие необходимости в pub/sub, списках или множествах
3. Встроенные решения: moka и lru — когда внешние сервисы не нужны
3.1. Moka: высокопроизводительный concurrent cache
Крейт moka (форк caffeine из Java) предоставляет потокобезопасный кэш с автоматической вытеснением по LFU/LRU. Идеален для in-process кэширования в веб-серверах.
use moka::sync::Cache;
use std::time::Duration;
fn create_cache() -> Cache<String, Vec<u8>> {
Cache::builder()
.max_capacity(10_000) // Максимум 10 000 записей
.time_to_live(Duration::from_secs(600)) // TTL 10 минут
.time_to_idle(Duration::from_secs(120)) // Удалить, если не использовалось 2 минуты
.build()
}
fn main() {
let cache = create_cache();
cache.insert("key".to_string(), b"expensive_data".to_vec());
if let Some(data) = cache.get("key") {