In breve. Una cache Redis sopravvive al riavvio dell'applicazione, e quindi dopo un rilascio può servire per ore valori calcolati dal codice vecchio. Nel mio backend la risposta non è svuotare Redis ma cambiare il prefisso delle chiavi: ogni chiave ha la forma <versione>:<cache>::<chiave>, lo script di rilascio scrive la versione nuova (l'hash breve del commit) in APP_CACHE_VERSION, e le chiavi vecchie diventano irraggiungibili. Non vengono cancellate: scadono col loro TTL. Il costo è nascosto in quella frase. Con i 27 TTL del file di configurazione, che vanno da 60 secondi a 7 giorni, ogni rilascio lascia in memoria una copia orfana di ciascuna cache per la durata del suo TTL. Nelle mie misure 1.000 chiavi da 1 KB occupano 1,16 MB: una cache orfana costa quanto una cache viva, finché non scade.
Metodo. Ho letto il codice del backend e ho eseguito su Redis 8.0.5, in un'istanza mia (porta 6391, --save "", --appendonly no, cartella temporanea), i comandi che riproducono ciò che Spring scrive. Non ho avviato il backend: quindi tutto quello che attribuisco a Spring viene dal codice e dalla documentazione, non da una chiave osservata mentre l'applicazione la scriveva. Dove un blocco è un estratto del repository, lo dico. L'istanza di sviluppo del sito è stata solo letta.
Il problema: la cache che sa troppo
Perché una cache Redis serve dati vecchi dopo un deploy?
Un @Cacheable di Spring salva il risultato di un metodo in Redis, e Redis è un processo a parte: riavviare la JVM non lo tocca. Se il rilascio cambia come si calcola un valore ma non la chiave con cui lo si cerca, la prima richiesta dopo il rilascio trova il valore di prima e lo restituisce. Il commento nella classe di configurazione lo dice senza giri: le voci «sopravvivono ai deploy» e «il vecchio valore continua a essere servito fino alla scadenza naturale del TTL». Il TTL più lungo di quel file è di sette giorni.
Lo stesso commento afferma che «Redis è persistente (RDB/AOF)». Sull'istanza di sviluppo non lo è: appendonly vale no e save è 3600 1 300 100 60 10000, cioè solo istantanee RDB a intervalli. Quella frase l'ho tolta dal mio ragionamento: ciò che conta qui è che il processo Redis resti in piedi, non che i dati siano scritti su disco, e la differenza la spiega l'articolo sulla persistenza di Redis. Il controllo è di sola lettura, sull'istanza di sviluppo:
$ redis-cli -p 6379 config get appendonly | tr '\n' ' '
appendonly no
$ redis-cli -p 6379 config get save | tr '\n' ' '
save 3600 1 300 100 60 10000
Le due righe contraddicono il commento, ma solo per l'ambiente di sviluppo: la configurazione di produzione non l'ho verificata, quindi non posso dire che anche lì il commento sia sbagliato, solo che non basta a dimostrare che sia giusto.
Come si invalida una cache Redis senza FLUSHALL?
Per evitare il valore vecchio ci sono due modi, e sono diversi per ciò che costano:
- Svuotare Redis al rilascio (
FLUSHALL,FLUSHDB): semplice, immediato, e cancella anche le chiavi che con la cache non c'entrano. - Cambiare il prefisso: le chiavi vecchie restano dove sono ma nessuno le cerca più; scadono da sole.
- Invalidare solo ciò che è cambiato con
@CacheEvict: preciso, ma richiede di sapere, rilascio per rilascio, quali valori sono diventati sbagliati.
La prima strada è esclusa da una condizione che si vede solo guardando l'istanza: sulla stessa Redis vivono chiavi che non passano dal gestore della cache, come le chiavi di revoca dei JWT o quelle di idempotenza. Svuotare tutto le cancellerebbe, e di cosa succede quando spariscono ho scritto nell'articolo sulla persistenza. La terza è la più precisa e la più fragile, perché dipende da una memoria umana. Il prefisso è l'unica delle tre che non richiede di decidere nulla ad ogni rilascio, e per questo la scelta cade lì.
Come il prefisso arriva nella chiave
La configurazione, riga per riga
La classe RedisCacheConfig costruisce una configurazione di base e la copia per ogni cache cambiando solo il TTL. Il punto che interessa è una riga: computePrefixWith, che sostituisce il prefisso predefinito di Spring (il nome della cache seguito da ::) con la versione, il nome della cache e ::. Questo è l'estratto, dal repository, non eseguito da me:
private RedisCacheConfiguration buildDefaultConfig(String cacheVersion, Duration defaultTtl) {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(defaultTtl)
.disableCachingNullValues()
.computePrefixWith(cacheName -> cacheVersion + ":" + cacheName + "::")
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(buildRedisObjectMapper())));
}
Tre righe hanno un effetto che non si vede dal nome. entryTtl imposta la scadenza di default; senza, le chiavi non scadrebbero mai e l'orfana di un rilascio resterebbe per sempre. disableCachingNullValues impedisce di salvare un risultato nullo: una ricerca che non trova niente viene rifatta ogni volta, ma non resta in cache un «non trovato» sbagliato dopo che il dato è comparso. E computePrefixWith riceve il nome della cache a ogni chiamata, ed è per questo che il risultato è un prefisso diverso per ciascuna.
La versione arriva dal parametro ${app.cache.version:v1}, che in application.yml è ${APP_CACHE_VERSION:v1}. Il v1 dopo i due punti è il valore se la variabile d'ambiente non esiste: chi lancia il backend in locale senza impostarla ottiene chiavi che iniziano per v1:, e due versioni diverse del codice in sviluppo condividerebbero lo stesso spazio. È la ragione per cui in produzione la variabile viene scritta dallo script.
La chiave che Spring scrive, riprodotta a mano
Per sapere come appare in Redis una chiave così, ne scrivo una con la stessa forma. Non è un'osservazione dell'applicazione: è il formato che la configurazione sopra produce, ricostruito. Uso aaa come versione di prova e un TTL di 600 secondi, come per il primo cache della lista:
$ redis-cli -p 6391 set aaa:article-metadata::redis-persistenza "{\"slug\":\"redis-persistenza\"}" ex 600
OK
$ redis-cli -p 6391 --scan --pattern 'aaa:*'
aaa:article-metadata::redis-persistenza
$ redis-cli -p 6391 ttl aaa:article-metadata::redis-persistenza
600
I tre segmenti si leggono da sinistra: la versione (aaa), la cache (article-metadata) e, dopo i due punti doppi, la chiave del metodo. Il comando TTL restituisce 600 appena scritta, e il numero scende di un secondo ogni secondo; se restituisse -1 vorrebbe dire che la chiave non scade, ed è proprio ciò che il TTL configurato serve a evitare. Il comando che avrei voluto lanciare sull'applicazione vera, --scan --pattern '*::*', sull'istanza di sviluppo ha restituito zero chiavi: il backend, nella sessione in cui ho guardato, non aveva popolato nessuna cache.
Ventisette TTL e quanto pesa un rilascio
La mappa dei TTL
Le cache hanno scadenze diverse, scelte caso per caso nel file di configurazione. Una voce ha questa forma, estratta dal repository:
Map.entry(ARTICLE_METADATA, defaultConfig.entryTtl(Duration.ofMinutes(10))),
Map.entry(ENTITY_STATS, defaultConfig.entryTtl(Duration.ofMinutes(1))),
Map.entry(SSL_CERT, defaultConfig.entryTtl(Duration.ofHours(1))),
Map.entry(TARIC_CACHE, defaultConfig.entryTtl(Duration.ofDays(7))),
Per sapere quante sono e come si distribuiscono le ho contate con un piccolo script, che legge il file e converte ogni durata in secondi. Lo script e l'uscita, entrambi eseguiti:
import re
s = open('backend/src/main/java/dev/federicocalo/portfolio/infrastructure/cache/RedisCacheConfig.java').read()
e = re.findall(r'Map\.entry\((\w+),\s*defaultConfig\.entryTtl\(Duration\.of(\w+)\((\d+)\)', s)
sec = {'Seconds': 1, 'Minutes': 60, 'Hours': 3600, 'Days': 86400}
v = sorted(int(n) * sec[u] for _, u, n in e)
print(len(e), 'entry; min', v[0], 'max', v[-1], 'mediana', v[len(v)//2])
print('fino a 5 min', sum(x <= 300 for x in v), '| 5 min-1 h', sum(300 < x <= 3600 for x in v), '| oltre 1 h', sum(x > 3600 for x in v))
27 entry; min 60 max 604800 mediana 3600
fino a 5 min 7 | 5 min-1 h 13 | oltre 1 h 7
Il numero che serve a questo articolo è il massimo: nella mappa è 604.800 secondi, sette giorni, ed è della cache taric-cache. Ma il javadoc della classe dice che quella cache viene scritta direttamente via StringRedisTemplate, fuori da Spring Cache, e quindi fuori dal prefisso: la sua voce nella mappa non produce orfane. Per le altre 26 il massimo è 86.400 secondi, ventiquattr'ore, e la mediana un'ora. Se rilasci dieci volte nello stesso pomeriggio, una cache da un'ora ha fino a dieci generazioni in memoria contemporaneamente.
Il rilascio, riprodotto con chiavi vere
Per vedere l'effetto in numeri riempio l'istanza con una cache sotto la versione aaa: 10.000 chiavi da 1.000 byte con TTL 600, 6.000 con TTL 900 e 4.000 con TTL di 24 ore, più due chiavi estranee al gestore, come quelle di revoca e di idempotenza:
$ for i in $(seq 1 10000); do echo "SET aaa:article-metadata::s$i $V EX 600"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 10000
$ for i in $(seq 1 6000); do echo "SET aaa:http-headers::h$i $V EX 900"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 6000
$ for i in $(seq 1 4000); do echo "SET aaa:vat-validation::t$i $V EX 86400"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 4000
$ redis-cli -p 6391 set jwt:revoked:abc 1 ex 900 ; redis-cli -p 6391 set dq:idem:xyz 1 ex 86400
OK
OK
$ redis-cli -p 6391 info memory | grep -E '^used_memory_human'; redis-cli -p 6391 dbsize
used_memory_human:22.89M
20002
Le due chiavi estranee, jwt:revoked:abc e dq:idem:xyz, servono più avanti. Le altre venti mila hanno tre prefissi di cache ma una sola versione. Ora simulo il rilascio: la versione diventa bbb, la prima richiesta di ogni cache la ripopola, e conto cosa c'è sotto ciascun prefisso:
$ for i in $(seq 1 1000); do echo "SET bbb:article-metadata::s$i $V EX 600"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 1000
$ redis-cli -p 6391 --scan --pattern 'aaa:*' --count 1000 | wc -l
20000
$ redis-cli -p 6391 --scan --pattern 'bbb:*' --count 1000 | wc -l
1000
$ redis-cli -p 6391 info memory | grep -E '^used_memory_human'
used_memory_human:24.05M
$ redis-cli -p 6391 ttl aaa:vat-validation::t1 ; redis-cli -p 6391 ttl aaa:article-metadata::s1 ; redis-cli -p 6391 ttl bbb:article-metadata::s1
86399
598
600
$ redis-cli -p 6391 exists jwt:revoked:abc dq:idem:xyz
2
Le ventimila chiavi aaa:* sono ancora tutte lì, e la cache nuova è cominciata con mille: used_memory è salita da 22,89 a 24,05 MB, cioè 1,16 MB per mille chiavi da un kilobyte. Le orfane hanno i loro TTL (598 secondi ancora, 86.399 per quelle da un giorno) e il valore nuovo ne ha di freschi (600). E le due chiavi estranee sono intatte: EXISTS con due argomenti restituisce 2. Questo è il guadagno del prefisso sul FLUSHALL, ed è il motivo per cui il commento dello script di rilascio scrive «nessun FLUSHALL».
Dal commit alla variabile d'ambiente
Lo script di rilascio
Il passo che cambia la versione è nel blocco «2c» di scripts/deploy/backend.sh. Questo è l'estratto, dal repository: legge l'hash breve del commit e lo scrive in .env.prod, sostituendo la riga se c'è e aggiungendola se manca:
cache_version="$(sudo -u "${DEPLOY_USER}" git -C "${REPO_DIR}" rev-parse --short HEAD)"
env_file="${BACKEND_DIR}/.env.prod"
if grep -q '^APP_CACHE_VERSION=' "${env_file}"; then
sed -i "s/^APP_CACHE_VERSION=.*/APP_CACHE_VERSION=${cache_version}/" "${env_file}"
else
echo "APP_CACHE_VERSION=${cache_version}" >> "${env_file}"
fi
La scelta dell'hash del commit come versione ha una conseguenza che il commento non nomina: ogni rilascio, anche quello che cambia un testo di una pagina, invalida tutta la cache. È sicuro e grossolano. Il valore resta quello del commit anche se il rilascio non tocca nessun codice che influenzi i dati in cache, e chi guarda il database dopo un rilascio vede un picco di letture: la cache riparte vuota. Ci sono due modi per ridurlo (versione fissa finché non cambia un file di codice che produce dati, oppure @CacheEvict mirati), ma entrambi spostano la responsabilità sulla memoria di chi rilascia.
Il caso del rilascio interrotto
La variabile viene scritta prima della compilazione e il backend la legge solo all'avvio. Se il rilascio si ferma fra i due momenti, il file .env.prod contiene già la versione nuova mentre il processo in esecuzione usa ancora la vecchia, e il primo riavvio, anche manuale, cambierà prefisso senza che nessuno abbia rilasciato nulla. È un comportamento corretto e può sorprendere: il prefisso segue il file, non il codice in esecuzione. Non l'ho riprodotto, lo deduco dalla lettura dello script e da come Spring legge le proprietà all'avvio; chi gestisce il servizio lo verifichi con grep APP_CACHE_VERSION prima di dedurre qualcosa da una cache fredda.
Due fatti che il codice dice e il commento no
Una proprietà spring.cache.redis nel YAML ha sempre effetto?
La documentazione di Spring Boot spiega che con Redis si configura la cache anche con le proprietà spring.cache.redis.*, e che per esempio spring.cache.redis.time-to-live=10m crea cache con TTL di dieci minuti. Nel mio caso quella proprietà sarebbe inerte: la classe RedisCacheConfig definisce un proprio CacheManager, e la configurazione automatica di Boot rinuncia a crearne un altro quando ne trova uno. Il file application.yml lo dice in un commento e conserva le righe commentate. Ecco l'estratto, dal repository:
# NOTE: `spring.cache.redis.*` (Boot's auto-configured RedisCacheManager) is INERT here -
# RedisCacheConfig defines its OWN @Bean CacheManager (@ConditionalOnMissingBean wins the
# race), so the real default TTL/null-caching knobs are `app.cache.default-ttl` below and
# `.disableCachingNullValues()` hardcoded in RedisCacheConfig#buildDefaultConfig.
La prova che avrei voluto dare è leggere il TTL di una chiave scritta dall'applicazione, impostare la proprietà e rileggerlo: non dovrebbe cambiare. Non l'ho fatta, perché richiede di avviare il backend. Quindi questa è un'affermazione di lettura: il commento del repository, la pagina di Spring Boot che dice cosa succede quando c'è un CacheManager definito (la configurazione automatica prova a rilevare un provider solo se non hai definito un bean di quel tipo) e il fatto che la proprietà, nel file, è già commentata. Il valore che governa davvero il default è app.cache.default-ttl, PT5M se la variabile d'ambiente non c'è.
KEYS per svuotare una cache
Esiste anche una via manuale: CacheAdminService.evict, raggiunta da un endpoint di amministrazione, svuota una cache sola. Il suo commento dice che Cache#clear() è una «SCAN+DEL limitata al prefisso». La documentazione di Spring Data Redis dice altro: l'implementazione predefinita usa KEYS e DEL, e per passare a SCAN bisogna costruire il gestore della scrittura con una BatchStrategy. Nel codice, il gestore è costruito con RedisCacheManager.builder(factory) e non ho trovato nessuna strategia a scansione. Il dubbio rimane un'ipotesi: non l'ho confermato con MONITOR su un'applicazione in esecuzione, che sarebbe la prova. Il costo, però, l'ho misurato, ed è il motivo per cui conta.
KEYS contro SCAN, in microsecondi
Lo schema della misura
Ho riempito un'istanza fino a 201.003 chiavi e ho cercato le 1.000 che iniziano per bbb:, prima con KEYS, poi con SCAN a blocchi di mille. Per misurare la durata dentro il server e non il giro completo, ho abilitato la SLOWLOG con soglia zero e letto la durata registrata per ogni comando. È lo stesso strumento dell'articolo su latenza, SLOWLOG e memoria per chiave, e conta una trappola che ho incontrato: la SLOWLOG tiene per default 128 voci, quindi i 201 giri di SCAN avevano espulso il mio KEYS. Ho alzato slowlog-max-len a 1024 e ripetuto.
r.config_set('slowlog-max-len', 1024); r.config_set('slowlog-log-slower-than', 0); r.slowlog_reset()
for rep in range(3): r.keys('bbb:*')
cur = 0
while True:
cur, ks = r.scan(cur, match='bbb:*', count=1000)
if cur == 0: break
sl = r.slowlog_get(1000)
KEYS bbb:* (201003 chiavi nel db) durata server us: [14862, 19821, 15334]
SCAN count 1000: chiamate 201 max us 276 mediana us 229 somma us 46014
Un KEYS blocca il server fra 14,9 e 19,8 millisecondi, tutto in un colpo, e per quel tempo nessun altro comando viene eseguito. Lo SCAN fa 201 chiamate da 0,23 ms di mediana e 0,28 ms di massimo, per un totale di 46 ms: costa da due a tre volte di più in totale e non blocca mai per più di un quarto di millisecondo. Con 200.000 chiavi l'effetto di KEYS è di decine di millisecondi, non di secondi; con un keyspace dieci volte più grande, il costo cresce con il numero di chiavi, che è il motivo per cui la pagina del comando lo sconsiglia in produzione. Non ho misurato quel caso, né la stessa prova su Redis 7.4.7.
Il trade-off: che cosa farei
Terrei il prefisso di versione, e lo preferisco al FLUSHALL per una ragione sola, che è la presenza di chiavi non ricostruibili nella stessa istanza. Lo pagherei con la memoria delle orfane e con un picco di letture sul database dopo ogni rilascio. Ho scartato la versione fissa (l'hash del commit) con invalidazione manuale perché sposta una decisione su chi rilascia alle sei di sera, e ho scartato @CacheEvict come unico meccanismo per la stessa ragione.
- Metti sempre un TTL su ogni cache: senza, l'orfana non scade e il prefisso diventa una perdita di memoria.
- Tieni le chiavi non ricostruibili fuori dal gestore, o in un'istanza a parte: il prefisso funziona perché non le tocca.
- Non fidarti di una proprietà del YAML senza controllare quale bean la legge.
- Controlla come svuota una cache il tuo gestore: se è
KEYS, su un keyspace grande è un blocco. - Dopo un rilascio, guarda le letture al database, non solo gli errori: la cache fredda si vede lì.
Le cinque regole hanno ordine di costo: la prima e la seconda si decidono una volta sola e proteggono dai danni gravi, la terza e la quarta sono verifiche di lettura che richiedono un'ora ciascuna, l'ultima è un'abitudine. Se ne puoi fare una sola questa settimana, fai la prima: un conto delle chiavi senza TTL (redis-cli --scan e TTL su un campione) ti dice se il prefisso che hai ti sta costando una perdita.
Cosa sai fare adesso
Sai spiegare perché una cache Redis serve dati del codice vecchio dopo un rilascio, come il prefisso <versione>:<cache>::<chiave> lo evita senza toccare le altre chiavi, e quanto costa lasciare le chiavi orfane in memoria: una generazione per ogni TTL. Sai leggere un file di configurazione della cache e ricavarne la distribuzione dei TTL con dieci righe di script, e sai che svuotare una cache con il gestore di default può significare un KEYS che blocca il server per qualche decina di millisecondi ogni duecentomila chiavi.
Per capire cosa sopravvive a un riavvio del processo Redis, e perché il commento sulla persistenza non basta, c'è l'articolo su RDB, AOF e cosa sopravvive; per cosa accade quando l'istanza è piena e le orfane pesano, maxmemory, eviction e OOM. Per il lato architetturale (dove sta la cache rispetto al dominio) c'è l'articolo sul livello di infrastruttura con JPA e Redis, e sui pattern di produzione in generale dodici pattern Spring Boot per la produzione. Il passo dopo è il lock con proprietario; quando la cache o Redis smettono di rispondere, la risposta è nell'articolo sul fail-open e fail-closed.
Vuoi rivedere come il tuo backend Spring usa la cache? Guarda la consulenza su Clean Architecture →
Fonti
- Spring Boot, Caching - il rilevamento dei provider solo se non hai definito un bean
CacheManager, la proprietàspring.cache.redis.time-to-livee il consiglio di tenere attivo il prefisso di chiave. Consultata il 2026-11-12. - Spring Data Redis, Redis Cache -
computePrefixWithe la frase «The cache implementation defaults to use KEYS and DEL to clear the cache», con laBatchStrategyper passare aSCAN. Consultata il 2026-11-12. - Redis, KEYS - l'avviso sull'uso in produzione. Letta l'8 novembre 2026 per l'articolo sulla misura, non riletta oggi.
- Redis, SCAN - l'iterazione a blocchi con cursore, usata nella misura. Non riletta per questo articolo: ne cito solo il nome e il funzionamento a cursore, già usati nella misura.
- Misure proprie: i comandi dei blocchi sopra, eseguiti il 12 novembre 2026 su Redis 8.0.5 nativo (porta 6391,
--save "",--appendonly no); l'istanza di sviluppo del sito (porta 6379) è stata soltanto letta. - Codice di questo sito, letto il 12 novembre 2026:
RedisCacheConfig.java(righe 40-70, 232-300),CacheAdminService.java,application.yml(righe 92-101 e 192-196),scripts/deploy/backend.sh(blocco 2c, righe 59-73).
Le prime quattro voci sono documentazione: la prima e la seconda sostengono le affermazioni su Spring che non ho potuto provare eseguendo l'applicazione, e se una pagina cambia fai fede a lei. Le ultime due si rifanno: i comandi su una tua istanza, e le righe di codice aprendo i file. Dove il commento di un file e il suo codice divergono, ho seguito il codice.
Commenti
Caricamento commenti...
Accedi per commentare