La lección anterior terminó con una consigna: primero se elimina el trabajo innecesario. El endpoint de estaciones pasó de 2,4 segundos a 91 milisegundos quitando 42 consultas y añadiendo un índice, sin caché y sin una máquina más. Ese orden no es negociable, y es también el marco de esta lección: la caché es la palanca que se usa cuando el trabajo ya es mínimo y aun así se repite.

Porque eso es exactamente lo que ocurre en Ribalta. El catálogo de estaciones se consulta unas cien veces por segundo en hora punta y cambia cuando el ayuntamiento inaugura una estación nueva, es decir, cuatro veces al año. Las tarifas se consultan en cada cálculo de importe y se revisan una vez al año. Ejecutar una consulta perfectamente indexada de 3 milisegundos cien veces por segundo para obtener siempre la misma respuesta es trabajo mínimo repetido: el caso de libro de la caché.

Veremos la abstracción de caché de Spring y por qué el código no depende del proveedor, las anotaciones con todos sus atributos y sus trampas —las claves, condition y unless, el proxy—, Caffeine para caché local y Redis para caché distribuida, y después la parte que de verdad separa una caché útil de una fuente de errores: la invalidación. Terminaremos midiéndola y probándola, porque una caché de la que no se conoce la tasa de aciertos es una decisión tomada a ciegas.

Contenido

  1. Cuándo una caché es la respuesta correcta
  2. Qué datos de CicloUrbana merecen caché
  3. La abstracción de caché de Spring
  4. Las anotaciones y sus atributos
  5. Claves: la parte que más errores produce
  6. condition, unless y el problema del null
  7. La trampa del proxy, otra vez
  8. Proveedores: cuál elegir y por qué
  9. Caffeine: la caché local de CicloUrbana
  10. Redis: la caché distribuida
  11. Local frente a distribuida
  12. Invalidación: la parte difícil
  13. Estampida de caché
  14. La caché de segundo nivel de Hibernate
  15. La caché que no cuesta nada: ETag y Cache-Control
  16. Medir la caché
  17. Probar código cacheado
  18. Errores Comunes y Consejos
  19. Ejercicios

  1. Cuándo una caché es la respuesta correcta

Una caché guarda el resultado de una operación cara para no repetirla. Suena inofensivo, y es la razón por la que se abusa de ella: una caché mal puesta no falla, miente. Devuelve datos correctos que ya no son ciertos, y lo hace de forma intermitente y difícil de reproducir.

La regla que gobierna toda la lección es esta: primero arregla la consulta, luego cachea. Si un endpoint tarda 2 segundos por un N+1, una caché lo dejará en 5 milisegundos y el problema seguirá ahí, esperando al primer fallo de caché, al primer despliegue, al primer dato nuevo. Peor aún: habrás escondido el síntoma que te habría llevado a la causa. Una caché sobre código optimizado multiplica una mejora real; una caché sobre código malo compra silencio.

Con eso claro, un dato es buen candidato cuando cumple las tres condiciones a la vez:

Condición Por qué importa Cómo se comprueba
Se lee muchas veces Sin repetición no hay nada que ahorrar Métricas de invocación por endpoint (09-03)
Se escribe pocas veces Cada escritura obliga a invalidar; con escrituras frecuentes la caché no llega a llenarse Ritmo real de UPDATE sobre esa tabla
Tolera estar ligeramente desactualizado Toda caché sirve datos de hace n segundos Decisión de negocio, no técnica

La tercera es la que hay que negociar con quien conoce el dominio, no resolver en el editor. Y hay una cuarta condición implícita: el resultado debe caber. Cachear un listado paginado completo de tres millones de alquileres no es una caché, es una segunda base de datos peor hecha.

  1. Qué datos de CicloUrbana merecen caché

Dato ¿Caché? Motivo TTL
Catálogo de estaciones (nombre, dirección, capacidad) Sí Miles de lecturas por minuto, cambia trimestralmente 10 min
Tarifas de Ribalta (estándar, estudiante, jubilado) Sí Se consulta en cada cálculo, cambia una vez al año 1 h
Ficha del usuario para autorización Sí, con cuidado Muy leída; invalidar al desactivar o cambiar de rol 5 min
Zonas y polígonos del municipio Sí Prácticamente inmutable 24 h
Bicicletas disponibles por estación No Cambia cada segundo; un dato de hace 30 s hace que el ciudadano llegue a una estación vacía —
Alquiler en curso de un usuario No Es el estado que gobierna la regla de negocio de 04-08 —
Importe calculado de un alquiler concreto No Se usa una sola vez; no hay repetición —
Respuesta de la pasarela de pagos (07-06) Nunca Cachear un cobro es un error de otra categoría —

Los dos «no» del centro son la lección de este apartado. Disponibilidad en tiempo real y caché son incompatibles, y la tentación es enorme porque es justo el endpoint más consultado. La respuesta correcta a ese caso no es cachear el número de bicicletas: es que la consulta sea barata (el índice compuesto de 09-01) y, si hiciera falta más, mantener un contador actualizado en la propia tabla de estaciones. La distinción práctica: cachea el catálogo, nunca el estado.

  1. La abstracción de caché de Spring

Spring no implementa una caché: define una abstracción —tres interfaces y un aspecto— y delega en un proveedor real.

flowchart LR
    A["@Cacheable en EstacionService"] --> B[Proxy AOP<br/>CacheInterceptor]
    B --> C[CacheManager]
    C --> D["Cache 'estaciones'"]
    D --> E1[Caffeine<br/>memoria local]
    D --> E2[Redis<br/>distribuida]
    D --> E3[ConcurrentMapCache<br/>por defecto]
    B -->|fallo de cache| F[Metodo real -> BD]

Las piezas: Cache es el contrato de una caché concreta (get, put, evict, clear); CacheManager localiza cachés por nombre; y el CacheInterceptor, un aspecto igual que el de @Transactional (04-07), decide antes de cada invocación si hay que llamar al método o devolver lo guardado.

La consecuencia práctica es la que importa: tu código no menciona a Caffeine ni a Redis. Anotas @Cacheable("estaciones") y cambias de proveedor tocando una dependencia y una clase de configuración. En CicloUrbana empezaremos con Caffeine y pasaremos a Redis al escalar a varias instancias, sin tocar una línea de EstacionService.

Se activa con una anotación en una clase de configuración:

package com.ciclourbana.comun.cache;

