Mantener caché y base de datos consistentes usando Redis

1. Descripción general

Poner Redis delante de la base de datos ayuda a reducir carga y acelerar lecturas. A cambio, tienes dos lugares guardando el mismo dato, y pueden desincronizarse. Un usuario cambia el precio de un producto, pero la página de detalles sigue mostrando el precio viejo durante 10 minutos. Ese es un error de consistencia típico.

En este artículo usaremos el patrón cache-aside: al leer, buscamos primero en caché, y al escribir, actualizamos la base de datos y luego eliminamos el caché después de que la transacción hace commit. Veremos las race conditions que aún quedan y cómo reducirlas. Al final hay un test integrado con Redis real usando Testcontainers.

2. Por qué caché y base de datos se desincronizar

Base de datos y Redis no comparten una transacción. No hay manera de escribir en ambos de forma atómica. El orden y el momento de las operaciones determinan si los datos se desincronizarán o no.

Tres errores comunes:

  • Actualizar caché en lugar de eliminarlo. Dos requests escribiendo simultáneamente pueden hacerlo en la base de datos en orden A→B, pero en caché en orden B→A. El caché mantiene el valor de A para siempre, o hasta que expire el TTL.
  • Eliminar caché antes de commit. Un request de lectura se cuela entre la eliminación del caché y el commit. Ese request lee el valor viejo de la base de datos y lo recarga en caché.
  • Falla la eliminación del caché y nadie se entera. Timeout de Redis, una excepción es tragada, y el caché mantiene datos viejos.

El resto del artículo maneja cada uno de estos errores en orden.

3. Configurar el proyecto

Añade estas dependencias para Redis, JPA y una base de datos de prueba:

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

Configura la conexión a Redis:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

El timeout corto es intencional. Si Redis es lento, quieres que la solicitud caiga rápidamente a la base de datos, no quieres que se quede colgada varios segundos.

Entity y repository:

@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; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

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

No cacheamos la entity directamente, sino un record inmutable:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

Así evitamos problemas de serialización con proxies de Hibernate. También mantiene el formato en caché independiente de la estructura de la tabla.

4. Lectura según cache-aside

@Service
public class ProductService {

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

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

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

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("Producto " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

El flujo es así:

  • redis.opsForValue().get(...) intenta obtener del caché. Si existe, devuelve de inmediato. Este es un cache hit.
  • Si no existe (cache miss), el servicio lee de la base de datos y escribe en Redis con TTL de 10 minutos.
  • El TTL es una red de seguridad. Todos los mecanismos en las secciones siguientes pueden fallar. El TTL asegura que datos incorrectos no persistan más de 10 minutos.

La primera llamada con id = 1, Redis contendrá:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Teclado mecánico\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. Escritura: actualizar base de datos, luego eliminar caché después de commit

5.1. Eliminar en lugar de actualizar

Cuando el precio cambia, eliminamos la clave en lugar de escribir el precio nuevo en caché. La siguiente lectura cargará los datos nuevamente de la base de datos, y la base de datos es la fuente de verdad. Eliminar es idempotente: eliminar dos veces da el mismo resultado. El orden entre eliminaciones tampoco importa. Gracias a esto, el error “sobrescritura de orden incorrecto” de la sección 2 desaparece.

5.2. Esperar al commit de la transacción

Si llamas a redis.delete(...) dentro de un método @Transactional, la orden de eliminación se ejecuta antes del commit. Este es un escenario posible:

T1 (escritura)                      T2 (lectura)
UPDATE price = 900 (sin commit)
DEL product:1
                                    GET product:1 -> miss
                                    SELECT -> price = 1200 (valor antiguo)
                                    SET product:1 = 1200
COMMIT
=> caché mantiene 1200 hasta que expire el TTL

La solución es publicar un evento y manejarlo en la fase AFTER_COMMIT:

public record ProductChangedEvent(Long productId) {}

Añade un método de escritura a ProductService:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Un listener se encarga de eliminar el caché:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("No se pudo eliminar caché {}, confiando en TTL", key, e);
        }
    }
}

Algunos puntos importantes:

  • @TransactionalEventListener(phase = AFTER_COMMIT) solo se ejecuta cuando la transacción hace commit exitosamente. Si la transacción se revierte, el caché no se elimina. Eso es correcto, porque los datos no cambiaron.
  • Errores al eliminar caché no rompen la solicitud de escritura. La base de datos ya hizo commit, lanzar una excepción aquí solo haría que el cliente crea que la operación falló.
  • El log de advertencia te ayuda a monitorear la frecuencia de errores. Mientras tanto, el TTL limita cuánto tiempo los datos viejos persisten.

