Salta al contenuto principale

Redis senza memoria: maxmemory, eviction e l'errore OOM

Un'istanza piena rifiuta le scritture o butta via chiavi. Quale politica cancella i dati che non puoi ricostruire, misurato su Redis 8.0.5 e 7.4.7.

Cosa ne pensi di questo articolo?

Redis senza memoria: maxmemory, eviction e l'errore OOM
Redisfedericocalo.dev

In Breve

Un'istanza piena rifiuta le scritture o butta via chiavi. Quale politica cancella i dati che non puoi ricostruire, misurato su Redis 8.0.5 e 7.4.7.

In breve. Quando un'istanza Redis raggiunge maxmemory fa una di due cose, e la scegli tu con maxmemory-policy: rifiuta ogni scrittura con OOM command not allowed when used memory > 'maxmemory', oppure butta via chiavi per far posto a quella nuova. Non esiste una terza strada. Con noeviction e un limite di 2 MB ho visto riuscire 6.358 scritture su 30.000 e fallire le altre 23.642, comprese quelle che non c'entravano con la cache. Con allkeys-lru non ne è fallita nessuna, ma su 2.000 chiavi di revoca senza scadenza ne sono sopravvissute 732, 340 e 247 in tre giri: Redis ha cancellato i dati che nessuno può ricostruire. Con volatile-lru, stessa composizione, tutte e 2.000, tre giri su tre. La politica giusta non dipende dal carico in astratto: dipende da che cosa convive nell'istanza, e quello si misura prima di scegliere.

Metodo. Ogni comando è dell'8 novembre 2026, su istanze usa-e-getta avviate da me con --save "" --appendonly no: Redis 8.0.5 nativo e 7.4.7 dall'immagine redis/redis-stack-server:latest. Dove le due danno numeri diversi lo scrivo. L'istanza di sviluppo del sito è stata solo letta; la configurazione di produzione non l'ho verificata, quindi quando parlo del sito parlo dello sviluppo.

Che cosa succede quando la memoria finisce

Il limite predefinito non è un limite

Il primo controllo è se un limite esista. Sull'istanza di sviluppo del sito non c'è, e la politica è quella che rifiuta tutto:

$ redis-cli -p 6379 info server | grep -E '^redis_version'
redis_version:8.0.5
$ redis-cli -p 6379 config get maxmemory
maxmemory
0
$ redis-cli -p 6379 config get maxmemory-policy
maxmemory-policy
noeviction
$ redis-cli -p 6379 info memory | grep -E '^used_memory_human|^maxmemory_human'
used_memory_human:7.96M
maxmemory_human:0B

La documentazione dice che maxmemory a zero significa nessun limite al dataset, ed è il predefinito a 64 bit; a 32 bit vale un limite implicito di 3 GB. Senza configurazione, quindi, il limite di Redis è la RAM della macchina, e l'unico meccanismo che resta è l'assassino della memoria del sistema operativo: non sa quali chiavi contino, né che quel processo sia un database.

Con noeviction l'istanza diventa di sola lettura

Metto un limite assurdamente basso, 2 MB, così il comportamento arriva in pochi secondi invece che in settimane. Trentamila SET da 100 byte in pipe, su 8.0.5:

$ redis-cli -p 6395 config set maxmemory 2mb
OK
$ redis-cli -p 6395 config set maxmemory-policy noeviction
OK
$ V=$(printf 'v%.0s' $(seq 1 100))
$ for i in $(seq 1 30000); do echo "SET chiave:$i $V"; done | redis-cli -p 6395 --pipe | tail -1
errors: 23642, replies: 30000
$ redis-cli -p 6395 dbsize
6358
$ redis-cli -p 6395 info memory | grep -E '^used_memory_human|^maxmemory_human'
used_memory_human:2.05M
maxmemory_human:2.00M
$ redis-cli -p 6395 info stats | grep evicted_keys
evicted_keys:0

Entrano 6.358 chiavi, 23.642 scritture sono rifiutate, nessuna chiave è sfrattata. Sul 7.4.7 gli stessi comandi danno errors: 26034 e dbsize 3966: ne entrano meno perché quell'immagine carica moduli che occupano memoria all'avvio. Non è un confronto fra versioni, è un confronto fra installazioni; il comportamento è identico.

Non tutti i comandi muoiono, e la linea è precisa

Chi legge «Redis è pieno» immagina un servizio fermo. Con l'istanza satura e noeviction:

$ for cmd in "set nuova x" "incr contatore" "append chiave:1 y" "expire chiave:1 100" "get chiave:1" "dbsize" "del chiave:1"; do printf '%-22s -> %s\n' "$cmd" "$(redis-cli -p 6395 $cmd | head -c 62)"; done
set nuova x            -> OOM command not allowed when used memory > 'maxmemory'.
incr contatore         -> OOM command not allowed when used memory > 'maxmemory'.
append chiave:1 y      -> OOM command not allowed when used memory > 'maxmemory'.
expire chiave:1 100    -> 1
get chiave:1           -> vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
dbsize                 -> 6358
del chiave:1           -> 1

Fallisce tutto ciò che aggiunge dati, e la documentazione dice perché: Redis controlla la memoria quando un client esegue un comando che aggiunge dati. Leggere, cancellare e dare una scadenza a una chiave esistente funzionano. Da qui la conseguenza che morde: se la tua sonda su Redis fa solo PING e una lettura, resta verde mentre l'applicazione non riesce più a scrivere una sessione.

Impostare maxmemory pari alla RAM libera è sicuro?

No, e lo scarto è grosso: maxmemory si confronta con used_memory, i byte che Redis chiede al suo allocatore, non con la memoria residente che vede il sistema operativo. Nella misura qui sotto un'istanza che dichiarava 6,51 MB ne occupava 13,75 di RAM vera, e il limite non se ne accorge.

Istanza vergine, limite 16 MB, trentamila chiavi, nessuno sfratto:

$ redis-cli -p 6395 info memory | grep -E '^used_memory_human|^used_memory_rss_human|^mem_fragmentation_ratio'   # istanza vuota
used_memory_human:741.32K
used_memory_rss_human:8.07M
mem_fragmentation_ratio:12.61
$ for i in $(seq 1 30000); do echo "SET chiave:$i $V"; done | redis-cli -p 6395 --pipe | tail -1
errors: 0, replies: 30000
$ redis-cli -p 6395 info memory | grep -E '^used_memory:|^used_memory_rss:|^mem_fragmentation_ratio|^mem_allocator'
used_memory:6833192
used_memory_rss:14413824
mem_fragmentation_ratio:2.18
mem_allocator:jemalloc-5.3.0

Redis conta 6,51 MB contro un limite di 16 e non sfratta; il sistema operativo vede 13,75 MB residenti. Il 2,18 non è trasportabile su un'istanza da svariati GB, perché l'istanza vuota occupa già 8,07 MB residenti e qui quel costo fisso domina: la documentazione di INFO avverte che mem_fragmentation_ratio comprende anche codice, librerie condivise e stack.

La regola che resta vera a ogni scala è un'altra: maxmemory non è un tetto alla RAM del processo, è un tetto a una contabilità interna. Impostarlo pari a tutta la memoria libera significa scegliere il momento in cui il sistema operativo ucciderà Redis.

Le politiche: otto che funzionano, due che la pagina elenca

L'elenco che il tuo server accetta davvero

Non ho copiato l'elenco dalla documentazione: l'ho chiesto al server, una politica per volta, perché dipende dalla versione.

$ for p in noeviction allkeys-lru allkeys-lfu allkeys-random volatile-lru volatile-lfu volatile-random volatile-ttl allkeys-lrm volatile-lrm; do printf '%-18s %s\n' "$p" "$(redis-cli -p 6395 config set maxmemory-policy $p 2>&1)"; done
noeviction         OK
allkeys-lru        OK
allkeys-lfu        OK
allkeys-random     OK
volatile-lru       OK
volatile-lfu       OK
volatile-random    OK
volatile-ttl       OK
allkeys-lrm        ERR CONFIG SET failed (possibly related to argument 'maxmemory-policy') - argument(s) must be one of the following: volatile-lru, volatile-lfu, volatile-random, volatile-ttl, allkeys-lru, allkeys-lfu, allkeys-random, noeviction
volatile-lrm       ERR CONFIG SET failed (possibly related to argument 'maxmemory-policy') - argument(s) must be one of the following: volatile-lru, volatile-lfu, volatile-random, volatile-ttl, allkeys-lru, allkeys-lfu, allkeys-random, noeviction

Otto accettate, due rifiutate, stesso esito sul 7.4.7. Eppure allkeys-lrm e volatile-lrm sono elencate nella pagina che redis.io serve come «latest», con tanto di consiglio su quando usarle: più in basso, nella stessa pagina, si legge che LRM esiste a partire da Redis 8.6. Quella pagina documenta più edizioni insieme, quindi la prova che una politica esista da te è un CONFIG SET, non un paragrafo.

Perché volatile-lru non sfratta niente senza TTL?

Perché le politiche volatile-* considerano candidate allo sfratto solo le chiavi dotate di scadenza: se nel database non ce n'è nessuna, non c'è niente da sfrattare, e Redis rifiuta le scritture come farebbe con noeviction. È la trappola che costa un incidente, perché la configurazione sembra prudente e fa l'opposto di quel che promette.