@Configuration
@EnableCaching     // sin esto, las anotaciones no hacen absolutamente nada
public class ConfiguracionCache { }

Olvidar @EnableCaching es el primer error clásico, y es silencioso: el código compila, las pruebas pasan y la caché sencillamente no existe.

  1. Las anotaciones y sus atributos

Anotación Qué hace Atributos clave Uso en CicloUrbana
@Cacheable Si hay entrada, la devuelve sin ejecutar el método; si no, ejecuta y guarda value/cacheNames, key, condition, unless, sync Leer una estación o el catálogo
@CachePut Siempre ejecuta el método y guarda el resultado Los mismos que @Cacheable Actualizar una estación y refrescar su entrada
@CacheEvict Borra entradas key, allEntries, beforeInvocation Borrar una estación
@Caching Agrupa varias anotaciones del mismo tipo o mezcladas cacheable, put, evict Una operación que toca dos cachés
@CacheConfig Valores por defecto a nivel de clase cacheNames, keyGenerator Evitar repetir "estaciones" en cada método

La diferencia entre @Cacheable y @CachePut es la que más confunde y es sencilla: @Cacheable puede saltarse el método; @CachePut nunca. Por eso @CachePut es la anotación de las actualizaciones y @Cacheable la de las lecturas. Ponerlas juntas sobre el mismo método es casi siempre un error de diseño.

@CacheEvict tiene dos atributos con consecuencias. allEntries = true vacía la caché entera en lugar de una clave: es el martillo, correcto tras una importación masiva y exagerado al modificar una estación. Y beforeInvocation decide cuándo se borra: por defecto es false, de modo que se borra después de que el método termine bien y una excepción deja la caché intacta —normalmente lo que se quiere—, mientras que con true se borra antes, lo que conviene cuando un fallo a mitad podría dejar la caché con datos falsos.

@Service
@CacheConfig(cacheNames = "estaciones")     // valor por defecto para toda la clase
public class EstacionService {

    @Cacheable(key = "#id")
    public EstacionResponse buscar(Long id) { /* consulta a la BD */ }

    @CachePut(key = "#result.id")            // guarda el resultado ya actualizado
    public EstacionResponse actualizar(Long id, ActualizarEstacionRequest peticion) { }

    @CacheEvict(key = "#id")
    public void borrar(Long id) { }

    @CacheEvict(allEntries = true)           // tras una importacion masiva
    public void importarDesdeAyuntamiento(List<EstacionCsv> filas) { }
}

  1. Claves: la parte que más errores produce

Cada entrada se guarda bajo una clave. Sin key, Spring usa SimpleKeyGenerator, cuyas reglas son fáciles de recordar y peligrosas de asumir: sin argumentos → SimpleKey.EMPTY (una única entrada para todo el método); un argumento → el argumento mismo; varios argumentos → un SimpleKey que los combina.

Los tres problemas que produce ese comportamiento por defecto:

El método sin argumentos. @Cacheable("estaciones") public List<EstacionResponse> listarTodas() guarda una sola entrada bajo SimpleKey.EMPTY. Funciona, pero si mañana se añade un parámetro soloActivas, la clave cambia sola y el comportamiento con ella.

El objeto como argumento. Si el argumento es un record, su equals/hashCode son correctos y la clave funciona. Si es una entidad JPA con equals basado en el id —o peor, sin equals—, la clave será la identidad del objeto y nunca habrá aciertos: cada petición crea una instancia nueva. Es el fallo más frustrante, porque la caché parece configurada y su tasa de aciertos es cero.

El Pageable. @Cacheable public Page<X> listar(Pageable p) usa el Pageable como clave. Técnicamente funciona —PageRequest implementa equals—, pero genera una entrada por combinación de página, tamaño y orden: cientos de entradas casi idénticas. Cachear listados paginados casi nunca compensa.

La solución es declarar la clave con SpEL:

@Cacheable(cacheNames = "estaciones", key = "#id")                    // un argumento
public EstacionResponse buscar(Long id) { }

@Cacheable(cacheNames = "tarifas", key = "#tipo.name() + ':' + #minutos")   // varios
public BigDecimal calcular(TipoTarifa tipo, long minutos) { }

@Cacheable(cacheNames = "usuarios", key = "#peticion.correo")   // propiedad, no objeto
public UsuarioResponse porCorreo(BuscarUsuarioRequest peticion) { }

@CachePut(cacheNames = "estaciones", key = "#result.id")   // #result: solo en @CachePut
public EstacionResponse actualizar(ActualizarEstacionRequest peticion) { }

Dentro de SpEL están disponibles #nombreDelArgumento —requiere compilar con -parameters, que el spring-boot-starter-parent ya activa—, #p0/#a0 por posición, #root.methodName, #root.target y #result en las anotaciones que se evalúan después de invocar.

Cuando la lógica de la clave se repite, un generador propio evita duplicarla:

@Component("claveEstacion")
public class GeneradorClaveEstacion implements KeyGenerator {
    @Override
    public Object generate(Object destino, Method metodo, Object... args) {
        return metodo.getName() + ':' +
               Arrays.stream(args).map(String::valueOf).collect(Collectors.joining(":"));
    }
}

Se usa con @Cacheable(cacheNames = "estaciones", keyGenerator = "claveEstacion"). key y keyGenerator son excluyentes: declarar los dos es un error de arranque.

Una regla que ahorra incidentes: la clave debe ser un valor pequeño, inmutable y con equals/hashCode correctos —un Long, un String, un record sencillo—. Si dudas, construye un String explícito.

  1. condition, unless y el problema del null

Ambos atributos filtran, pero en momentos distintos y esa diferencia es la clave:

condition unless
Cuándo se evalúa Antes de invocar Después de invocar
Qué decide Si se consulta y se guarda en caché Si se descarta el guardado
#result disponible No Sí
Semántica «cachea si...» «no caches si...»
@Cacheable(cacheNames = "estaciones", key = "#id",
           condition = "#id != null && #id > 0",             // no cachear peticiones absurdas
           unless = "#result == null || !#result.activa()")  // no cachear lo que no sirve
public EstacionResponse buscar(Long id) { }

El error clásico: cachear la ausencia. Si buscar(999) devuelve null o Optional.empty() y se guarda, tenemos dos problemas simultáneos. El primero, que cuando la estación 999 se cree de verdad, el servicio seguirá diciendo durante todo el TTL que no existe. El segundo, más sutil: con Optional, @Cacheable guarda el Optional vacío como un valor perfectamente válido, así que la caché sí acierta y devuelve «no existe» sin consultar. Depende del caso cuál es el comportamiento deseado —cachear ausencias es una defensa legítima contra un ataque que pide identificadores inexistentes—, pero tiene que ser una decisión consciente, y se expresa con unless = "#result == null" o con un TTL corto y propio para esa caché.