Nota: Si publishEvent se llama en un lugar sin transacción, @TransactionalEventListener por defecto ignorará el evento y el caché nunca se eliminará. Asegúrate de que el método de escritura tenga @Transactional. Si es necesario, establece fallbackExecution = true.

6. Race conditions restantes y delayed double delete

Incluso cuando se elimina después de commit, hay un escenario raro:

T2 (lectura)                        T1 (escritura)
GET product:1 -> miss
SELECT -> price = 1200
                                    UPDATE price = 900, COMMIT
                                    DEL product:1
SET product:1 = 1200   <- escritura tardía

T2 lee el valor viejo antes de que T1 haga commit, pero escribe en caché después de que T1 ya eliminó. Este escenario necesita que T2 sea ralentizado justo en el momento correcto, por ejemplo por una pausa de GC o una red inestable. Es raro pero sucede.

Una manera simple de mitigarlo es delayed double delete: eliminar una vez más después de un breve intervalo.

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

Spring Boot configura automáticamente un TaskScheduler cuando habilitas @EnableScheduling en una clase de configuración. La segunda eliminación después de 500ms eliminará el valor viejo que T2 escribió tarde. Elige un retraso mayor que el tiempo típico de una lectura de base de datos en tu aplicación.

Nota: Delayed double delete solo reduce la ventana de error, no la elimina completamente. El programador vive en memoria, así que se pierde si la aplicación se reinicia. No lo consideres una garantía absoluta.

7. Cuando la eliminación de caché debe ser garantizada

Con datos sensibles como saldo o inventario, “confiar en TTL” podría no ser aceptable. Hay dos enfoques comunes:

  • Transactional outbox: escribe una fila cache_invalidation en la misma base de datos, en la misma transacción que el cambio de negocio. Un job periódico lee esta tabla, elimina la clave en Redis, y solo marca la fila como completada cuando la eliminación es exitosa. Como la fila de outbox se compite con los datos, el evento de eliminación de caché nunca se pierde.
  • Change Data Capture (CDC): un proceso lee el binlog/WAL de la base de datos y elimina el caché correspondiente. Este enfoque desacopla la lógica de caché del código de negocio, pero la infraestructura es más compleja.

Ambos son eventual consistency. Garantizan que el caché eventualmente será consistente, pero no inmediatamente.

8. Pruebas con Redis real

Mockechar StringRedisTemplate no detecta errores sobre el momento del commit. Prueba con Redis real usando Testcontainers:

<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>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

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

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("Teclado mecánico", new BigDecimal("1200000"))).getId();

        productService.findById(id); // carga caché
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

Esta prueba verifica tres cosas:

  • @ServiceConnection(name = "redis") apunta automáticamente spring.data.redis.* al contenedor, así que no necesitas más configuración.
  • Después de updatePrice, la clave no está en Redis. Esto prueba que el listener AFTER_COMMIT ejecutó.
  • La siguiente lectura devuelve el precio nuevo de la base de datos.

Nota: No marques este método de prueba como @Transactional. Spring revertirá la transacción de la prueba al final, así que AFTER_COMMIT nunca se dispara y la aserción falla.

Resultado cuando ejecutas:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. Cuándo no usar este enfoque

  • Datos que requieren consistencia fuerte, como saldos de cuentas usados para decidir si se permite una transacción. No los leas de caché. Léelos directamente de la base de datos, con los bloqueos apropiados.
  • Datos que se escriben más de lo que se leen. El caché se invalida constantemente, así que la tasa de hits es baja. Pagas por la complejidad sin obtener casi nada.
  • Claves que son agregaciones de múltiples entidades, como “top 10 productos”. Es difícil saber qué clave eliminar cuando una entidad cambia. Para este tipo de claves, un TTL corto suele ser más simple y seguro que una invalidación precisa.
  • Múltiples servicios escriben la misma tabla. Los eventos en tu aplicación no ven cambios de otros servicios. En este caso, CDC tiene más sentido.

10. Conclusión

Mantener Redis y base de datos consistentes no significa mantenerlos sincronizados perfectamente. El objetivo es hacer la ventana de desincronización pequeña y acotada. La fórmula básica tiene cuatro pasos:

  1. Leer según cache-aside, siempre establecer un TTL.
  2. Escribir en la base de datos, luego eliminar la clave, no actualizar la clave.
  3. Eliminar solo después de commit, usando @TransactionalEventListener(phase = AFTER_COMMIT).
  4. Añade delayed double delete, outbox o CDC según cuán sensibles sean los datos.

Finalmente, prueba con Redis real y no dejes que @Transactional en las pruebas oculte el comportamiento después de commit.

rediscachespring-bootconsistencyjava