Perdita di Messaggi Redis Pub/Sub: Cause e Alternative Affidabili
Scopri perché Redis Pub/Sub perde messaggi durante disconnessioni di rete o consumatori lenti ed esplora pattern come Redis Streams e code basate su liste per una consegna garantita.
Perdita di Messaggi Redis Pub/Sub: Cause e Alternative Affidabili
Ricordo la prima volta che Redis Pub/Sub mi ha bruciato. Era tardi, circa le 23:00, e il nostro sistema di notifiche ha iniziato a perdere messaggi. Non tutti — solo abbastanza perché gli utenti se ne accorgessero prima di noi. L'ingegnere di turno (io, sfortunatamente) ha passato due ore a setacciare i log dell'applicazione prima che la verità evidente emergesse: Redis Pub/Sub non mette in coda nulla. Non è un broker di messaggi. È una manichetta antincendio, e se non ti trovi direttamente di fronte con la bocca aperta, perderai qualcosa.
Questo è ciò che nessuno ti dice quando usi Redis Pub/Sub per la prima volta. È lì, nella documentazione, tecnicamente, ma è facile trascurarlo quando sei entusiasta di quanto sia semplice l'API. Pubblichi da un lato, ti iscrivi dall'altro, e funziona. Finché non smette.
La realtà del "fire-and-forget"
Redis Pub/Sub opera su un principio brutalmente semplice: quando pubblichi un messaggio, Redis lo invia a ogni abbonato connesso in quel canale in quel preciso momento. Se un abbonato non è connesso, o se è connesso ma non riesce a tenere il passo, il messaggio svanisce. Non c'è un livello di persistenza, nessun meccanismo di riconoscimento, nessuna coda di messaggi morti. Il messaggio esiste solo in transito.
Fammi fare un esempio concreto. Supponiamo di avere un servizio che pubblica aggiornamenti sullo stato degli ordini e un altro servizio che si iscrive per inviare email di conferma. Sotto carico normale, tutto funziona. Poi il tuo servizio email ha un intoppo — forse il relay SMTP è lento, o c'è una pausa per la garbage collection. Durante quell'intoppo, Redis continua a inviare messaggi. Il buffer TCP dell'abbonato si riempie. Alla fine, la connessione cade. Quando l'abbonato si riconnette, riprende da ora, non da dove aveva interrotto. Ogni messaggio pubblicato durante la finestra di disconnessione è perso.
L'ho misurato in pratica con un setup di test semplice: un publisher che invia 10.000 messaggi al secondo e un abbonato che occasionalmente si blocca per 50 millisecondi. Anche con una singola pausa breve, perderai dozzine di messaggi. L'abbonato non sa mai che sono stati inviati. Il publisher non sa mai che sono stati persi. Redis è perfettamente contento — ha fatto esattamente ciò per cui è stato progettato.
Cosa causa effettivamente la perdita di messaggi
Ci sono tre scenari principali in cui Pub/Sub perde messaggi, e vale la pena capirli tutti perché si presenteranno in modi diversi.
L'instabilità di rete è la più ovvia. Qualsiasi partizione di rete temporanea tra l'abbonato e Redis interrompe la connessione. Redis lo rileva tramite il timeout del client (default 60 secondi, ma potresti averlo impostato più basso). Durante quella finestra, tutti i messaggi pubblicati sono persi per quell'abbonato. Altri abbonati potrebbero riceverli perfettamente, il che rende il debug ancora più divertente — vedrai uno stato incoerente tra i servizi e ti chiederai se stai impazzendo.
I consumatori lenti sono più insidiosi perché la connessione rimane aperta. Redis usa un modello push, il che significa che scrive sui socket degli abbonati alla velocità con cui i publisher producono. Se un abbonato non riesce a elaborare i messaggi abbastanza velocemente, il buffer di ricezione TCP del kernel si riempie. Una volta che il buffer è pieno, Redis non può scrivere più dati e la connessione alla fine fallisce. L'abbonato potrebbe non accorgersi nemmeno di essere in ritardo fino alla disconnessione.
Ho visto questo accadere con abbonati che eseguono scritture sincrone sul database per ogni messaggio. A basso volume, va bene. Al picco, il database diventa il collo di bottiglia, l'abbonato resta indietro e i messaggi si accumulano nel buffer TCP. Quando quel buffer trabocca, la connessione si resetta e l'abbonato perde tutto ciò che non aveva ancora letto dal socket.
Le disconnessioni del client durante distribuzioni o riavvii sono la terza grande categoria. Se stai facendo distribuzioni rolling e un'istanza dell'abbonato si ferma, perde tutto ciò che è stato pubblicato durante la sua assenza. Non c'è un meccanismo "recuperami". Quando torna online, ricomincia da capo.
Una cosa che mi ha sorpreso: anche un arresto pulito non aiuta. Se il tuo abbonato si disiscrive con grazia prima di uscire, perde comunque i messaggi pubblicati tra la disiscrizione e il ritorno. La disiscrizione è istantanea — non c'è un'opzione "tieni i miei messaggi per un minuto".
Quando Pub/Sub va effettivamente bene
Non voglio far sembrare Redis Pub/Sub inutile. È eccellente per casi d'uso specifici, e lo uso ancora regolarmente. La chiave è capire quali sono questi casi d'uso.
Le notifiche in tempo reale dove una perdita occasionale è accettabile funzionano alla grande. Pensa a punteggi sportivi in diretta, ticker azionari o indicatori di digitazione in un'app di chat. Se un utente perde un aggiornamento del punteggio, il prossimo arriva comunque in pochi secondi. I dati hanno una breve durata e nessun requisito di durabilità.
La scoperta dei servizi e la trasmissione di configurazioni sono un altro punto di forza. Quando cambi un feature flag e lo pubblichi a tutte le istanze dell'applicazione, va bene se un'istanza che si sta riavviando perde l'aggiornamento — recupererà lo stato corrente quando tornerà online o al prossimo aggiornamento periodico.
Ho anche usato Pub/Sub con successo per l'invalidazione della cache su più server applicativi. Pubblica una chiave della cache da invalidare e ogni server cancella la sua cache locale. Se un server perde il messaggio, il caso peggiore è che serva dati obsoleti fino alla scadenza naturale della cache. Non ideale, ma nemmeno catastrofico.
Il filo comune qui: Pub/Sub funziona quando i messaggi sono effimeri per natura, quando la perdita è recuperabile tramite altri meccanismi e quando non hai bisogno di garanzie di ordinamento o consegna exactly-once.
Redis Streams: l'alternativa integrata
Redis Streams, introdotto in Redis 5.0, è ciò a cui mi rivolgo ora quando ho bisogno di una consegna affidabile dei messaggi. Non è Pub/Sub con persistenza aggiunta — è un modello fondamentalmente diverso, più vicino a un log distribuito come Kafka che a un meccanismo di broadcast.
Con Streams, i messaggi vengono aggiunti a un log e rimangono lì fino a quando non vengono esplicitamente riconosciuti. I consumatori possono disconnettersi, riavviarsi, restare indietro e ancora recuperare. Lo stream conserva i messaggi in base a una lunghezza massima o a un periodo di conservazione, quindi controlli quanta storia mantenere.
Ecco come differisce il modello mentale. In Pub/Sub, ti iscrivi a un canale e i messaggi fluiscono verso di te. In Streams, tiri i messaggi al tuo ritmo. Un gruppo di consumatori tiene traccia di quali messaggi ogni consumatore ha riconosciuto, così puoi avere più consumatori che leggono dallo stesso stream senza duplicazione (o con duplicazione intenzionale, se vuoi fan-out).
Un setup base di Streams assomiglia a questo:
XADD orders * status confirmed order_id 12345
Questo aggiunge un messaggio allo stream orders. Il * dice a Redis di generare automaticamente un ID. Poi il tuo consumatore legge con:
XREADGROUP GROUP email-processor worker-1 COUNT 10 STREAMS orders >
Il > significa "dammi messaggi che non sono stati ancora consegnati a nessun consumatore in questo gruppo". Dopo l'elaborazione, il consumatore riconosce:
XACK orders email-processor <message-id>
Se il consumatore si blocca prima di riconoscere, il messaggio rimane in sospeso. Un altro consumatore nel gruppo può rivendicarlo con XCLAIM dopo un timeout. Questo è il meccanismo di riconoscimento e riconsegna che Pub/Sub manca completamente.
Il modello dei gruppi di consumatori in pratica
I gruppi di consumatori sono ciò che rende Streams veramente utile per l'elaborazione affidabile. Ogni gruppo mantiene la propria posizione nello stream, quindi puoi avere un gruppo per le notifiche email, un altro per l'analisi e un altro per il logging di audit — tutti che leggono lo stesso stream indipendentemente.
All'interno di un gruppo, i messaggi sono distribuiti tra i consumatori. Questo ti dà scalabilità orizzontale: aggiungi più istanze di consumatori e condivideranno il carico. Se un'istanza muore, i suoi messaggi in sospeso diventano disponibili per altre istanze da rivendicare.
Ho scoperto che la lista delle voci in sospeso è preziosa per il monitoraggio. Puoi eseguire XPENDING per vedere quali messaggi non sono stati riconosciuti e da quanto tempo sono in sospeso. Questo evidenzia immediatamente i consumatori lenti — molto meglio che scoprire la perdita di messaggi giorni dopo tramite reclami degli utenti.
Un avvertimento con Streams: gli ID dei messaggi sono timestamp monotonamente crescenti, il che significa che non puoi facilmente inserire messaggi fuori ordine. Se hai bisogno di un ordinamento rigoroso all'interno di uno stream, questa è in realtà una caratteristica. Se hai bisogno di dare priorità a certi messaggi, avrai bisogno di più stream o di un approccio diverso.
Code basate su liste per esigenze più semplici
Prima che Streams esistesse, il pattern standard per la messaggistica affidabile con Redis erano le code basate su liste con pop bloccanti. Questo pattern è ancora perfettamente valido, specialmente se sei su una versione più vecchia di Redis o vuoi qualcosa di estremamente semplice.
L'idea è semplice: i produttori fanno LPUSH o RPUSH di messaggi su una lista, e i consumatori fanno BLPOP o BRPOP per bloccarsi fino all'arrivo di un messaggio. Il pop bloccante è cruciale — senza di esso, faresti polling, che spreca CPU e aggiunge latenza.
L'affidabilità deriva da una lista secondaria "di elaborazione". Il consumatore sposta atomicamente un messaggio dalla coda in sospeso a una coda di elaborazione usando BRPOPLPUSH (o LMOVE in Redis 6.2+). Dopo l'elaborazione, rimuove il messaggio dalla coda di elaborazione. Se il consumatore si blocca, la coda di elaborazione trattiene il messaggio, e un processo di monitoraggio può spostare gli elementi obsoleti di nuovo alla coda in sospeso.
Ho costruito questo pattern diverse volte, e funziona, ma è più codice di quanto ci si aspetterebbe. Devi gestire i timeout, decidere per quanto tempo un messaggio può stare nella coda di elaborazione prima di considerarlo abbandonato e gestire i casi limite relativi all'elaborazione duplicata. Streams formalizza essenzialmente tutto questo, motivo per cui mi sono allontanato per lo più dalle code basate su liste fatte a mano.
L'unico posto dove uso ancora code basate su liste è per code di lavoro dove l'ordine di elaborazione non ha importanza e voglio l'implementazione più semplice possibile. A volte una lista e un loop BLPOP sono tutto ciò di cui hai bisogno, e aggiungere Streams sarebbe overengineering.
Pub/Sub shardato in Redis 7
Redis 7 ha introdotto Pub/Sub shardato, che vale la pena menzionare perché risolve un problema diverso dalla perdita di messaggi. Con Pub/Sub normale, ogni messaggio viene trasmesso a ogni nodo in un cluster, anche se nessun abbonato su un dato nodo è interessato a quel canale. Questo spreca larghezza di banda dell'interconnessione del cluster.
Pub/Sub shardato lega i canali a slot specifici del cluster, quindi i messaggi si propagano solo ai nodi che hanno effettivamente abbonati per quel canale. È un'ottimizzazione delle prestazioni, non una caratteristica di affidabilità. Perderai comunque messaggi in caso di disconnessione. Ma se stai eseguendo Pub/Sub su larga scala in un ambiente clusterizzato, vale la pena saperlo.
Fare la scelta: Pub/Sub vs Streams vs liste
Dopo anni di convivenza con questi pattern, il mio processo decisionale si è semplificato a poche domande.
Primo: puoi tollerare la perdita di messaggi? Se sì, e se i dati sono effimeri, Pub/Sub probabilmente va bene. Otterrai la latenza più bassa e il modello operativo più semplice.
Secondo: hai bisogno di persistenza e riproduzione dei messaggi? Se sì, Streams è la risposta. La capacità di rielaborare i messaggi dopo una correzione di bug del consumatore mi ha salvato più di una volta. Con Pub/Sub, se il tuo consumatore aveva un bug che lo ha portato a gestire male i messaggi per un'ora, quei messaggi sono persi per sempre. Con Streams, puoi resettare la posizione del gruppo di consumatori e riprodurli.
Terzo: hai bisogno di più gruppi di consumatori indipendenti che leggono gli stessi dati? Streams gestisce questo nativamente. Con Pub/Sub, ogni abbonato riceve ogni messaggio, che potrebbe essere ciò che vuoi, ma non c'è modo di avere diversi gruppi di abbonati che mantengono posizioni indipendenti.
Quarto: qual è la tua versione di Redis? Se sei bloccato su qualcosa di più vecchio di 5.0, Streams non è disponibile, e stai guardando code basate su liste o un broker di messaggi esterno. Sono stato in questa situazione, e onestamente, se hai bisogno di messaggistica affidabile e non puoi usare Streams, considererei se Redis è lo strumento giusto. RabbitMQ o NATS potrebbero essere più adatti.
Il lato operativo di cui nessuno parla
Ecco qualcosa che ho imparato a mie spese: monitorare Pub/Sub è ingannevolmente difficile. Puoi monitorare i conteggi delle connessioni e le iscrizioni ai canali con PUBSUB NUMSUB, ma non puoi vedere quanti messaggi vengono persi. Non c'è una metrica per "messaggi pubblicati ma non ricevuti" perché Redis non tiene traccia di questo.
Con Streams, ottieni visibilità. XINFO GROUPS mostra il ritardo del consumatore. XPENDING mostra i messaggi non riconosciuti. Puoi impostare avvisi quando il ritardo supera una soglia. Questa visibilità operativa da sola ha reso Streams degno del passaggio per me.
La gestione della memoria è un'altra considerazione. I messaggi Pub/Sub esistono solo in memoria e solo durante il transito, quindi l'uso della memoria è limitato dal tuo tasso di pubblicazione e dalla velocità del consumatore. Streams memorizza i messaggi fino a quando non vengono tagliati, quindi devi pensare alle politiche di conservazione. Di solito imposto una lunghezza massima dello stream (MAXLEN) basata sul throughput previsto e sulla memoria disponibile, e monitoro la lunghezza dello stream per cogliere accumuli inaspettati.
Cosa faccio effettivamente ora
Oggigiorno, per impostazione predefinita uso Redis Streams per qualsiasi caso d'uso di messaggistica che richieda affidabilità. L'API è leggermente più complessa di Pub/Sub, ma non di molto, e le garanzie di affidabilità ne valgono la pena. Tengo Pub/Sub per le cose effimere — invalidazione della cache, presenza in tempo reale, quel genere di cose.
Per messaggistica particolarmente critica (elaborazione dei pagamenti, evasione ordini), mi sono allontanato completamente da Redis e uso broker di messaggi dedicati. Redis è fantastico per molte cose, ma non è ottimizzato per la persistenza su disco di code di messaggi ad alto volume. Se hai bisogno che i messaggi sopravvivano a un riavvio completo di Redis con zero perdite, devi configurare la persistenza AOF con appendfsync always, che penalizza le prestazioni di scrittura. A quel punto, qualcosa come Kafka o Pulsar ha più senso.
Ma per la vasta via di mezzo — dove la perdita di messaggi sarebbe fastidiosa o costosa ma non catastrofica, e dove vuoi rimanere nell'ecosistema Redis che già conosci — Streams colpisce un punto dolce. È stato abbastanza affidabile per me in produzione, e la semplicità operativa di non introdurre un nuovo componente infrastrutturale ha un valore reale.
L'errore originale che ho fatto con Pub/Sub non riguardava realmente la tecnologia. Riguardava il non leggere le clausole scritte in piccolo, l'assumere che "messaggistica" implicasse "garanzie di consegna dei messaggi". Redis Pub/Sub non fa tali garanzie, e non finge di farlo. Una volta che lo capisci, puoi usarlo appropriatamente e ricorrere a Streams quando hai bisogno di di più.