Cuidado también con la evaluación: condition y unless son SpEL evaluados en cada invocación. Una expresión que llama a un método caro convierte el ahorro en gasto.

  1. La trampa del proxy, otra vez

Es la tercera vez que aparece en el curso, con @Transactional (04-07) y con @Async (07-03), y el mecanismo es idéntico: las anotaciones de caché las aplica un proxy, y una llamada interna no pasa por el proxy.

@Service
public class EstacionService {

    public List<EstacionResponse> listarActivas() {
        return buscarTodas().stream()                 // this.buscarTodas(): SIN CACHE
                .filter(EstacionResponse::activa).toList();
    }

    @Cacheable("estaciones")
    public List<EstacionResponse> buscarTodas() { /* consulta a la BD */ }
}

listarActivas() va a la base de datos siempre. Ni el compilador ni Spring avisan, y la única señal es una tasa de aciertos anómalamente baja: por eso el apartado 16 no es opcional. Las salidas, en orden de preferencia: mover el método cacheado a otro bean —lo correcto, porque la caché suele indicar una frontera de responsabilidad—; inyectarse a sí mismo con @Lazy (feo pero explícito); o usar AopContext.currentProxy(), que exige @EnableAspectJAutoProxy(exposeProxy = true) y es el último recurso.

Y dos condiciones que se olvidan: el método debe ser public —un private, protected o de paquete se ignora en silencio— y no final, porque un proxy CGLIB no puede sobrescribirlo.

  1. Proveedores: cuál elegir y por qué

Spring Boot autoconfigura el proveedor según lo que encuentre en el classpath:

Proveedor Alcance TTL Estadísticas Cuándo usarlo
ConcurrentMapCache (por defecto) Local No No Solo pruebas y prototipos
Caffeine Local Sí Sí (recordStats) Una instancia, o datos tolerantes a divergencia
Redis Distribuida Sí Parcial Varias instancias con coherencia
Hazelcast Distribuida, en la propia JVM Sí Sí Malla de datos, sin servidor aparte
EhCache 3 (JSR-107) Local, con desbordamiento a disco Sí Sí Heredado; cachés muy grandes

Por qué el proveedor por defecto no vale en producción. ConcurrentMapCache es un ConcurrentHashMap: no tiene caducidad ni tamaño máximo. Todo lo que entra se queda para siempre, así que dos cosas terminan pasando: los datos se quedan obsoletos indefinidamente y la memoria crece hasta el OutOfMemoryError. Es una caché de demostración, y Spring la usa solo porque necesita un valor por defecto. Si en el arranque de producción ves ConcurrentMapCacheManager, falta una dependencia.

El criterio de elección, en una línea: una sola instancia y datos que toleran divergencia de segundos → Caffeine; varias instancias que deben ver lo mismo → Redis. Nada más, y en particular: no se elige Redis «por si acaso», porque introduce una dependencia de red en el camino de lectura y una latencia de milisegundo donde Caffeine tarda nanosegundos.

  1. Caffeine: la caché local de CicloUrbana

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Con la dependencia presente, Spring Boot configura CaffeineCacheManager sin más. La configuración por propiedades:

spring.cache:
  type: caffeine
  cache-names: estaciones,tarifas,zonas       # se crean al arrancar
  caffeine.spec: maximumSize=1000,expireAfterWrite=10m,recordStats

Qué significa cada término de spec. maximumSize=1000 limita las entradas: al superarlas, Caffeine desaloja según su algoritmo Window TinyLFU, que combina frecuencia y recencia y acierta bastante más que un LRU clásico. expireAfterWrite=10m caduca la entrada diez minutos después de escribirla, se lea o no —el TTL clásico—; su alternativa, expireAfterAccess, cuenta desde el último acceso, con el riesgo de que un dato muy consultado no caduque nunca, así que para datos que cambian se usa expireAfterWrite. Y recordStats activa el recuento de aciertos y fallos: sin él, el apartado 16 no tiene nada que medir y /actuator/caches no dirá gran cosa.

Un spec único aplica a todas las cachés, lo que rara vez es lo adecuado: las tarifas admiten una hora y el catálogo diez minutos. Para afinar por caché se declara el gestor:

@Configuration
@EnableCaching
public class ConfiguracionCache {

    @Bean
    CacheManager cacheManager() {
        SimpleCacheManager gestor = new SimpleCacheManager();
        gestor.setCaches(List.of(
                cache("estaciones", 1_000, Duration.ofMinutes(10)),
                cache("tarifas",       50, Duration.ofHours(1)),
                cache("zonas",        200, Duration.ofHours(24))));
        return gestor;
    }

    private CaffeineCache cache(String nombre, long tamano, Duration ttl) {
        return new CaffeineCache(nombre, Caffeine.newBuilder()
                .maximumSize(tamano).expireAfterWrite(ttl)
                .recordStats()                 // imprescindible para las metricas de 09-03
                .build());
    }
}

Cómo se afina. El tamaño se elige por el número de elementos distintos que se consultan de verdad —Ribalta tiene 40 estaciones, así que maximumSize=1000 sobra y es deliberado: mejor sobrar que desalojar—. Y el TTL sale de la pregunta de negocio del apartado 1: ¿cuántos segundos puede un ciudadano ver el nombre antiguo de una estación? Si la respuesta es «diez minutos», ese es el TTL; si es «ninguno», no es candidata a caché con TTL, sino a invalidación explícita.

  1. Redis: la caché distribuida

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  cache.type: redis
  data.redis:
    host: ${REDIS_HOST:localhost}
    port: 6379
    timeout: 500ms              # fallar rapido: la cache no puede bloquear la peticion
    lettuce.pool.max-active: 16

Con eso ya funciona, pero la serialización por defecto es JDK —binaria, ilegible y frágil ante cualquier cambio de clase—. La configuración que CicloUrbana usa en realidad:

@Bean
RedisCacheConfiguration configuracionBase() {
    ObjectMapper mapper = JsonMapper.builder()
            .addModule(new JavaTimeModule())                       // LocalDateTime, Instant
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .activateDefaultTyping(BasicPolymorphicTypeValidator.builder()
                    .allowIfSubType("com.ciclourbana.").build(),    // solo nuestras clases
                    ObjectMapper.DefaultTyping.NON_FINAL)
            .build();
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()                            // apartado 6
            .serializeValuesWith(SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer(mapper)));
}

