Redis & Caching
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against redis 1.7.1 (redis-rs), deadpool-redis 0.23.1 and moka 0.12.16 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Redis itself — data types, persistence, eviction, clustering — is covered in the Redis section; this page shows how an axum service uses it. Everything here also works with Valkey, the open-source Redis fork.
[dependencies]
redis = { version = "1.7.1", features = ["tokio-comp", "connection-manager"] }
deadpool-redis = "0.23.1" # alternative to ConnectionManager
moka = { version = "0.12.16", features = ["future"] }
Connections: ConnectionManager or a pool?
-
redis::aio::ConnectionManagerwraps one multiplexed connection: many concurrent commands share a single TCP connection (Redis pipelines them), and the manager reconnects automatically after failures. It isCloneand cheap, so it goes straight into axum state. This is the right default for request/response commands. -
deadpool-redismaintains a pool of connections. Use it for commands that block a connection (BLPOP,WATCH/MULTItransactions, long Lua scripts) or when one connection’s throughput is not enough. -
Pub/sub subscribers need a dedicated connection (
client.get_async_pubsub()), because a subscribed connection can do nothing else.
Cache-aside with TTLs
Cache-aside (lazy loading): read from the cache; on a miss, load from the database and store the result with a time-to-live; on writes, update the database and delete the cache entry. Bookshelf caches book details, which are read far more often than they change:
use std::time::Duration;
use axum::{
Json, Router,
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
routing::get,
};
use redis::{AsyncCommands, aio::ConnectionManager};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
const TTL: Duration = Duration::from_secs(300);
#[derive(Clone)]
struct AppState {
db: PgPool,
redis: ConnectionManager,
}
#[derive(Serialize, Deserialize, Clone)]
struct BookDetails {
isbn: String,
title: String,
year: i16,
}
fn cache_key(isbn: &str) -> String {
format!("bookshelf:v1:book:{isbn}") // namespace + version: change "v1" when the cached shape changes
}
async fn show(State(state): State<AppState>, Path(isbn): Path<String>) -> Result<impl IntoResponse, StatusCode> {
let mut redis = state.redis.clone();
let key = cache_key(&isbn);
// 1. try the cache; a Redis failure must not break the endpoint
match redis.get::<_, Option<String>>(&key).await {
Ok(Some(json)) => {
if let Ok(book) = serde_json::from_str::<BookDetails>(&json) {
return Ok(([("x-cache", "hit")], Json(book)));
}
}
Ok(None) => {}
Err(err) => tracing::warn!(%err, "cache read failed"),
}
// 2. miss: load from Postgres
let book = sqlx::query_as!(BookDetails, "SELECT isbn, title, year FROM books WHERE isbn = $1", isbn)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// 3. populate with a TTL (SET key value EX 300)
if let Ok(json) = serde_json::to_string(&book) {
let stored: redis::RedisResult<()> = redis.set_ex(&key, json, TTL.as_secs()).await;
if let Err(err) = stored {
tracing::warn!(%err, "cache write failed");
}
}
Ok(([("x-cache", "miss")], Json(book)))
}
async fn rename(State(state): State<AppState>, Path(isbn): Path<String>, title: String) -> Result<StatusCode, StatusCode> {
let updated = sqlx::query!("UPDATE books SET title = $1, version = version + 1 WHERE isbn = $2", title, isbn)
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if updated.rows_affected() == 0 {
return Err(StatusCode::NOT_FOUND);
}
// invalidate AFTER the database write succeeded
let mut redis = state.redis.clone();
let deleted: redis::RedisResult<()> = redis.del(cache_key(&isbn)).await;
if let Err(err) = deleted {
tracing::warn!(%err, "cache invalidation failed; entry expires with its TTL");
}
Ok(StatusCode::NO_CONTENT)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let client = redis::Client::open(std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()))?;
let redis = ConnectionManager::new(client).await?;
let app = Router::new()
.route("/api/v1/books/{isbn}", get(show))
.route("/api/v1/books/{isbn}/title", axum::routing::put(rename))
.layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(
header::CACHE_CONTROL,
header::HeaderValue::from_static("private, max-age=0, must-revalidate"),
))
.with_state(AppState { db, redis });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Source: redis: ConnectionManager and axum example: tokio-redis
$ curl -si localhost:3000/api/v1/books/9780441013593 | grep x-cache
x-cache: miss
$ curl -si localhost:3000/api/v1/books/9780441013593 | grep x-cache
x-cache: hit
$ redis-cli TTL bookshelf:v1:book:9780441013593
(integer) 298
Source: Redis: TTL
Design notes:
-
Fail open. The cache is an optimisation; every Redis error is logged and the request continues against the database. Set short timeouts on the Redis client so a sick Redis cannot slow every request.
-
Invalidate, don’t update. Deleting the key on writes (and letting the next read repopulate it) avoids racing writers leaving stale data; the TTL bounds staleness if a delete is lost.
-
Version your keys (
v1) so a deployment that changes the cached JSON shape does not read old entries. -
Stampedes: when a hot key expires, many requests miss at once and all hit the database. Mitigations: add random jitter to TTLs, refresh hot keys before expiry, or let one request recompute while others wait (moka’s
get_withdoes this in process).
The deadpool-redis equivalent of the connection setup is
deadpool_redis::Config::from_url(url).create_pool(Some(deadpool_redis::Runtime::Tokio1))?, and each handler then
calls pool.get().await? to borrow a connection.
HTTP caching: let clients and CDNs do the work
The cheapest cache is the one in front of your service. Set Cache-Control deliberately on every response:
| Header | Meaning |
|---|---|
|
fingerprinted static assets ( |
|
public catalogue pages and API lists that may be a minute old |
|
per-user data; clients revalidate with
|
|
secrets, tokens, account pages |
Vary must list every request header that changes the response (Accept-Encoding, Accept, Origin), or shared
caches will serve the wrong variant.
In-process caches with moka
For small, hot data — configuration from the database, the list of genres, rate-limit counters per instance — a
memory cache avoids even the Redis round trip. moka is a concurrent cache with size limits, TTL/TTI expiry and an
async API; get_with makes concurrent callers for the same key wait for a single computation:
use std::time::Duration;
use axum::{Json, Router, extract::State, routing::get};
use moka::future::Cache;
#[derive(Clone)]
struct AppState {
genres: Cache<&'static str, std::sync::Arc<Vec<String>>>,
}
async fn load_genres_from_db() -> Vec<String> {
tokio::time::sleep(Duration::from_millis(200)).await; // pretend this is slow
vec!["fantasy".into(), "science-fiction".into(), "classics".into()]
}
async fn genres(State(state): State<AppState>) -> Json<Vec<String>> {
// only one caller computes the value; concurrent callers await the same future
let value = state.genres.get_with("all", async { std::sync::Arc::new(load_genres_from_db().await) }).await;
Json(value.as_ref().clone())
}
#[tokio::main]
async fn main() {
let state = AppState {
genres: Cache::builder().max_capacity(1_000).time_to_live(Duration::from_secs(60)).build(),
};
let app = Router::new().route("/api/v1/genres", get(genres)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: moka: future::Cache
Each instance has its own memory cache, so invalidation does not reach the others; keep TTLs short or broadcast invalidations over pub/sub (below).
Pub/sub: fan-out across instances
The live review feed (see WebSockets) uses a tokio::sync::broadcast channel to
reach every client connected to this process. With several replicas, an event raised on one instance must reach
clients on all of them. Redis pub/sub bridges them: every instance subscribes to a channel and re-broadcasts what it
receives locally, and publishing goes through Redis:
use axum::{
Router,
extract::State,
routing::{get, post},
};
use futures::StreamExt;
use redis::{AsyncCommands, aio::ConnectionManager};
use tokio::sync::broadcast;
const CHANNEL: &str = "bookshelf:reviews";
#[derive(Clone)]
struct AppState {
redis: ConnectionManager,
local: broadcast::Sender<String>,
}
/// One task per instance: Redis channel -> local broadcast.
async fn relay(client: redis::Client, local: broadcast::Sender<String>) -> redis::RedisResult<()> {
let mut pubsub = client.get_async_pubsub().await?;
pubsub.subscribe(CHANNEL).await?;
let mut messages = pubsub.on_message();
while let Some(msg) = messages.next().await {
if let Ok(payload) = msg.get_payload::<String>() {
let _ = local.send(payload); // Err only means no local subscriber right now
}
}
Ok(())
}
async fn post_review(State(state): State<AppState>, body: String) -> &'static str {
let mut redis = state.redis.clone();
let published: redis::RedisResult<i64> = redis.publish(CHANNEL, body).await;
match published {
Ok(_) => "published\n",
Err(_) => "queued for retry\n",
}
}
async fn next_review(State(state): State<AppState>) -> String {
let mut rx = state.local.subscribe();
match tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await {
Ok(Ok(review)) => format!("{review}\n"),
_ => "no review yet\n".to_owned(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = redis::Client::open(std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()))?;
let (local, _) = broadcast::channel(256);
tokio::spawn(relay(client.clone(), local.clone()));
let state = AppState { redis: ConnectionManager::new(client).await?, local };
let app = Router::new()
.route("/api/v1/reviews", post(post_review))
.route("/api/v1/reviews/next", get(next_review))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Source: redis: aio::PubSub
$ curl -s localhost:3000/api/v1/reviews/next & # waits for the next review
$ curl -s -d 'Dune: 5 stars' localhost:3000/api/v1/reviews
published
Dune: 5 stars
Source: Redis: Pub/Sub and redis: aio::PubSub
Redis pub/sub is fire-and-forget: a subscriber that is disconnected at the moment of publishing misses the message.
That is acceptable for live notifications; for events that must not be lost, use Redis Streams (XADD/XREADGROUP)
or a message broker (see Messaging). The relay task should reconnect with backoff
when its loop ends.
Where the books are outdated
Yoon (Beginning Axum, chapter 2) caches with an in-memory HashMap behind a mutex, which grows without bound and is
per instance; moka and Redis provide eviction and sharing. Older redis-rs examples (0.2x) open a dedicated connection per
request with get_async_connection; current redis-rs (1.x) provides get_multiplexed_async_connection and
ConnectionManager, which share one connection and reconnect automatically.