Trentamila chiavi senza scadenza, politica volatile-lru:

$ redis-cli -p 6395 config set maxmemory-policy volatile-lru
OK
$ for i in $(seq 1 30000); do echo "SET chiave:$i $V"; done | redis-cli -p 6395 --pipe | tail -1
errors: 23642, replies: 30000
$ redis-cli -p 6395 dbsize
6358
$ redis-cli -p 6395 info stats | grep evicted_keys
evicted_keys:0

Gli stessi 23.642 errori e le stesse 6.358 chiavi di noeviction, zero sfratti. La documentazione lo dice in una riga facile da leggere senza coglierne la portata: le volatile-xxx si comportano come noeviction se nessuna chiave ha una scadenza associata. Chi la configura pensando «sfratta solo la roba scadenzabile, quindi è sicura» ottiene un'istanza che va in errore su ogni scrittura e che in INFO dichiara una politica di sfratto senza sfrattare mai.

Sul mio ambiente di sviluppo, due candidati su 2.440

Prima di scegliere una volatile-* serve sapere quante chiavi hanno davvero un TTL. Sulla mia istanza di sviluppo, in sola lettura:

$ redis-cli -p 6379 dbsize
2440
$ redis-cli -p 6379 --scan --count 1000 | sed -E 's/[0-9a-f]{6,}.*//; s/:.*//' | sort | uniq -c | sort -rn
   2426 article
     11 dataset
      1 sdmx
      1 open_meteo_archive
      1 kb_fiscal_paese_eval_green
$ redis-cli -p 6379 --scan --count 1000 > /tmp/k.txt; sed 's/^/TTL /' /tmp/k.txt | redis-cli -p 6379 | sort | uniq -c | sort -rn
   2438 -1
      1 42708
      1 1227342
$ redis-cli -p 6379 memory usage article:1416:en
2880

Le 2.426 chiavi article:* sono gli hash per articolo e lingua del microservizio Python: cinque campionate stanno fra 2.880 e 3.052 byte, e nessuna ha scadenza. Delle 2.440 chiavi totali, 2.438 non hanno TTL: le sole due scadenze appartengono a risposte di servizi esterni messe in cache. Qui una volatile-* avrebbe due candidati su 2.440 e si comporterebbe come noeviction. La forma di quegli hash sta nell'articolo sulle strutture dati e quanto costano; qui conta che occupano quasi tutta la memoria e non scadono mai.

Due popolazioni con nature opposte

La composizione cambia appena l'applicazione Java gira, perché quella le scadenze le mette: in RedisCacheConfig.java ci sono 27 cache dichiarate una per una, ognuna col suo TTL, da un minuto a sette giorni, col prefisso costruito alla riga 300 come cacheVersion + ":" + cacheName + "::".

  • Cache dell'applicazione: ha un TTL, è ricostruibile con una query al database.
  • Indici del microservizio: nessun TTL, ricostruibili solo rifacendo l'indicizzazione al prossimo avvio.
  • Chiavi di sicurezza, come la revoca dei token: hanno un TTL, ma perderle riapre un token già revocato.
  • Dove vivono: nella stessa istanza e nello stesso database, non per ipotesi. Il backend punta a ${REDIS_HOST:localhost} sulla porta ${REDIS_PORT:6379} (application.yml, righe 79-80), il microservizio ha redis_url: str = "redis://localhost:6379" (config.py, riga 60).

Il criterio che tiene insieme le prime tre voci è una domanda sola: se questa chiave sparisce, chi la rimette? La cache se la rimette da sé alla prima richiesta, gli indici solo al riavvio del microservizio, le chiavi di revoca mai. La quarta voce è quella che rende il problema reale, perché senza la convivenza nello stesso database le prime tre non si farebbero concorrenza e la politica di sfratto non dovrebbe scegliere fra loro. È da qui che si decide, non dal profilo di traffico.

Chi viene cancellato: la misura che decide la politica

Duemila chiavi di revoca contro trentamila di cache

Ricostruisco quella composizione in piccolo: 2.000 chiavi di revoca senza TTL, poi 30.000 di cache con TTL di un'ora, limite 4 MB. Tre giri per politica, perché un giro solo non dice niente.