@Bean
RedisCacheManagerBuilderCustomizer ttlPorCache() {          // TTL distinto por cache
    return builder -> builder
            .withCacheConfiguration("tarifas", configuracionBase().entryTtl(Duration.ofHours(1)))
            .withCacheConfiguration("zonas",   configuracionBase().entryTtl(Duration.ofHours(24)));
}

El problema clásico de la serialización. Es la primera piedra con la que tropieza todo el mundo, y tiene tres caras. La primera: sin JavaTimeModule, un LocalDateTime explota con Java 8 date/time type not supported by default. La segunda: aunque no explote, si se serializa como [2026,8,31,7,42] en lugar de ISO-8601, al deserializar en otra versión de la aplicación fallará —de ahí WRITE_DATES_AS_TIMESTAMPS desactivado—. Y la tercera, la peor: sin información de tipo, un List<EstacionResponse> se deserializa como List<LinkedHashMap> y salta un ClassCastException en un sitio que no tiene nada que ver. Por eso se activa el tipado, y siempre con un validador que restrinja los paquetes permitidos: un tipado por defecto abierto sobre datos que un atacante pueda influir es una vulnerabilidad de deserialización conocida.

Un consejo que evita horas: si el valor cacheado cambia de forma, cambia también el nombre de la caché (estaciones → estaciones-v2). Durante un despliegue progresivo (08-04) conviven dos versiones de la aplicación leyendo el mismo Redis, y una entrada escrita por la nueva puede ser ilegible para la vieja.

Redis en el docker-compose.yml de 07-04, junto a PostgreSQL:

  redis:
    image: redis:7-alpine
    container_name: ciclourbana-redis
    command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    ports: ["6379:6379"]
    networks: [red-ciclourbana]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 5

--maxmemory-policy allkeys-lru es la política correcta para una caché: cuando Redis llena su memoria, desaloja las claves menos usadas en lugar de rechazar escrituras. La política por defecto (noeviction) es la adecuada si Redis fuera la fuente de verdad; como aquí es una caché, allkeys-lru.

Y para probarlo de verdad, un contenedor real con Testcontainers (06-05):

@SpringBootTest
@Testcontainers
class CacheRedisIT {

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

    @DynamicPropertySource
    static void propiedades(DynamicPropertyRegistry reg) {
        reg.add("spring.data.redis.host", REDIS::getHost);
        reg.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
    }
}

  1. Local frente a distribuida

Caché local (Caffeine) Caché distribuida (Redis)
Latencia de acceso Nanosegundos (misma JVM) ~0,5-2 ms (ida y vuelta de red)
Coherencia entre instancias Ninguna: cada una tiene su copia Una sola copia compartida
Invalidación Solo local: las demás no se enteran Global e inmediata
Al arrancar Vacía: hay que calentarla Ya poblada por las demás instancias
Memoria Consume heap de la aplicación Fuera del proceso
Punto de fallo añadido No Sí: hay que degradar con elegancia
Coste operativo Cero Un servicio más que mantener y vigilar

Por qué al escalar la caché local deja de ser inocente. Con una instancia, @CacheEvict borra la entrada y el siguiente lector ve el dato nuevo. Con tres instancias detrás del balanceador (08-04), la petición de actualización llega a una de ellas: esa borra su copia y las otras dos siguen sirviendo el nombre antiguo durante todo el TTL. El resultado es la peor clase de error: el ciudadano refresca la pantalla y ve el nombre nuevo, el viejo y otra vez el nuevo, según a qué instancia le toque. Y no es reproducible en local.

Hay tres respuestas válidas. TTL cortos y aceptar la divergencia, si el negocio lo tolera —diez minutos de nombre antiguo no matan a nadie—. Redis, si debe verse igual en todas partes. O caché local con invalidación difundida por un canal de mensajes (el pub/sub de Redis o el bus de 07-05), que da la latencia de Caffeine con la coherencia de Redis a cambio de complejidad. CicloUrbana empieza por la primera y pasa a la segunda para la caché de usuarios, donde una autorización obsoleta sí importa.

  1. Invalidación: la parte difícil

Phil Karlton lo resumió: «solo hay dos cosas difíciles en informática: invalidar cachés y nombrar cosas». Es una broma con una verdad exacta: guardar es trivial, saber cuándo lo guardado dejó de ser cierto es el problema entero.

Hay dos estrategias, y no son excluyentes:

TTL (caducidad) Invalidación explícita
Cómo funciona La entrada muere sola pasado un tiempo El código borra la entrada al cambiar el dato
Frescura Desactualización acotada por el TTL Inmediata
Complejidad Ninguna Hay que acordarse en cada camino de escritura
Riesgo Datos viejos durante el TTL Olvidar un camino → datos viejos para siempre
Cuándo Datos tolerantes; siempre, como red de seguridad Datos que deben verse al momento

La recomendación de CicloUrbana: las dos. Invalidación explícita para que el cambio se vea al instante, y TTL siempre puesto como red bajo el trapecio, porque tarde o temprano alguien añadirá un camino de escritura que no invalida —una migración de Flyway, una tarea programada, un UPDATE manual del ayuntamiento—.

La versión ingenua:

@Transactional
@CacheEvict(cacheNames = "estaciones", key = "#id")
public EstacionResponse actualizar(Long id, ActualizarEstacionRequest peticion) { }

Y aquí está el error que casi nadie ve la primera vez. La anotación de caché se evalúa alrededor del método, pero la transacción se confirma... también alrededor del método, y en un orden que no controlas. Con la configuración por defecto, el CacheInterceptor puede borrar la entrada antes de que la transacción confirme. Se abre entonces una ventana de milisegundos en la que la caché está vacía y la base de datos todavía tiene el valor antiguo: si otra petición lee en ese instante, repuebla la caché con el dato viejo y lo deja ahí durante todo el TTL. Peor todavía si la transacción termina en rollback: se ha invalidado por un cambio que nunca ocurrió, lo que es inofensivo, pero el caso anterior no lo es en absoluto.

La solución correcta es invalidar después de confirmar, con el mecanismo de eventos de 04-07:

