Caché
La caché almacena el resultado de un trabajo costoso — una consulta pesada, un
fragmento renderizado, una llamada a una API de terceros — para que la siguiente
petición lo obtenga al instante en lugar de recalcularlo. Rustango te da un
único trait Cache con backends intercambiables (in-memory, Redis, base de
datos), un helper de cálculo-al-fallar (get_or_set) y helpers JSON tipados.
Cambia el backend sin tocar un solo sitio de llamada: las llamadas siguen
igual, solo cambia la configuración.
¿Un término nuevo aquí? caché, TTL, clave, backend — ver el glosario.
Fuente:
rustango::cache(Cache,InMemoryCache,NullCache,get_or_set,get_json,set_json,BoxedCache,from_settings) — tras la featurecache(activa por defecto).RedisCacherequiere la featurecache-redis(desactivada por defecto).Versión ejecutable: cada fragmento está copiado de
cache_doc.rs(cargo test -p rustango --test cache_doc); el backend de base de datos se prueba con dogfooding sobre SQLite mediantecache_db_backend_sqlite_live.rs.
Tabla de contenidos
- Paso 1 — Elegir un backend
- Paso 2 — get / set / delete
- Paso 3 — get_or_set (cache-aside)
- Valores JSON tipados
- TTL y expiración
- Cambiar de backend
- Caché con multi-tenancy
- Referencia
- Véase también
Paso 1 — Elegir un backend
Cada backend implementa el mismo trait Cache, así que tu código es idéntico sea
cual sea el que elijas. El código de la aplicación mantiene un BoxedCache
(Arc<dyn Cache>) y nunca nombra el tipo concreto:
use rustango::cache::{BoxedCache, InMemoryCache};
use std::sync::Arc;
let cache: BoxedCache = Arc::new(InMemoryCache::new());
| Backend | Feature | Usar para |
|---|---|---|
InMemoryCache | cache | dev, tests, un solo proceso (HashMap por proceso + TTL) |
RedisCache | cache-redis | producción; compartido entre réplicas |
DatabaseCache | cache | producción sin Redis; una tabla rustango_cache |
NullCache | cache | deshabilitar la caché (cada lectura falla) — práctico en tests |
Paso 2 — get / set / delete
El núcleo del trait son cuatro métodos async. set toma un TTL opcional
(None = sin expiración); get retorna Option<String> (None en un fallo):
use rustango::cache::{Cache, InMemoryCache};
let cache = InMemoryCache::new();
assert_eq!(cache.get("greeting").await?, None); // miss
cache.set("greeting", "hello", None).await?; // store, no expiry
assert_eq!(cache.get("greeting").await?.as_deref(), Some("hello"));
assert!(cache.exists("greeting").await?);
cache.delete("greeting").await?; // gone
También hay variantes por lotes — get_many / set_many / delete_many.
Paso 3 — get_or_set (cache-aside)
Esta es la que usarás más. get_or_set retorna el valor cacheado, o — en un
fallo — ejecuta tu factory, almacena el resultado con un TTL y lo retorna. La
factory solo se ejecuta en un fallo:
use rustango::cache::get_or_set;
use std::time::Duration;
let stats: HomeStats = get_or_set(
&*cache, // &dyn Cache
"home:stats",
|| async { compute_home_stats(&pool).await }, // runs only on a miss
Some(Duration::from_secs(60)), // cache for 60s
).await?;
El test que lo respalda llama a get_or_set dos veces para la misma clave y
afirma que la factory se ejecutó exactamente una vez — la segunda llamada se
sirve desde la caché.
Invalida al escribir. Cache-aside significa datos obsoletos hasta que expira el TTL. Para datos que cambian, haz también
delete(key)cuando los escribes — p. ej. desde una señalpost_save— para que la siguiente lectura recalcule.
Valores JSON tipados
get_json / set_json serializan cualquier tipo Serialize/Deserialize a
JSON, de modo que cacheas structs y listas, no solo strings:
use rustango::cache::{get_json, set_json};
#[derive(serde::Serialize, serde::Deserialize)]
struct Profile { id: i64, name: String }
set_json(&*cache, "profile:7", &profile, None).await?;
let back: Option<Profile> = get_json(&*cache, "profile:7").await?; // None on a miss
(get_or_set los usa por debajo, por lo que su tipo de valor debe ser
Serialize + Deserialize.)
TTL y expiración
Pasa una Duration a set (o get_or_set) y la entrada desaparece después de
ella. Verificado: una entrada de 50 ms es legible de inmediato y desaparece tras
80 ms.
cache.set("flash", "x", Some(Duration::from_millis(50))).await?;
// ...50ms later...
assert_eq!(cache.get("flash").await?, None); // expired
InMemoryCache::with_default_ttl(d) fija un TTL por defecto que se aplica cuando
pasas None.
Un TTL es una cota superior, no una garantía
InMemoryCache está acotado en tamaño por defecto — 256 MiB o 100 000
entradas, lo que se alcance primero — con desalojo LRU aproximado. Una avalancha
de claves únicas no puede hacer crecer el proceso sin límite, pero también
significa que una entrada puede desaparecer antes de que expire su TTL si es
la menos usada recientemente cuando se alcanza el presupuesto. El desalojo
elimina primero las entradas ya expiradas y después las menos usadas
recientemente, hasta cumplir ambos presupuestos.
Así que trata una lectura de caché como «puede estar ausente» incluso dentro del TTL. Eso vale para cualquier backend de caché, pero aquí tiene una causa razonable y ajustable:
InMemoryCache::new()
.with_max_bytes(512 * 1024 * 1024) // subir el presupuesto
.with_max_entries(0) // 0 = sin límite (comportamiento previo al acotado)
El TTL en sí se aplica de forma perezosa, en la lectura — no hay hilo de desalojo en segundo plano, así que una entrada expirada sigue ocupando su hueco hasta que algo la pida o el desalojo la alcance.
Cambiar de backend
Como todo es el trait Cache, cambiar de in-memory a Redis es un cambio de una
línea en el arranque — normalmente impulsado por la configuración para que
difiera según el entorno:
// Build the cache from `[cache]` settings. `from_settings_async` is the one
// to reach for — it can build the backends that need to connect.
let cache: BoxedCache = rustango::cache::from_settings_async(&settings.cache).await?;
backend | from_settings (síncrono) | from_settings_async |
|---|---|---|
memory, null, file | ✓ | ✓ |
redis | pánico — no se puede construir de forma síncrona | ✓ |
db | pánico — necesita un &Pool en tiempo de ejecución | error; constrúyelo donde esté el pool |
El resolutor síncrono lanza un pánico en lugar de sustituir el backend
(#1400). Antes avisaba y
devolvía una caché en memoria, que no es una caché compartida degradada: es
otra distinta. Una caché por proceso multiplica el límite de
CacheRateLimitLayer por el número de réplicas y deja de hacer que
verify_single_use falle en cerrado, así que el mismo enlace de restablecimiento
funciona una vez por réplica. Ambas cosas están documentadas como funcionales
porque la caché es compartida, y un aviso al arrancar no llega a quien depura
eso una semana después.
db no puede salir de [cache] en absoluto, porque DatabaseCache necesita un
pool que los ajustes no llevan:
let cache = DatabaseCache::new(pool.clone(), "rustango_cache");
cache.ensure_table().await?;
let boxed: BoxedCache = std::sync::Arc::new(cache);
En producción, apúntalo a Redis (compartido entre todas tus réplicas):
use rustango::cache::RedisCache; // needs the `cache-redis` feature
let cache: BoxedCache = std::sync::Arc::new(RedisCache::new("redis://localhost").await?);
Las mismas llamadas get / set / get_or_set — solo cambió el constructor.
Caché con multi-tenancy
Cache es un almacén plano indexado por &str, y con multi-tenancy eso
convierte la clave natural en la clave que se filtra: un handler — o peor, una
tarea de fondo, que no tiene ningún tenant ambiental del que tirar — escribe
"stats:monthly" para un tenant y todos los demás lo leen.
Envuelve la caché compartida en un ScopedCache para que el namespace se
aplique por ti y el sitio de llamada no pueda olvidarlo:
use rustango::cache::ScopedCache;
// From the Org the resolver already produced:
let cache = ScopedCache::for_tenant(shared.clone(), &t.org.slug);
cache.set("stats:monthly", &json, ttl).await?; // stored as tenant:acme:stats:monthly
cache.get("stats:monthly").await?; // reads only acme's entry
cache.clear().await?; // drops ONLY acme's entries
ScopedCache es en sí mismo un Cache, así que encaja en cualquier cosa que
tome un BoxedCache — cache_page, cache_fragment, los rate limiters,
DistributedLock. Reenvía al backend interno con las claves mapeadas en lugar
de reimplementar nada, de modo que las primitivas nativas (Redis INCRBY,
SET NX, MGET) conservan su atomicidad y su batching.
Contadores atómicos y bloqueos. Cache::incr está detrás del
rate limiting y el bloqueo por cuenta; Cache::add
(set-if-absent) está detrás de DistributedLock. Cache::add es atómico en los tres —
RedisCache (SET NX), InMemoryCache (que mantiene su cerrojo durante el
read-modify-write) y DatabaseCache, que hace el test-and-set bajo cerrojos de
fila, de modo que una carrera se resuelve con exactamente un ganador.
Cache::incr es atómico en los dos primeros pero recae en el valor no atómico
por defecto con DatabaseCache. Un DistributedLock es seguro en los tres; un
contador o bloqueo deba ser exacto entre réplicas.
Dos cosas que conviene saber:
| Es un namespace, no una frontera | Todo sigue viviendo en un solo backend, y el código que tenga la caché sin acotar puede leer cualquier clave. La idea es que el camino ergonómico sea el correcto. |
clear() necesita enumerar claves | Va por Cache::delete_prefix, y todos los backends integrados lo implementan: InMemoryCache filtra su mapa, DatabaseCache lanza DELETE … LIKE 'prefix%' con %, _ y el carácter de escape escapados, RedisCache usa SCAN+MATCH con los metacaracteres de glob escapados, y FileCache recorre su directorio y compara la clave guardada en cada entrada. Un backend que no lo implemente ahora devuelve un error en lugar de recurrir a un fallback. |
El Cache::clear() sin acotar sigue siendo global al proceso, así que usa la
vista acotada siempre que el cambio de un solo tenant sea lo que disparó la
invalidación.
Referencia
Trait Cache: get · set(key, value, ttl) · delete · exists ·
get_many / set_many / delete_many · get_or(key, default).
Helpers libres: get_or_set(cache, key, factory, ttl) ·
get_json / set_json · from_settings(&CacheSettings).
Qué está construido sobre la caché: las sesiones del lado
del servidor, la limitación de tasa distribuida
(CacheRateLimitLayer), las claves de idempotencia y los feature flags toman
todos un BoxedCache — de modo que una sola instancia de Redis los respalda a
todos.
Véase también
- Trabajos en segundo plano — la otra mitad de mantener rápidas las peticiones (diferir el trabajo en lugar de cachear su resultado).
- Sesiones — un almacén del lado del servidor construido
sobre
Cache. - Middleware —
CacheRateLimitLayercomparte un contador entre réplicas vía la caché. - Cookbook del ORM — invalidar lecturas cacheadas desde una señal
post_save.
