Introduction: Why Caching in Rust is a Challenge and an Advantage
In high-load systems built with Rust, caching ceases to be a simple matter of adding a library. Due to the strict ownership system, the absence of a garbage collector, and thread safety requirements, traditional approaches require rethinking. However, Rust offers unique capabilities: predictable response times, zero GC overhead, and full control over memory. In this article, we will explore advanced caching techniques: from distributed solutions (Redis, Memcached) to built-in caches (moka, lru) and their combinations.
1. Asynchronous Work with Redis via deadpool-redis
1.1. Why Not redis-rs Directly?
The redis-rs client is synchronous. For asynchronous applications on tokio, a connection pool is required. deadpool-redis provides an asynchronous pool that manages connections and automatically reconnects on failures.
1.2. Example: Caching with TTL and Serialization
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);
// Attempt to read from cache 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 seconds let _: () = redis::cmd("SETEX") .arg(&key) .arg(300) .arg(&data) .query_async(&mut conn) .await .unwrap();}1.3. Advanced Strategy: Cache-Aside with Locks
For cache stampede (when many requests simultaneously try to repopulate the cache), use an application-level mutex:
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: Lightness and Speed with memcache-async
2.1. Memcached Features for Rust
Memcached is simpler than Redis: no persistence and complex data structures, but it is faster for simple key-value operations. The memcache-async crate provides an asynchronous client on tokio.
2.2. Example: Batch Loading and Pipelining
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?;
// P