// 1. El servicio publica un evento dentro de la transaccion
@Transactional
public EstacionResponse actualizar(Long id, ActualizarEstacionRequest peticion) {
    Estacion estacion = estacionRepositorio.findById(id).orElseThrow(...);
    estacion.actualizar(peticion.nombre(), peticion.direccion(), peticion.capacidad());
    publicador.publishEvent(new EstacionModificada(id));
    return mapeador.aRespuesta(estacion);
}

// 2. Un componente aparte invalida cuando el commit ya ocurrio
@Component
public class InvalidadorCacheEstaciones {

    private final CacheManager cacheManager;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void alModificarEstacion(EstacionModificada evento) {
        Optional.ofNullable(cacheManager.getCache("estaciones"))
                .ifPresent(cache -> cache.evict(evento.id()));
    }
}

Tres ventajas sobre la anotación. Nunca se invalida por un cambio que no llegó a confirmarse. No hay ventana de repoblación con datos viejos, porque cuando se borra la entrada la base de datos ya tiene el valor nuevo. Y la responsabilidad queda separada: el servicio de dominio publica que algo cambió y no sabe que existe una caché, que es exactamente la relación correcta entre ambos.

Dos casos más. Al crear una estación no hay entrada que borrar, pero sí un listado agregado que ha quedado obsoleto: @CacheEvict(cacheNames = "estaciones", allEntries = true) o, mejor, una caché separada para el catálogo completo. Y en las importaciones masivas, invalidar entrada a entrada es absurdo: se vacía la caché entera una vez al terminar.

  1. Estampida de caché

La entrada del catálogo de estaciones caduca a las 8:03. En ese instante hay 200 peticiones en vuelo: las 200 fallan a la vez y las 200 ejecutan la consulta a la vez. Es la cache stampede, y su firma en la gráfica es un pico limpio de latencia y de conexiones al pool cada expireAfterWrite, con el resto del tiempo en calma.

Tres mitigaciones, de la más simple a la mejor:

Bloqueo (sync = true). @Cacheable(cacheNames = "estaciones", key = "#id", sync = true) hace que solo un hilo calcule el valor mientras los demás esperan a que termine. Resuelve la estampida con una línea. Sus límites: solo lo soportan algunos proveedores —Caffeine sí, Redis no de forma nativa—, es un bloqueo por instancia, y es incompatible con unless.

Recarga en segundo plano (refreshAfterWrite). Propio de Caffeine: pasado ese tiempo, la primera petición que llega devuelve el valor viejo inmediatamente y dispara la recarga en otro hilo. Ninguna petición espera nunca. Caffeine.newBuilder().refreshAfterWrite(Duration.ofMinutes(5)).expireAfterWrite(Duration.ofMinutes(30)).build(clave -> cargar(clave)) combina las dos: refresco a los 5 minutos, caducidad dura a los 30 por si la recarga falla siempre. Es la mejor opción para el catálogo de Ribalta.

Jitter. Si todas las entradas se escriben a la vez —al arrancar, o tras un vaciado— caducan a la vez. Añadir una variación aleatoria al TTL (10m ± 20 %) las reparte en el tiempo. En Caffeine se consigue con expireAfter(Expiry); en Redis, con TTL calculado al escribir.

Y una cuarta que es de arquitectura: precalentar la caché al arrancar con un ApplicationRunner que cargue las 40 estaciones. Barato, y evita que la primera oleada de tráfico tras un despliegue pague todos los fallos.

  1. La caché de segundo nivel de Hibernate

Es una caché de entidades, dentro del ORM, y opera en un plano distinto. La de primer nivel es el contexto de persistencia y dura lo que la transacción (04-07): existe siempre y no se configura. La de segundo nivel es compartida por todas las sesiones y hay que activarla explícitamente (hibernate.cache.use_second_level_cache, un proveedor como EhCache o Infinispan, y @Cache en cada entidad), a lo que se suma la caché de consultas para los resultados de las queries.

Caché de aplicación (Spring) Segundo nivel de Hibernate
Qué guarda El resultado del método: DTOs ya mapeados Entidades por identificador, en formato desmontado
Qué ahorra Consulta y mapeo y lógica Solo la consulta por id
Visibilidad Explícita: se ve en el código Implícita: no se ve en ninguna parte
Invalidación Tú la controlas Hibernate la gestiona... si todo pasa por Hibernate
Riesgo Errores tuyos Datos rancios ante @Query de modificación, Flyway o SQL externo

Por qué el curso prefiere la de aplicación. Porque es explícita: al leer @Cacheable("estaciones") sabes que hay una caché y dónde está. La de segundo nivel es invisible en el código, y cuando produce un dato obsoleto la investigación es larga porque nadie recuerda que estaba activada. Además ahorra menos: la de aplicación se salta también el mapeo con MapStruct (03-05) y la construcción de los DTOs, mientras que la de segundo nivel solo evita el viaje SQL —y ni siquiera lo hace para consultas que no sean por id, salvo activando además la caché de consultas, que es notoriamente delicada—.

¿Cuándo tiene sentido? En un dominio con muchas entidades de referencia inmutables leídas por id desde muchos sitios, y con todas las escrituras pasando por Hibernate. Si hay Flyway modificando datos (04-08) o un proceso externo tocando la base, quedan fuera de su control y servirá datos falsos sin avisar.

  1. La caché que no cuesta nada: ETag y Cache-Control

Antes de cachear en el servidor, conviene recordar que la petición más rápida es la que no se hace. Con las cabeceras HTTP de 03-03:

@GetMapping("/{id}")
public ResponseEntity<EstacionResponse> buscar(@PathVariable Long id) {
    EstacionResponse estacion = estacionService.buscar(id);
    return ResponseEntity.ok()
            .eTag("\"" + estacion.version() + "\"")               // el @Version de 04-03
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
            .body(estacion);
}

Cache-Control: max-age=300, public autoriza al navegador, a la app móvil y a cualquier proxy intermedio a reutilizar la respuesta cinco minutos sin preguntar: cero peticiones al servidor. Y el ETag, junto con ShallowEtagHeaderFilter o calculado a mano a partir del @Version, permite que el cliente pregunte con If-None-Match y reciba un 304 Not Modified de doscientos bytes: se ahorra la serialización y el ancho de banda, aunque no el trabajo del servidor.

La combinación de las tres capas es lo que rinde: Cache-Control evita la petición, el ETag evita el cuerpo y @Cacheable evita la consulta. Con una advertencia de seguridad: cachePublic() solo en respuestas no personalizadas. Marcar como public una respuesta que depende del usuario autenticado hace que un proxy sirva los datos de un ciudadano a otro; para eso está cachePrivate().

  1. Medir la caché

