Rustango docs
← Guides

Mise en cache

La mise en cache stocke le résultat d'un travail coûteux — une requête lourde, un fragment rendu, un appel d'API tierce — afin que la requête suivante l'obtienne instantanément au lieu de le recalculer. Rustango vous donne un unique trait Cache avec des backends interchangeables (in-memory, Redis, base de données), un helper de calcul-au-miss (get_or_set), et des helpers JSON typés. Changez de backend sans toucher à un seul site d'appel : les appels restent identiques, seule la configuration change.

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 terme vous est inconnu ? cache, TTL, clé, backend — voir le glossaire.

Source : rustango::cache (Cache, InMemoryCache, NullCache, get_or_set, get_json, set_json, BoxedCache, from_settings) — derrière la feature cache (activée par défaut). RedisCache requiert la feature cache-redis (désactivée par défaut).

Version exécutable : chaque extrait est copié depuis cache_doc.rs (cargo test -p rustango --test cache_doc) ; le backend base de données est éprouvé en dogfooding sur SQLite par cache_db_backend_sqlite_live.rs.

Table des matières


Étape 1 — Choisir un backend

Chaque backend implémente le même trait Cache, donc votre code est identique quel que soit celui que vous choisissez. Le code applicatif détient un BoxedCache (Arc<dyn Cache>) et ne nomme jamais le type concret :

use rustango::cache::{BoxedCache, InMemoryCache};
use std::sync::Arc;

let cache: BoxedCache = Arc::new(InMemoryCache::new());
BackendFeatureÀ utiliser pour
InMemoryCachecachedev, tests, processus unique (HashMap par processus + TTL)
RedisCachecache-redisproduction ; partagé entre réplicas
DatabaseCachecacheproduction sans Redis ; une table rustango_cache
NullCachecachedésactiver la mise en cache (chaque lecture rate) — pratique en tests

Étape 2 — get / set / delete

