Tipos de caché con Redis: estrategias para elegir la correcta

Jorge SaavedraJorge Saavedra
·30 de marzo, 2026·8 min de lecturaNuevo
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
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.

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.
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

AppRedisPostgreSQL1GET key2miss (nil)3SELECT query4data5SET key (TTL)
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 hit
En 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

AppRedisPostgreSQL1SELECT products2data[]3Pipeline SETCuando llega el tráfico:4GET (cache hit)5data
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:101
kotlin
// 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

AppRedisPostgreSQL1GET page:32hit: dataPasos 3-5 en background (async)3async GET page:44SELECT page 45SET page:4
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

AppRedisPostgreSQL1UPDATE2OK3SET (actualizar caché)
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 = 101
kotlin
// 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

AppRedisPostgreSQL1INCR (inmediato)2OK (< 1ms)Flush asíncrono3GET pending4UPDATE batch
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 nada
kotlin
// 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

EstrategiaCuándo cargarConsistenciaCaso de uso ideal
Cache ReactivoAl primer missEventual (TTL)Datos con acceso impredecible
Cache en CalienteAntes del tráficoAlta (precarga manual)Ventas flash, eventos masivos
Cache ProactivoAntes del request, por predicciónEventualPaginación, recomendaciones
Write-ThroughEn cada escrituraFuerteInventarios, datos financieros
Write-BehindRedis ahora, DB despuésEventual (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.

Posts que podrian interesarte