Una caché sin métricas es una decisión sin verificar. Las dos preguntas son ¿está acertando? y ¿está creciendo sin control?

/actuator/caches (07-01) enumera las cachés y sus gestores, útil para confirmar que existen las que crees. Pero lo que importa son las estadísticas, que exigen recordStats. Con Caffeine y recordStats activado, Micrometer publica automáticamente:

Métrica Qué mide Qué vigilar
cache.gets{result="hit"} Aciertos Debe dominar ampliamente
cache.gets{result="miss"} Fallos Un ratio alto = caché inútil o clave mal elegida
cache.puts Escrituras Si ≈ fallos, cada fallo repuebla: normal
cache.evictions Desalojos por tamaño Constantes = maximumSize demasiado pequeño
cache.size Entradas actuales Pegado al máximo = revisar el dimensionado

La tasa de aciertos es hits / (hits + misses). Como orientación: por encima del 90 % la caché está haciendo su trabajo; entre el 50 % y el 90 % conviene revisar el TTL o el tamaño; por debajo del 20 % la caché está costando más de lo que ahorra y casi siempre indica una de las tres causas ya vistas —clave mal construida, autoinvocación por el proxy o datos que sencillamente no se repiten—.

Para que estas métricas existan hay que registrar la caché en el MeterRegistry, cosa que la autoconfiguración hace sola si el CacheManager es el autoconfigurado; si lo declaras tú (apartado 9), añade @Bean de tipo CacheMetricsRegistrar o marca las cachés con CaffeineCacheMetrics.monitor(registry, cache, "estaciones"). Toda la instrumentación se explica en 09-03 y se representa en Grafana en 09-04, donde un panel de tasa de aciertos por caché es de los que más rápido detecta una regresión.

  1. Probar código cacheado

Las pruebas unitarias no ven la caché, y esto sorprende a mucha gente. Un EstacionServiceTest que instancia el servicio con new y le pasa un mock de Mockito (06-03) trabaja con el objeto real, sin proxy: @Cacheable no hace nada. Es correcto y además deseable —la prueba unitaria comprueba la lógica, no la infraestructura—, pero implica que la caché solo se puede probar en integración.

@SpringBootTest
@AutoConfigureCache                       // fuerza un CacheManager real, no el de pruebas
class EstacionServiceCacheIT {

    @Autowired EstacionService estacionService;
    @Autowired CacheManager cacheManager;
    @MockitoBean EstacionRepositorio estacionRepositorio;   // el colaborador, simulado

    @BeforeEach
    void limpiar() {   // una cache es estado compartido: hay que aislar las pruebas
        cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
    }

    @Test
    void laSegundaLecturaNoConsultaLaBaseDeDatos() {
        given(estacionRepositorio.findById(1L)).willReturn(Optional.of(unaEstacion()));

        estacionService.buscar(1L);
        estacionService.buscar(1L);

        verify(estacionRepositorio, times(1)).findById(1L);   // la prueba de la cache
        assertThat(cacheManager.getCache("estaciones").get(1L)).isNotNull();
    }

    @Test
    void actualizarInvalidaLaEntrada() {
        estacionService.buscar(1L);
        estacionService.actualizar(1L, new ActualizarEstacionRequest("Plaza Mayor II", 26));
        assertThat(cacheManager.getCache("estaciones").get(1L)).isNull();
    }
}

Lo que hace válida esta prueba es verify(..., times(1)): se demuestra que el método no se ejecutó, que es la definición operativa de «la caché funcionó». Y la segunda prueba cubre lo que de verdad se rompe con el tiempo, que no es guardar sino invalidar.

Dos advertencias. Vacía las cachés entre pruebas —una caché es estado compartido y produce el peor tipo de prueba, la que depende del orden—. Y desactiva la caché en el perfil test cuando estorbe, con spring.cache.type: none, que Spring reconoce y que convierte las anotaciones en no-operaciones sin tocar el código.

Errores Comunes y Consejos

Olvidar @EnableCaching. Todo compila, nada se cachea, ninguna advertencia. Compruébalo con la prueba de integración del apartado 17.

Cachear para tapar un N+1, o cachear datos en tiempo real. Primero se arregla la consulta (09-01): la caché sobre código malo compra silencio, no rendimiento, y esconde el síntoma que llevaba a la causa. Y las bicicletas disponibles por estación cambian cada segundo, así que un dato de hace medio minuto envía al ciudadano a una estación vacía: cachea el catálogo, nunca el estado.

Autoinvocación. Un método cacheado llamado desde otro de la misma clase no pasa por el proxy y nunca acierta. La señal es una tasa de aciertos cercana a cero: por eso se miden.

Usar una entidad JPA como clave o como valor. Como clave, casi nunca acierta; como valor, arrastra colecciones perezosas que estallan al serializar en Redis o al leerse fuera de la sesión. Cachea DTOs.

Invalidar dentro de la transacción. Abre una ventana en la que otra petición repuebla la caché con el valor antiguo y lo deja durante todo el TTL. Se invalida en AFTER_COMMIT.

Usar ConcurrentMapCache en producción. Sin TTL ni tamaño máximo: datos eternamente obsoletos y memoria que solo sube. Si en el log de arranque aparece ConcurrentMapCacheManager, falta una dependencia.

Caché local con varias instancias, sin darse cuenta. Funciona en local y produce en producción datos que parpadean según la instancia que atienda. Decide TTL corto, Redis o invalidación difundida, pero decide.

Consejo: pon siempre un TTL, aunque invalides explícitamente, como red de seguridad para el día en que alguien añada un camino de escritura que no invalida. Nombra las cachés como constantes (public static final String CACHE_ESTACIONES = "estaciones";) y úsalas en anotaciones e invalidadores, porque una cadena mal escrita crea una caché nueva y vacía en silencio. Y revisa la tasa de aciertos un mes después de desplegar: una caché por debajo del 20 % debe borrarse, ya que cuesta memoria, complejidad y riesgo de datos obsoletos a cambio de nada.

Ejercicios

Ejercicio 1: decidir qué cachear

