Redis: elegir cache-aside, write-through o write-behind

1. Descripción general

Agregar Redis delante de la base de datos es la forma más rápida de reducir la latencia y la carga del sistema. La dificultad está en otra parte: debes decidir cómo escribir los datos para que la caché y la base de datos no se desincronicen. Hay tres estrategias populares: cache-aside, write-through y write-behind.

Este artículo implementa las tres con Spring Boot 3 y Redis. Luego las compara en términos de consistencia, rendimiento y riesgo de pérdida de datos. Después de leer, sabrás qué estrategia elegir para cada tipo de datos.

2. Preparación

2.1. Dependencias

Usamos Spring Data Redis, Spring Data JPA con H2 como base de datos de ejemplo, y Testcontainers para pruebas:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configuración de conexión a Redis en local:

spring.data.redis.host=localhost
spring.data.redis.port=6379

2.2. Entity, repositorio y RedisTemplate

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    // getters y setters
}

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Spring Boot crea automáticamente StringRedisTemplate. Para guardar Product como JSON, declaramos un RedisTemplate separado:

@Configuration
@EnableScheduling
public class RedisConfig {

    @Bean
    RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Product>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
        return template;
    }
}

La clave tiene forma product:{id}, el valor es JSON del Product. La anotación @EnableScheduling se usa en la sección write-behind.

Las tres estrategias necesitan ejecutar código después de que confirme la transacción. Agrupamos esto en un ayudante:

public final class AfterCommit {

    public static void run(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }
}

Si estamos dentro de una transacción, action solo se ejecuta cuando la confirmación es exitosa. Si la transacción revierte, la caché no se afecta.

3. Cache-aside (carga diferida)

En cache-aside, la aplicación gestiona la caché ella misma:

  • Lectura: busca en Redis primero. Si no está en caché, lee de la base de datos y escribe el resultado en Redis con TTL.
  • Escritura: actualiza la base de datos, luego elimina la clave en Redis.
@Service
public class ProductCacheAsideService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductCacheAsideService(ProductRepository repository,
                                    RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    public Optional<Product> findById(Long id) {
        Product cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
        return fromDb;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(newPrice);
        AfterCommit.run(() -> redis.delete(key(id)));
        return product;
    }

    static String key(Long id) {
        return "product:" + id;
    }
}

El comportamiento del código anterior:

  • La primera llamada a findById(1L) lee de la base de datos y crea la clave product:1 con TTL de 10 minutos.
  • Las siguientes llamadas dentro de esos 10 minutos se devuelven desde Redis, sin tocar la base de datos.
  • updatePrice modifica la entidad, el detección de cambios de JPA escribe en la base de datos al confirmar, luego se elimina la clave. La siguiente lectura recargará el nuevo valor.

Verifica con redis-cli:

redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Teclado mecánico\",\"price\":1200000.00}"
(integer) 587

Nota: Al escribir, elimina la clave en lugar de set el nuevo valor. Supongamos que dos solicitudes se actualizan al mismo tiempo. Si ambas hacen set, el orden de escritura en Redis puede ser opuesto al orden de confirmación en la base de datos, y la caché mantiene el valor anterior indefinidamente. Eliminar la clave asegura que la próxima lectura obtenga el valor más reciente. Además, hay que eliminar después de confirmar. Si eliminas antes, una solicitud de lectura que se interponga podría recargar datos antiguos en la caché.

Incluso si lo haces correctamente, cache-aside aún tiene una pequeña ventana de carrera: una solicitud lee datos antiguos de la base de datos y los escribe en la caché después de que se acaba de eliminar la clave. TTL es la red de seguridad final para este caso. Nunca hagas caché sin establecer TTL.

Para datos simples, puedes usar @Cacheable y @CacheEvict de Spring Cache para lograr el mismo efecto con menos código. Su esencia sigue siendo cache-aside.

4. Write-through

En write-through, cada escritura actualiza tanto la base de datos como la caché de forma sincrónica. La solicitud solo devuelve cuando ambas han terminado. La lectura sigue siendo como cache-aside, pero la tasa de aciertos es más alta porque los datos recién escritos ya están en Redis.

@Service
public class ProductWriteThroughService {

