Il modello di ripetizione: creazione di microservizi resilienti

Il modello di ripetizione: creazione di microservizi resilienti

In un’architettura di microservizi, i servizi comunicano su una rete anziché su chiamate in memoria. Sebbene questo disaccoppiamento consenta un massiccio ridimensionamento orizzontale e implementazioni indipendenti, introduce anche una grave vulnerabilità: la rete è inaffidabile.

In qualsiasi momento, un servizio downstream potrebbe riscontrare un breve problema tecnico di rete, un picco temporaneo della CPU, un rapido conflitto di blocco del database o un riavvio dell’aggiornamento in sequenza. Questi guasti temporanei sono noti come guasti transitori.

Se il tuo servizio genera immediatamente un errore e fallisce la richiesta nel momento in cui fallisce una chiamata downstream, crei un’esperienza utente fragile. Molti di questi errori temporanei possono invece essere risolti automaticamente attendendo un attimo e riprovando. È qui che entra in gioco il Modello di nuovo tentativo.

In questa guida esploreremo il modello Retry, come funziona dietro le quinte, i pericoli delle implementazioni ingenue e come implementarlo correttamente in Java (Resilience4j) e Go.


L’analogia con il mondo reale: ricomposizione di una linea occupata

Immagina di provare a chiamare un amico. Componi il loro numero, ma ricevi il segnale di occupato perché sono attualmente impegnati in un’altra chiamata.

Ti arrendi immediatamente, elimini il loro contatto e pensi che non potrai mai più parlare con loro? Ovviamente no. Riattacchi, aspetti un minuto e componi di nuovo il numero. Se sono ancora occupati, potresti attendere cinque minuti prima di riprovare.

Alla fine, la chiamata termina e il tuo nuovo tentativo ha esito positivo.

Diagramma dell'architettura del modello di tentativo che mostra il tentativo di richiesta, il ritardo di backoff e il secondo tentativo riuscito

Nei microservizi:

  • La chiamata è una richiesta API a un servizio downstream.
  • Il segnale di occupato è un errore di rete temporaneo o una risposta 503 Service Unavailable.
  • La ricomposizione è un tentativo di nuovo tentativo.
  • Il tempo di attesa è la durata del backoff.

Il pericolo: tentativi ingenui e “tempeste di tentativi”

Implementare un meccanismo di ripetizione sembra banale a prima vista: basta avvolgere la chiamata HTTP in un ciclo for e continuare a provare finché non riesce. Tuttavia, un’implementazione ingenua dei nuovi tentativi può facilmente trasformare un piccolo inconveniente in un’interruzione catastrofica dell’intero sistema.

Immagina un servizio a valle che si trova in difficoltà a causa di un improvviso aumento del traffico. Il suo database funziona al 99% di utilizzo della CPU e le richieste iniziano a scadere.

Se 100 servizi client rilevano tutti un timeout e riprovano immediatamente 3 volte senza attendere, triplicheranno improvvisamente il volume di traffico che colpisce il servizio downstream già soffocante. Questa improvvisa amplificazione del traffico è nota come Retry Storm (o problema Thundering Herd).

Invece di aiutare il servizio downstream a riprendersi, i tuoi tentativi continueranno a spingerlo verso il basso, impedendogli di recuperare.


La soluzione: backoff e jitter

Per evitare tentativi di tempesta, un sistema resiliente deve utilizzare due strategie essenziali: Backoff e Jitter.

1. Strategie di backoff

Il backoff determina quanto tempo un client deve attendere prima di effettuare un successivo tentativo.

  • Backoff fisso: il client attende un periodo di tempo costante (ad esempio, esattamente 200 ms) tra i tentativi. Anche se semplice, corre comunque il rischio di picchi di traffico sincronizzato.
  • Backoff esponenziale: il tempo di attesa aumenta esponenzialmente con ogni tentativo fallito (ad esempio, 100 ms, 200 ms, 400 ms, 800 ms). Ciò concede al servizio downstream progressivamente più tempo per il ripristino man mano che l’errore persiste.

2. Jitter (casualità)

Anche con il backoff esponenziale, se un blip della rete causa il fallimento di 1.000 richieste nello stesso istante, tutti i 1.000 client calcoleranno esattamente lo stesso ritardo di backoff. Di conseguenza, riproveranno tutti simultaneamente a ondate, colpendo il servizio downstream con picchi sincronizzati.

Jitter risolve questo problema aggiungendo una varianza casuale al ritardo di backoff.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

Distribuendo i tentativi su un intervallo casuale, il carico sul servizio downstream viene attenuato, consentendone il ripristino senza problemi.


La regola d’oro dei nuovi tentativi: idempotenza

Prima di applicare nuovi tentativi a qualsiasi endpoint API, devi porre una domanda fondamentale: Questa operazione è idempotente?

Un’operazione idempotente è quella in cui effettuare più richieste identiche ha esattamente lo stesso effetto di effettuare una singola richiesta.

  • Idempotente: lettura di un profilo utente (GET /users/123), aggiornamento di un intero campo email (PUT /users/123/email) o eliminazione di un elemento (DELETE /items/456).
  • Non idempotente: creazione di un nuovo ordine (POST /orders) o elaborazione di un addebito su carta di credito (POST /payments).

Supponiamo di chiamare POST /payments per addebitare a un cliente $ 50. Il gateway di pagamento a valle riceve la richiesta, addebita correttamente la carta di credito, ma poi si verifica un blip di rete prima che possa inviarti la risposta 200 OK.

Il servizio registra un timeout, presuppone che la chiamata non sia riuscita e riprova automaticamente. Se il gateway di pagamento non è progettato per gestire richieste duplicate, al cliente verrà addebitato due volte.

[!ATTENZIONE] Non ritentare mai operazioni non idempotenti a meno che il servizio downstream non supporti le chiavi di idempotenza (identificatori di richiesta univoci utilizzati per rilevare ed eliminare transazioni duplicate).


Esempi di implementazione

Diamo un’occhiata a come implementare il pattern Retry in Java e Go.

1. Java (Resilience4j e Spring Boot)

Resilience4j fornisce un modulo di ripetizione robusto e altamente configurabile. Ecco come configurarlo per un servizio di fatturazione esterno.

Configurazione (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

Implementazione del codice

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Vai (Golang)

In Go, possiamo scrivere un elegante retry runner con backoff esponenziale e jitter randomizzato utilizzando timer di libreria standard.

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

Riprova rispetto all’interruttore automatico: quando utilizzare quale?

Gli sviluppatori spesso confondono il modello di nuovo tentativo con il modello dell’interruttore automatico. Sebbene entrambi mirano a migliorare la resilienza del sistema, gestiscono tipi di guasti fondamentalmente diversi:

Metrico Riprova modello Modello interruttore automatico
Obiettivo primario Si ripristina automaticamente da guasti transitori (di breve durata). Impedisce che errori persistenti (di lunga durata) interrompano il chiamante.
Strategia Riprovare immediatamente o dopo una breve attesa di backoff. Fail-fast immediato senza richiamare il servizio di destinazione.
Impatto a valle Aumenta temporaneamente il carico sul servizio downstream. Protegge il servizio downstream dal traffico, consentendone il ripristino.
Trigger tipici Brevi disconnessioni di rete, timeout del database, cadute di socket. Il servizio downstream è completamente inattivo e restituisce continui 500 o timeout.

La coppia di potere: combinarli entrambi

Nei sistemi di produzione, questi due modelli sono progettati per essere utilizzati insieme.

Quando si effettua una richiesta downstream, questa deve passare prima attraverso il Circuit Breaker e poi attraverso il wrapper Retry.

Se si verifica un problema tecnico temporaneo, il meccanismo Riprova lo intercetta e riprova. Tuttavia, se il servizio a valle è inattivo, i guasti si accumuleranno. L’interruttore rileva i guasti consecutivi, “scatta” l’apertura e blocca tutte le chiamate future.

Ora, quando provi a chiamare il servizio, l’interruttore automatico si guasta immediatamente, ignorando completamente il ciclo di riprova e risparmiando le risorse di elaborazione.


Procedure consigliate per il modello di ripetizione

  1. Riprova solo errori temporanei: controlla i codici di stato HTTP. Riprova con 503 Service Unavailable, 429 Too Many Requests (rispettando le intestazioni Retry-After se presenti) e i timeout di rete. NON riprovare con 400 Bad Request, 401 Unauthorized o 404 Not Found: questi tentativi non riusciranno mai.
  2. Applica jitter: non utilizzare mai un timer di ripetizione fisso senza introdurre jitter casuale.
  3. Limita numero massimo di tentativi: interrompi i tentativi dopo una soglia ragionevole (in genere da 3 a 5 tentativi). I tentativi illimitati consumano risorse e riducono la latenza.
  4. Fai attenzione ai tentativi a cascata: se il servizio A chiama il servizio B (che riprova 3 volte) e il servizio B chiama il servizio C (che riprova 3 volte), un errore in C può comportare $ 3 \times 3 = 9$ chiamate a cascata. Applicare i tentativi solo ai limiti in cui hanno più senso.
  5. Imposta sempre timeout rigorosi: assicurati che i timeout di rete siano più brevi dei periodi di backoff, in modo che i thread non vengano conservati a tempo indeterminato.

Conclusione

Il Retry Pattern è una potente prima linea di difesa contro l’inaffidabilità della rete nelle architetture di microservizi. Se abbinato a Exponential Backoff, Jitter e un Circuit Breaker, puoi proteggere i tuoi sistemi da interruzioni a catena e creare un’esperienza di autoriparazione continua per i tuoi utenti.