El dolor: un microservicio lento y una base de datos saturada
Teníamos un microservicio core escrito en Java con Quarkus que, entre otras cosas, consultaba catálogos de productos. El problema era simple: estos catálogos no cambian frecuentemente, pero eran consultados miles de veces por minuto. Cada llamada implicaba un golpe a nuestra base de datos PostgreSQL, y en horas pico, la latencia de ese endpoint se disparaba por encima de los 800ms. Era un cuello de botella clásico y estaba degradando la experiencia de usuario en toda la plataforma.
Un caché en memoria (in-memory) era una opción, pero nos dejaba vendidos al escalar horizontalmente. Si levantábamos 5 pods del servicio, cada uno tendría su propio caché inconsistente. La solución real pasaba por un caché distribuido, y para eso, Redis era el candidato perfecto.
Paso 1: Configurar Quarkus para hablar con Redis
Lo primero es lo primero: hacer que nuestra aplicación Quarkus sepa cómo comunicarse con el servidor Redis. Gracias al ecosistema de Quarkus, esto es increíblemente sencillo. Solo necesitas añadir la extensión de cliente Redis y configurar la conexión en application.properties.
Asegúrate de tener esta dependencia en tu pom.xml:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
</dependency>
Luego, la configuración de conexión. En mi caso, usaba variables de entorno para la flexibilidad entre dev, staging y prod, pero para este ejemplo, lo pondré directo:
# application.properties
# Conexión principal a Redis
quarkus.redis.hosts = redis://tu-servidor-redis:6379
# Quarkus necesita un nombre para cada caché que vayas a usar
quarkus.cache.redis.product-catalog.expire-after-write = 24H
quarkus.cache.redis.product-catalog.key-prefix = catalog::products
Aquí definimos un caché llamado product-catalog que expira después de 24 horas y le asignamos un prefijo a las llaves para mantener el orden en Redis. Esto es crucial para evitar colisiones si usas la misma instancia de Redis para múltiples propósitos.
Paso 2: Implementar el patrón Cache-Aside con anotaciones
Ahora viene la magia. En lugar de gestionar manualmente la lógica de ‘revisar el caché, si no está, ir a la DB y luego guardarlo’, vamos a delegar todo ese trabajo a Quarkus usando la API de JCache con anotaciones. Es limpio y declarativo.
Modifiqué mi clase de servicio (ProductService.java) de esta forma. Observa el antes y el después del método clave:
Antes: La llamada directa a la base de datos
// ProductService.java
@ApplicationScoped
public class ProductService {
@Inject
ProductRepository repository;
public List<Product> getFullCatalog() {
// Siempre, siempre, siempre a la base de datos...
return repository.findAll().list();
}
}
Después: Con el caché activado
Simplemente agregamos una anotación al método. ¡Eso es todo!
// ProductService.java
import io.quarkus.cache.CacheResult;
import javax.enterprise.context.ApplicationScoped;
import java.util.List;
@ApplicationScoped
public class ProductService {
@Inject
ProductRepository repository;
@CacheResult(cacheName = "product-catalog")
public List<Product> getFullCatalog() {
// La primera vez que se llama, este código se ejecuta.
// Las llamadas subsecuentes (durante 24h) devolverán el resultado desde Redis
// sin volver a tocar este método.
System.out.println("Accediendo a la base de datos... ¡Esto no debería pasar seguido!");
return repository.findAll().list();
}
}
Con @CacheResult(cacheName = "product-catalog"), Quarkus intercepta la llamada. La primera vez, ejecuta el método, toma el resultado (la lista de productos), la serializa y la guarda en Redis bajo una llave autogenerada dentro del caché ‘product-catalog’. En las siguientes llamadas, si la llave existe en Redis, devuelve el dato directamente sin ejecutar el método de nuevo. La latencia bajó de ~800ms a ~15ms.
Lecciones aprendidas y conclusión
Implementar este caché fue un cambio de juego para la performance del sistema, pero me dejó algunas lecciones importantes:
- Estrategia de Invalidación: Usar un TTL (Time-To-Live) como
expire-after-writees simple, pero ¿qué pasa si un producto cambia de precio? Necesitas una estrategia para invalidar explícitamente el caché. Para eso, puedes usar la anotación@CacheInvalidateAllen un método que se ejecute cuando actualizas el catálogo. - Serialización: Quarkus se encarga de la serialización por ti, pero es vital entender cómo lo hace. Asegúrate de que tus objetos (DTOs) sean serializables. En nuestro caso, no tuvimos problemas con los objetos de Panache.
- Monitoreo es clave: No basta con implementarlo. Debes monitorear el *hit rate* de tu caché. Un *hit rate* bajo significa que el caché no está siendo efectivo y podrías tener un problema con la generación de llaves o una estrategia de expiración demasiado agresiva. Usamos Grafana para visualizar las métricas de Redis.
En resumen, combinar Quarkus y Redis para un caché distribuido es una solución robusta, escalable y sorprendentemente fácil de implementar que puede resolver cuellos de botella críticos en arquitecturas de microservicios. No subestimes el poder de un buen caché; es una de las herramientas más efectivas en nuestro arsenal de backend.