    private static final Duration TTL = Duration.ofMinutes(30);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductWriteThroughService(ProductRepository repository,
                                      RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    @Transactional
    public Product save(Product product) {
        Product saved = repository.saveAndFlush(product);
        AfterCommit.run(() -> redis.opsForValue()
                .set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
        return saved;
    }
}

La línea saveAndFlush escribe en la base de datos inmediatamente para obtener id y detectar errores de restricción temprano. Después de confirmar, el registro se envía a Redis. Cuando un producto recién creado se lee nuevamente, el resultado es un acierto de caché en la primera lectura.

Ventajas de write-through:

  • La caché siempre está “caliente” con datos recién escritos, sin falta de caché en la primera lectura.
  • Adecuado para datos que se leen inmediatamente después de escribirse, como perfiles de Customer o carrito de compras.

Desventajas:

  • Cada escritura cuesta un viaje redondo adicional a Redis, por lo que aumenta la latencia de escritura.
  • La caché se llena con registros que nadie lee. TTL ayuda a limpiar, pero sigue consumiendo memoria.
  • Aún hay carrera cuando múltiples escritores hacen set simultáneamente, como se mencionó en la sección 3. Para datos actualizados simultáneamente de múltiples formas, necesitas agregar versión, por ejemplo usando @Version y solo sobrescribir cuando la versión es más nueva, o volver a eliminar la clave.

Nota: Si Redis falla después de que la base de datos ha confirmado, la caché carece de la versión más reciente. Captura la excepción y elimina la clave como plan de respaldo. No dejes que la solicitud falle cuando la base de datos ya ha escrito correctamente.

5. Write-behind (write-back)

En write-behind, la aplicación solo escribe en Redis y devuelve inmediatamente. Un proceso en segundo plano recopila los cambios y escribe en la base de datos en lotes. La base de datos ya no está en el camino de la solicitud, por lo que la escritura es muy rápida. A cambio, Redis se convierte temporalmente en la fuente de verdad de los datos.

@Service
public class ProductWriteBehindService {

    private static final String DIRTY_SET = "products:dirty";
    private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;
    private final StringRedisTemplate stringRedis;

    public ProductWriteBehindService(ProductRepository repository,
                                     RedisTemplate<String, Product> redis,
                                     StringRedisTemplate stringRedis) {
        this.repository = repository;
        this.redis = redis;
        this.stringRedis = stringRedis;
    }

    public void updatePrice(Long id, BigDecimal newPrice) {
        String key = ProductCacheAsideService.key(id);
        Product product = Optional.ofNullable(redis.opsForValue().get(key))
                .or(() -> repository.findById(id))
                .orElseThrow();
        product.setPrice(newPrice);
        redis.opsForValue().set(key, product);              // sin TTL
        stringRedis.opsForSet().add(DIRTY_SET, id.toString());
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        String id;
        while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
            Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
            if (product == null) {
                continue;
            }
            try {
                repository.save(product);
            } catch (RuntimeException ex) {
                log.warn("Flush falló para product {}, reintentando después", id, ex);
                stringRedis.opsForSet().add(DIRTY_SET, id);
                break;
            }
        }
    }
}

El flujo de procesamiento es el siguiente:

  • updatePrice actualiza el registro en Redis y agrega id al conjunto products:dirty. La solicitud termina en unos milisegundos.
  • Cada 5 segundos, flush() saca cada id usando SPOP y escribe el registro correspondiente en la base de datos.
  • Si un producto se actualiza 100 veces en 5 segundos, la base de datos solo recibe una escritura. Este es el mayor beneficio de write-behind.
  • Cuando la escritura falla, id se devuelve al conjunto para reintentar en el siguiente pase.

Este es el modelo más riesgoso de las tres estrategias. Hay algunos puntos que deben tratarse obligatoriamente:

  • Pérdida de datos cuando Redis falla. Necesitas habilitar AOF con appendfsync everysec o always, y ejecutar Redis con replicación. De todas formas, aún puedes perder alrededor de 1 segundo de datos.
  • Evicción. La clave dirty no tiene TTL. Establece maxmemory-policy en noeviction o volatile-*, de lo contrario Redis podría eliminar datos que aún no se han escrito.
  • Múltiples instancias. Gracias a que SPOP es atómico, dos instancias no seleccionan el mismo id. Sin embargo, si una instancia muere justo después de SPOP y antes de save, ese cambio queda atrapado en Redis hasta la siguiente actualización. Para mayor certeza, usa Redis Streams con grupos de consumidores y XACK.
  • Consultas en la base de datos ven datos antiguos. Los informes, otros trabajos u otros servicios que leen directamente de la base de datos estarán atrasados varios segundos respecto a la realidad.

Nota: No uses write-behind para saldos de cuentas, pedidos Order, pagos a través de PaymentService, ni ningún dato donde perder un registro sea un incidente. Es adecuado para vistas, me gusta, posición de lectura, last-seen: datos que se escriben mucho y pueden perder un poco.

6. Prueba del comportamiento de cache-aside

La prueba siguiente usa un Redis real ejecutándose en Testcontainers. @ServiceConnection configura automáticamente host y puerto para Spring Boot:

@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired
    ProductCacheAsideService service;

    @Autowired
    ProductRepository repository;

    @Autowired
    RedisTemplate<String, Product> redis;

    @Test
    void shouldCacheOnReadAndEvictAfterUpdate() {
        Product saved = repository.save(new Product("Teclado mecánico", new BigDecimal("1200000")));
        String key = ProductCacheAsideService.key(saved.getId());

        service.findById(saved.getId());
        assertThat(redis.hasKey(key)).isTrue();

        service.updatePrice(saved.getId(), new BigDecimal("990000"));
        assertThat(redis.hasKey(key)).isFalse();

        Product reloaded = service.findById(saved.getId()).orElseThrow();
        assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
        assertThat(redis.hasKey(key)).isTrue();
    }
}

La prueba verifica el ciclo de vida completo de la clave: se crea al leer, se elimina después de actualizar, luego se recarga con el nuevo valor. La clase de prueba no lleva @Transactional. Si la adjuntas, la transacción de la prueba se revertirá, el callback afterCommit nunca se ejecutará, y la segunda afirmación fallará.

7. Comparación y cómo elegir

Criterio Cache-aside Write-through Write-behind
Latencia de lectura en primer acceso Alta (fallo) Baja Baja
Latencia de escritura Baja Media (escribe en 2 lugares) Muy baja
Consistencia con BD Buena (TTL como red de seguridad) Buena Eventual, retraso de unos segundos
Riesgo de pérdida de datos No No Sí
Complejidad Baja Media Alta
Reduce carga de escritura en BD No No Sí (agrupa lotes)

Algunos consejos al elegir:

  • Por defecto, usa cache-aside. Es simple, tolera bien los fallos (si Redis muere, aún puedes leer de la base de datos), y es suficiente para la mayoría de datos de lectura frecuente y escritura infrecuente como catálogos de productos o configuración.
  • Elige write-through cuando los datos se leen normalmente inmediatamente después de escribirse y quieres evitar fallos en la primera lectura. En la práctica, muchos sistemas combinan: escriben como write-through, leen como cache-aside.
  • Solo elige write-behind cuando la base de datos es realmente el cuello de botella de escritura y los datos aceptan pérdida. Si no tienes métricas que demuestren que la base de datos es un cuello de botella, no pagues el costo de esta complejidad.
  • No necesitas elegir una sola estrategia para todo el sistema. Cada tipo de datos puede usar una estrategia diferente.

Algunos errores comunes con las tres estrategias:

  • Cache stampede. Una clave caliente expira, miles de solicitudes fallan simultáneamente y se concentran en la base de datos. Puedes mitigarlo con TTL con jitter aleatorio, o bloquear con SET key value NX PX para que solo una solicitud recargue.
  • Cache penetration. Las solicitudes preguntan continuamente por un id que no existe, así que siempre fallan. Cachea también los resultados vacíos con un TTL corto.
  • Cambiar la estructura de clase y olvidar limpiar la caché. El JSON antiguo no se puede deserializar a la nueva clase. Agrega versión a la clave, por ejemplo product:v2:{id}.

8. Conclusión

El artículo ha implementado tres estrategias de caché con Redis y Spring Boot 3. Cache-aside carga datos cuando es necesario y elimina la clave después de confirmar. Write-through sincroniza ambos lugares al escribir. Write-behind intercambia durabilidad de datos por alta velocidad de escritura.

Regla corta: comienza con cache-aside y siempre establece TTL. Sube a write-through cuando necesites caché caliente. Solo usa write-behind para datos que aceptan pérdida, con AOF, política de evicción apropiada y mecanismo de reintento claro.

rediscachespring-bootjavarendimiento