El ayuntamiento pide acelerar cuatro endpoints: (a) GET /api/v1/tarifas, el catálogo de las tres tarifas, 400 peticiones/minuto, cambia una vez al año; (b) GET /api/v1/estaciones/{id}/disponibilidad, número de bicicletas libres, 6.000 peticiones/minuto, cambia cada segundo; (c) GET /api/v1/usuarios/{id}, ficha del usuario con su tipo de tarifa y si está activo, 2.000 peticiones/minuto, cambia cuando el ciudadano edita su perfil o el ayuntamiento lo desactiva; (d) GET /api/v1/alquileres/{id}/importe, el importe calculado de un alquiler concreto, 900 peticiones/minuto. Decide para cada uno si se cachea, con qué proveedor, qué TTL y qué estrategia de invalidación, y justifica los «no».

Ejercicio 2: encontrar los tres errores

Este código no funciona como su autor cree. Encuentra los tres defectos, explica el síntoma de cada uno y escribe la versión corregida.

@Service
public class TarifaService {

    @Cacheable("tarifas")
    public List<TarifaResponse> listar() { return tarifaRepositorio.findAll().stream()... }

    public TarifaResponse buscarPorTipo(TipoTarifa tipo) {
        return listar().stream().filter(t -> t.tipo() == tipo).findFirst().orElse(null);
    }

    @Transactional
    @CacheEvict(cacheNames = "tarifas", key = "#tarifa.tipo")
    public void actualizar(Tarifa tarifa) { tarifaRepositorio.save(tarifa); }
}

Ejercicio 3: la caché que parpadea

CicloUrbana se ha escalado a tres instancias en Kubernetes (08-04) manteniendo Caffeine con expireAfterWrite=30m. El ayuntamiento renombra «Estación Norte» a «Estación Norte - Intercambiador» y llama diciendo que la app «unas veces muestra el nombre nuevo y otras el viejo». Explica exactamente qué está pasando, propón tres soluciones con sus contrapartidas, elige una y escribe la configuración y el código necesarios.

Soluciones

Solución 1.

(a) Tarifas: sí, caso ideal. Cumple las tres condiciones con holgura —muy leído, casi nunca escrito, y una tarifa antigua durante una hora no causa daño porque el cambio de tarifas se anuncia con antelación—. Caffeine, maximumSize=50, expireAfterWrite=1h, más @CacheEvict(allEntries = true) en el AFTER_COMMIT de la actualización. Con solo tres tarifas y una divergencia irrelevante entre instancias, Redis no aporta nada.

(b) Disponibilidad: no. Falla la condición decisiva, la tolerancia a datos viejos: el propósito del endpoint es saber si ahora mismo hay una bicicleta, y un dato de hace 30 segundos manda al ciudadano a una estación vacía —el peor fallo posible en producto, porque no parece un error, parece un servicio que miente—. La respuesta correcta es hacer la consulta barata: el índice compuesto (estacion_id, estado) de 09-01 la deja en menos de 1 ms. Si aun así no bastara, la solución es un contador desnormalizado bicicletas_disponibles en la tabla estaciones, actualizado transaccionalmente al iniciar y finalizar un alquiler: eso es precálculo dentro de la transacción, no caché, y por tanto siempre coherente. Una excepción admisible sería un TTL de 2-3 segundos, que amortigua ráfagas sin desactualización perceptible; es una decisión de negocio y hay que plantearla como tal.

(c) Usuario: sí, con cuidado, y es el caso interesante. Muy leído y poco escrito, pero la desactivación tiene implicaciones de seguridad: si el ayuntamiento bloquea a un ciudadano moroso y la caché sigue diciendo que está activo, seguirá alquilando bicicletas. Por eso: Redis —debe verse igual en las tres instancias—, entryTtl=5m como red de seguridad e invalidación explícita en AFTER_COMMIT en los tres caminos de escritura: editar perfil, cambiar tipo de tarifa y activar/desactivar. Y una regla que conviene grabarse: la comprobación de autorización sensible no se apoya solo en la caché; el JWT de 05-04 ya lleva los roles y su caducidad corta es la defensa real.

(d) Importe: no. Falla la primera condición: aunque haya 900 peticiones por minuto, cada una es de un alquiler distinto. No hay repetición de clave y la tasa de aciertos sería casi cero: una caché que solo cuesta memoria e invalidación. Lo que sí puede cachearse es el catálogo de tarifas que el cálculo consulta, que es el caso (a). Distinción general: se cachean los parámetros del cálculo, no su resultado individual.

Solución 2.

Defecto 1 — autoinvocación (apartado 7). buscarPorTipo llama a this.listar(), que no pasa por el proxy, así que el 100 % de las llamadas por tipo van a la base de datos. Síntoma: la caché existe, cache.puts es bajo, y la tasa de aciertos es ridícula comparada con el tráfico. Peor aún, el fallo es intermitente en apariencia: si alguien llama antes a listar() desde un controlador, esa sí acierta.

Defecto 2 — la clave de invalidación no existe. listar() no tiene argumentos, luego su clave es SimpleKey.EMPTY; @CacheEvict(key = "#tarifa.tipo") borra una clave (ESTUDIANTE) que nunca se ha escrito. Síntoma: actualizar una tarifa no tiene ningún efecto visible durante todo el TTL; el evict se ejecuta, no falla y no borra nada, que es la peor combinación posible.

Defecto 3 — invalidación dentro de la transacción (apartado 12). Aunque la clave fuera correcta, @CacheEvict puede ejecutarse antes del commit: otra petición concurrente repuebla con el valor antiguo. Y si el save termina en rollback, se habrá invalidado por un cambio inexistente.

Versión corregida:

@Service
public class TarifaService {

    public static final String CACHE_TARIFAS = "tarifas";

    @Cacheable(cacheNames = CACHE_TARIFAS, key = "'todas'")   // clave explicita y estable
    public List<TarifaResponse> listar() { ... }

    @Cacheable(cacheNames = CACHE_TARIFAS, key = "#tipo.name()")   // pasa por el proxy
    public TarifaResponse buscarPorTipo(TipoTarifa tipo) {
        return tarifaRepositorio.findByTipo(tipo).map(mapeador::aRespuesta).orElseThrow(...);
    }

    @Transactional
    public void actualizar(Tarifa tarifa) {
        tarifaRepositorio.save(tarifa);
        publicador.publishEvent(new TarifaModificada(tarifa.getTipo()));
    }
}

@Component
class InvalidadorCacheTarifas {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void alModificarTarifa(TarifaModificada evento) {
        Cache cache = cacheManager.getCache(TarifaService.CACHE_TARIFAS);
        if (cache != null) { cache.evict(evento.tipo().name()); cache.evict("todas"); }
    }
}