Le cœur du trait est quatre méthodes async. set prend un TTL optionnel (None = pas d'expiration) ; get retourne Option<String> (None sur un miss) :

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

Il existe aussi des variantes par lots — get_many / set_many / delete_many.


Étape 3 — get_or_set (cache-aside)

C'est celle que vous utiliserez le plus. get_or_set retourne la valeur en cache, ou — sur un miss — exécute votre factory, stocke le résultat avec un TTL, et le retourne. La factory ne s'exécute que sur un miss :

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?;

Le test sous-jacent appelle get_or_set deux fois pour la même clé et affirme que la factory s'est exécutée exactement une fois — le second appel est servi depuis le cache.

Invalidez à l'écriture. Le cache-aside signifie des données périmées jusqu'à ce que le TTL expire. Pour des données qui changent, faites aussi delete(key) quand vous les écrivez — p. ex. depuis un signal post_save — pour que la lecture suivante recalcule.


Valeurs JSON typées

get_json / set_json sérialisent n'importe quel type Serialize/Deserialize en JSON, de sorte que vous mettez en cache des structs et des listes, pas seulement des chaînes :

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 les utilise en interne, c'est pourquoi son type de valeur doit être Serialize + Deserialize.)


TTL et expiration

Passez une Duration à set (ou get_or_set) et l'entrée disparaît après elle. Vérifié : une entrée de 50 ms est lisible immédiatement et disparue après 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) définit un TTL par défaut appliqué lorsque vous passez None.

Un TTL est une borne supérieure, pas une garantie

InMemoryCache est borné en taille par défaut — 256 Mio ou 100 000 entrées, selon ce qui est atteint en premier — avec une éviction LRU approximative. Un afflux de clés uniques ne peut donc pas faire croître le processus sans limite, mais cela signifie aussi qu'une entrée peut disparaître avant l'expiration de son TTL si elle est la moins récemment utilisée au moment où le budget est atteint. L'éviction supprime d'abord les entrées déjà expirées, puis les moins récemment utilisées, jusqu'à respecter les deux budgets.

Traitez donc une lecture de cache comme « peut être absente » même à l'intérieur du TTL. C'est vrai de tout backend de cache, mais ici la cause est compréhensible et réglable :

InMemoryCache::new()
    .with_max_bytes(512 * 1024 * 1024)   // relever le budget
    .with_max_entries(0)                 // 0 = illimité (comportement d'avant le bornage)

Le TTL lui-même est appliqué paresseusement, à la lecture — il n'y a pas de thread d'éviction en arrière-plan, donc une entrée expirée occupe encore sa place jusqu'à ce que quelque chose la demande ou que l'éviction l'atteigne.


Changer de backend

Parce que tout repose sur le trait Cache, passer de l'in-memory à Redis est un changement d'une ligne au démarrage — habituellement piloté par la config afin qu'il diffère selon l'environnement :

// 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 (synchrone)from_settings_async
memory, null, file✓✓
redispanique — non constructible de façon synchrone✓
dbpanique — exige un &Pool à l'exécutionerreur ; construisez-le là où est le pool

Le résolveur synchrone panique plutôt que de substituer un backend (#1400). Il avertissait auparavant et renvoyait un cache en mémoire, qui n'est pas un cache partagé dégradé : c'est un autre cache. Un cache par processus multiplie la limite de CacheRateLimitLayer par le nombre de réplicas et empêche verify_single_use d'échouer en fermeture, si bien que le même lien de réinitialisation fonctionne une fois par réplica. Les deux sont documentés comme fonctionnant parce que le cache est partagé, et un avertissement au démarrage n'atteint pas la personne qui débogue cela une semaine plus tard.

db ne peut pas venir de [cache] du tout, car DatabaseCache a besoin d'un pool que les réglages ne transportent pas :

let cache = DatabaseCache::new(pool.clone(), "rustango_cache");
cache.ensure_table().await?;
let boxed: BoxedCache = std::sync::Arc::new(cache);

En production, pointez-le vers Redis (partagé entre tous vos réplicas) :

use rustango::cache::RedisCache;   // needs the `cache-redis` feature
let cache: BoxedCache = std::sync::Arc::new(RedisCache::new("redis://localhost").await?);

Les mêmes appels get / set / get_or_set — seul le constructeur a changé.


La mise en cache en multi-tenancy

Cache est un magasin plat indexé par &str, ce qui en multi-tenancy fait de la clé naturelle la clé qui fuit : un handler — ou pire une tâche d'arrière-plan, qui n'a aucun tenant ambiant où puiser — écrit "stats:monthly" pour un tenant et tous les autres le relisent.

Enveloppez le cache partagé dans un ScopedCache pour que l'espace de noms soit appliqué à votre place et que le site d'appel ne puisse pas l'oublier :

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 est lui-même un Cache, il se glisse donc partout où un BoxedCache est attendu — cache_page, cache_fragment, les rate limiters, DistributedLock. Il transmet au backend interne avec les clés remappées plutôt que de réimplémenter quoi que ce soit, si bien que les primitives natives (Redis INCRBY, SET NX, MGET) gardent leur atomicité et leur traitement par lots.

Compteurs atomiques et verrous. Cache::incr est derrière le rate limiting et le verrouillage par compte ; Cache::add (set-if-absent) est derrière DistributedLock. Cache::add est atomique sur les trois — RedisCache (SET NX), InMemoryCache (qui garde son verrou pendant le read-modify-write) et DatabaseCache, qui effectue le test-and-set sous des verrous de ligne, de sorte qu'une course se résout en exactement un gagnant. Cache::incr est atomique sur les deux premiers mais retombe sur le comportement non atomique par défaut avec DatabaseCache — un DistributedLock est donc sûr sur les trois, mais passez à Redis lorsqu'un compteur ou un verrou doit être exact entre réplicas.

Deux choses à savoir :

C'est un espace de noms, pas une frontièreTout vit encore dans un seul backend, et du code détenant le cache non cadré peut lire n'importe quelle clé. L'idée est que le chemin ergonomique soit le chemin correct.
clear() a besoin d'énumérer les clésIl passe par Cache::delete_prefix, et tous les backends intégrés l'implémentent : InMemoryCache filtre sa map, DatabaseCache émet DELETE … LIKE 'prefix%' avec %, _ et le caractère d'échappement eux-mêmes échappés, RedisCache utilise SCAN+MATCH avec les métacaractères glob échappés, et FileCache parcourt son répertoire et compare la clé stockée dans chaque entrée. Un backend qui ne l'implémente pas renvoie désormais une erreur plutôt qu'un repli.

Le Cache::clear() non cadré reste global au processus : utilisez la vue cadrée dès que c'est le changement d'un seul tenant qui a déclenché l'invalidation.


Référence

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).

Ce qui est bâti sur le cache : les sessions côté serveur, la limitation de débit distribuée (CacheRateLimitLayer), les clés d'idempotence, et les feature flags prennent tous un BoxedCache — de sorte qu'une seule instance Redis les adosse tous.


Voir aussi

  • Tâches d'arrière-plan — l'autre moitié pour garder les requêtes rapides (différer le travail au lieu de mettre en cache son résultat).
  • Sessions — un stockage côté serveur bâti sur Cache.
  • Middleware — CacheRateLimitLayer partage un compteur entre réplicas via le cache.
  • Cookbook de l'ORM — invalider les lectures en cache depuis un signal post_save.