Cache con Redis: TTL, Eviction y Cache Stampede
1. Descripción general
Redis es una opción popular para almacenar datos en caché, reducir la carga de la base de datos y acelerar el tiempo de respuesta. Pero usar Redis no es tan simple como simplemente set y get — necesitas entender cómo funciona TTL (Time To Live), qué mecanismo utiliza Redis para eliminar claves cuando se queda sin memoria (eviction), y lo más importante: cómo evitar el cache stampede — la situación en la que cientos de solicitudes golpean la base de datos simultáneamente cuando una clave hot se vence.
Este artículo cubre desde la configuración básica de TTL, las políticas de eviction de Redis, hasta técnicas prácticas para combatir el stampede: mutex lock, logical expiration y request coalescing. Después de leerlo, sabrás cómo diseñar una capa de caché que pueda soportar alta carga sin romper la base de datos.
2. Configurar Redis con Spring Boot
Primero, añade la dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configura la conexión:
spring:
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 16
max-idle: 8
El bean RedisTemplate se utiliza para operar con pares clave/valor con tipos de datos personalizados:
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
GenericJackson2JsonRedisSerializer almacena el objeto en forma de JSON junto con información de clase, lo que facilita la deserialización al tipo correcto cuando se lee — es más conveniente que JdkSerializationRedisSerializer, que es difícil de leer con redis-cli.
3. TTL — Time To Live
3.1. Establecer TTL al escribir una clave
@Service
public class ProductCacheService {
private final RedisTemplate<String, Object> redisTemplate;
public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void cacheProduct(Long productId, Product product) {
String key = "product:" + productId;
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
}
}
La línea set(key, product, Duration.ofMinutes(10)) escribe la clave con un TTL de 10 minutos. Después de este tiempo, Redis elimina la clave automáticamente — se llama passive expiration combinado con active expiration (Redis escanea aleatoriamente las claves con TTL cada 100ms para limpiar antes, evitando que las claves vencidas sigan ocupando RAM mientras se espera a que se lean).
Verifica el TTL restante:
redis-cli TTL product:123
(integer) 542
El valor -1 significa que la clave existe pero no tiene TTL (existe permanentemente). El valor -2 significa que la clave no existe.
3.2. TTL fijo vs TTL aleatorio (jitter)
Si almacenas en caché miles de claves simultáneamente con el mismo TTL, todas vencerán al mismo tiempo — causando una “racha” de cache misses. La solución es añadir jitter: sumar aleatoriamente un pequeño intervalo al TTL.
public void cacheProductWithJitter(Long productId, Product product) {
long baseSeconds = 600;
long jitter = ThreadLocalRandom.current().nextLong(0, 60);
redisTemplate.opsForValue()
.set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}
Cada clave vencerá en un momento ligeramente diferente (600–660 segundos), evitando el efecto “thundering herd” cuando muchas claves expiran en el mismo instante.
Nota: El jitter no resuelve el cache stampede para una única clave hot (por ejemplo, página de inicio, producto muy vendido). Ese problema requiere técnicas en la sección 5.
4. Política de eviction cuando Redis se queda sin memoria
TTL maneja que las claves se eliminen automáticamente con el tiempo. Pero cuando Redis alcanza el límite maxmemory, necesita eliminar algunas claves según la política de eviction, sin importar si la clave tiene TTL o no.
Configura en redis.conf:
maxmemory 512mb
maxmemory-policy allkeys-lru
Las políticas más comunes:
| Política | Significado |
|---|---|
noeviction |
No elimina nada, devuelve error OOM al escribir más. Predeterminado de Redis. |
allkeys-lru |
Elimina la clave menos recientemente utilizada (Least Recently Used), aplicado a todas las claves. |
volatile-lru |
Similar a lo anterior pero solo elimina claves con TTL. |
allkeys-lfu |
Elimina la clave con menor frecuencia de acceso (Least Frequently Used). |
volatile-ttl |
Prioriza eliminar la clave con el TTL más corto restante. |
volatile-random |
Elimina aleatoriamente del conjunto de claves con TTL. |
Para un caso de uso de caché puro, allkeys-lru o allkeys-lfu es una opción segura porque Redis liberará memoria automáticamente en lugar de devolver errores. Si utilizas Redis tanto para caché como para almacenar datos importantes que no deben perderse (tokens de sesión, por ejemplo), separa las instancias o usa volatile-lru para solo eliminar claves marcadas con TTL.
Nota:
noeviction(predeterminado) hará que la aplicación reciba el errorOOM command not allowedcuando Redis se llene. Si usas Redis para caché, siempre configuramaxmemory-policyapropiadamente, no dejes el valor predeterminado.
Verifica la configuración actual:
redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
5. ¿Qué es Cache Stampede?
Cache stampede (también llamado dogpile effect) ocurre cuando una clave hot — leída por cientos/miles de solicitudes cada segundo — repentinamente vence. Todas las solicitudes pierden el caché casi simultáneamente, todas acuden corriendo a la base de datos para reconstruir los datos, sobrecargando la base de datos durante cientos de milisegundos.
public Product getProduct(Long id) {
String key = "product:" + id;
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// La clave acaba de vencer -> cientos de threads acceden aquí simultáneamente
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
return product;
}
Con claves de productos muy vendidos, el código anterior es completamente razonable en condiciones normales, pero en el momento exacto en que vence el TTL, productRepository.findById podría ser llamado cientos de veces simultáneamente para el mismo id.
5.1. Solución: Distributed lock (mutex)
Permite que solo una solicitud reconstruya el caché, las otras esperan o usan el valor anterior.
public Product getProductSafely(Long id) {
String key = "product:" + id;
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
String lockKey = "lock:product:" + id;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
try {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// No obtuve el lock -> espera un poco e intenta leer el caché de nuevo
return waitAndRetry(key, id);
}
}
private Product waitAndRetry(String key, Long id) {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
}
// Se acabó el tiempo de espera, fallback: llamar directamente a DB
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
setIfAbsent es equivalente al comando SET lockKey 1 NX EX 5 en Redis — atómico, solo un thread puede crear el lock con éxito. Los threads que no obtengan el lock harán polling del caché cada 100ms en lugar de llamar a la base de datos directamente.
5.2. Solución: Logical expiration (sin usar TTL real)
En lugar de dejar que Redis elimine la clave automáticamente, almacena el momento de vencimiento lógico dentro del value, y nunca permites que la clave desaparezca realmente. Cuando detectas que los datos han “expirado lógicamente”, un thread reconstruye en segundo plano (background), otros threads siguen devolviendo los datos antiguos inmediatamente (stale-while-revalidate).
public record CachedValue<T>(T data, Instant logicalExpireAt) {}
public Product getProductWithLogicalTtl(Long id) {
String key = "product:" + id;
CachedValue<Product> cached =
(CachedValue<Product>) redisTemplate.opsForValue().get(key);
if (cached == null) {
return rebuildAndCache(id, key);
}
if (Instant.now().isAfter(cached.logicalExpireAt())) {
String lockKey = "lock:product:" + id;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
// Reconstruir de forma asíncrona, devolver datos antiguos inmediatamente a la solicitud actual
CompletableFuture.runAsync(() -> {
try {
rebuildAndCache(id, key);
} finally {
redisTemplate.delete(lockKey);
}
});
}
}
return cached.data(); // siempre devolver data, aunque pueda estar un poco antigua
}
private Product rebuildAndCache(Long id, String key) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
// TTL físico más largo que TTL lógico para evitar que la clave desaparezca durante la reconstrucción
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
return product;
}
El punto clave: el TTL físico en Redis (15 minutos) siempre es más largo que el TTL lógico (10 minutos). La clave nunca desaparecerá de repente de Redis — solo se marca como “antigua” y se reconstruye en segundo plano. Las solicitudes nunca tienen que esperar a la base de datos, incluso inmediatamente después de que los datos expire lógicamente.
6. Prueba del comportamiento anti-stampede
Escribe tests para confirmar que solo hay una llamada a la base de datos cuando múltiples threads pierden el caché simultáneamente:
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {
@Autowired
private ProductCacheService cacheService;
@MockBean
private ProductRepository productRepository;
@Test
void soloUnThreadPuedeCallDatabaseCuandoCacheMiss() throws InterruptedException {
Long productId = 42L;
Product product = new Product(productId, "Teclado mecánico");
given(productRepository.findById(productId)).willReturn(Optional.of(product));
int threadCount = 50;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
cacheService.getProductSafely(productId);
latch.countDown();
});
}
latch.await(5, TimeUnit.SECONDS);
executor.shutdown();
verify(productRepository, times(1)).findById(productId);
}
}
@Test
void sinLockDatabaseEsLlamadoMuchosVeces() {
Long productId = 99L;
Product product = new Product(productId, "Teclado mecánico");
given(productRepository.findById(productId)).willReturn(Optional.of(product));
List<Product> results = IntStream.range(0, 50)
.parallel()
.mapToObj(i -> cacheService.getProductUnsafe(productId))
.toList();
assertThat(results).hasSize(50);
verify(productRepository, atLeast(2)).findById(productId); // prueba que hay stampede
}
La primera prueba usa getProductSafely (con lock) esperando que findById sea llamado solo 1 vez aunque 50 threads ejecuten en paralelo. La segunda prueba usa la versión sin lock para demostrar lo opuesto — verás que findById se llamará múltiples veces, probando que el stampede realmente ocurre sin tratamiento.
7. Errores comunes
- Olvidar configurar TTL: la clave existe para siempre, Redis se llena lentamente de RAM, tienes que depender completamente de la política de eviction — fácil eliminar accidentalmente claves que siguen siendo útiles.
- TTL demasiado corto para claves hot: aumenta la frecuencia de cache misses, hace que el stampede sea más probable. Para claves con alto tráfico, es preferible usar logical expiration en lugar de TTL físico corto.
- Lock sin TTL: si la instancia que sostiene el lock se cae a mitad del camino, el lock existirá para siempre, otros threads esperarán indefinidamente. Siempre configura TTL para locks (
setIfAbsentconDuration). - Usar
noevictionpara Redis caché: la aplicación lanzará errores cuando Redis se llene en lugar de limpiar automáticamente. Solo tiene sentido si Redis se usa para almacenar datos que no pueden perderse. - Serializar objetos de forma inconsistente: cambiar campos en la clase
Productsin manejar versionado puede causar fallos al deserializar desde caché antiguo. Considera agregar un mecanismo de cache-busting (cambiar el prefijo de clave) cuando cambia el esquema.
8. Conclusión
TTL ayuda a que los datos en caché se actualicen automáticamente con el tiempo, pero necesitas añadir jitter para evitar que muchas claves expiren simultáneamente. La política de eviction determina cómo Redis maneja el caso cuando se queda sin memoria — elige allkeys-lru/allkeys-lfu para caché puro, evita el noeviction predeterminado. Para claves hot, TTL por sí solo no es suficiente: necesitas mutex lock o logical expiration para asegurar que solo un thread reconstruye el caché, mientras el resto lee datos antiguos en lugar de bombardear la base de datos. Aplicando correctamente estas tres técnicas, tu capa de caché resistirá mucho mejor las cargas altas comparado con un simple set/get.