Rustango docs
← Guías

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.

Caching in Rustango: get_or_set checks the cache, runs the factory only on a miss, stores the result with a TTL, and serves hits instantly; the same Cache trait backs InMemory, Redis, and DB

¿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 feature cache (activa por defecto). RedisCache requiere la feature cache-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 mediante cache_db_backend_sqlite_live.rs.

Tabla de contenidos


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());
BackendFeatureUsar para
InMemoryCachecachedev, tests, un solo proceso (HashMap por proceso + TTL)
RedisCachecache-redisproducción; compartido entre réplicas
DatabaseCachecacheproducción sin Redis; una tabla rustango_cache
NullCachecachedeshabilitar 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ñal post_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?;
backendfrom_settings (síncrono)from_settings_async
memory, null, file✓✓
redispánico — no se puede construir de forma síncrona✓
dbpánico — necesita un &Pool en tiempo de ejecuciónerror; 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 fronteraTodo 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 clavesVa 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 — CacheRateLimitLayer comparte un contador entre réplicas vía la caché.
  • Cookbook del ORM — invalidar lecturas cacheadas desde una señal post_save.