backendredisarquitecturarendimientobuenas-practicas
Las consultas lentas a la base de datos son uno de los cuellos de botella más comunes en aplicaciones web de mediana y alta escala. A medida que el tráfico crece, llegar a la base de datos en cada request se vuelve insostenible: los tiempos de respuesta suben, los costos escalan y la experiencia del usuario se degrada. La solución clásica es interponer una capa de caché.
Redis es hoy el estándar de facto para implementar caché en aplicaciones backend. Es rápido, flexible y soporta múltiples estructuras de datos. Pero Redis es solo una herramienta: la estrategia que eliges para cargar y actualizar el caché determina si tu sistema será consistente, veloz o frágil bajo presión.
Redis
Remote Dictionary Server. Base de datos en memoria, de código abierto, que soporta estructuras como strings, hashes, listas y sets. Se usa ampliamente como capa de caché, broker de mensajes y almacén de sesiones.
En este post revisamos las cinco estrategias de caché más usadas con Redis, con ejemplos concretos de comandos y lógica de aplicación para que puedas elegir la adecuada para tu caso.
Cache Reactivo (Cache-Aside / Lazy Loading)
Es la estrategia más común y la que probablemente ya conoces. El principio es simple: la aplicación consulta Redis primero. Si el dato está ahí (cache hit), lo devuelve directamente. Si no está (cache miss), consulta la base de datos, guarda el resultado en Redis y lo retorna. El caché solo se llena con lo que realmente se solicita.
Cache-Aside: flujo de lectura
bash
# 1. Consultar Redis
GET user:123
# (nil) — cache miss
# 2. Consultar la base de datos y guardar en Redis con TTL de 1 hora
SET user:123 '{"id":123,"name":"Ana","email":"ana@example.com"}' EX 3600
# 3. En el siguiente request, el dato ya está en caché
GET user:123
# '{"id":123,"name":"Ana","email":"ana@example.com"}' — cache hitEn código, el flujo queda así:
kotlin
// Repositorio de Redis que encapsula las operaciones de caché
@Repository
class UserCacheRepository(
private val redisTemplate: RedisTemplate<String, String>
) {
private val objectMapper = ObjectMapper()
fun findById(userId: Long): User? {
val data = redisTemplate.opsForValue().get("user:$userId") ?: return null
return objectMapper.readValue(data, User::class.java)
}
fun save(user: User, ttl: Duration = Duration.ofHours(1)) {
redisTemplate.opsForValue().set(
"user:${user.id}",
objectMapper.writeValueAsString(user),
ttl
)
}
}
// El servicio usa el repositorio de caché, no RedisTemplate directamente
@Service
class UserService(
private val userCacheRepository: UserCacheRepository,
private val userRepository: UserRepository
) {
fun getUser(userId: Long): User? {
// Intentar desde caché
userCacheRepository.findById(userId)?.let { return it }
// Cache miss: consultar la base de datos
val user = userRepository.findById(userId).orElse(null)
if (user != null) {
userCacheRepository.save(user)
}
return user
}
}El trade-off del cold start
La desventaja del cache reactivo es el primer request: siempre llegará a la base de datos porque el caché está vacío. En aplicaciones con picos de tráfico predecibles (un evento, un lanzamiento), este cold start puede generar una carga masiva de misses simultáneos. A ese fenómeno se le llama cache stampede.
Cache-aside es ideal cuando no puedes predecir qué datos se van a consultar, o cuando el conjunto de datos es tan grande que precargar todo sería un desperdicio de memoria.
Cache en Caliente (Hot Cache / Warm Cache)
El cache en caliente es el opuesto al lazy loading: los datos se cargan en Redis antes de que llegue el tráfico. La idea es que cuando los usuarios empiecen a hacer requests, el caché ya esté listo y no haya ningún miss inicial.
Hot Cache: precarga antes del tráfico
Este patrón se usa en escenarios donde puedes anticipar exactamente qué datos se van a necesitar: ventas flash, partidos en vivo, lanzamientos de productos o el inicio del día laboral en un sistema de reportes.
bash
# Precalentar el caché con el catálogo de productos antes de una venta flash
HSET product:101 name "Mechanical keyboard" price "89.99" stock "250"
HSET product:102 name "27-inch monitor" price "349.99" stock "80"
HSET product:103 name "Ergonomic chair" price "199.99" stock "120"
# Verificar que están cargados
HGETALL product:101kotlin
// Repositorio de Redis que encapsula las operaciones de caché
@Repository
class ProductCacheRepository(
private val redisTemplate: RedisTemplate<String, String>
) {
fun saveAll(products: List<Product>, ttl: Duration = Duration.ofHours(2)) {
redisTemplate.executePipelined { connection ->
for (product in products) {
val key = "product:${product.id}"
val map = mapOf(
"name" to product.name,
"price" to product.price.toString(),
"stock" to product.stock.toString()
)
connection.hashCommands().hSet(
key.toByteArray(),
map.mapKeys { it.key.toByteArray() }.mapValues { it.value.toByteArray() }
)
connection.keyCommands().expire(key.toByteArray(), ttl.seconds)
}
null
}
}
}
// El servicio usa el repositorio de caché, no RedisTemplate directamente
@Service
class CatalogWarmingService(
private val productCacheRepository: ProductCacheRepository,
private val productRepository: ProductRepository
) {
fun warmCatalog(productIds: List<Long>) {
// Se ejecuta minutos antes de que abra la venta flash
val products = productIds.mapNotNull { productRepository.findById(it).orElse(null) }
productCacheRepository.saveAll(products)
println("Cache warmed: ${products.size} products loaded.")
}
}Pipelines de Redis
Cuando necesitas ejecutar muchos comandos de escritura seguidos, usa un pipeline de Redis. En vez de hacer un round-trip por red por cada comando, los agrupa y los envía todos juntos. Para precalentar miles de claves, la diferencia de tiempo es significativa.
El punto débil del cache en caliente es el mantenimiento: si los datos cambian (el precio de un producto se actualiza, el stock baja), tienes que invalidar o actualizar las claves correspondientes de forma explícita.
Cache Proactivo (Prefetch Cache)
El cache proactivo va un paso más allá del warming: precarga datos basándose en patrones de comportamiento del usuario, sin esperar a que haga el request. Es carga predictiva.
El caso de uso más claro es la paginación. Cuando un usuario está viendo la página 3 de resultados, es muy probable que vaya a pedir la página 4. En vez de esperar ese request, el sistema precarga la página 4 en background mientras el usuario lee la 3.
Cache Proactivo: precarga predictiva
kotlin
// Repositorio de Redis que encapsula las operaciones de caché
@Repository
class PageCacheRepository(
private val redisTemplate: RedisTemplate<String, String>
) {
private val objectMapper = ObjectMapper()
fun find(page: Int): List<Article>? {
val data = redisTemplate.opsForValue().get("search:results:page:$page") ?: return null
return objectMapper.readValue(data, object : TypeReference<List<Article>>() {})
}
fun save(page: Int, articles: List<Article>, ttl: Duration = Duration.ofSeconds(300)) {
redisTemplate.opsForValue().set(
"search:results:page:$page",
objectMapper.writeValueAsString(articles),
ttl
)
}
fun exists(page: Int): Boolean {
return redisTemplate.hasKey("search:results:page:$page") == true
}
}
// El servicio usa el repositorio de caché, no RedisTemplate directamente
@Service
class PaginationService(
private val pageCacheRepository: PageCacheRepository,
private val articleRepository: ArticleRepository
) {
fun getPage(page: Int, size: Int = 20): List<Article> {
pageCacheRepository.find(page)?.let {
// Precargar la siguiente página de forma asíncrona
prefetchNextPage(page + 1, size)
return it
}
val offset = (page - 1) * size
val data = articleRepository.findAllOrderByDateDesc(size, offset)
pageCacheRepository.save(page, data)
return data
}
@Async
fun prefetchNextPage(page: Int, size: Int) {
if (!pageCacheRepository.exists(page)) {
val offset = (page - 1) * size
val data = articleRepository.findAllOrderByDateDesc(size, offset)
pageCacheRepository.save(page, data)
}
}
}Otros casos donde el prefetch tiene sentido: cargar el perfil del siguiente usuario en una cola de revisión, precargar recomendaciones personalizadas al iniciar sesión, o adelantar los datos de un reporte que se genera cada mañana a la misma hora.
No todo vale la pena precargarlo
El cache proactivo tiene sentido cuando la probabilidad de que el dato se use es alta. Si precarigas datos que nadie pide, solo estás consumiendo memoria de Redis sin beneficio. Mide el hit rate de tus prefetches antes de escalar esta estrategia.
Write-Through Cache
En las estrategias anteriores el foco estaba en las lecturas. Las estrategias write-through y write-behind se enfocan en las escrituras: qué pasa cuando un dato cambia.
En write-through, cada vez que la aplicación escribe un dato, lo escribe simultáneamente en Redis y en la base de datos. Ambos destinos se actualizan en la misma operación, de forma síncrona. El resultado es consistencia fuerte: Redis y la DB siempre tienen los mismos datos.
Write-Through: escritura sincrónica
bash
# Actualizar el stock de un producto
# Escrito en Redis y en la DB en la misma transacción de negocio
SET product:101:stock 249
# Simultáneamente → UPDATE products SET stock = 249 WHERE id = 101kotlin
// Repositorio de Redis que encapsula las operaciones de caché
@Repository
class StockCacheRepository(
private val redisTemplate: RedisTemplate<String, String>
) {
fun update(productId: Long, stock: Int, ttl: Duration = Duration.ofHours(1)) {
redisTemplate.opsForValue().set(
"product:$productId:stock",
stock.toString(),
ttl
)
}
}
// El servicio usa el repositorio de caché, no RedisTemplate directamente
@Service
class StockService(
private val stockCacheRepository: StockCacheRepository,
private val productRepository: ProductRepository
) {
@Transactional
fun updateStock(productId: Long, newStock: Int) {
// Write-through: primero la DB, luego el caché
productRepository.updateStock(productId, newStock)
// Si la transacción falla, nunca llegamos a esta línea
stockCacheRepository.update(productId, newStock)
}
}El precio a pagar es latencia en escritura. Cada write ahora toca dos sistemas en vez de uno. Para aplicaciones con alta frecuencia de escrituras (un contador de visitas que se actualiza en cada request, por ejemplo), este overhead puede ser inaceptable.
Write-through es la opción correcta cuando la consistencia no es negociable: sistemas financieros, inventarios en tiempo real, cualquier escenario donde un dato desactualizado en caché causaría un error de negocio visible.
Write-Behind Cache (Write-Back)
Write-behind invierte las prioridades: la aplicación escribe primero en Redis y la persistencia a la base de datos ocurre de forma asíncrona, con un pequeño retardo. Desde la perspectiva de la aplicación, el write termina en microsegundos porque solo toca la memoria. La base de datos se actualiza después, en background.
Write-Behind: escritura asíncrona
bash
# Incrementar el contador de likes de un artículo
# Solo se escribe en Redis; la DB se actualiza en batch cada 30 segundos
INCR article:456:likes
# Redis responde en < 1 ms — la DB todavía no sabe nadakotlin
// Repositorio de Redis que encapsula las operaciones de caché
@Repository
class LikeCacheRepository(
private val redisTemplate: RedisTemplate<String, String>
) {
fun increment(articleId: Long): Long {
return redisTemplate.opsForValue().increment("article:$articleId:likes") ?: 0
}
fun markPending(articleId: Long) {
redisTemplate.opsForSet().add("likes:pending", articleId.toString())
}
fun getPendingIds(): Set<String> {
return redisTemplate.opsForSet().members("likes:pending") ?: emptySet()
}
fun getCount(articleId: String): Long? {
return redisTemplate.opsForValue().get("article:$articleId:likes")?.toLongOrNull()
}
fun clearPending() {
redisTemplate.delete("likes:pending")
}
}
// El servicio usa el repositorio de caché, no RedisTemplate directamente
@Service
class LikeService(
private val likeCacheRepository: LikeCacheRepository,
private val articleRepository: ArticleRepository
) {
// Escritura: solo Redis
fun recordLike(articleId: Long): Long {
val newTotal = likeCacheRepository.increment(articleId)
likeCacheRepository.markPending(articleId)
return newTotal
}
// Flush periódico (se ejecuta cada 30 segundos)
@Scheduled(fixedRate = 30_000)
@Transactional
fun flushLikesToDb() {
val pendingIds = likeCacheRepository.getPendingIds()
if (pendingIds.isEmpty()) return
for (articleId in pendingIds) {
val total = likeCacheRepository.getCount(articleId) ?: continue
articleRepository.updateLikes(articleId.toLong(), total)
}
likeCacheRepository.clearPending()
println("Flush complete: ${pendingIds.size} articles updated.")
}
}El riesgo real del write-behind
Si Redis se cae antes de que los datos se hayan persistido en la base de datos, esos writes se pierden. Es un trade-off deliberado: cambias durabilidad por rendimiento. Antes de adoptar esta estrategia, evalúa qué tan crítica es la pérdida de esos datos. Para contadores de likes, perder unos pocos segundos de actualizaciones es tolerable. Para transacciones financieras, no lo es.
Write-behind brilla en casos de uso con altísima frecuencia de escritura donde la consistencia inmediata no es un requisito: contadores de vistas, métricas de engagement, actualizaciones de posición en juegos, o cualquier dato que se mueva rápido y cuya pérdida ocasional sea aceptable.
Comparación rápida
| Estrategia | Cuándo cargar | Consistencia | Caso de uso ideal |
|---|---|---|---|
| Cache Reactivo | Al primer miss | Eventual (TTL) | Datos con acceso impredecible |
| Cache en Caliente | Antes del tráfico | Alta (precarga manual) | Ventas flash, eventos masivos |
| Cache Proactivo | Antes del request, por predicción | Eventual | Paginación, recomendaciones |
| Write-Through | En cada escritura | Fuerte | Inventarios, datos financieros |
| Write-Behind | Redis ahora, DB después | Eventual (riesgo de pérdida) | Contadores, métricas de alta frecuencia |
Para cerrar
No existe una estrategia de caché universalmente correcta. Cache-aside resuelve bien la mayoría de los casos generales. Write-through es la elección obvia cuando no puedes tolerar inconsistencia. Write-behind es la solución cuando el volumen de escrituras es el cuello de botella. El cache en caliente y el proactivo atacan problemas específicos de latencia en el primer request y experiencia de usuario.
Redis es solo la herramienta. Lo que determina si tu sistema de caché es robusto o frágil es la estrategia que eliges y qué tan bien la ajustas a tus patrones reales de lectura y escritura. Un caché mal diseñado no solo no ayuda: puede generar inconsistencias difíciles de depurar y falsos positivos que enmascaran problemas en la base de datos.
El primer paso es siempre medir: ¿cuáles son tus datos más consultados? ¿Con qué frecuencia cambian? ¿Cuánto te cuesta un dato desactualizado? Con esas respuestas claras, elegir la estrategia correcta se vuelve un ejercicio de ingeniería, no de intuición.