$ for POL in allkeys-lru volatile-lru allkeys-lfu allkeys-random; do for giro in 1 2 3; do
    redis-cli -p 6397 flushall > /dev/null
    redis-cli -p 6397 config set maxmemory 4mb > /dev/null
    redis-cli -p 6397 config set maxmemory-policy $POL > /dev/null
    redis-cli -p 6397 config resetstat > /dev/null
    for i in $(seq 1 2000);  do echo "SET jwt:revoked:$i 1"; done       | redis-cli -p 6397 --pipe > /dev/null 2>&1
    for i in $(seq 1 30000); do echo "SET cache:v1:$i $V EX 3600"; done | redis-cli -p 6397 --pipe > /dev/null 2>&1
    printf '%-16s giro %s  revoca %4s/2000  cache %5s/30000  sfratti %6s\n' "$POL" "$giro" \
      "$(redis-cli -p 6397 eval "return #redis.call('KEYS','jwt:revoked:*')" 0)" \
      "$(redis-cli -p 6397 eval "return #redis.call('KEYS','cache:v1:*')" 0)" \
      "$(redis-cli -p 6397 info stats | grep '^evicted_keys:' | cut -d: -f2 | tr -d '\r')"
  done; done
allkeys-lru      giro 1  revoca  732/2000  cache 13566/30000  sfratti  22615
allkeys-lru      giro 2  revoca  340/2000  cache  9010/30000  sfratti  25172
allkeys-lru      giro 3  revoca  247/2000  cache  7105/30000  sfratti  24924
volatile-lru     giro 1  revoca 2000/2000  cache  5764/30000  sfratti  24236
volatile-lru     giro 2  revoca 2000/2000  cache 10757/30000  sfratti  20926
volatile-lru     giro 3  revoca 2000/2000  cache  9102/30000  sfratti  24020
allkeys-lfu      giro 1  revoca  261/2000  cache  7233/30000  sfratti  24511
allkeys-lfu      giro 2  revoca  275/2000  cache  8360/30000  sfratti  25210
allkeys-lfu      giro 3  revoca  219/2000  cache  6821/30000  sfratti  25908
allkeys-random   giro 1  revoca   82/2000  cache  5923/30000  sfratti  25996
allkeys-random   giro 2  revoca  146/2000  cache  7042/30000  sfratti  24812
allkeys-random   giro 3  revoca  315/2000  cache 10025/30000  sfratti  21839

Il risultato ha due letture, da tenere separate perché hanno un grado di certezza diverso.

  • Categorica: volatile-lru ha conservato tutte le 2.000 chiavi senza scadenza in tutti e tre i giri. Non per fortuna: quelle chiavi non sono nemmeno candidate.
  • Statistica: tutte le allkeys-* hanno cancellato la grande maggioranza delle chiavi di revoca, in misura molto variabile da un giro all'altro.
  • Sul 7.4.7: la prima lettura tiene identica (2.000 su 2.000, tre giri su tre); con allkeys-lru ne restano 626, 14 e 589, e la dispersione è la stessa.

Fra le due letture pesa la prima, e non perché i numeri siano più grandi: è l'unica che si può promettere a chi userà il sistema. «volatile-lru non toccherà le chiavi senza TTL» è una garanzia che discende dalla definizione della politica; «con allkeys-lru se ne perde la maggior parte» è la media di un'estrazione, e il giro che ne ha lasciate 14 su 2.000 poteva altrettanto bene lasciarne zero. Una garanzia e una statistica non si usano nello stesso modo quando si scrive un requisito.

Perché allkeys-lru non cancella sempre le stesse chiavi?

Perché l'LRU di Redis non è un vero LRU: la documentazione lo dichiara un'approssimazione che campiona poche chiavi a caso e sfratta le più inattive fra quelle campionate, e il numero di campioni è maxmemory-samples, che sulle mie istanze vale 5. La dispersione fra un giro e l'altro non è rumore di misura, è l'algoritmo; l'LFU funziona allo stesso modo, con un contatore probabilistico al posto del tempo.

Ho provato a misurare se alzare maxmemory-samples da 5 a 10 cambiasse l'esito. Non pubblico quel confronto: due esecuzioni della configurazione identica, campioni a 5, mi hanno dato 740 e 82 chiavi sopravvissute su 2.000, e con questa dispersione la differenza fra 5 e 10 non è distinguibile dal caso. Resta la conclusione che non dipende dalla taratura: con una allkeys-* non prevedi quali chiavi perdi, solo che le perdi.

volatile-ttl sceglie chi stava per morire comunque

Una politica non guarda gli accessi ma la scadenza residua, e si comporta in modo molto più prevedibile. Due famiglie di 15.000 chiavi, TTL di 60 e di 86.400 secondi, limite 4 MB:

$ redis-cli -p 6395 config set maxmemory-policy volatile-ttl
OK
$ for i in $(seq 1 15000); do echo "SET breve:$i $V EX 60"; echo "SET lunga:$i $V EX 86400"; done | redis-cli -p 6395 --pipe | tail -1
errors: 0, replies: 30000
$ redis-cli -p 6395 eval "return #redis.call('KEYS','breve:*')" 0
1712
$ redis-cli -p 6395 eval "return #redis.call('KEYS','lunga:*')" 0
12006
$ redis-cli -p 6395 info stats | grep evicted_keys
evicted_keys:20708

