Caching: cache-aside, write-through y TTL
1. Descripción general
Cache ayuda a reducir la carga en la base de datos y acorta el tiempo de respuesta. A cambio, debes aceptar que los datos pueden estar obsoletos. Este artículo presenta dos estrategias comunes de lectura/escritura de caché: cache-aside y write-through. También explica cómo usar TTL (time-to-live) para limitar lo antigua que puede ser la información.
Al terminar de leer, podrás implementar ambas estrategias en Java puro, pasar a usar anotaciones de Spring Cache, configurar TTL para Caffeine y Redis, y evitar algunos errores comunes.
2. Conceptos básicos
Antes de meternos en código, aclaremos algunos términos:
- Cache hit: los datos están disponibles en el caché, se devuelven inmediatamente.
- Cache miss: el caché no tiene los datos, hay que leerlos de la fuente original (base de datos, otro servicio).
- Datos obsoletos: los datos en el caché difieren de la base de datos.
- TTL: el tiempo de vida de una entrada. Vencido el TTL, la entrada se considera inválida.
Toda estrategia de caché gira en torno a una pregunta: cuando cambian los datos, ¿quién es responsable de actualizar el caché y cuándo?
Utilizaremos un dominio simple a lo largo del artículo:
public record Product(long id, String name, BigDecimal price) {}
public interface ProductRepository {
Optional<Product> findById(long id);
Product save(Product product);
}
3. Un caché con TTL escrito a mano
Para entender el mecanismo claramente, escribiremos un pequeño caché con TTL. El tiempo se obtiene a través de Supplier<Instant>, lo que permite que las pruebas puedan “avanzar” el reloj.
public class TtlCache<K, V> {
private record Entry<V>(V value, Instant expiresAt) {}
private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
private final Duration ttl;
private final Supplier<Instant> now;
public TtlCache(Duration ttl, Supplier<Instant> now) {
this.ttl = ttl;
this.now = now;
}
public Optional<V> get(K key) {
Entry<V> entry = store.get(key);
if (entry == null) {
return Optional.empty();
}
if (now.get().isAfter(entry.expiresAt())) {
store.remove(key, entry);
return Optional.empty();
}
return Optional.of(entry.value());
}
public void put(K key, V value) {
store.put(key, new Entry<>(value, now.get().plus(ttl)));
}
public void evict(K key) {
store.remove(key);
}
}
Cómo funciona esta clase:
putguarda el valor junto con el momento de vencimientoexpiresAt = now + ttl.getverifica la expiración. Si la entrada está vencida, se elimina y la función devuelveOptional.empty(), es decir, un cache miss.store.remove(key, entry)solo elimina exactamente la entrada que se leyó. Así, si otro thread acaba de hacerputcon un valor nuevo, ese valor no se elimina por error.
Esta clase es suficiente para propósitos de ilustración. En producción, deberías usar Caffeine o Redis (sección 6).
4. Cache-aside
4.1. La idea
Con cache-aside (también llamado lazy loading), la aplicación gestiona el caché por sí sola:
- Lectura: busca en el caché primero. Si hay miss, lee de la base de datos, escribe el resultado en el caché y lo devuelve.
- Escritura: actualiza la base de datos primero, luego elimina la entrada del caché. La siguiente lectura cargará los datos nuevos.
public class ProductService {
private final ProductRepository repository;
private final TtlCache<Long, Product> cache;
public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
this.repository = repository;
this.cache = cache;
}
public Optional<Product> findById(long id) {
Optional<Product> cached = cache.get(id);
if (cached.isPresent()) {
return cached; // cache hit
}
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> cache.put(id, p)); // cargar en caché
return fromDb;
}
public Product updatePrice(long id, BigDecimal newPrice) {
Product current = repository.findById(id).orElseThrow();
Product saved = repository.save(new Product(id, current.name(), newPrice));
cache.evict(id); // eliminar después de escribir en DB
return saved;
}
}
Explicación:
findByIdsolo llama alrepositoryen caso de cache miss. Si el producto no existe, no cacheamos nada.updatePriceescribe en la base de datos primero, luego haceevict. Este orden es importante; la sección 8 explicará por qué.- Elegimos eliminar la entrada en lugar de sobrescribir con el nuevo valor. Esto es más simple porque el caché solo se carga desde un lugar: la ruta de lectura.
4.2. Cuándo usar
Cache-aside es apropiado cuando:
- La tasa de lectura es mucho mayor que la de escritura, como en catálogos de productos o perfiles de usuario.
- Solo quieres cachear datos que realmente se acceden.
- El sistema debe seguir funcionando si la caché se cae. En ese caso, todas las solicitudes van directamente a la base de datos: más lento pero sin errores.
Desventajas: la primera solicitud después de cada miss siempre es lenta. Además, si otro proceso escribe directamente en la base de datos sin borrar la caché, los datos serán antiguos hasta que expire el TTL.
5. Write-through
5.1. La idea
Con write-through, toda operación de escritura pasa por la capa de caché. Esta capa escribe sincrónicamente en la base de datos, luego actualiza la caché con el nuevo valor. Cuando la escritura devuelve el control, la caché y la base de datos están sincronizadas.
public class WriteThroughProductStore {
private final ProductRepository repository;
private final TtlCache<Long, Product> cache;
public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
this.repository = repository;
this.cache = cache;
}
public Product save(Product product) {
Product saved = repository.save(product); // 1. escribir en DB (síncrono)
cache.put(saved.id(), saved); // 2. actualizar caché
return saved;
}
public Optional<Product> findById(long id) {
return cache.get(id).or(() -> {
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> cache.put(id, p));
return fromDb;
});
}
}
Diferencias con cache-aside:
savesobrescribe la caché con el valor recién guardado en lugar de eliminarlo. La siguiente lectura será un cache hit.- Solo escribimos en la caché cuando
repository.savetiene éxito. Si la base de datos lanza una excepción, la caché no se toca, por lo que nunca contendrá datos que nunca fueron guardados. findByIdaún necesita una rama de fallback, porque la entrada puede haber expirado por TTL o nunca haber sido escrita mediantesave.
5.2. Cuándo usar
Write-through es adecuado cuando los datos recién escritos se leen inmediatamente después, como en carritos de compra o configuraciones de usuario. El precio a pagar:
- Cada escritura es un poco más lenta porque tienes que escribir en dos lugares.
- La caché puede contener muchos datos que nadie lee. Deberías combinarla con TTL y límites de tamaño.
Nota: No confundas write-through con write-behind (write-back). Write-behind escribe en la caché primero, luego en la base de datos de forma asincrónica. Es más rápido, pero si el nodo de caché muere antes de poder hacer flush, perderás datos. Solo usa write-behind si aceptas ese riesgo.
6. Usando Spring Cache
Escribir a mano ayuda a entender el mecanismo. En proyectos reales, deberías usar Spring Cache con Caffeine (caché local) o Redis (caché distribuida). Añade las dependencias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Activa el caché con @EnableCaching:
@SpringBootApplication
@EnableCaching
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
6.1. Cache-aside con @Cacheable y @CacheEvict
@Service
public class CatalogService {
private final ProductRepository repository;
public CatalogService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Optional<Product> findById(long id) {
return repository.findById(id);
}
@CacheEvict(cacheNames = "products", key = "#product.id")
public Product update(Product product) {
return repository.save(product);
}
}
@Cacheablees cache-aside en forma declarativa. Spring verifica la caché primero; si hay miss, llama al método y guarda el resultado.- Con tipo de retorno
Optional, Spring cachea el valor interior.unless = "#result == null"previene cachear el caso de no encontrar nada. @CacheEvictpor defecto corre después de que el método regresa exitosamente. Este es exactamente el orden correcto “escribir en DB luego eliminar caché”.
6.2. Write-through con @CachePut
@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
return repository.save(product);
}
@CachePut siempre ejecuta el método, luego escribe el valor de retorno en la caché. Esto es equivalente a WriteThroughProductStore.save de la sección 5.
Nota: Las anotaciones de caché funcionan a través de proxy. Si llamas a
this.findById(...)desde otro método en la misma clase, el caché se omite completamente. Llama a través de otro bean.
7. Configurar TTL
7.1. Caffeine
spring:
cache:
type: caffeine
cache-names: products
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
expireAfterWrite=10m: la entrada expira 10 minutos después de la última escritura.maximumSize=10000: cuando se excede el límite, Caffeine elimina entradas poco usadas. Siempre establece este límite para evitar que la caché se coma todo el heap.
7.2. Redis
Añade spring-boot-starter-data-redis, luego configura:
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::
Redis elimina automáticamente las claves cuando expira el TTL. Esta caché se comparte entre múltiples instancias de la aplicación.
7.3. ¿Cuánto TTL elegir?
No hay un número universal. Pregúntate: ¿cuánto tiempo puede estar obsoleto el dato como máximo?
Tasas de cambio, inventario : unos segundos – 1 minuto
Precios de productos, catálogos : 5 – 30 minutos
Configuración, lista de provincias/ciudades : varias horas – 1 día
TTL corto te da datos más frescos pero menor tasa de hit. TTL largo hace lo contrario. Incluso con evict proactivo, deberías establecer TTL como una red de seguridad para los evicts faltantes.
7.4. Agregar jitter para evitar expiración simultánea
Supón que cargas 10.000 productos a la vez con TTL de 10 minutos. Todos expirarán al mismo tiempo y la base de datos recibirá una avalancha de solicitudes. Este fenómeno se llama cache stampede. La solución es agregar un intervalo aleatorio al TTL:
public void put(K key, V value) {
long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
store.put(key, new Entry<>(value, expiresAt));
}
Con Spring + Caffeine, puedes añadir @Cacheable(..., sync = true). Cuando múltiples threads hacen miss en la misma clave, solo uno va a la base de datos; los otros esperan el resultado.
8. Escribir pruebas
Usamos un repositorio fake para contar accesos a la base de datos, y un reloj que podemos controlar:
class ProductServiceTest {
static class CountingRepository implements ProductRepository {
final Map<Long, Product> data = new HashMap<>();
int findCalls = 0;
public Optional<Product> findById(long id) {
findCalls++;
return Optional.ofNullable(data.get(id));
}
public Product save(Product p) {
data.put(p.id(), p);
return p;
}
}
AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
CountingRepository repository = new CountingRepository();
ProductService service = new ProductService(
repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));
@BeforeEach
void setUp() {
repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
}
@Test
void secondReadIsServedFromCache() {
service.findById(1L);
service.findById(1L);
assertThat(repository.findCalls).isEqualTo(1);
}
@Test
void entryExpiresAfterTtl() {
service.findById(1L);
clock.set(clock.get().plus(Duration.ofMinutes(11)));
service.findById(1L);
assertThat(repository.findCalls).isEqualTo(2);
}
@Test
void updateEvictsStaleValue() {
service.findById(1L);
service.updatePrice(1L, new BigDecimal("45000"));
assertThat(service.findById(1L))
.get()
.extracting(Product::price)
.isEqualTo(new BigDecimal("45000"));
}
}
secondReadIsServedFromCache: la segunda lectura no accede a la base de datos.entryExpiresAfterTtl: después de adelantar el reloj más de 10 minutos, hay cache miss y la base de datos se consulta nuevamente.updateEvictsStaleValue: después de actualizar, el lector ve el nuevo precio, no el antiguo en la caché.
9. Errores comunes
Eliminar caché antes de escribir en la base de datos. Considera este escenario: el thread A elimina la caché. El thread B lee, obtiene un miss, trae el valor antiguo de la base de datos y lo escribe en la caché. Luego el thread A escribe el nuevo valor en la base de datos. El resultado es que la caché retiene el valor antiguo hasta que expire el TTL. Escribe en la base de datos primero, elimina la caché después.
Caché dentro de una transacción no confirmada. Si haces @CachePut en un método @Transactional y la transacción hace rollback, la caché contendrá datos que no existen en la base de datos. Coloca las operaciones de caché fuera de la transacción o usa TransactionAwareCacheManagerProxy.
Caché local en múltiples instancias. Caffeine vive dentro de la JVM de cada instancia. @CacheEvict en la instancia A no elimina la caché de la instancia B. Cuando ejecutas múltiples instancias y necesitas coherencia, usa Redis o acepta la antigüedad con TTL corto.
Objetos de caché que pueden cambiar. Caffeine devuelve la misma referencia que está en la caché. Si el código modifica ese objeto, estás modificando también la caché. Usa record u objetos immutable como en los ejemplos del artículo.
Nota: No deberías cachear datos que cambian continuamente y requieren consistencia absoluta, como el saldo de una cuenta o asientos disponibles al momento de pago. En estos casos, leer directamente de la base de datos con bloqueos apropiados es más seguro.
10. Conclusión
El artículo ha comparado dos estrategias principales de caché:
- Cache-aside: la aplicación carga la caché en caso de miss y la elimina después de escribir. Esta es la forma más simple y tolerante a fallos, adecuada para la mayoría de casos con muchas lecturas.
- Write-through: toda escritura actualiza simultáneamente la base de datos y la caché, intercambiando datos frescos inmediatamente después de escribir.
Independientemente de la estrategia elegida, siempre necesitas TTL como red de seguridad, deberías limitar el tamaño de la caché, y agregar jitter para evitar stampede. Comienza con @Cacheable + Caffeine. Cuando escales a múltiples instancias, cambia a Redis, y siempre ten pruebas que demuestren que la caché realmente reduce el número de accesos a la base de datos.