El dolor: una API en agonía y dashboards que no cargaban

Todo empezó con un dashboard. Era crítico para la operación, pero tardaba una eternidad en cargar. Las llamadas a una API REST clave promediaban 800ms, y en horas pico, la latencia se disparaba a más de 2 segundos, provocando timeouts en cadena. El monitoreo en Grafana era claro: la base de datos (Postgres) estaba bien, pero los pods del microservicio en Kubernetes sufrían picos de CPU constantes. El problema no era de cómputo, era de repetición: estábamos consultando la misma data de catálogo, que cambia muy poco, una y otra vez.

Una caché en memoria con Caffeine fue la primera idea, pero se descartó rápido. Con múltiples réplicas del servicio, cada pod tendría su propia caché inconsistente. Necesitábamos una solución centralizada y distribuida. Redis era la respuesta obvia.

La Arquitectura de la Solución: Redis y el patrón Cache-Aside

Decidí implementar el patrón Cache-Aside (Lectura a través de la caché). Es simple, robusto y nos da control total sobre el flujo de datos. La lógica es directa:

  • 1. El cliente solicita datos: La petición llega a nuestro servicio Quarkus.
  • 2. Primero, a la caché: El servicio intenta obtener los datos desde Redis usando una clave única (ej. products:all).
  • 3. Cache Hit (Acierto): Si Redis tiene los datos, los devuelve inmediatamente. Fin del proceso. Rápido y eficiente.
  • 4. Cache Miss (Fallo): Si Redis no tiene los datos (la clave no existe o expiró), el servicio procede a consultar la base de datos principal.
  • 5. Poblando la caché: Una vez obtenidos los datos de la base de datos, el servicio los almacena en Redis con un TTL (Time-To-Live) adecuado antes de devolverlos al cliente.

Manos al código: Implementación en Quarkus

Quarkus hace que la integración con Redis sea trivial. Primero, las dependencias en el pom.xml:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-redis-client</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jsonb</artifactId>
</dependency>

Luego, la configuración en application.properties para conectar con nuestra instancia de Redis:

# Redis configuration
quarkus.redis.hosts = redis://localhost:6379

Y aquí está la lógica en el servicio. Inyectamos RedisClient y lo usamos directamente. Fíjate cómo manejamos la serialización y deserialización de nuestro DTO Product.

@ApplicationScoped
public class ProductService {

    @Inject
    RedisClient redisClient;

    @Inject
    Jsonb jsonb;

    private static final String CACHE_KEY = "products:all";

    public List<Product> getAllProducts() {
        // 1. Intentar obtener desde Redis
        Response cachedResponse = redisClient.get(CACHE_KEY);

        if (cachedResponse != null) {
            // Cache Hit! Deserializar y devolver
            return jsonb.fromJson(cachedResponse.toString(), new ArrayList<Product>() {}.getClass().getGenericSuperclass());
        }

        // 2. Cache Miss: Obtener de la base de datos
        List<Product> productsFromDb = fetchFromDatabase();

        // 3. Almacenar en Redis con un TTL de 10 minutos
        redisClient.setex(CACHE_KEY, "600", jsonb.toJson(productsFromDb));

        return productsFromDb;
    }

    private List<Product> fetchFromDatabase() {
        // Aquí iría la lógica para consultar la base de datos con Panache, JPA, etc.
        System.out.println("Accediendo a la base de datos... Operación costosa.");
        // ... Lógica de DB
        return List.of(new Product("NUB-001", "Cloud Core Server"));
    }
}

Lecciones Aprendidas y Resultados Finales

La implementación fue un éxito rotundo. La latencia promedio del endpoint bajó de 800ms a menos de 50ms. Los picos de CPU en los pods desaparecieron y la experiencia del usuario en el dashboard mejoró de la noche a la mañana.

Mis conclusiones clave:

  • El TTL es tu mejor amigo: Definir un TTL agresivo pero seguro es fundamental. Para nuestros catálogos que cambian un par de veces al día, 10-15 minutos fue el punto ideal.
  • La serialización importa: JSON es simple, pero para un performance extremo, considera formatos binarios como Protobuf o Avro. Para nuestro caso, JSON fue suficiente.
  • Invalida con estrategia: Para escrituras, teníamos que invalidar la caché. Implementamos un simple redisClient.del(CACHE_KEY) cada vez que un producto se actualizaba a través de otra API. Simple pero efectivo.
  • Monitorea tu Hit Ratio: Configura métricas para saber qué porcentaje de tus peticiones están siendo resueltas por la caché. Un Hit Ratio bajo indica que tu TTL es muy corto o tus claves no son las correctas.

Implementar una caché distribuida no es ciencia de cohetes, pero requiere pensar cuidadosamente en los patrones de acceso a tus datos y en la estrategia de invalidación. Para nosotros, fue la palanca que movió la aguja de una API lenta a una de alto rendimiento.