Delle chiavi con un minuto di vita ne restano 1.712, di quelle con un giorno 12.006: sette volte tante. È lo strumento giusto quando il TTL esprime già quanto quel dato ti serve, e la documentazione aggiunge un effetto utile: usando bene le scadenze arrivi al limite più raramente, perché le chiavi spesso muoiono prima di dover essere sfrattate. Lo stesso ragionamento su un flusso è il taglio con MAXLEN di Pub/Sub e Streams.

LFU tiene un contatore, e lo puoi leggere

$ redis-cli -p 6397 config set maxmemory-policy allkeys-lfu
OK
$ redis-cli -p 6397 config get lfu-log-factor
lfu-log-factor
10
$ redis-cli -p 6397 set calda v; redis-cli -p 6397 set fredda v
OK
OK
$ for i in $(seq 1 1000); do echo "GET calda"; done | redis-cli -p 6397 --pipe > /dev/null
$ redis-cli -p 6397 object freq calda
20
$ redis-cli -p 6397 object freq fredda
5
$ redis-cli -p 6397 config set maxmemory-policy allkeys-lru
OK
$ redis-cli -p 6397 object freq calda
ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.

Mille letture hanno portato il contatore da 5 a 20. Non è un conteggio: la documentazione lo descrive come un contatore di Morris, probabilistico e con decadimento, e pubblica una tabella fra fattore logaritmico e accessi che per lfu-log-factor 10 e mille accessi dà 18. Su tre esecuzioni ho letto 18, 20 e 21: vicino alla tabella, non uguale, ed è il comportamento che un contatore probabilistico promette. OBJECT FREQ serve a dirti se la tua idea di «chiave calda» è la stessa di Redis, prima di affidargli la scelta.

L'avvertimento dell'ultima riga va preso alla lettera: subito dopo quel cambio di politica OBJECT IDLETIME mi ha restituito 11.229.495 secondi su una chiave creata pochi secondi prima. Non so spiegare quel numero e non ne ricavo conclusioni: lo riporto perché se lo vedi non è la tua chiave che ha viaggiato nel tempo.

Il caso che mi riguarda: un rilascio che raddoppia la cache

Il prefisso di versione, e le chiavi orfane

Lo script di rilascio del backend calcola la versione della cache come l'abbreviazione del commit corrente (scripts/deploy/backend.sh, riga 66); RedisCacheConfig la usa come primo segmento di ogni chiave. Il commento alla riga 63 dichiara la scelta: nessun FLUSHALL, perché sull'istanza ci sono anche cose che non appartengono a quel gestore di cache, e il censimento di prima dice quali.

Il meccanismo è corretto, e lo racconta l'articolo sulla cache di Spring. Qui interessa la conseguenza sulla memoria: dopo un rilascio le chiavi col prefisso vecchio non vengono cancellate, muoiono di TTL. Per la finestra pari al TTL più lungo l'istanza ospita due cache intere.

La simulazione, con noeviction

Simulo la composizione con redis-cli, senza avviare l'applicazione: 2.426 valori da 2.800 byte senza TTL al posto degli indici, 15.000 chiavi aaa0000 con TTL di dieci minuti al posto della cache, limite 16 MB. Poi faccio ciò che fa un rilascio: riscrivo le stesse 15.000 col prefisso bbb1111. Uso stringhe e non hash perché conta l'occupazione, non la struttura.

$ redis-cli -p 6395 config set maxmemory 16mb; redis-cli -p 6395 config set maxmemory-policy noeviction
OK
OK
$ B=$(printf 'b%.0s' $(seq 1 2800))
$ for i in $(seq 1 2426); do echo "SET article:$i:it $B"; done | redis-cli -p 6395 --pipe | tail -1
errors: 0, replies: 2426
$ for i in $(seq 1 15000); do echo "SET aaa0000:article-metadata::$i $V EX 600"; done | redis-cli -p 6395 --pipe | tail -1
errors: 0, replies: 15000
$ redis-cli -p 6395 info memory | grep '^used_memory_human'
used_memory_human:14.45M
$ for i in $(seq 1 15000); do echo "SET bbb1111:article-metadata::$i $V EX 600"; done | redis-cli -p 6395 --pipe | tail -1
errors: 8215, replies: 15000
$ redis-cli -p 6395 eval "return #redis.call('KEYS','aaa0000:*')" 0
15000
$ redis-cli -p 6395 ttl aaa0000:article-metadata::1
599
$ redis-cli -p 6395 eval "return #redis.call('KEYS','bbb1111:*')" 0
6785

Il rilascio nuovo ha scritto 6.785 chiavi su 15.000 e si è schiantato sulle altre 8.215, mentre le 15.000 del precedente sono tutte vive, con quasi dieci minuti di TTL davanti. La parte che conta viene adesso, perché un'istanza piena non distingue una cache da un dato di sicurezza:

$ redis-cli -p 6395 set jwt:revoked:9f3c 1
OOM command not allowed when used memory > 'maxmemory'.
$ redis-cli -p 6395 setex sessione 900 x
OOM command not allowed when used memory > 'maxmemory'.
$ redis-cli -p 6395 get aaa0000:article-metadata::1 | head -c 10
vvvvvvvvvv
$ redis-cli -p 6395 info commandstats | grep -E 'cmdstat_(set|setex):'
cmdstat_set:calls=24211,usec=57244,usec_per_call=2.36,rejected_calls=8216,failed_calls=0
cmdstat_setex:calls=0,usec=0,usec_per_call=0.00,rejected_calls=1,failed_calls=0

Un rilascio ordinario, che nessuno considera un'operazione rischiosa, ha reso impossibile registrare la revoca di un token e scrivere una sessione. Non c'è un errore di programmazione: c'è una politica che, scelta per proteggere i dati non ricostruibili, ha protetto anche la cache vecchia e ha sacrificato tutte le scritture nuove senza distinguerle. Sul 7.4.7 lo stesso esperimento dà 4.892 chiavi scritte su 15.000 e lo stesso rifiuto sulla revoca.

Le stesse chiavi, con le due politiche di sfratto

Rifaccio la sequenza identica cambiando solo la politica, e guardo chi paga il rilascio:

# con allkeys-lru (8.0.5)
vettori in vita: 1569 su 2426
aaa0000 in vita: 14298 su 15000
bbb1111 in vita: 14389 su 15000
set jwt:revoked:9f3c 1     -> OK
evicted_keys:2170

# con volatile-lru (8.0.5), stessa sequenza
vettori in vita: 2426 su 2426
aaa0000 in vita:  7561 su 15000
bbb1111 in vita:  7195 su 15000
set jwt:revoked:9f3c 1     -> OK
evicted_keys:16104

Con allkeys-lru nessuna scrittura fallisce e il servizio resta in piedi, ma 857 dei 2.426 indici sono spariti: il microservizio li riscrive all'avvio, quindi non sono perduti per sempre, però fino a quel momento 857 articoli sono invisibili alla ricerca semantica e nessuno riceve un errore.

Con volatile-lru gli indici sono tutti al loro posto e il conto lo paga solo la cache, con sette volte e mezzo gli sfratti: 16.104 contro 2.170, perché i candidati sono solo le chiavi di cache. Il conto degli sfratti torna: sfrattare un indice da 2.800 byte libera quindici volte la memoria di una voce di cache da 100, quindi chi può toccare gli indici arriva sotto il limite con molte cancellazioni in meno. Sul 7.4.7 la coppia dà 1.968 contro 2.426 su 2.426. L'indice di cui parlo è quello dell'articolo sul setup di RediSearch in produzione: lì c'è come si costruisce, qui che cosa gli succede quando lo spazio finisce.

Diagnosticare un'istanza satura

INFO memory: i quattro campi che contano

  • used_memory: i byte chiesti all'allocatore. È questo il numero confrontato con maxmemory.
  • used_memory_rss: quello che vedono top e ps, cioè la RAM che la macchina considera occupata.
  • used_memory_dataset: used_memory meno il sovraccarico, cioè quanta di quella memoria è dato tuo.
  • mem_not_counted_for_evict: i buffer transitori di replica e AOF, che non contano per lo sfratto.

I quattro campi non pesano uguale. Il primo è il solo che decide il comportamento di Redis; gli altri tre servono a capire perché quel numero è dove è. Se in un cruscotto ne entra uno solo, metti used_memory contro maxmemory, perché è il confronto che genera l'errore OOM; se invece stai dimensionando la macchina, quello che conta è used_memory_rss, perché è il numero che il sistema operativo guarda quando decide quale processo uccidere.

Rifacendo la sequenza del rilascio e leggendo INFO memory alla fine, in una esecuzione a parte:

$ redis-cli -p 6395 info memory | grep -E '^used_memory:|^used_memory_human:|^used_memory_dataset:|^maxmemory:|^maxmemory_human:|^mem_not_counted_for_evict:'
used_memory:16792680
used_memory_human:16.01M
used_memory_dataset:14027232
maxmemory:16777216
maxmemory_human:16.00M
mem_not_counted_for_evict:0

