Il modello di fallback: progettare il degrado graduale nei microservizi

Il modello di fallback: progettare il degrado graduale nei microservizi

In un’architettura di microservizi, i servizi formano una rete di chiamate di rete distribuite. Sebbene ciò consenta ai team di creare e scalare i servizi in modo indipendente, significa anche che l’affidabilità complessiva del sistema è forte quanto il suo anello più debole. Se un servizio critico si interrompe o non risponde, può innescare un errore a cascata che interrompe l’intera applicazione.

Restituire agli utenti un generico “Errore interno del server 500” o una pagina vuota nel momento in cui una singola dipendenza fallisce rappresenta un’esperienza utente scadente. Invece, i sistemi resilienti sono costruiti per degradarsi dolcemente quando le cose vanno male.

È qui che entra in gioco il Fallback Pattern. Definendo un percorso di esecuzione sicuro e alternativo quando una chiamata al servizio primario fallisce, puoi mantenere la tua applicazione funzionante, anche in uno stato degradato.

In questa guida esploreremo il modello di fallback, le strategie comuni per implementarlo, come interagisce con altri modelli di resilienza e come scrivere la logica di fallback in Java (Resilience4j) e Go.


L’analogia con il mondo reale: il piano di riserva del coffee shop

Immagina di entrare in un bar locale per comprare un cappuccino. Il barista inserisce il tuo ordine, ma quando tocchi la tua carta, il terminale di pagamento segnala un errore di connessione: il provider Internet del negozio sta riscontrando un’interruzione.

Il bar spegne immediatamente le luci, chiude le porte e rimanda a casa tutti i clienti?

Ovviamente no. Implementano una strategia di fallback:

  • Se hanno contanti a portata di mano, chiedono se è possibile pagare in contanti.
  • Se sei un cliente abituale, il barista potrebbe scrivere il tuo nome e l’ordine in un registro e chiederti di pagare alla tua prossima visita.
  • Potrebbero utilizzare un lettore di carte offline che memorizza il token della carta localmente ed elabora il pagamento in un secondo momento quando Internet viene ripristinato.
Diagramma dell'architettura del modello di fallback che mostra l'errore del servizio primario e il routing a un gestore di fallback per recuperare i dati memorizzati nella cache/backup

Nella progettazione del software:

  • L’Ordine del Caffè è la richiesta del cliente.
  • Il Card Terminal è il servizio downstream primario (ad esempio, un’API del gateway di pagamento).
  • L’interruzione di Internet è un timeout della rete o un arresto anomalo del servizio.
  • Il registro/lettore offline è il percorso di esecuzione di fallback.

Strategie comuni di fallback

A seconda della logica aziendale e della criticità del servizio guasto, è possibile scegliere tra diverse strategie di fallback:

1. Valori predefiniti statici

La strategia più semplice consiste nel restituire un valore statico sicuro e preconfigurato. Ciò è molto efficace per le funzionalità non critiche in cui è accettabile la visualizzazione di dati vuoti o predefiniti.

  • Esempio: se un servizio di personalizzazione del profilo fallisce, restituisce un’immagine avatar predefinita e un saluto generico.
  • Esempio: se un servizio di consigli fallisce, restituisce un elenco vuoto o un elenco codificato di best-seller universali anziché generare un errore.

2. Risposte memorizzate nella cache (non aggiornate durante la riconvalida)

Se i dati attivi non sono disponibili, puoi ricorrere ai dati obsoleti di sola lettura da una cache locale o da un archivio di memoria distribuito velocemente come Redis.

  • Esempio: se il servizio di inventario dei prodotti non funziona, visualizza la quantità in stock memorizzata nella cache da 5 minuti fa, insieme a un sottile messaggio dell’interfaccia utente che indica che i dati potrebbero non essere completamente aggiornati.
  • Esempio: se un servizio di impostazioni utente non funziona, carica il profilo utente memorizzato nella cache invece di bloccarne il flusso di accesso.

3. Servizio alternativo (multi-provider)

Quando si esegue un’operazione critica che deve avere esito positivo, è possibile configurare un fornitore di servizi secondario come backup.

  • Esempio: se il tuo gateway di pagamento principale (ad esempio Stripe) restituisce un errore 5xx o va in timeout, il meccanismo di fallback reindirizza immediatamente la richiesta di transazione a un gateway secondario (ad esempio PayPal o Adyen).
  • Esempio: se un’API di geocodifica non funziona, ricorrere a un provider di mappatura secondario.

4. Coda per dopo (buffer asincrono)

Per le operazioni di scrittura che non richiedono un’elaborazione sincrona immediata, il fallback può bufferizzare la richiesta in una coda locale o in un database per riprovarla in seguito.

  • Esempio: se un servizio di notifica e-mail non è attivo, scrivere il payload della notifica in una coda di messaggi non recapitabili o in una tabella di database locale. Un operatore in background leggerà da questa coda e consegnerà le e-mail una volta che il servizio di notifica sarà di nuovo integro.

Il trio della resilienza: Riprova contro Circuit Breaker contro Fallback

Per creare un’architettura altamente resiliente, è necessario combinare il modello Fallback con i modelli Retry e Circuit Breaker. Formano una linea di difesa a tre livelli:

Modello Ruolo Azione Scenario
Sequenza di riprova Risolve problemi transitori di breve durata. Ripete la richiesta dopo un breve ritardo (backoff + jitter). Brevi interruzioni dei pacchetti di rete, reset del socket.
Interruttore automatico Previene l’esaurimento delle risorse. Viaggi aperti per bloccare immediatamente le chiamate a un servizio in errore (fail-fast). Tempi di inattività persistenti del servizio, blocchi del database.
Modello di riserva Preserva l’esperienza dell’utente. Esegue un’azione alternativa quando la chiamata principale fallisce o viene bloccata. Quando i tentativi sono esauriti o l’interruttore è aperto.

Come lavorano insieme

  1. Una richiesta in entrata colpisce il gateway del servizio.
  2. La richiesta passa attraverso il Circuit Breaker.
  3. Se il Circuit Breaker è chiuso, la richiesta passa al wrapper Retry, che effettua la chiamata effettiva.
  4. Se si verifica un errore temporaneo, il meccanismo Riprova tenta nuovamente la chiamata.
  5. Se tutti i tentativi falliscono o se l’interruttore automatico era già aperto (non riesce rapidamente a risparmiare risorse), il gestore fallback intercetta l’errore e restituisce la risposta danneggiata/memorizzata nella cache.

Implementazioni del codice

Diamo un’occhiata a come possiamo implementare la logica di fallback sia in Java che in Go.

1. Java (Resilienza4j)

In Java, Resilience4j è lo standard di settore per la tolleranza agli errori. Possiamo definire un metodo di fallback in modo dichiarativo utilizzando le annotazioni o a livello di codice.

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;

@Service
public class ProductService {

    private final InventoryClient inventoryClient;
    private final CacheManager cacheManager;

    public ProductService(InventoryClient inventoryClient, CacheManager cacheManager) {
        this.inventoryClient = inventoryClient;
        this.cacheManager = cacheManager;
    }

    // Bind this method to a circuit breaker. If it fails or is open, route to fallback
    @CircuitBreaker(name = "inventoryService", fallbackMethod = "getInventoryFallback")
    public List<String> getProductInventory(String category) {
        return inventoryClient.fetchStockByCategory(category);
    }

    // Fallback method must have the same return type and accept the same parameters,
    // plus a Throwable parameter containing the error that triggered it
    public List<String> getInventoryFallback(String category, Throwable throwable) {
        System.err.println("Primary inventory service failed: " + throwable.getMessage());
        
        // Attempt to fetch from local cache (Strategy 2)
        List<String> cachedStock = cacheManager.get("inventory:" + category, List.class);
        if (cachedStock != null) {
            return cachedStock;
        }

        // Return static empty list if cache is empty (Strategy 1)
        return Collections.emptyList();
    }
}

2. Vai (Golang)

In Go, possiamo scrivere decoratori di fallback puliti utilizzando modelli di programmazione funzionale, che ci consentono di racchiudere qualsiasi gestore di esecuzione con un gestore secondario.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// Request represents a simple payload
type Request struct {
	UserID string
}

// Response represents the returned data
type Response struct {
	Data   string
	Status string
}

// ServiceFunc represents our primary function signature
type ServiceFunc func(ctx context.Context, req Request) (Response, error)

// WithFallback wraps a service function with fallback logic
func WithFallback(primary ServiceFunc, fallback ServiceFunc) ServiceFunc {
	return func(ctx context.Context, req Request) (Response, error) {
		res, err := primary(ctx, req)
		if err != nil {
			fmt.Printf("[Warning] Primary call failed: %v. Running fallback...\n", err)
			return fallback(ctx, req)
		}
		return res, nil
	}
}

func main() {
	// 1. Define primary service that occasionally fails
	primaryService := func(ctx context.Context, req Request) (Response, error) {
		return Response{}, errors.New("database connection timeout (504)")
	}

	// 2. Define fallback service that retrieves cached data
	fallbackService := func(ctx context.Context, req Request) (Response, error) {
		// Simulating cached read
		return Response{
			Data:   fmt.Sprintf("Stale cached profile data for user %s", req.UserID),
			Status: "DEGRADED (CACHED)",
		}, nil
	}

	// 3. Wrap them together
	resilientService := WithFallback(primaryService, fallbackService)

	// 4. Execute
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	req := Request{UserID: "user_992"}
	response, err := resilientService(ctx, req)
	if err != nil {
		fmt.Printf("Operation completely failed: %v\n", err)
	} else {
		fmt.Printf("Response Received:\n - Status: %s\n - Data: %s\n", response.Status, response.Data)
	}
}

Migliori pratiche per il modello di fallback

  1. Mantenere il percorso di fallback privo di dipendenze: la logica di fallback non deve fare affidamento sugli stessi sistemi o infrastrutture downstream che hanno appena fallito. Se il tuo database non è attivo, anche il ricorso a un’altra query del database sullo stesso server probabilmente fallirà.
  2. Esegui velocemente: la logica di fallback deve essere eseguita rapidamente. Evita calcoli complessi o chiamate nidificate lente nei percorsi di fallback. L’obiettivo è restituire una risposta all’utente il più rapidamente possibile.
  3. Avviso e monitoraggio: registra sempre le esecuzioni di fallback e incrementa i contatori di telemetria. Se il tuo sistema sta eseguendo percorsi di fallback, significa che il sistema sta funzionando in uno stato degradato. Sono necessari avvisi per sapere quando i tassi di fallback superano le soglie normali.
  4. Rendere l’interfaccia utente resiliente: collaborare a stretto contatto con gli ingegneri frontend per garantire che l’interfaccia utente sia progettata per accettare payload di fallback (come raccolte vuote o stati parziali) senza interrompere il layout lato client.

Conclusione

Il Fallback Pattern è la polizza assicurativa definitiva per l’affidabilità dei microservizi. Anticipando i fallimenti e fornendo una logica di fallback elegante, trasformi i crash gravi in ​​interruzioni sottili e gestibili. La combinazione di questo modello con Nuovi tentativi e Interruttori automatici garantisce che la tua applicazione possa resistere a gravi interruzioni esterne continuando a servire gli utenti.