Patrón Refresh-Ahead Cache
Refresca proactivamente entradas de caché antes de que expiren para eliminar fallos de caché en claves calientes y mantener latencia de lectura consistente.
Descripción general
Refresh-ahead recarga proactivamente entradas de caché antes de que expiren. Un proceso en segundo plano monitoriza entradas que se acercan a su límite de TTL y las refresca desde la base de datos. La caché siempre tiene datos frescos, y las lecturas nunca fallan en claves calientes.
Este patrón elimina el pico de latencia que ocurre cuando una clave popular expira. En lugar de que una petición espere un fallo de caché + recarga de base de datos, los datos ya están refrescados. El compromiso es trabajo en segundo plano: el sistema recarga datos incluso si nadie los lee. Para una comprensión más profunda de estrategias de caché, consulta la documentación de Redis sobre patrones de caché.
Flujo Refresh-Ahead
Cuándo usarlo
- Las claves calientes deben servirse siempre desde caché con latencia de fallo cero
- Los fallos de caché en claves populares causan picos de latencia perceptibles para usuarios
- La base de datos puede manejar la carga de refresco en segundo plano
- Puedes predecir qué claves son suficientemente calientes para justificar refresco proactivo
- La consistencia de lectura importa más que la consistencia de escritura
Para alternativas, consulta el Patrón Read-Through Cache y Prevención de Cache Stampede.
Solución
Python con hilo de refresco en segundo plano
import redis
import json
import threading
import time
class RefreshAheadCache:
def __init__(self, redis_client: redis.Redis, ttl: int = 3600, refresh_threshold: float = 0.8):
self.redis = redis_client
self.ttl = ttl
self.refresh_threshold = refresh_threshold # Refrescar cuando 80% del TTL ha pasado
self._refresh_callbacks = {}
self._running = True
self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
self._refresh_thread.start()
def register(self, key: str, loader: callable, ttl: int = None):
self._refresh_callbacks[key] = {
"loader": loader,
"ttl": ttl or self.ttl
}
# Poblacion inicial
self._refresh_key(key)
def get(self, key: str) -> any:
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
# Fallback: cargar sincronamente si no esta registrado o el refresco fallo
if key in self._refresh_callbacks:
return self._refresh_key(key)
return None
def _refresh_loop(self):
while self._running:
time.sleep(10) # Comprobar cada 10 segundos
for key, config in list(self._refresh_callbacks.items()):
ttl_remaining = self.redis.ttl(key)
# Refrescar si el TTL esta por debajo del umbral o la key falta
if ttl_remaining < 0 or ttl_remaining < config["ttl"] * (1 - self.refresh_threshold):
try:
self._refresh_key(key)
except Exception as e:
# Log error, mantener datos stale, reintentar siguiente ciclo
print(f"Refresh failed for {key}: {e}")
def _refresh_key(self, key: str) -> any:
config = self._refresh_callbacks[key]
value = config["loader"]()
self.redis.setex(key, config["ttl"], json.dumps(value))
return value
def shutdown(self):
self._running = False
self._refresh_thread.join(timeout=10)
cache = RefreshAheadCache(
redis.Redis(host='localhost', port=6379),
ttl=300,
refresh_threshold=0.8
)
# Registrar hot keys para refresco proactivo
cache.register(
"product:featured",
lambda: db.query_all("SELECT * FROM products WHERE featured = TRUE LIMIT 10"),
ttl=300
)
cache.register(
"config:app_settings",
lambda: db.query_all("SELECT key, value FROM app_settings"),
ttl=600
)
# Las lecturas siempre hacen hit en cache
featured_products = cache.get("product:featured")
TypeScript con refresco en segundo plano
import { createClient } from 'redis';
interface RefreshConfig {
loader: () => Promise<any>;
ttl: number;
}
class RefreshAheadCache {
private redis: ReturnType<typeof createClient>;
private refreshCallbacks: Map<string, RefreshConfig> = new Map();
private refreshThreshold: number;
private refreshTimer: NodeJS.Timeout | null = null;
private running: boolean = true;
constructor(redisClient: ReturnType<typeof createClient>, refreshThreshold = 0.8) {
this.redis = redisClient;
this.refreshThreshold = refreshThreshold;
this.startRefreshLoop();
}
async register(key: string, loader: () => Promise<any>, ttl: number): Promise<void> {
this.refreshCallbacks.set(key, { loader, ttl });
await this.refreshKey(key);
}
async get<T>(key: string): Promise<T | null> {
const cached = await this.redis.get(key);
if (cached !== null) {
return JSON.parse(cached) as T;
}
const config = this.refreshCallbacks.get(key);
if (config) {
return await this.refreshKey(key) as T;
}
return null;
}
private startRefreshLoop(): void {
this.refreshTimer = setInterval(() => this.checkAndRefresh(), 10000);
}
private async checkAndRefresh(): Promise<void> {
for (const [key, config] of this.refreshCallbacks) {
const ttlRemaining = await this.redis.ttl(key);
if (ttlRemaining < 0 || ttlRemaining < config.ttl * (1 - this.refreshThreshold)) {
try {
await this.refreshKey(key);
} catch (error) {
console.error(`Refresh failed for ${key}:`, error);
}
}
}
}
private async refreshKey(key: string): Promise<any> {
const config = this.refreshCallbacks.get(key)!;
const value = await config.loader();
await this.redis.set(key, JSON.stringify(value), { EX: config.ttl });
return value;
}
async shutdown(): Promise<void> {
this.running = false;
if (this.refreshTimer) clearInterval(this.refreshTimer);
}
}
Java con Scheduled Executor
import redis.clients.jedis.Jedis;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class RefreshAheadCacheManager {
private final Jedis redis;
private final ObjectMapper mapper = new ObjectMapper();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
private final Map<String, RefreshConfig> refreshConfigs = new ConcurrentHashMap<>();
private final double refreshThreshold;
private record RefreshConfig(Callable<Object> loader, int ttl) {}
public RefreshAheadCacheManager(Jedis jedis, double refreshThreshold) {
this.redis = jedis;
this.refreshThreshold = refreshThreshold;
scheduler.scheduleAtFixedRate(this::checkAndRefresh, 10, 10, TimeUnit.SECONDS);
}
public void register(String key, Callable<Object> loader, int ttl) {
refreshConfigs.put(key, new RefreshConfig(loader, ttl));
refreshKey(key);
}
public <T> T get(String key, Class<T> type) {
String cached = redis.get(key);
if (cached != null) {
try {
return mapper.readValue(cached, type);
} catch (Exception e) {
// Pasar al refresco
}
}
RefreshConfig config = refreshConfigs.get(key);
if (config != null) {
return refreshKey(key, type);
}
return null;
}
private void checkAndRefresh() {
for (var entry : refreshConfigs.entrySet()) {
String key = entry.getKey();
RefreshConfig config = entry.getValue();
long ttlRemaining = redis.ttl(key);
if (ttlRemaining < 0 || ttlRemaining < config.ttl() * (1 - refreshThreshold)) {
try {
refreshKey(key);
} catch (Exception e) {
// Mantener datos stale, reintentar siguiente ciclo
}
}
}
}
private void refreshKey(String key) {
RefreshConfig config = refreshConfigs.get(key);
if (config == null) return;
try {
Object value = config.loader().call();
redis.setex(key, config.ttl(), mapper.writeValueAsString(value));
} catch (Exception e) {
throw new RuntimeException("Refresh failed for " + key, e);
}
}
@SuppressWarnings("unchecked")
private <T> T refreshKey(String key, Class<T> type) {
refreshKey(key);
String cached = redis.get(key);
try {
return mapper.readValue(cached, type);
} catch (Exception e) {
throw new RuntimeException("Deserialization failed for " + key, e);
}
}
public void shutdown() {
scheduler.shutdown();
}
}
Explicación
Refresh-ahead funciona monitorizando el TTL de cada clave registrada. Un proceso en segundo plano comprueba periódicamente si el TTL restante de una clave ha caído por debajo de un umbral (ej. 20% restante). Si es así, recarga los datos desde la base de datos y reinicia el TTL.
El umbral de refresco determina cuán agresivamente se refrescan las entradas. Un umbral de 0.8 significa refrescar cuando 80% del TTL ha transcurrido (20% restante). Un umbral más alto refresca antes, reduciendo el riesgo de un fallo pero aumentando la carga en base de datos.
La idea clave es que las lecturas siempre se sirven desde caché. El refresco en segundo plano absorbe la carga de base de datos. Si un refresco falla, los datos obsoletos de la caché se siguen sirviendo hasta que el siguiente ciclo de refresco tenga éxito. Esto proporciona degradación elegante. Para guía de producción, consulta las mejores prácticas de AWS ElastiCache.
Variantes
| Enfoque | Disparador | Ideal para |
|---|---|---|
| Refresco por tiempo | Comprobar TTL cada N segundos | Claves calientes en estado estable |
| Refresco por evento | Refrescar en evento de cambio en BD | Datos que cambian impredeciblemente |
| Refresco probabilístico | Refresco aleatorio cerca de expiración | Claves de alto tráfico, distribuye carga |
| Refresco híbrido | Tiempo + evento combinados | Patrones de acceso mixtos |
| Refresco adaptativo | Ajustar umbral según frecuencia de acceso | Patrones de tráfico variables |
Buenas prácticas
-
Para una guía más profunda, consulta el Patrón Read-Through Cache.
-
Solo refresca claves calientes: refresh-ahead recarga datos independientemente de las lecturas. Registra solo claves que se lean suficientemente frecuente para justificar el trabajo en segundo plano.
-
Establece un umbral de refresco: refresca cuando 70-90% del TTL ha transcurrido. Muy temprano desperdicia carga en base de datos; muy tarde arriesga un fallo si el refresco falla.
-
Mantén datos obsoletos en fallo de refresco: si la base de datos no está disponible temporalmente, sirve el valor obsoleto de la caché. Registra el fallo y reintenta en el siguiente ciclo.
-
Monitoriza la tasa de éxito de refresco: si los refrescos fallan frecuentemente, la base de datos puede estar sobrecargada. Aumenta el intervalo de refresco o reduce el número de claves registradas.
-
Usa un hilo o proceso separado: el trabajo de refresco no debe bloquear peticiones de lectura. Usa un hilo demonio, scheduled executor, o proceso trabajador separado.
Errores comunes
- Refrescar todas las claves: refrescar cada clave cacheada derrocha el propósito. Solo las claves calientes se benefician de refresh-ahead. Las claves frías deben confiar en la expiración por TTL.
- Sin manejo de fallos: si un refresco falla y la clave expira, todas las lecturas fallan. Siempre mantén datos obsoletos y reintenta el refresco.
- Bloquear lecturas en refresco: si el refresco corre en el camino de lectura, añade latencia. Ejecuta refrescos en un hilo en segundo plano, nunca en el manejador de petición.
- Umbral demasiado bajo: refrescar al 10% de TTL restante deja casi sin tiempo para recuperar de un refresco fallido. Establece el umbral a al menos 20% restante.
- No monitorizar la carga de base de datos: refresh-ahead añade carga constante a la base de datos. Si la base de datos ya está bajo presión, los refrescos empeoran la situación.
Véase también
- Patrón Read-Through Cache — Carga perezosa con refresco síncrono en fallo
- Prevención de Cache Stampede — Expiración temprana probabilística para prevenir thundering herd
- Patrón de Invalidez de Caché — Estrategias para mantener la caché consistente con la base de datos
- Patrón Write-Through Cache — Escritura síncrona a caché y base de datos
- Patrón Write-Behind Cache — Escritura asíncrona con persistencia diferida
- Patrones de Caché en Redis — Documentación oficial de Redis sobre patrones de caché comunes
- Mejores prácticas de AWS ElastiCache — Guía de estrategias de caché en la nube de AWS
Preguntas frecuentes
¿En qué se diferencia refresh-ahead de read-through?
Read-through refresca en un fallo de caché: la petición espera la carga desde base de datos. Refresh-ahead refresca antes del fallo: un proceso en segundo plano recarga datos mientras la caché sigue sirviendo el valor antiguo. Read-through añade latencia en fallo; refresh-ahead elimina la latencia de fallo.
¿Cuándo debo usar refresh-ahead sobre un TTL más largo?
Usa refresh-ahead cuando los datos cambian frecuentemente y un TTL más largo serviría datos obsoletos durante demasiado tiempo. Usa un TTL más largo cuando los datos cambian infrecuentemente y la obsolescencia es aceptable. Refresh-ahead mantiene los datos más frescos sin añadir latencia de fallo.
¿Qué pasa si la base de datos está caída durante un refresco?
El refresco falla. La caché sigue sirviendo el valor obsoleto hasta que expira. Una vez expirado, las lecturas fallan y el loader intenta una carga directa desde base de datos. Si eso también falla, la aplicación debe manejar el error (respaldo, reintento, o respuesta de error).
¿Puedo combinar refresh-ahead con cache stampede prevention?
Sí. Refresh-ahead maneja el caso común (claves calientes cerca de expirar). Cache stampede prevention maneja casos extremos (fallo de refresco, picos de tráfico inesperados). Juntos proporcionan protección proactiva y reactiva.
Recursos Relacionados
Patron Read-Through Cache
Una capa de cache transparente que intercepta peticiones de lectura, obtiene del origen de datos en caso de miss y puebla el cache automaticamente.
PatternPatron Cache Stampede Prevention
Previene thundering herd de cache misses con locks, single-flight y refresco temprano para proteger la base de datos de recargas concurrentes.
PatternPatrón Cache Invalidation
Estrategias para mantener frescos los datos en caché: expiración TTL, invalidación explícita, write-through y evicción basada en eventos.
PatternPatrón Write-Behind Cache
Escribe en el caché de forma síncrona y persiste en la base de datos de forma asíncrona para cargas de escritura intensivas con consistencia eventual.
PatternPatron Write-Through Cache
Escribe sincronamente en cache y backing store para que el cache siempre tenga los datos mas recientes sin invalidacion basada en TTL.