Salta al contenuto principale

07 - SQS vs SNS vs EventBridge: Quando Usare Quale Servizio AWS

SQS, SNS ed EventBridge rispondono a tre domande diverse: un modello di visibility timeout, DLQ, FIFO e fan-out con filtro, più i limiti da verificare.

Cosa ne pensi di questo articolo?

07 - SQS vs SNS vs EventBridge: Quando Usare Quale Servizio AWS
Event-Driven ArchitectureFoto:RDNE Stock projectPexels

In Breve

SQS, SNS ed EventBridge rispondono a tre domande diverse: un modello di visibility timeout, DLQ, FIFO e fan-out con filtro, più i limiti da verificare.

Tre domande prima di scegliere un servizio

Dentro AWS ci sono tre modi diffusi per far viaggiare un messaggio, e la scelta si riduce a tre domande. Il messaggio deve essere lavorato da un solo consumatore tra molti identici? Allora è una coda: SQS. Deve arrivare a tutti quelli che lo vogliono? Allora è un topic: SNS. I destinatari sono molti, cambiano spesso e devono scegliere in base al contenuto? Allora è un bus con regole: EventBridge, descritto nell'articolo precedente. Si combinano: il pattern più comune è un topic che alimenta una coda per ogni consumatore.

Qui non c'è un account AWS: costruisco in piccolo i comportamenti che fanno decidere (visibility timeout, ricezioni ripetute, DLQ, ordinamento per gruppo, deduplicazione, fan-out con filtro) e li eseguo con un tempo virtuale, poi riporto dalla guida ufficiale i numeri che contano, consultata il 2026-11-12. È un modello delle regole documentate, non il servizio: ogni volta che il servizio vero si comporta diversamente, o non l'ho potuto provare, lo scrivo.

La coda: cosa fa un messaggio dopo che qualcuno lo riceve

In SQS un messaggio non sparisce quando lo ricevi: diventa invisibile per il visibility timeout (30 secondi per default, al massimo 12 ore secondo la guida), e torna disponibile se nel frattempo non lo cancelli. È un contratto semplice: il consumatore dice di aver finito cancellando il messaggio; se non lo fa, SQS suppone che sia morto e lo riconsegna. Il modello sotto implementa questa regola, la consegna alla DLQ dopo un numero massimo di ricezioni e le due varianti FIFO (ordine per gruppo, deduplicazione per cinque minuti). Il tempo è un contatore, così le uscite sono riproducibili.

// Modello minimo di una coda con visibility timeout, DLQ e modalita' FIFO. Il tempo e' virtuale (secondi).
type Msg = { id: number; body: string; group?: string; ricevuto: number; visibileDa: number };

class Coda {
  msgs: Msg[] = [];
  private visti = new Map<string, number>();   // dedupId -> istante dell'invio (solo FIFO)
  private n = 0;
  nome: string;
  private clock: { t: number };
  private opz: { visibility?: number; fifo?: boolean; maxReceive?: number; dlq?: Coda };
  constructor(nome: string, clock: { t: number }, opz: { visibility?: number; fifo?: boolean; maxReceive?: number; dlq?: Coda } = {}) {
    this.nome = nome; this.clock = clock; this.opz = opz;
  }

  send(body: string, o: { group?: string; dedupId?: string } = {}): boolean {
    if (this.opz.fifo && o.dedupId) {
      const prima = this.visti.get(o.dedupId);
      if (prima !== undefined && this.clock.t - prima < 300) return false; // finestra di 5 minuti
      this.visti.set(o.dedupId, this.clock.t);
    }
    this.msgs.push({ id: ++this.n, body, group: o.group, ricevuto: 0, visibileDa: 0 });
    return true;
  }

  receive(max = 10): Msg[] {
    const out: Msg[] = [];
    for (const m of [...this.msgs]) {
      if (out.length >= max) break;
      if (m.visibileDa > this.clock.t) continue;                   // ancora invisibile
      // FIFO: finche' un messaggio del gruppo e' in volo, i successivi dello stesso gruppo restano fermi.
      if (this.opz.fifo && this.msgs.some((x) => x.group === m.group && x.id < m.id)) continue;
      if (this.opz.maxReceive !== undefined && m.ricevuto >= this.opz.maxReceive && this.opz.dlq) {
        this.msgs = this.msgs.filter((x) => x !== m);
        this.opz.dlq.msgs.push({ ...m, ricevuto: 0, visibileDa: 0 });
        continue;                                                   // spostato nella DLQ, non consegnato
      }
      m.ricevuto++;
      m.visibileDa = this.clock.t + (this.opz.visibility ?? 30);
      out.push(m);
    }
    return out;
  }

  delete(m: Msg): void { this.msgs = this.msgs.filter((x) => x.id !== m.id); }
}

receive è il metodo che conta. Salta i messaggi ancora invisibili (visibileDa), conta una ricezione e posticipa la visibilità di visibility secondi. Se la coda è FIFO, un messaggio non viene consegnato finché ne esiste uno più vecchio dello stesso gruppo: così l'ordine per gruppo è garantito anche con più consumatori.

Se il messaggio ha già raggiunto maxReceive ricezioni, finisce nella DLQ invece di essere consegnato. I messaggi del gruppo A aspettano A1 anche se B è libero: i gruppi sono il modo di avere ordine e parallelismo insieme. Una differenza rispetto al servizio vero: il momento esatto dello spostamento nella DLQ e le garanzie di ordine su carichi alti sono dettagli della guida, non di questo modello.

Consegna ripetuta e messaggio velenoso

Il primo scenario dimostra perché un consumatore SQS deve essere idempotente: il lavoro dura 40 secondi e il timeout è 30, quindi il messaggio viene consegnato una seconda volta mentre il primo consumatore sta ancora lavorando. Succede ai consumatori lenti, non è un errore di SQS. Il secondo scenario mostra il rimedio per i messaggi che non si possono mai elaborare: dopo tre ricezioni il messaggio velenoso va nella DLQ e il messaggio buono non è mai stato bloccato.

const clock = { t: 0 };

// 1) Standard: il consumatore e' piu' lento del visibility timeout, il messaggio viene consegnato due volte.
const std = new Coda('std', clock, { visibility: 30 });
std.send('ordine-1');
const a = std.receive()[0];                       // t=0: il consumatore A inizia (ci mettera' 40 s)
clock.t = 31;
const b = std.receive()[0];                       // t=31: il timeout e' scaduto, B riceve lo stesso messaggio
console.log(`1) A ha ricevuto "${a.body}" a t=0, B lo riceve a t=31 (ricezioni: ${b.ricevuto})`);

// 2) Poison message: fallisce sempre, dopo maxReceive=3 ricezioni finisce nella DLQ.
clock.t = 0;
const dlq = new Coda('dlq', clock);
const q = new Coda('ordini', clock, { visibility: 30, maxReceive: 3, dlq });
q.send('veleno'); q.send('buono');
for (let giro = 1; giro <= 5; giro++) {
  for (const m of q.receive()) {
    if (m.body === 'buono') { q.delete(m); console.log(`2) t=${clock.t}: "buono" elaborato`); }
    else console.log(`2) t=${clock.t}: "${m.body}" fallisce (ricezione ${m.ricevuto})`);
  }
  clock.t += 31;
}
console.log(`2) in coda: ${q.msgs.length}, in DLQ: ${dlq.msgs.map((m) => m.body)}`);

Nella prima riga di uscita B riceve a t=31 lo stesso messaggio che A aveva ricevuto a t=0, e il contatore di ricezioni vale 2. Per il secondo scenario, veleno fallisce a t=0, 31 e 62; al giro successivo non viene più consegnato ed è nella DLQ, mentre buono è stato elaborato subito.

Il messaggio può restare nella DLQ per essere analizzato o rigiocato, cosa di cui si occupa l'articolo sulla dead letter queue. La guida chiama maxReceiveCount il numero di volte in cui un consumatore può ricevere il messaggio prima che sia spostato: con 1 un solo errore basta a mandarlo nella DLQ, e va scelto più alto per tollerare i guasti temporanei.

FIFO: ordine per gruppo e deduplicazione

La coda Standard garantisce, per la guida, consegna almeno una volta, con possibili copie e arrivi fuori ordine, ed è la scelta di default. La coda FIFO aggiunge l'ordine entro un gruppo (MessageGroupId) e la deduplicazione: la guida dice che un nuovo invio con lo stesso identificativo entro l'intervallo di cinque minuti non introduce duplicati nella coda. Attenzione: questo non è «esattamente una volta» a livello di consumatore, perché un consumatore lento può comunque ricevere due volte per via del timeout.

// 3) FIFO: ordine per gruppo e deduplicazione entro 5 minuti.
clock.t = 0;
const fifo = new Coda('ordini.fifo', clock, { fifo: true });
const esiti = [
  fifo.send('A1', { group: 'A', dedupId: 'a1' }), fifo.send('A2', { group: 'A', dedupId: 'a2' }),
  fifo.send('B1', { group: 'B', dedupId: 'b1' }), fifo.send('A1', { group: 'A', dedupId: 'a1' }),
];
console.log('3) invii accettati:', esiti);
const r1 = fifo.receive();
console.log('3) prima ricezione:', r1.map((m) => m.body));
r1.filter((m) => m.body === 'A1').forEach((m) => fifo.delete(m));
console.log('3) dopo la cancellazione di A1:', fifo.receive().map((m) => m.body));

I quattro invii restituiscono true, true, true, false: il secondo invio di A1 con lo stesso dedupId viene ignorato. La prima ricezione consegna A1 e B1 ma non A2, che aspetta la cancellazione di A1; dopo la cancellazione, A2 esce. Il prezzo del gruppo unico è il parallelismo: se tutti i messaggi usano lo stesso gruppo, un solo messaggio alla volta è in lavorazione. La guida consiglia di usare un gruppo diverso per ogni produttore se serve l'ordine rigoroso tra più produttori; scegli il gruppo in base all'entità che deve restare in ordine, per esempio l'ordine di un cliente, non l'intero sistema.

Il topic: copiare il messaggio in tante code

Un topic SNS non conserva niente per i consumatori lenti: consegna in push a tutti gli iscritti. Per questo si accoppia a SQS: ogni consumatore ha la sua coda iscritta al topic, e la coda fa da buffer. La guida dice che i messaggi arrivano alle applicazioni che vogliono la notifica immediata e restano nella coda per quelle che li elaborano più tardi. Una sottoscrizione può avere una filter policy: un oggetto JSON che definisce quali messaggi riceve, valutato sugli attributi del messaggio o sul corpo secondo lo scope scelto; senza policy, la sottoscrizione riceve tutto.

// 4) SNS + SQS: un topic copia il messaggio nelle code iscritte il cui filtro combacia.
const inventario = new Coda('inventario', clock), notifiche = new Coda('notifiche', clock), audit = new Coda('audit', clock);
const sottoscrizioni = [
  { coda: inventario, filtro: { eventType: ['OrdineCreato'] } },
  { coda: notifiche, filtro: { eventType: ['OrdineCreato', 'OrdineSpedito'] } },
  { coda: audit, filtro: null },                                  // nessun filtro: riceve tutto
];
const pubblica = (eventType: string) => {
  for (const s of sottoscrizioni) if (!s.filtro || s.filtro.eventType.includes(eventType)) s.coda.send(eventType);
};
['OrdineCreato', 'OrdineSpedito', 'OrdineAnnullato'].forEach(pubblica);
for (const s of sottoscrizioni) console.log(`4) ${s.coda.nome.padEnd(10)}`, s.coda.msgs.map((m) => m.body));

// Output (esecuzione dell'intero file):
// 1) A ha ricevuto "ordine-1" a t=0, B lo riceve a t=31 (ricezioni: 2)
// 2) t=0: "veleno" fallisce (ricezione 1)
// 2) t=0: "buono" elaborato
// 2) t=31: "veleno" fallisce (ricezione 2)
// 2) t=62: "veleno" fallisce (ricezione 3)
// 2) in coda: 0, in DLQ: veleno
// 3) invii accettati: [ true, true, true, false ]
// 3) prima ricezione: [ 'A1', 'B1' ]
// 3) dopo la cancellazione di A1: [ 'A2' ]
// 4) inventario [ 'OrdineCreato' ]
// 4) notifiche  [ 'OrdineCreato', 'OrdineSpedito' ]
// 4) audit      [ 'OrdineCreato', 'OrdineSpedito', 'OrdineAnnullato' ]

Le tre sottoscrizioni del modello sono quelle dell'architettura tipica: inventario vuole solo gli ordini creati, notifiche due tipi, audit tutto perché non ha filtro. L'uscita mostra le copie: tre messaggi pubblicati, una copia in un caso, due in un altro, tre nel terzo. Se sbagli un filtro, il messaggio non va a nessuna coda senza che ci sia un errore da vedere, quindi conviene avere sempre una sottoscrizione di audit senza filtro. Nel modello il filtro è un'uguaglianza su un valore; il servizio vero ha operatori più ricchi, descritti nella guida, ma meno di quelli di EventBridge, che filtra su qualunque campo del JSON.

Cosa dice la guida: i numeri da conoscere

I numeri sotto vengono dalla documentazione ufficiale consultata il 2026-11-12; cambiano nel tempo e per tipo di coda, quindi ricontrollali sulla pagina delle quote prima di dimensionare.

  • Il visibility timeout di default è 30 secondi, con massimo 12 ore dalla prima ricezione.
  • Il long polling aspetta fino a 20 secondi: riduce le risposte vuote.
  • Si possono inviare, ricevere o cancellare fino a 10 messaggi in una sola richiesta.
  • Un messaggio può avere fino a 10 attributi e la dimensione massima è 1 MiB (1.048.576 byte).
  • La conservazione di default è 4 giorni, e il ritardo di consegna arriva a 15 minuti.
  • Una coda Standard ha un limite di circa 120.000 messaggi in volo.

Il numero che morde di più è il timeout, perché si regola sul tempo di elaborazione peggiore del consumatore, non sulla media: troppo basso produce le doppie consegne viste sopra, troppo alto ritarda il recupero quando un consumatore muore. Il limite dei messaggi in volo conta quando consumatori lenti e molti messaggi si sommano; gli altri valori sono parametri di progetto. Non riporto costi per milione di messaggi, latenza né throughput massimo: sono numeri che cambiano e che qui non posso né misurare né citare da una pagina verificata; per i costi vale la pagina dei prezzi di AWS.

Il confronto, senza cifre

DomandaSQSSNSEventBridge
Chi riceveUn consumatore tra quelli collegati alla codaTutti gli iscritti, con filtro opzionaleI target delle regole che combaciano
Buffer per i consumatori lentiSì, è la sua funzioneNo, consegna in push (si usa con SQS)Retry per target, DLQ opzionale
OrdinePer gruppo, nelle code FIFONei topic FIFODichiarato per il Custom Event Bus; per il Classic la pagina consultata non lo dichiara
FiltroNessunoSu attributi o corpoSu qualunque campo del JSON
Archivio e replayNo: un messaggio cancellato non si recuperaNon verificato in questa sedeSì, documentato

Leggi la tabella per riga più che per colonna. Il buffer è la riga che separa SQS da tutto il resto: nessuna altra combinazione ti dà una coda che assorbe un picco. L'ordine è la riga che si copia più spesso male: anche SNS ha una versione FIFO, e anche per EventBridge la guida distingue i due tipi di bus.

Il filtro è il segnale di scelta tra SNS ed EventBridge: se ti basta un attributo, il topic è più semplice; se devi guardare il contenuto, il bus. L'archivio con replay di EventBridge è una ragione per sceglierlo anche quando il filtro non serve, perché permette di alimentare un consumatore nuovo; per SNS non ho verificato cosa offra la guida attuale, e prima di decidere va controllato.

Scegliere, in pratica

Usa SQS quando il lavoro va diviso tra consumatori identici o deve assorbire un picco, o quando serve l'ordine per gruppo. Usa SNS con una coda per ogni consumatore quando lo stesso evento deve raggiungere più servizi e ognuno ha i suoi tempi. Usa EventBridge quando i destinatari cambiano spesso o devono scegliere in base ai dati, o quando serve il replay.

Lo stesso flusso può usarli tutti: un bus smista per contenuto verso una coda per consumatore, e la coda tiene il buffer e i ritentativi. Qualunque sia la scelta, il consumatore deve essere idempotente (si veda l'articolo sull'idempotenza), perché la consegna ripetuta è prevista dal servizio e non un incidente.

Che cosa sai fare adesso

Sai scegliere tra coda, topic e bus partendo da tre domande, sai predire che cosa accade a un messaggio quando il consumatore è lento o fallisce, e sai perché un filtro sbagliato fa sparire i messaggi senza errori. Sai quali numeri controllare sulla guida e quali non ti servono. Il passo successivo è la gestione dei fallimenti: la dead letter queue. Per le operazioni che più servizi devono completare insieme c'è la saga, e per l'intervallo in cui i dati divergono la consistenza eventuale.

Fonti

Il modello (code.ts) è stato eseguito con Node.js v24.11.0 e il tempo è virtuale: le uscite sono reali per il modello, non misure di SQS o SNS. Nessun servizio AWS è stato chiamato.

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