Redis si è fermato 15.464 byte sopra il tetto, cioè praticamente sul limite, e dei 16,01 MB contati come usati soltanto 13,38 sono dati: gli altri 2,64 MB, un sesto del totale, sono sovraccarico. La documentazione avverte che un comando che aggiunge molti dati in un colpo può superare il limite temporaneamente e di molto, quindi un superamento istantaneo non è di per sé un guasto; qui le scritture erano piccole e lo scarto è rimasto minimo.

INFO stats: tre casi che sembrano uguali

$ redis-cli -p 6395 info stats | grep -E '^evicted_keys:|^expired_keys:|^current_eviction_exceeded_time:'
expired_keys:0
evicted_keys:0
current_eviction_exceeded_time:136
  • Sfratti a zero e current_eviction_exceeded_time diverso da zero: l'istanza è sopra il limite e non sfratta. Sei in noeviction o in volatile-* senza candidati, e qualcuno riceve errori.
  • Sfratti che crescono: la cache funziona da cache. La documentazione suggerisce di leggerli con keyspace_hits e keyspace_misses: molti sfratti con poche letture riuscite indicano che la politica butta le chiavi sbagliate.
  • expired_keys alto e pochi sfratti: i tuoi TTL fanno il loro lavoro. È la situazione che vuoi.

Quello che distingue i tre casi non è quanta memoria sia occupata, che può essere identica: è chi sta pagando. Nel primo paga il client, con un errore in faccia; nel secondo paga il dato, che sparisce senza che nessuno lo sappia; nel terzo non paga nessuno. Un allarme impostato solo su used_memory li rende indistinguibili, e il primo è il più urgente dei tre, perché è l'unico in cui qualcuno sta già ricevendo errori adesso.

INFO commandstats: quale comando è stato rifiutato

Questo campo chiude l'indagine, e l'ho trovato suggerito dalla pagina stessa dell'eviction: commandstats riporta per ogni comando quante chiamate sono state rifiutate, e serve proprio a trovare chi sbatte contro il limite. Nella misura di prima cmdstat_set dichiarava rejected_calls=8216, che combacia con gli 8.215 errori del pipe più il mio SET a mano. Il valore non è il totale: un rejected_calls su setex e zero su get ti dice quale parte dell'applicazione sta perdendo scritture, ed è una diagnosi, non un sintomo.

Cambiare il limite a caldo, e perché non resta scritto

$ redis-cli -p 6395 dbsize                      # con limite 16mb, allkeys-lru
30000
$ redis-cli -p 6395 config set maxmemory 4mb
OK
$ redis-cli -p 6395 dbsize
23440
$ redis-cli -p 6395 info stats | grep evicted_keys
evicted_keys:13616
$ redis-cli -p 6397 info server | grep '^config_file'
config_file:
$ redis-cli -p 6397 config rewrite
ERR The server is running without a config file

CONFIG SET riconfigura Redis a runtime senza riavviare il server: seimilacinquecentosessanta chiavi sono sparite nel tempo di un comando, senza che nessun client avesse chiesto niente. E la modifica vive solo in memoria: su un'istanza avviata senza file di configurazione CONFIG REWRITE non ha dove scrivere, e dopo un riavvio con la stessa riga di comando ho ritrovato maxmemory 16777216 e allkeys-lru, i valori d'avvio. È il modo tipico in cui una configurazione aggiustata durante un incidente alle tre di notte si dissolve al riavvio, e l'incidente torna.

Il trade-off: che cosa sceglierei, e che cosa ho scartato

Dopo queste misure la scelta non è fra «sicuro» e «veloce», perché nessuna delle due opzioni è sicura. noeviction protegge i dati non ricostruibili al prezzo di rifiutare tutte le scritture quando è pieno, incluse quelle critiche, e di farlo in silenzio per chi guarda solo un PING. Una politica di sfratto protegge la disponibilità delle scritture al prezzo di cancellare dati senza dirlo a nessuno.

Con la composizione che ho misurato sceglierei volatile-lru: è l'unica configurazione in cui le due popolazioni vengono trattate in modo diverso, e la distinzione coincide con quella che conta. Ho scartato allkeys-lru perché sacrifica gli indici senza avvisare, e volatile-ttl perché i TTL della mia cache sono scelti per freschezza del dato, non per importanza: farne un criterio di sopravvivenza sfratterebbe per prime le cache più aggiornate.

  • Fissa un maxmemory esplicito, più basso della RAM disponibile, tenendo conto che i buffer di replica e AOF non sono nel conto.
  • Prima di scegliere volatile-*, conta le chiavi con TTL: senza candidati è noeviction con un altro nome.
  • Non dedurre la politica dal carico: deducila dal censimento del keyspace, chiedendoti per ogni famiglia che cosa succede se sparisce.
  • Allarma current_eviction_exceeded_time, non solo used_memory: distingue «pieno e sfratta» da «pieno e rifiuta».
  • Se cambi la configurazione a caldo, scrivila dove sopravvive al riavvio: CONFIG SET da sola non lo fa.

Le cinque voci non sono in ordine di importanza ma di sequenza, e le ultime tre dipendono dalla seconda, che è l'unica che richiede una misura. Se oggi ne puoi fare una sola, fai quella: contare le chiavi con TTL costa un comando e ti dice se la politica che hai scritto in configurazione esiste davvero o è noeviction con un altro nome.

C'è però una soluzione migliore di qualunque politica, e la suggerisce la documentazione: le politiche volatile-* servono soprattutto quando si vuole usare una sola istanza sia come cache sia per un insieme di chiavi persistenti, e in quel caso conviene valutare due istanze separate. È il mio caso alla lettera. Scegliere la politica è il passo da sviluppatore intermedio; capire che il problema è una decisione architetturale sbagliata a monte, due classi di dati con requisiti opposti nello stesso processo, e che la politica è solo il modo meno peggio di conviverci, è il passo verso il senior. Il costo non è nullo: due processi da osservare, due configurazioni, due connessioni e un consumo di base pagato due volte, poco più di otto megabyte residenti per istanza vuota sulle mie misure. Per un sito con una macchina sola è un conto da fare, non una conclusione scontata; quello che non si può fare è lasciare la decisione a un algoritmo che campiona cinque chiavi a caso.

Cosa sai fare adesso

Sai distinguere i due comportamenti possibili di un'istanza al limite e riconoscerli da INFO invece che dai reclami degli utenti, sai che una politica volatile-* vale quanto il numero di chiavi con TTL e sai contarle in un comando, e sai che maxmemory non limita la RAM del processo. Soprattutto sai che la domanda da cui partire non è «quale politica è la migliore» ma «che cosa convive in questa istanza, e che cosa succede se ne perdo un pezzo»: a quella si risponde con un censimento, non con una preferenza.

Per la forma dei dati che occupano quello spazio c'è l'articolo sulle strutture dati e quanto costano; per rendere atomica una sequenza che non lo è, Lua, EVAL ed EVALSHA. Il passo prima di questo è cosa sopravvive a un riavvio fra RDB e AOF, il passo dopo è Spring Cache su Redis, con TTL e prefisso di versione; per misurare latenza, SLOWLOG e memoria per chiave c'è l'articolo dedicato. Su una macchina sola e piccola il contesto di capacità è quello dell'hosting low-cost su VPS, che mostra limite e politica già scelti; come si ricostruisce tutto dopo un disastro sta invece in disaster recovery e strategia di backup.

Hai un'istanza Redis dove cache e dati critici convivono? Prenota una call →

Fonti

  • Redis, Key eviction - elenco delle politiche, maxmemory a zero e il limite implicito di 3 GB a 32 bit, le volatile-xxx che si comportano come noeviction senza chiavi con scadenza, il controllo della memoria sui comandi che aggiungono dati, l'LRU approssimato con maxmemory-samples, il contatore di Morris e la tabella di lfu-log-factor (fattore 10 e mille accessi: 18), LRM a partire da Redis 8.6, i buffer esclusi dal conto, il consiglio di due istanze separate, commandstats per trovare i comandi rifiutati. Consultata il 2026-11-08.
  • Redis, INFO - definizioni di used_memory, used_memory_rss, used_memory_dataset, mem_fragmentation_ratio (che comprende anche i costi del processo), mem_not_counted_for_evict, evicted_keys e rejected_calls. Consultata il 2026-11-08.
  • Redis, CONFIG SET - riconfigurazione a runtime senza riavvio del server. Consultata il 2026-11-08.
  • Redis, Memory optimization - riscontro sul fatto che l'occupazione dipende dalla codifica interna e non dal solo contenuto. Consultata il 2026-11-08.
  • Misure proprie: tutti i comandi dei blocchi qui sopra, eseguiti l'8 novembre 2026 su Redis 8.0.5 nativo e 7.4.7 (redis/redis-stack-server:latest); l'istanza di sviluppo sulla porta 6379 è stata soltanto letta.
  • Codice del repository, letto per riga: RedisCacheConfig.java (27 cache con TTL propri, prefisso alla riga 300, app.cache.version alla riga 232), scripts/deploy/backend.sh (riga 66 la versione dal commit, riga 63 il perché di nessun FLUSHALL), application.yml (righe 79-80), ml-service/app/config.py (riga 60).

Le prime quattro voci servono a controllare che non abbia frainteso la documentazione, le ultime due a rifare le misure da zero. Se vuoi verificarne una sola, verifica la riga sulle politiche volatile-* nella pagina dell'eviction: è una frase, e mezzo articolo si appoggia su quella.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti