El problema: Una API REST que se ahogaba con su propio éxito
Te suena familiar, ¿verdad? Tienes una API REST que es el core de tu producto. Al principio todo es velocidad, pero con el tiempo y la carga, cada request empieza a pesar. En mi caso, el endpoint principal de consulta de productos había pasado de unos cómodos 150ms a picos insostenibles de más de 2000ms. El culpable era el de siempre: consultas complejas a la base de datos que se repetían una y otra vez para obtener datos que rara vez cambiaban.
La solución no era escalar la base de datos de forma infinita. Necesitaba una estrategia más inteligente, una capa de velocidad que se interpusiera entre la request y la costosa llamada a la BD. Necesitaba un caché distribuido.
La arquitectura de la solución: Quarkus + Redis al rescate
Elegí Quarkus por su naturaleza cloud-native, su increíble velocidad de arranque y su bajo consumo de memoria, ideal para microservicios. Para el caché, la elección fue obvia: Redis. Es el estándar de facto por su velocidad in-memory, flexibilidad en estructuras de datos y su probada robustez en producción.
El plan era simple: antes de ejecutar la consulta a la base de datos, mi servicio revisaría si el resultado ya existía en Redis. Si estaba (un cache hit), lo devolvería instantáneamente. Si no (un cache miss), iría a la base de datos, guardaría el resultado en Redis con un TTL (Time To Live) y luego lo devolvería al cliente.
Manos al código: La implementación paso a paso
1. Configurando las dependencias en Maven
Lo primero es añadir el cliente de Redis para Quarkus en nuestro pom.xml. Quarkus lo hace increíblemente sencillo.
<!-- pom.xml -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
</dependency>
2. Conectando con Redis desde application.properties
Ahora, le decimos a Quarkus dónde encontrar nuestro servidor Redis. Todo centralizado en application.properties.
# application.properties
# Redis Host
quarkus.redis.hosts = redis://tu-servidor-redis:6379
# Opcional: si tienes autenticación
# quarkus.redis.password = tu_password_super_secreto
# Opcional: seleccionar una base de datos específica
# quarkus.redis.database = 0
3. Creando un servicio de caché genérico
Para no acoplar mi lógica de negocio directamente a Redis, creé un servicio que encapsula las operaciones de get y set. Usé la inyección de dependencias de Quarkus (@Inject) para obtener el cliente de Redis.
// ProductCacheService.java
import io.quarkus.redis.client.RedisClient;
import io.vertx.redis.client.Response;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
@ApplicationScoped
public class ProductCacheService {
@Inject
RedisClient redisClient;
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String TTL_SECONDS = "3600"; // Cache por 1 hora
public String getProductById(String productId) {
Response response = redisClient.get(PRODUCT_KEY_PREFIX + productId);
return response != null ? response.toString() : null;
}
public void setProduct(String productId, String productJson) {
// Usamos SET con EX para definir el TTL en la misma operación
redisClient.set(Arrays.asList(PRODUCT_KEY_PREFIX + productId, productJson, "EX", TTL_SECONDS));
}
}
4. Modificando el Endpoint JAX-RS
Aquí es donde ocurre la magia. Modifiqué el endpoint para que consultara primero el servicio de caché.
// ProductResource.java
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@Path("/products")
public class ProductResource {
@Inject
ProductRepository productRepository; // Nuestro repositorio de base de datos
@Inject
ProductCacheService cacheService; // Nuestro nuevo servicio de caché
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response getProductById(@PathParam("id") String id) {
// 1. Intentar obtener desde el caché
String cachedProduct = cacheService.getProductById(id);
if (cachedProduct != null) {
// Cache Hit! Devolvemos directamente
return Response.ok(cachedProduct)
.header("X-Cache-Status", "HIT")
.build();
}
// 2. Cache Miss: ir a la base de datos
Product product = productRepository.findById(id);
if (product == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
// Suponiendo que tenemos un método para serializar a JSON
String productJson = product.toJson();
// 3. Guardar en caché para futuras requests
cacheService.setProduct(id, productJson);
return Response.ok(productJson)
.header("X-Cache-Status", "MISS")
.build();
}
}
Lecciones aprendidas desde la trinchera y resultados
El resultado fue inmediato y espectacular. La latencia promedio del endpoint cayó de ~2000ms a ~50ms en las peticiones cacheadas (hits). La carga sobre la base de datos se desplomó.
Mis aprendizajes clave fueron:
- No todo es cacheable: Identifica los datos calientes (muy leídos, poco escritos). Esos son tus candidatos perfectos.
- Define una estrategia de invalidación: ¿Qué pasa si un producto cambia de precio? Necesitas un plan para invalidar o actualizar la entrada en el caché. El TTL es la forma más simple, pero para datos críticos podrías necesitar una invalidación explícita (cache-aside pattern).
- Monitorea tu Hit Rate: Una métrica clave es el ratio de aciertos (hits vs. misses). Un hit rate bajo puede indicar que tu TTL es muy corto o que estás cacheando los datos incorrectos.
- La serialización importa: El tiempo que tardas en convertir tu objeto a JSON (o el formato que uses) para guardarlo en Redis suma a la latencia en los cache misses. Optimiza ese proceso.
Implementar un caché distribuido no es una solución mágica, pero cuando se aplica al problema correcto, el impacto en el performance y la escalabilidad de tu backend es brutal. Y con un stack como Quarkus y Redis, la implementación es sorprendentemente limpia y directa.