Los cuatro cambios: buscarPorTipo consulta al repositorio en lugar de reutilizar listar(), así que su @Cacheable sí actúa; ambas claves son explícitas; la invalidación ocurre en AFTER_COMMIT; y se borran las dos entradas, porque cambiar una tarifa invalida también el listado agregado —el detalle que más se olvida cuando conviven una caché por elemento y otra por colección—.

Solución 3.

Qué está pasando. Caffeine vive dentro de cada JVM. La petición PUT /api/v1/estaciones/2 llegó a una sola instancia —la que el balanceador eligió—, que actualizó PostgreSQL e invalidó su copia. Las otras dos instancias no se han enterado de nada y siguen sirviendo «Estación Norte» durante los 30 minutos del expireAfterWrite. Como el balanceador reparte las peticiones, el ciudadano ve el nombre nuevo aproximadamente un tercio de las veces. Es irreproducible en local, donde solo hay una instancia, y por eso la caché local «deja de ser inocente» al escalar.

Tres soluciones y sus contrapartidas. (1) TTL corto: bajar expireAfterWrite a 60 s. Coste cero, y la incoherencia dura como mucho un minuto, pero la tasa de aciertos cae y el problema no desaparece, solo se acorta. (2) Redis: una única copia compartida, invalidación global e instantánea, y la caché sobrevive a los reinicios del despliegue progresivo; a cambio, un servicio más que operar, una dependencia de red en cada lectura y la serialización del apartado 10. (3) Caffeine con invalidación difundida por pub/sub: latencia de nanosegundos y coherencia casi inmediata, pero es la opción con más piezas móviles y hay que resolver qué pasa cuando una instancia se pierde un mensaje.

Elección: Redis. El catálogo de estaciones lo consultan todas las instancias, tiene decenas de entradas —volumen ridículo para Redis— y la coherencia visible al ciudadano es un requisito del ayuntamiento. Además Redis ya entrará para la caché de usuarios del ejercicio 1, así que el coste operativo se amortiza.

spring:
  cache:
    type: redis
  data:
    redis:
      host: ${REDIS_HOST:redis}
      timeout: 500ms
@Bean
RedisCacheManagerBuilderCustomizer configuracionPorCache() {
    return builder -> builder
            .withCacheConfiguration("estaciones", base().entryTtl(Duration.ofMinutes(10)))
            .withCacheConfiguration("usuarios",   base().entryTtl(Duration.ofMinutes(5)));
}

@Component
class InvalidadorCacheEstaciones {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void alModificar(EstacionModificada evento) {
        cacheManager.getCache("estaciones").evict(evento.id());   // borra para las tres
    }
}

Con el evict en AFTER_COMMIT y una sola copia en Redis, la invalidación es global: la instancia que atiende la modificación borra la entrada para todas, y la siguiente lectura de cualquiera de las tres repuebla con el nombre nuevo. Se mantiene el TTL de 10 minutos como red de seguridad (apartado 12) y hay que decidir el comportamiento ante caída de Redis: con timeout: 500ms y un CacheErrorHandler que registre el fallo en lugar de propagarlo, la aplicación degrada a consultar la base de datos, que es lento pero correcto. Lo contrario —que un Redis caído derribe el endpoint de estaciones— sería cambiar un problema de coherencia por uno de disponibilidad.

Conclusión

CicloUrbana tiene ahora su segunda palanca de rendimiento, y la tiene con el orden correcto: primero se arregla la consulta y solo después se cachea, porque una caché sobre código malo compra silencio y esconde la causa. Sabes decidir qué merece caché con las tres condiciones —muy leído, poco escrito, tolerante a estar ligeramente desactualizado— y por qué en Ribalta eso significa cachear el catálogo de estaciones y las tarifas, pero jamás la disponibilidad en tiempo real ni el alquiler en curso: se cachea el catálogo, nunca el estado.

Dominas la abstracción de Spring —@EnableCaching, CacheManager, Cache y el CacheInterceptor— y por qué tu código nunca menciona a Caffeine ni a Redis. Conoces las cinco anotaciones con sus atributos, la diferencia real entre @Cacheable y @CachePut, y qué hacen allEntries y beforeInvocation. Sabes que las claves son la mayor fuente de errores silenciosos —el método sin argumentos, la entidad como clave, el Pageable que genera cientos de entradas— y las declaras con SpEL o con un KeyGenerator propio. Distingues condition de unless por el momento en que se evalúan, y tienes identificado el error de cachear un null o un Optional.empty() sin haberlo decidido. Y has visto por tercera vez la trampa del proxy, con su firma inconfundible: una tasa de aciertos cercana a cero.

Del lado de la infraestructura, sabes por qué ConcurrentMapCache no vale en producción, configuras Caffeine con maximumSize, expireAfterWrite y recordStats afinando caché por caché, y montas Redis con TTL por caché, serialización JSON y las tres caras del problema de los tipos —JavaTimeModule, ISO-8601 y el tipado restringido por paquete—, con su contenedor en el docker-compose.yml de 07-04 y su prueba con Testcontainers. Tienes la tabla de local frente a distribuida y, sobre todo, la razón por la que al escalar a tres instancias una caché local deja de ser inocente. Y has trabajado la parte difícil: invalidar en AFTER_COMMIT y nunca dentro de la transacción, TTL como red de seguridad junto a la invalidación explícita, las mitigaciones de la estampida —sync, refreshAfterWrite, jitter y precalentado—, la comparación con la caché de segundo nivel de Hibernate y por qué preferimos la explícita, y la caché HTTP con ETag y Cache-Control que no cuesta nada.

Queda un cabo suelto que ha aparecido en casi todos los apartados: medir. La tasa de aciertos que distingue una caché útil de una inútil, cache.evictions que delata un tamaño mal elegido, y antes de eso la latencia p95 de 09-01, las pausas de GC, las conexiones en espera del pool. Todos esos números existen ya dentro del proceso, publicados por el Actuator que montamos en 07-01, y hasta ahora los hemos consultado de oído. La siguiente lección, Monitoreo con Spring Boot Actuator, los convierte en instrumentación de verdad: Micrometer como fachada de métricas, los tipos de medidor, las métricas que ya existen sin escribir código, la regla de oro de la cardinalidad de las etiquetas, y las métricas de negocio de la red de Ribalta —alquileres iniciados por tarifa, bicicletas disponibles por estación, duración de los trayectos— con percentiles que se pueden agregar entre instancias.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados