El patrón alternativo: diseño de una degradación elegante en microservicios

El patrón alternativo: diseño de una degradación elegante en microservicios

En una arquitectura de microservicios, los servicios forman una red de llamadas de red distribuidas. Si bien esto permite a los equipos crear y escalar servicios de forma independiente, también significa que la confiabilidad general de su sistema es tan fuerte como su eslabón más débil. Si un servicio crítico deja de funcionar o deja de responder, puede desencadenar una falla en cascada que interrumpa toda la aplicación.

Devolver un “Error interno del servidor 500” genérico o una página en blanco a los usuarios en el momento en que falla una sola dependencia es una mala experiencia para el usuario. En cambio, los sistemas resilientes están diseñados para degradarse con gracia cuando las cosas van mal.

Aquí es donde entra en juego el patrón alternativo. Al definir una ruta de ejecución alternativa y segura cuando falla una llamada de servicio principal, puede mantener su aplicación funcional, incluso en un estado degradado.

En esta guía, exploraremos el patrón de respaldo, las estrategias comunes para implementarlo, cómo interactúa con otros patrones de resiliencia y cómo escribir lógica de respaldo en Java (Resilience4j) y Go.


La analogía del mundo real: el plan de respaldo de la cafetería

Imagina que entras a una cafetería local para comprar un café con leche. El barista ingresa su pedido, pero cuando va a tocar su tarjeta, la terminal de pago muestra un error de conexión: el proveedor de Internet de la tienda está experimentando una interrupción.

¿La cafetería apaga inmediatamente las luces, cierra las puertas y envía a todos los clientes a casa?

Por supuesto que no. Implementan una estrategia alternativa:

  • Si tienen efectivo a la mano, preguntan si se puede pagar en efectivo.
  • Si es un cliente habitual, el barista podría escribir su nombre y su pedido en un libro mayor y pedirle que pague en su próxima visita.
  • Es posible que utilicen un lector de tarjetas fuera de línea que almacene el token de la tarjeta localmente y procese el pago más tarde, cuando se recupere Internet.
Diagrama de arquitectura del patrón de respaldo que muestra la falla del servicio principal y el enrutamiento a un controlador de respaldo para recuperar datos almacenados en caché o de respaldo

En diseño de software:

  • La Orden de Café es la solicitud del cliente.
  • El terminal de tarjetas es el principal servicio descendente (por ejemplo, una API de pasarela de pago).
  • La interrupción de Internet es un tiempo de espera de la red o una caída del servicio.
  • El libro mayor/lector sin conexión es la ruta de ejecución alternativa.

Estrategias alternativas comunes

Dependiendo de la lógica empresarial y la importancia del servicio fallido, puede elegir entre varias estrategias alternativas:

1. Valores predeterminados estáticos

La estrategia más sencilla es devolver un valor estático preconfigurado y seguro. Esto es muy eficaz para funciones no críticas donde es aceptable mostrar datos en blanco o predeterminados.

  • Ejemplo: si falla un servicio de personalización de perfil, devuelva una imagen de avatar predeterminada y un saludo genérico.
  • Ejemplo: si un servicio de recomendación falla, devuelva una lista vacía o una lista codificada de los más vendidos universales en lugar de generar un error.

2. Respuestas almacenadas en caché (obsoletas mientras se revalidan)

Si los datos en vivo no están disponibles, puede recurrir a datos obsoletos de solo lectura desde un caché local o un almacén de memoria distribuida rápidamente como Redis.

  • Ejemplo: si un servicio de inventario de productos deja de funcionar, muestra la cantidad en stock almacenada en caché desde hace 5 minutos, junto con un mensaje sutil en la interfaz de usuario que indica que es posible que los datos no estén completamente actualizados.
  • Ejemplo: si falla un servicio de configuración de usuario, cargue el perfil de usuario almacenado en caché en lugar de bloquear su flujo de inicio de sesión.

3. Servicio alternativo (multiproveedor)

Al ejecutar una operación crítica que debe tener éxito, puede configurar un proveedor de servicios secundario como respaldo.

  • Ejemplo: si su pasarela de pago principal (p. ej., Stripe) devuelve un error 5xx o se agota el tiempo de espera, el mecanismo alternativo redirige inmediatamente la solicitud de transacción a una puerta de enlace secundaria (p. ej., PayPal o Adyen).
  • Ejemplo: si falla una API de codificación geográfica, recurra a un proveedor de mapas secundario.

4. Cola para más tarde (búfer asíncrono)

Para operaciones de escritura que no requieren procesamiento sincrónico inmediato, el respaldo puede almacenar en búfer la solicitud en una cola o base de datos local para volver a intentarla más tarde.

  • Ejemplo: si un servicio de notificación por correo electrónico no funciona, escriba la carga útil de la notificación en una cola de mensajes fallidos o en una tabla de base de datos local. Un trabajador en segundo plano leerá esta cola y entregará los correos electrónicos una vez que el servicio de notificación vuelva a funcionar.

El trío de resiliencia: reintento, disyuntor y retroceso

Para crear una arquitectura altamente resistente, debe combinar el patrón Fallback con los patrones Reintentar y Disyuntor. Forman una línea de defensa de tres niveles:

Patrón Rol Acción Escenario
Patrón de reintento Resuelve fallos transitorios de corta duración. Repite la solicitud después de un breve retraso (retroceso + jitter). Breves caídas de paquetes de red, reinicios de sockets.
Disyuntor Previene el agotamiento de los recursos. Los viajes se abren para bloquear llamadas a un servicio que falla inmediatamente (falla rápida). Tiempo de inactividad persistente del servicio, bloqueos de bases de datos.
Patrón alternativo Preserva la experiencia del usuario. Ejecuta una acción alternativa cuando la llamada principal falla o está bloqueada. Cuando se agotan los reintentos o el disyuntor está abierto.

Cómo trabajan juntos

  1. Una solicitud entrante llega a la puerta de enlace del servicio.
  2. La solicitud pasa por el Disyuntor.
  3. Si el disyuntor está cerrado, la solicitud va al contenedor Reintentar, que realiza la llamada real.
  4. Si se produce un error transitorio, el mecanismo de reintento intenta realizar la llamada nuevamente.
  5. Si todos los reintentos fallan, o si el disyuntor ya estaba abierto (fallando rápidamente al ahorrar recursos), el Manejador de respaldo intercepta el error y devuelve la respuesta degradada/en caché.

Implementaciones de código

Veamos cómo podemos implementar la lógica alternativa tanto en Java como en Go.

1. Java (Resiliencia4j)

En Java, Resilience4j es el estándar de la industria para la tolerancia a fallas. Podemos definir un método alternativo de forma declarativa mediante anotaciones o mediante programación.

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. Ir (Golang)

En Go, podemos escribir decoradores alternativos limpios utilizando patrones de programación funcionales, que nos permiten envolver cualquier controlador de ejecución con un controlador secundario.

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)
	}
}

Mejores prácticas para el patrón alternativo

  1. Mantenga la ruta alternativa libre de dependencias: la lógica alternativa no debe depender de los mismos sistemas o infraestructura posteriores que acaban de fallar. Si su base de datos no funciona, es probable que también falle recurrir a otra consulta de base de datos en el mismo servidor.
  2. Ejecutar rápido: la lógica alternativa debe ejecutarse rápidamente. Evite cálculos complejos o llamadas anidadas lentas en sus rutas alternativas. El objetivo es devolver una respuesta al usuario lo más rápido posible.
  3. Alerta y monitorización: registre siempre las ejecuciones de respaldo e incremente los contadores de telemetría. Si su sistema está ejecutando rutas alternativas, significa que está funcionando en un estado degradado. Necesita alertas para saber cuándo las tasas de respaldo exceden los umbrales normales.
  4. Haga que la interfaz de usuario sea resistente: trabaje estrechamente con los ingenieros de frontend para garantizar que la interfaz de usuario esté diseñada para aceptar cargas útiles alternativas (como colecciones vacías o estados parciales) sin alterar el diseño del lado del cliente.

Conclusión

El Patrón de respaldo es la póliza de seguro definitiva para la confiabilidad de los microservicios. Al anticipar fallas y proporcionar una lógica alternativa elegante, se pueden convertir fallas graves en interrupciones sutiles y manejables. La combinación de este patrón con Reintentos y Disyuntores garantiza que su aplicación pueda resistir cortes externos importantes mientras continúa brindando servicio a los usuarios.