La lliçó anterior va acabar amb una consigna: primer s'elimina la feina innecessària. L'endpoint d'estacions va passar de 2,4 segons a 91 mil·lisegons traient 42 consultes i afegint un índex, sense memòria cau i sense una màquina més. Aquest ordre no és negociable, i és també el marc d'aquesta lliçó: la memòria cau és la palanca que es fa servir quan la feina ja és mínima i tot i així es repeteix.
Perquè això és exactament el que passa a Ribalta. El catàleg d'estacions es consulta unes cent vegades per segon en hora punta i canvia quan l'ajuntament inaugura una estació nova, és a dir, quatre vegades l'any. Les tarifes es consulten a cada càlcul d'import i es revisen una vegada l'any. Executar una consulta perfectament indexada de 3 mil·lisegons cent vegades per segon per obtenir sempre la mateixa resposta és feina mínima repetida: el cas de llibre de la memòria cau.
Veurem l'abstracció de memòria cau de Spring i per què el codi no depèn del proveïdor, les anotacions amb tots els seus atributs i els seus paranys —les claus, condition i unless, el proxy—, Caffeine per a memòria cau local i Redis per a memòria cau distribuïda, i després la part que de debò separa una memòria cau útil d'una font d'errors: la invalidació. Acabarem mesurant-la i provant-la, perquè una memòria cau de la qual no es coneix la taxa d'encerts és una decisió presa a cegues.
Contingut
- Quan una memòria cau és la resposta correcta
- Quines dades de CicloUrbana mereixen memòria cau
- L'abstracció de memòria cau de Spring
- Les anotacions i els seus atributs
- Claus: la part que més errors produeix
condition,unlessi el problema delnull- El parany del proxy, un altre cop
- Proveïdors: quin triar i per què
- Caffeine: la memòria cau local de CicloUrbana
- Redis: la memòria cau distribuïda
- Local davant de distribuïda
- Invalidació: la part difícil
- Estampida de memòria cau
- La memòria cau de segon nivell d'Hibernate
- La memòria cau que no costa res:
ETagiCache-Control - Mesurar la memòria cau
- Provar codi amb memòria cau
- Errors Comuns i Consells
- Exercicis
- Quan una memòria cau és la resposta correcta
Una memòria cau desa el resultat d'una operació cara per no repetir-la. Sona inofensiu, i és la raó per la qual se'n abusa: una memòria cau mal posada no falla, menteix. Retorna dades correctes que ja no són certes, i ho fa de manera intermitent i difícil de reproduir.
La regla que governa tota la lliçó és aquesta: primer arregla la consulta, després posa memòria cau. Si un endpoint triga 2 segons per un N+1, una memòria cau el deixarà en 5 mil·lisegons i el problema continuarà allà, esperant la primera fallada de memòria cau, el primer desplegament, la primera dada nova. Encara pitjor: hauràs amagat el símptoma que t'hauria portat a la causa. Una memòria cau sobre codi optimitzat multiplica una millora real; una memòria cau sobre codi dolent compra silenci.
Amb això clar, una dada és bona candidata quan compleix les tres condicions alhora:
| Condició | Per què importa | Com es comprova |
|---|---|---|
| Es llegeix moltes vegades | Sense repetició no hi ha res a estalviar | Mètriques d'invocació per endpoint (09-03) |
| S'escriu poques vegades | Cada escriptura obliga a invalidar; amb escriptures freqüents la memòria cau no arriba a omplir-se | Ritme real d'UPDATE sobre aquesta taula |
| Tolera estar lleugerament desactualitzada | Tota memòria cau serveix dades de fa n segons | Decisió de negoci, no tècnica |
La tercera és la que cal negociar amb qui coneix el domini, no resoldre a l'editor. I hi ha una quarta condició implícita: el resultat ha de cabre. Posar a la memòria cau un llistat paginat complet de tres milions de lloguers no és una memòria cau, és una segona base de dades pitjor feta.
- Quines dades de CicloUrbana mereixen memòria cau
| Dada | Memòria cau? | Motiu | TTL |
|---|---|---|---|
| Catàleg d'estacions (nom, adreça, capacitat) | Sí | Milers de lectures per minut, canvia trimestralment | 10 min |
| Tarifes de Ribalta (estàndard, estudiant, jubilat) | Sí | Es consulta a cada càlcul, canvia una vegada l'any | 1 h |
| Fitxa de l'usuari per a autorització | Sí, amb compte | Molt llegida; invalidar en desactivar o canviar de rol | 5 min |
| Zones i polígons del municipi | Sí | Pràcticament immutable | 24 h |
| Bicicletes disponibles per estació | No | Canvia cada segon; una dada de fa 30 s fa que el ciutadà arribi a una estació buida | — |
| Lloguer en curs d'un usuari | No | És l'estat que governa la regla de negoci de 04-08 | — |
| Import calculat d'un lloguer concret | No | Es fa servir una sola vegada; no hi ha repetició | — |
| Resposta de la passarel·la de pagaments (07-06) | Mai | Posar un cobrament a la memòria cau és un error d'una altra categoria | — |
Els dos «no» del centre són la lliçó d'aquest apartat. Disponibilitat en temps real i memòria cau són incompatibles, i la temptació és enorme perquè és justament l'endpoint més consultat. La resposta correcta a aquest cas no és posar a la memòria cau el nombre de bicicletes: és que la consulta sigui barata (l'índex compost de 09-01) i, si calgués més, mantenir un comptador actualitzat a la mateixa taula d'estacions. La distinció pràctica: posa a la memòria cau el catàleg, mai l'estat.
- L'abstracció de memòria cau de Spring
Spring no implementa una memòria cau: defineix una abstracció —tres interfícies i un aspecte— i delega en un proveïdor real.
flowchart LR
A["@Cacheable a EstacioService"] --> B[Proxy AOP<br/>CacheInterceptor]
B --> C[CacheManager]
C --> D["Cache 'estacions'"]
D --> E1[Caffeine<br/>memoria local]
D --> E2[Redis<br/>distribuida]
D --> E3[ConcurrentMapCache<br/>per defecte]
B -->|fallada de cache| F[Metode real -> BD]
Les peces: Cache és el contracte d'una memòria cau concreta (get, put, evict, clear); CacheManager localitza memòries cau per nom; i el CacheInterceptor, un aspecte igual que el de @Transactional (04-07), decideix abans de cada invocació si cal cridar el mètode o retornar el que hi ha desat.
La conseqüència pràctica és la que importa: el teu codi no esmenta ni Caffeine ni Redis. Anotes @Cacheable("estacions") i canvies de proveïdor tocant una dependència i una classe de configuració. A CicloUrbana començarem amb Caffeine i passarem a Redis en escalar a diverses instàncies, sense tocar una línia d'EstacioService.
S'activa amb una anotació en una classe de configuració:
package com.ciclourbana.comu.cache;
@Configuration
@EnableCaching // sense aixo, les anotacions no fan absolutament res
public class ConfiguracioCache { }Oblidar @EnableCaching és el primer error clàssic, i és silenciós: el codi compila, les proves passen i la memòria cau senzillament no existeix.
- Les anotacions i els seus atributs
| Anotació | Què fa | Atributs clau | Ús a CicloUrbana |
|---|---|---|---|
@Cacheable |
Si hi ha entrada, la retorna sense executar el mètode; si no, executa i desa | value/cacheNames, key, condition, unless, sync |
Llegir una estació o el catàleg |
@CachePut |
Sempre executa el mètode i desa el resultat | Els mateixos que @Cacheable |
Actualitzar una estació i refrescar-ne l'entrada |
@CacheEvict |
Esborra entrades | key, allEntries, beforeInvocation |
Esborrar una estació |
@Caching |
Agrupa diverses anotacions del mateix tipus o barrejades | cacheable, put, evict |
Una operació que toca dues memòries cau |
@CacheConfig |
Valors per defecte a nivell de classe | cacheNames, keyGenerator |
Evitar repetir "estacions" a cada mètode |
La diferència entre @Cacheable i @CachePut és la que més confon i és senzilla: @Cacheable pot saltar-se el mètode; @CachePut mai. Per això @CachePut és l'anotació de les actualitzacions i @Cacheable la de les lectures. Posar-les juntes sobre el mateix mètode és gairebé sempre un error de disseny.
@CacheEvict té dos atributs amb conseqüències. allEntries = true buida la memòria cau sencera en lloc d'una clau: és el martell, correcte després d'una importació massiva i exagerat en modificar una estació. I beforeInvocation decideix quan s'esborra: per defecte és false, de manera que s'esborra després que el mètode acabi bé i una excepció deixa la memòria cau intacta —normalment el que es vol—, mentre que amb true s'esborra abans, cosa que convé quan una fallada a mitges podria deixar la memòria cau amb dades falses.
@Service
@CacheConfig(cacheNames = "estacions") // valor per defecte per a tota la classe
public class EstacioService {
@Cacheable(key = "#id")
public EstacioResponse cercar(Long id) { /* consulta a la BD */ }
@CachePut(key = "#result.id") // desa el resultat ja actualitzat
public EstacioResponse actualitzar(Long id, ActualitzarEstacioRequest peticio) { }
@CacheEvict(key = "#id")
public void esborrar(Long id) { }
@CacheEvict(allEntries = true) // despres d'una importacio massiva
public void importarDesDeAjuntament(List<EstacioCsv> files) { }
}
- Claus: la part que més errors produeix
Cada entrada es desa sota una clau. Sense key, Spring fa servir SimpleKeyGenerator, les regles del qual són fàcils de recordar i perilloses de donar per fetes: sense arguments → SimpleKey.EMPTY (una única entrada per a tot el mètode); un argument → el mateix argument; diversos arguments → un SimpleKey que els combina.
Els tres problemes que produeix aquest comportament per defecte:
El mètode sense arguments. @Cacheable("estacions") public List<EstacioResponse> llistarTotes() desa una sola entrada sota SimpleKey.EMPTY. Funciona, però si demà s'hi afegeix un paràmetre nomesActives, la clau canvia sola i el comportament amb ella.
L'objecte com a argument. Si l'argument és un record, el seu equals/hashCode són correctes i la clau funciona. Si és una entitat JPA amb equals basat en l'id —o pitjor, sense equals—, la clau serà la identitat de l'objecte i mai no hi haurà encerts: cada petició crea una instància nova. És la fallada més frustrant, perquè la memòria cau sembla configurada i la seva taxa d'encerts és zero.
El Pageable. @Cacheable public Page<X> llistar(Pageable p) fa servir el Pageable com a clau. Tècnicament funciona —PageRequest implementa equals—, però genera una entrada per combinació de pàgina, mida i ordre: centenars d'entrades gairebé idèntiques. Posar llistats paginats a la memòria cau gairebé mai no compensa.
La solució és declarar la clau amb SpEL:
@Cacheable(cacheNames = "estacions", key = "#id") // un argument
public EstacioResponse cercar(Long id) { }
@Cacheable(cacheNames = "tarifes", key = "#tipus.name() + ':' + #minuts") // diversos
public BigDecimal calcular(TipusTarifa tipus, long minuts) { }
@Cacheable(cacheNames = "usuaris", key = "#peticio.correu") // propietat, no objecte
public UsuariResponse perCorreu(CercarUsuariRequest peticio) { }
@CachePut(cacheNames = "estacions", key = "#result.id") // #result: nomes a @CachePut
public EstacioResponse actualitzar(ActualitzarEstacioRequest peticio) { }Dins de SpEL hi ha disponibles #nomDeLArgument —requereix compilar amb -parameters, que l'spring-boot-starter-parent ja activa—, #p0/#a0 per posició, #root.methodName, #root.target i #result a les anotacions que s'avaluen després d'invocar.
Quan la lògica de la clau es repeteix, un generador propi evita duplicar-la:
@Component("clauEstacio")
public class GeneradorClauEstacio implements KeyGenerator {
@Override
public Object generate(Object desti, Method metode, Object... args) {
return metode.getName() + ':' +
Arrays.stream(args).map(String::valueOf).collect(Collectors.joining(":"));
}
}Es fa servir amb @Cacheable(cacheNames = "estacions", keyGenerator = "clauEstacio"). key i keyGenerator són excloents: declarar-los tots dos és un error d'arrencada.
Una regla que estalvia incidents: la clau ha de ser un valor petit, immutable i amb equals/hashCode correctes —un Long, un String, un record senzill—. Si dubtes, construeix un String explícit.
condition, unless i el problema del null
condition, unless i el problema del nullTots dos atributs filtren, però en moments diferents i aquesta diferència és la clau:
condition |
unless |
|
|---|---|---|
| Quan s'avalua | Abans d'invocar | Després d'invocar |
| Què decideix | Si es consulta i es desa a la memòria cau | Si es descarta el desament |
#result disponible |
No | Sí |
| Semàntica | «desa si...» | «no desis si...» |
@Cacheable(cacheNames = "estacions", key = "#id",
condition = "#id != null && #id > 0", // no desar peticions absurdes
unless = "#result == null || !#result.activa()") // no desar el que no serveix
public EstacioResponse cercar(Long id) { }L'error clàssic: desar l'absència. Si cercar(999) retorna null o Optional.empty() i es desa, tenim dos problemes simultanis. El primer, que quan l'estació 999 es creï de debò, el servei continuarà dient durant tot el TTL que no existeix. El segon, més subtil: amb Optional, @Cacheable desa l'Optional buit com un valor perfectament vàlid, així que la memòria cau sí que encerta i retorna «no existeix» sense consultar. Depèn del cas quin és el comportament desitjat —desar absències és una defensa legítima contra un atac que demana identificadors inexistents—, però ha de ser una decisió conscient, i s'expressa amb unless = "#result == null" o amb un TTL curt i propi per a aquesta memòria cau.
Compte també amb l'avaluació: condition i unless són SpEL avaluats a cada invocació. Una expressió que crida un mètode car converteix l'estalvi en despesa.
- El parany del proxy, un altre cop
És la tercera vegada que apareix al curs, amb @Transactional (04-07) i amb @Async (07-03), i el mecanisme és idèntic: les anotacions de memòria cau les aplica un proxy, i una crida interna no passa pel proxy.
@Service
public class EstacioService {
public List<EstacioResponse> llistarActives() {
return cercarTotes().stream() // this.cercarTotes(): SENSE CACHE
.filter(EstacioResponse::activa).toList();
}
@Cacheable("estacions")
public List<EstacioResponse> cercarTotes() { /* consulta a la BD */ }
}llistarActives() va a la base de dades sempre. Ni el compilador ni Spring avisen, i l'únic senyal és una taxa d'encerts anòmalament baixa: per això l'apartat 16 no és opcional. Les sortides, en ordre de preferència: moure el mètode amb memòria cau a un altre bean —el correcte, perquè la memòria cau sol indicar una frontera de responsabilitat—; injectar-se a si mateix amb @Lazy (lleig però explícit); o fer servir AopContext.currentProxy(), que exigeix @EnableAspectJAutoProxy(exposeProxy = true) i és l'últim recurs.
I dues condicions que s'obliden: el mètode ha de ser public —un private, protected o de paquet s'ignora en silenci— i no final, perquè un proxy CGLIB no el pot sobreescriure.
- Proveïdors: quin triar i per què
Spring Boot autoconfigura el proveïdor segons el que trobi al classpath:
| Proveïdor | Abast | TTL | Estadístiques | Quan fer-lo servir |
|---|---|---|---|---|
ConcurrentMapCache (per defecte) |
Local | No | No | Només proves i prototips |
| Caffeine | Local | Sí | Sí (recordStats) |
Una instància, o dades tolerants a divergència |
| Redis | Distribuïda | Sí | Parcial | Diverses instàncies amb coherència |
| Hazelcast | Distribuïda, dins de la mateixa JVM | Sí | Sí | Malla de dades, sense servidor a part |
| EhCache 3 (JSR-107) | Local, amb desbordament a disc | Sí | Sí | Heretat; memòries cau molt grans |
Per què el proveïdor per defecte no val en producció. ConcurrentMapCache és un ConcurrentHashMap: no té caducitat ni mida màxima. Tot el que hi entra s'hi queda per sempre, així que dues coses acaben passant: les dades queden obsoletes indefinidament i la memòria creix fins a l'OutOfMemoryError. És una memòria cau de demostració, i Spring la fa servir només perquè necessita un valor per defecte. Si a l'arrencada de producció hi veus ConcurrentMapCacheManager, falta una dependència.
El criteri d'elecció, en una línia: una sola instància i dades que toleren divergència de segons → Caffeine; diverses instàncies que han de veure el mateix → Redis. Res més, i en particular: no es tria Redis «per si de cas», perquè introdueix una dependència de xarxa al camí de lectura i una latència de mil·lisegon on Caffeine triga nanosegons.
- Caffeine: la memòria cau local de CicloUrbana
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>Amb la dependència present, Spring Boot configura CaffeineCacheManager sense res més. La configuració per propietats:
spring.cache:
type: caffeine
cache-names: estacions,tarifes,zones # es creen en arrencar
caffeine.spec: maximumSize=1000,expireAfterWrite=10m,recordStatsQuè significa cada terme de spec. maximumSize=1000 limita les entrades: en superar-les, Caffeine desallotja segons el seu algorisme Window TinyLFU, que combina freqüència i recència i encerta bastant més que un LRU clàssic. expireAfterWrite=10m caduca l'entrada deu minuts després d'escriure-la, es llegeixi o no —el TTL clàssic—; la seva alternativa, expireAfterAccess, compta des de l'últim accés, amb el risc que una dada molt consultada no caduqui mai, així que per a dades que canvien es fa servir expireAfterWrite. I recordStats activa el recompte d'encerts i fallades: sense això, l'apartat 16 no té res a mesurar i /actuator/caches no dirà gran cosa.
Un spec únic s'aplica a totes les memòries cau, cosa que rarament és l'adequat: les tarifes admeten una hora i el catàleg deu minuts. Per afinar per memòria cau es declara el gestor:
@Configuration
@EnableCaching
public class ConfiguracioCache {
@Bean
CacheManager cacheManager() {
SimpleCacheManager gestor = new SimpleCacheManager();
gestor.setCaches(List.of(
cache("estacions", 1_000, Duration.ofMinutes(10)),
cache("tarifes", 50, Duration.ofHours(1)),
cache("zones", 200, Duration.ofHours(24))));
return gestor;
}
private CaffeineCache cache(String nom, long mida, Duration ttl) {
return new CaffeineCache(nom, Caffeine.newBuilder()
.maximumSize(mida).expireAfterWrite(ttl)
.recordStats() // imprescindible per a les metriques de 09-03
.build());
}
}Com s'afina. La mida es tria pel nombre d'elements diferents que es consulten de debò —Ribalta té 40 estacions, així que maximumSize=1000 sobra i és deliberat: millor que sobri que no pas desallotjar—. I el TTL surt de la pregunta de negoci de l'apartat 1: quants segons pot un ciutadà veure el nom antic d'una estació? Si la resposta és «deu minuts», aquest és el TTL; si és «cap», no és candidata a memòria cau amb TTL, sinó a invalidació explícita.
- Redis: la memòria cau distribuïda
<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 rapid: la cache no pot bloquejar la peticio
lettuce.pool.max-active: 16Amb això ja funciona, però la serialització per defecte és JDK —binària, illegible i fràgil davant de qualsevol canvi de classe—. La configuració que CicloUrbana fa servir en realitat:
@Bean
RedisCacheConfiguration configuracioBase() {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule()) // LocalDateTime, Instant
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.activateDefaultTyping(BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.ciclourbana.").build(), // nomes les nostres classes
ObjectMapper.DefaultTyping.NON_FINAL)
.build();
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues() // apartat 6
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(mapper)));
}
@Bean
RedisCacheManagerBuilderCustomizer ttlPerCache() { // TTL diferent per cache
return builder -> builder
.withCacheConfiguration("tarifes", configuracioBase().entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("zones", configuracioBase().entryTtl(Duration.ofHours(24)));
}El problema clàssic de la serialització. És la primera pedra amb què ensopega tothom, i té tres cares. La primera: sense JavaTimeModule, un LocalDateTime peta amb Java 8 date/time type not supported by default. La segona: encara que no peti, si es serialitza com [2026,8,31,7,42] en lloc d'ISO-8601, en deserialitzar en una altra versió de l'aplicació fallarà —d'aquí WRITE_DATES_AS_TIMESTAMPS desactivat—. I la tercera, la pitjor: sense informació de tipus, un List<EstacioResponse> es deserialitza com a List<LinkedHashMap> i salta un ClassCastException en un lloc que no hi té res a veure. Per això s'activa el tipatge, i sempre amb un validador que restringeixi els paquets permesos: un tipatge per defecte obert sobre dades que un atacant pugui influir és una vulnerabilitat de deserialització coneguda.
Un consell que evita hores: si el valor desat canvia de forma, canvia també el nom de la memòria cau (estacions → estacions-v2). Durant un desplegament progressiu (08-04) conviuen dues versions de l'aplicació llegint el mateix Redis, i una entrada escrita per la nova pot ser illegible per a la vella.
Redis al docker-compose.yml de 07-04, al costat de PostgreSQL:
redis:
image: redis:7-alpine
container_name: ciclourbana-redis
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
ports: ["6379:6379"]
networks: [xarxa-ciclourbana]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 5--maxmemory-policy allkeys-lru és la política correcta per a una memòria cau: quan Redis omple la seva memòria, desallotja les claus menys usades en lloc de rebutjar escriptures. La política per defecte (noeviction) és l'adequada si Redis fos la font de veritat; com que aquí és una memòria cau, allkeys-lru.
I per provar-ho de debò, un contenidor real amb Testcontainers (06-05):
@SpringBootTest
@Testcontainers
class CacheRedisIT {
@Container
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@DynamicPropertySource
static void propietats(DynamicPropertyRegistry reg) {
reg.add("spring.data.redis.host", REDIS::getHost);
reg.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
- Local davant de distribuïda
| Memòria cau local (Caffeine) | Memòria cau distribuïda (Redis) | |
|---|---|---|
| Latència d'accés | Nanosegons (mateixa JVM) | ~0,5-2 ms (anada i tornada de xarxa) |
| Coherència entre instàncies | Cap: cadascuna té la seva còpia | Una sola còpia compartida |
| Invalidació | Només local: les altres no se n'assabenten | Global i immediata |
| En arrencar | Buida: cal escalfar-la | Ja poblada per les altres instàncies |
| Memòria | Consumeix heap de l'aplicació | Fora del procés |
| Punt de fallada afegit | No | Sí: cal degradar amb elegància |
| Cost operatiu | Zero | Un servei més que mantenir i vigilar |
Per què en escalar la memòria cau local deixa de ser innocent. Amb una instància, @CacheEvict esborra l'entrada i el següent lector veu la dada nova. Amb tres instàncies darrere del balancejador (08-04), la petició d'actualització arriba a una d'elles: aquesta esborra la seva còpia i les altres dues continuen servint el nom antic durant tot el TTL. El resultat és la pitjor classe d'error: el ciutadà refresca la pantalla i veu el nom nou, el vell i un altre cop el nou, segons quina instància li toqui. I no és reproduïble en local.
Hi ha tres respostes vàlides. TTL curts i acceptar la divergència, si el negoci ho tolera —deu minuts de nom antic no maten ningú—. Redis, si s'ha de veure igual a tot arreu. O memòria cau local amb invalidació difosa per un canal de missatges (el pub/sub de Redis o el bus de 07-05), que dóna la latència de Caffeine amb la coherència de Redis a canvi de complexitat. CicloUrbana comença per la primera i passa a la segona per a la memòria cau d'usuaris, on una autorització obsoleta sí que importa.
- Invalidació: la part difícil
Phil Karlton ho va resumir: «només hi ha dues coses difícils en informàtica: invalidar memòries cau i posar nom a les coses». És una broma amb una veritat exacta: desar és trivial, saber quan el que s'ha desat va deixar de ser cert és el problema sencer.
Hi ha dues estratègies, i no són excloents:
| TTL (caducitat) | Invalidació explícita | |
|---|---|---|
| Com funciona | L'entrada mor sola passat un temps | El codi esborra l'entrada en canviar la dada |
| Frescor | Desactualització acotada pel TTL | Immediata |
| Complexitat | Cap | Cal recordar-se'n a cada camí d'escriptura |
| Risc | Dades velles durant el TTL | Oblidar un camí → dades velles per sempre |
| Quan | Dades tolerants; sempre, com a xarxa de seguretat | Dades que s'han de veure a l'instant |
La recomanació de CicloUrbana: totes dues. Invalidació explícita perquè el canvi es vegi a l'instant, i TTL sempre posat com a xarxa sota el trapezi, perquè tard o d'hora algú afegirà un camí d'escriptura que no invalida —una migració de Flyway, una tasca programada, un UPDATE manual de l'ajuntament—.
La versió ingènua:
@Transactional
@CacheEvict(cacheNames = "estacions", key = "#id")
public EstacioResponse actualitzar(Long id, ActualitzarEstacioRequest peticio) { }I aquí hi ha l'error que gairebé ningú no veu la primera vegada. L'anotació de memòria cau s'avalua al voltant del mètode, però la transacció es confirma... també al voltant del mètode, i en un ordre que no controles. Amb la configuració per defecte, el CacheInterceptor pot esborrar l'entrada abans que la transacció confirmi. S'obre llavors una finestra de mil·lisegons en què la memòria cau és buida i la base de dades encara té el valor antic: si una altra petició llegeix en aquest instant, repobla la memòria cau amb la dada vella i la hi deixa durant tot el TTL. Pitjor encara si la transacció acaba en rollback: s'ha invalidat per un canvi que mai no va passar, cosa que és inofensiva, però el cas anterior no ho és gens.
La solució correcta és invalidar després de confirmar, amb el mecanisme d'esdeveniments de 04-07:
// 1. El servei publica un esdeveniment dins de la transaccio
@Transactional
public EstacioResponse actualitzar(Long id, ActualitzarEstacioRequest peticio) {
Estacio estacio = estacioRepositori.findById(id).orElseThrow(...);
estacio.actualitzar(peticio.nom(), peticio.adreca(), peticio.capacitat());
publicador.publishEvent(new EstacioModificada(id));
return mapejador.aResposta(estacio);
}
// 2. Un component a part invalida quan el commit ja ha ocorregut
@Component
public class InvalidadorCacheEstacions {
private final CacheManager cacheManager;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alModificarEstacio(EstacioModificada esdeveniment) {
Optional.ofNullable(cacheManager.getCache("estacions"))
.ifPresent(cache -> cache.evict(esdeveniment.id()));
}
}Tres avantatges sobre l'anotació. Mai no s'invalida per un canvi que no va arribar a confirmar-se. No hi ha finestra de repoblació amb dades velles, perquè quan s'esborra l'entrada la base de dades ja té el valor nou. I la responsabilitat queda separada: el servei de domini publica que alguna cosa ha canviat i no sap que existeix una memòria cau, que és exactament la relació correcta entre tots dos.
Dos casos més. En crear una estació no hi ha entrada per esborrar, però sí un llistat agregat que ha quedat obsolet: @CacheEvict(cacheNames = "estacions", allEntries = true) o, millor, una memòria cau separada per al catàleg complet. I a les importacions massives, invalidar entrada a entrada és absurd: es buida la memòria cau sencera una vegada en acabar.
- Estampida de memòria cau
L'entrada del catàleg d'estacions caduca a les 8:03. En aquest instant hi ha 200 peticions en vol: les 200 fallen alhora i les 200 executen la consulta alhora. És la cache stampede, i la seva signatura al gràfic és un pic net de latència i de connexions al pool cada expireAfterWrite, amb la resta del temps en calma.
Tres mitigacions, de la més simple a la millor:
Bloqueig (sync = true). @Cacheable(cacheNames = "estacions", key = "#id", sync = true) fa que només un fil calculi el valor mentre els altres esperen que acabi. Resol l'estampida amb una línia. Els seus límits: només ho suporten alguns proveïdors —Caffeine sí, Redis no de manera nativa—, és un bloqueig per instància, i és incompatible amb unless.
Recàrrega en segon pla (refreshAfterWrite). Propi de Caffeine: passat aquest temps, la primera petició que arriba retorna el valor vell immediatament i dispara la recàrrega en un altre fil. Cap petició no espera mai. Caffeine.newBuilder().refreshAfterWrite(Duration.ofMinutes(5)).expireAfterWrite(Duration.ofMinutes(30)).build(clau -> carregar(clau)) combina les dues coses: refresc als 5 minuts, caducitat dura als 30 per si la recàrrega falla sempre. És la millor opció per al catàleg de Ribalta.
Jitter. Si totes les entrades s'escriuen alhora —en arrencar, o després d'un buidatge— caduquen alhora. Afegir una variació aleatòria al TTL (10m ± 20 %) les reparteix en el temps. A Caffeine s'aconsegueix amb expireAfter(Expiry); a Redis, amb TTL calculat en escriure.
I una quarta que és d'arquitectura: precalentar la memòria cau en arrencar amb un ApplicationRunner que carregui les 40 estacions. Barat, i evita que la primera onada de trànsit després d'un desplegament pagui totes les fallades.
- La memòria cau de segon nivell d'Hibernate
És una memòria cau d'entitats, dins de l'ORM, i opera en un pla diferent. La de primer nivell és el context de persistència i dura el que la transacció (04-07): existeix sempre i no es configura. La de segon nivell és compartida per totes les sessions i cal activar-la explícitament (hibernate.cache.use_second_level_cache, un proveïdor com EhCache o Infinispan, i @Cache a cada entitat), a la qual cosa se suma la memòria cau de consultes per als resultats de les queries.
| Memòria cau d'aplicació (Spring) | Segon nivell d'Hibernate | |
|---|---|---|
| Què desa | El resultat del mètode: DTOs ja mapejats | Entitats per identificador, en format desmuntat |
| Què estalvia | Consulta i mapatge i lògica | Només la consulta per id |
| Visibilitat | Explícita: es veu al codi | Implícita: no es veu enlloc |
| Invalidació | Tu la controles | Hibernate la gestiona... si tot passa per Hibernate |
| Risc | Errors teus | Dades rancies davant de @Query de modificació, Flyway o SQL extern |
Per què el curs prefereix la d'aplicació. Perquè és explícita: en llegir @Cacheable("estacions") saps que hi ha una memòria cau i on és. La de segon nivell és invisible al codi, i quan produeix una dada obsoleta la investigació és llarga perquè ningú no recorda que estava activada. A més estalvia menys: la d'aplicació se salta també el mapatge amb MapStruct (03-05) i la construcció dels DTOs, mentre que la de segon nivell només evita el viatge SQL —i ni tan sols ho fa per a consultes que no siguin per id, llevat que s'activi a més la memòria cau de consultes, que és notòriament delicada—.
Quan té sentit? En un domini amb moltes entitats de referència immutables llegides per id des de molts llocs, i amb totes les escriptures passant per Hibernate. Si hi ha Flyway modificant dades (04-08) o un procés extern tocant la base, queden fora del seu control i servirà dades falses sense avisar.
- La memòria cau que no costa res:
ETag i Cache-Control
ETag i Cache-ControlAbans de posar memòria cau al servidor, convé recordar que la petició més ràpida és la que no es fa. Amb les capçaleres HTTP de 03-03:
@GetMapping("/{id}")
public ResponseEntity<EstacioResponse> cercar(@PathVariable Long id) {
EstacioResponse estacio = estacioService.cercar(id);
return ResponseEntity.ok()
.eTag("\"" + estacio.versio() + "\"") // el @Version de 04-03
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(estacio);
}Cache-Control: max-age=300, public autoritza el navegador, l'app mòbil i qualsevol proxy intermedi a reutilitzar la resposta cinc minuts sense preguntar: zero peticions al servidor. I l'ETag, juntament amb ShallowEtagHeaderFilter o calculat a mà a partir del @Version, permet que el client pregunti amb If-None-Match i rebi un 304 Not Modified de dos-cents bytes: s'estalvia la serialització i l'amplada de banda, encara que no la feina del servidor.
La combinació de les tres capes és el que rendeix: Cache-Control evita la petició, l'ETag evita el cos i @Cacheable evita la consulta. Amb un advertiment de seguretat: cachePublic() només en respostes no personalitzades. Marcar com a public una resposta que depèn de l'usuari autenticat fa que un proxy serveixi les dades d'un ciutadà a un altre; per a això hi ha cachePrivate().
- Mesurar la memòria cau
Una memòria cau sense mètriques és una decisió sense verificar. Les dues preguntes són està encertant? i està creixent sense control?
/actuator/caches (07-01) enumera les memòries cau i els seus gestors, útil per confirmar que existeixen les que creus. Però el que importa són les estadístiques, que exigeixen recordStats. Amb Caffeine i recordStats activat, Micrometer publica automàticament:
| Mètrica | Què mesura | Què vigilar |
|---|---|---|
cache.gets{result="hit"} |
Encerts | Ha de dominar àmpliament |
cache.gets{result="miss"} |
Fallades | Una ràtio alta = memòria cau inútil o clau mal triada |
cache.puts |
Escriptures | Si ≈ fallades, cada fallada repobla: normal |
cache.evictions |
Desallotjaments per mida | Constants = maximumSize massa petita |
cache.size |
Entrades actuals | Enganxada al màxim = revisar el dimensionament |
La taxa d'encerts és hits / (hits + misses). Com a orientació: per sobre del 90 % la memòria cau està fent la seva feina; entre el 50 % i el 90 % convé revisar el TTL o la mida; per sota del 20 % la memòria cau costa més del que estalvia i gairebé sempre indica una de les tres causes ja vistes —clau mal construïda, autoinvocació pel proxy o dades que senzillament no es repeteixen—.
Perquè aquestes mètriques existeixin cal registrar la memòria cau al MeterRegistry, cosa que l'autoconfiguració fa sola si el CacheManager és l'autoconfigurat; si el declares tu (apartat 9), afegeix un @Bean de tipus CacheMetricsRegistrar o marca les memòries cau amb CaffeineCacheMetrics.monitor(registry, cache, "estacions"). Tota la instrumentació s'explica a 09-03 i es representa a Grafana a 09-04, on un panell de taxa d'encerts per memòria cau és dels que més ràpid detecta una regressió.
- Provar codi amb memòria cau
Les proves unitàries no veuen la memòria cau, i això sorprèn molta gent. Un EstacioServiceTest que instancia el servei amb new i li passa un mock de Mockito (06-03) treballa amb l'objecte real, sense proxy: @Cacheable no fa res. És correcte i a més desitjable —la prova unitària comprova la lògica, no la infraestructura—, però implica que la memòria cau només es pot provar en integració.
@SpringBootTest
@AutoConfigureCache // forca un CacheManager real, no el de proves
class EstacioServiceCacheIT {
@Autowired EstacioService estacioService;
@Autowired CacheManager cacheManager;
@MockitoBean EstacioRepositori estacioRepositori; // el collaborador, simulat
@BeforeEach
void netejar() { // una cache es estat compartit: cal aillar les proves
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
}
@Test
void laSegonaLecturaNoConsultaLaBaseDeDades() {
given(estacioRepositori.findById(1L)).willReturn(Optional.of(unaEstacio()));
estacioService.cercar(1L);
estacioService.cercar(1L);
verify(estacioRepositori, times(1)).findById(1L); // la prova de la cache
assertThat(cacheManager.getCache("estacions").get(1L)).isNotNull();
}
@Test
void actualitzarInvalidaLaEntrada() {
estacioService.cercar(1L);
estacioService.actualitzar(1L, new ActualitzarEstacioRequest("Placa Major II", 26));
assertThat(cacheManager.getCache("estacions").get(1L)).isNull();
}
}El que fa vàlida aquesta prova és verify(..., times(1)): es demostra que el mètode no es va executar, que és la definició operativa de «la memòria cau ha funcionat». I la segona prova cobreix el que de debò es trenca amb el temps, que no és desar sinó invalidar.
Dos advertiments. Buida les memòries cau entre proves —una memòria cau és estat compartit i produeix el pitjor tipus de prova, la que depèn de l'ordre—. I desactiva la memòria cau al perfil test quan faci nosa, amb spring.cache.type: none, que Spring reconeix i que converteix les anotacions en no-operacions sense tocar el codi.
Errors Comuns i Consells
Oblidar @EnableCaching. Tot compila, res no es desa a la memòria cau, cap advertiment. Comprova-ho amb la prova d'integració de l'apartat 17.
Posar memòria cau per tapar un N+1, o desar-hi dades en temps real. Primer s'arregla la consulta (09-01): la memòria cau sobre codi dolent compra silenci, no rendiment, i amaga el símptoma que portava a la causa. I les bicicletes disponibles per estació canvien cada segon, així que una dada de fa mig minut envia el ciutadà a una estació buida: posa a la memòria cau el catàleg, mai l'estat.
Autoinvocació. Un mètode amb memòria cau cridat des d'un altre de la mateixa classe no passa pel proxy i mai no encerta. El senyal és una taxa d'encerts propera a zero: per això es mesuren.
Fer servir una entitat JPA com a clau o com a valor. Com a clau, gairebé mai no encerta; com a valor, arrossega col·leccions mandroses que peten en serialitzar a Redis o en llegir-se fora de la sessió. Desa DTOs.
Invalidar dins de la transacció. Obre una finestra en què una altra petició repobla la memòria cau amb el valor antic i l'hi deixa durant tot el TTL. S'invalida a AFTER_COMMIT.
Fer servir ConcurrentMapCache en producció. Sense TTL ni mida màxima: dades eternament obsoletes i memòria que només puja. Si al log d'arrencada apareix ConcurrentMapCacheManager, falta una dependència.
Memòria cau local amb diverses instàncies, sense adonar-se'n. Funciona en local i produeix en producció dades que parpellegen segons la instància que atengui. Decideix TTL curt, Redis o invalidació difosa, però decideix.
Consell: posa sempre un TTL, encara que invalidis explícitament, com a xarxa de seguretat per al dia en què algú afegeixi un camí d'escriptura que no invalida. Anomena les memòries cau amb constants (public static final String CACHE_ESTACIONS = "estacions";) i fes-les servir a anotacions i invalidadors, perquè una cadena mal escrita crea una memòria cau nova i buida en silenci. I revisa la taxa d'encerts un mes després de desplegar: una memòria cau per sota del 20 % s'ha d'esborrar, ja que costa memòria, complexitat i risc de dades obsoletes a canvi de res.
Exercicis
Exercici 1: decidir què posar a la memòria cau
L'ajuntament demana accelerar quatre endpoints: (a) GET /api/v1/tarifes, el catàleg de les tres tarifes, 400 peticions/minut, canvia una vegada l'any; (b) GET /api/v1/estacions/{id}/disponibilitat, nombre de bicicletes lliures, 6.000 peticions/minut, canvia cada segon; (c) GET /api/v1/usuaris/{id}, fitxa de l'usuari amb el seu tipus de tarifa i si està actiu, 2.000 peticions/minut, canvia quan el ciutadà edita el seu perfil o l'ajuntament el desactiva; (d) GET /api/v1/lloguers/{id}/import, l'import calculat d'un lloguer concret, 900 peticions/minut. Decideix per a cadascun si es posa a la memòria cau, amb quin proveïdor, quin TTL i quina estratègia d'invalidació, i justifica els «no».
Exercici 2: trobar els tres errors
Aquest codi no funciona com el seu autor es pensa. Troba els tres defectes, explica el símptoma de cadascun i escriu la versió corregida.
@Service
public class TarifaService {
@Cacheable("tarifes")
public List<TarifaResponse> llistar() { return tarifaRepositori.findAll().stream()... }
public TarifaResponse cercarPerTipus(TipusTarifa tipus) {
return llistar().stream().filter(t -> t.tipus() == tipus).findFirst().orElse(null);
}
@Transactional
@CacheEvict(cacheNames = "tarifes", key = "#tarifa.tipus")
public void actualitzar(Tarifa tarifa) { tarifaRepositori.save(tarifa); }
}Exercici 3: la memòria cau que parpelleja
CicloUrbana s'ha escalat a tres instàncies a Kubernetes (08-04) mantenint Caffeine amb expireAfterWrite=30m. L'ajuntament reanomena «Estació Nord» a «Estació Nord - Intercanviador» i truca dient que l'app «unes vegades mostra el nom nou i altres el vell». Explica exactament què està passant, proposa tres solucions amb les seves contrapartides, tria'n una i escriu la configuració i el codi necessaris.
Solucions
Solució 1.
(a) Tarifes: sí, cas ideal. Compleix les tres condicions de sobres —molt llegit, gairebé mai escrit, i una tarifa antiga durant una hora no causa dany perquè el canvi de tarifes s'anuncia amb antelació—. Caffeine, maximumSize=50, expireAfterWrite=1h, més @CacheEvict(allEntries = true) a l'AFTER_COMMIT de l'actualització. Amb només tres tarifes i una divergència irrellevant entre instàncies, Redis no hi aporta res.
(b) Disponibilitat: no. Falla la condició decisiva, la tolerància a dades velles: el propòsit de l'endpoint és saber si ara mateix hi ha una bicicleta, i una dada de fa 30 segons envia el ciutadà a una estació buida —la pitjor fallada possible en producte, perquè no sembla un error, sembla un servei que menteix—. La resposta correcta és fer la consulta barata: l'índex compost (estacio_id, estat) de 09-01 la deixa en menys d'1 ms. Si tot i així no n'hi hagués prou, la solució és un comptador desnormalitzat bicicletes_disponibles a la taula estacions, actualitzat transaccionalment en iniciar i finalitzar un lloguer: això és precàlcul dins de la transacció, no memòria cau, i per tant sempre coherent. Una excepció admissible seria un TTL de 2-3 segons, que esmorteeix ràfegues sense desactualització perceptible; és una decisió de negoci i cal plantejar-la com a tal.
(c) Usuari: sí, amb compte, i és el cas interessant. Molt llegit i poc escrit, però la desactivació té implicacions de seguretat: si l'ajuntament bloqueja un ciutadà morós i la memòria cau continua dient que està actiu, continuarà llogant bicicletes. Per això: Redis —s'ha de veure igual a les tres instàncies—, entryTtl=5m com a xarxa de seguretat i invalidació explícita a AFTER_COMMIT als tres camins d'escriptura: editar perfil, canviar tipus de tarifa i activar/desactivar. I una regla que convé gravar-se: la comprovació d'autorització sensible no es recolza només en la memòria cau; el JWT de 05-04 ja porta els rols i la seva caducitat curta és la defensa real.
(d) Import: no. Falla la primera condició: encara que hi hagi 900 peticions per minut, cadascuna és d'un lloguer diferent. No hi ha repetició de clau i la taxa d'encerts seria gairebé zero: una memòria cau que només costa memòria i invalidació. El que sí que es pot desar és el catàleg de tarifes que el càlcul consulta, que és el cas (a). Distinció general: es desen els paràmetres del càlcul, no el seu resultat individual.
Solució 2.
Defecte 1 — autoinvocació (apartat 7). cercarPerTipus crida this.llistar(), que no passa pel proxy, així que el 100 % de les crides per tipus van a la base de dades. Símptoma: la memòria cau existeix, cache.puts és baix, i la taxa d'encerts és ridícula comparada amb el trànsit. Encara pitjor, la fallada és intermitent en aparença: si algú crida abans llistar() des d'un controlador, aquesta sí que encerta.
Defecte 2 — la clau d'invalidació no existeix. llistar() no té arguments, per tant la seva clau és SimpleKey.EMPTY; @CacheEvict(key = "#tarifa.tipus") esborra una clau (ESTUDIANT) que mai no s'ha escrit. Símptoma: actualitzar una tarifa no té cap efecte visible durant tot el TTL; l'evict s'executa, no falla i no esborra res, que és la pitjor combinació possible.
Defecte 3 — invalidació dins de la transacció (apartat 12). Encara que la clau fos correcta, @CacheEvict pot executar-se abans del commit: una altra petició concurrent repobla amb el valor antic. I si el save acaba en rollback, s'haurà invalidat per un canvi inexistent.
Versió corregida:
@Service
public class TarifaService {
public static final String CACHE_TARIFES = "tarifes";
@Cacheable(cacheNames = CACHE_TARIFES, key = "'totes'") // clau explicita i estable
public List<TarifaResponse> llistar() { ... }
@Cacheable(cacheNames = CACHE_TARIFES, key = "#tipus.name()") // passa pel proxy
public TarifaResponse cercarPerTipus(TipusTarifa tipus) {
return tarifaRepositori.findByTipus(tipus).map(mapejador::aResposta).orElseThrow(...);
}
@Transactional
public void actualitzar(Tarifa tarifa) {
tarifaRepositori.save(tarifa);
publicador.publishEvent(new TarifaModificada(tarifa.getTipus()));
}
}
@Component
class InvalidadorCacheTarifes {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alModificarTarifa(TarifaModificada esdeveniment) {
Cache cache = cacheManager.getCache(TarifaService.CACHE_TARIFES);
if (cache != null) { cache.evict(esdeveniment.tipus().name()); cache.evict("totes"); }
}
}Els quatre canvis: cercarPerTipus consulta el repositori en lloc de reutilitzar llistar(), així que el seu @Cacheable sí que actua; les dues claus són explícites; la invalidació passa a AFTER_COMMIT; i s'esborren les dues entrades, perquè canviar una tarifa invalida també el llistat agregat —el detall que més s'oblida quan conviuen una memòria cau per element i una altra per col·lecció—.
Solució 3.
Què està passant. Caffeine viu dins de cada JVM. La petició PUT /api/v1/estacions/2 va arribar a una sola instància —la que el balancejador va triar—, que va actualitzar PostgreSQL i va invalidar la seva còpia. Les altres dues instàncies no se n'han assabentat de res i continuen servint «Estació Nord» durant els 30 minuts de l'expireAfterWrite. Com que el balancejador reparteix les peticions, el ciutadà veu el nom nou aproximadament un terç de les vegades. És irreproduïble en local, on només hi ha una instància, i per això la memòria cau local «deixa de ser innocent» en escalar.
Tres solucions i les seves contrapartides. (1) TTL curt: baixar expireAfterWrite a 60 s. Cost zero, i la incoherència dura com a molt un minut, però la taxa d'encerts cau i el problema no desapareix, només s'escurça. (2) Redis: una única còpia compartida, invalidació global i instantània, i la memòria cau sobreviu als reinicis del desplegament progressiu; a canvi, un servei més per operar, una dependència de xarxa a cada lectura i la serialització de l'apartat 10. (3) Caffeine amb invalidació difosa per pub/sub: latència de nanosegons i coherència gairebé immediata, però és l'opció amb més peces mòbils i cal resoldre què passa quan una instància es perd un missatge.
Elecció: Redis. El catàleg d'estacions el consulten totes les instàncies, té desenes d'entrades —volum ridícul per a Redis— i la coherència visible al ciutadà és un requisit de l'ajuntament. A més Redis ja entrarà per a la memòria cau d'usuaris de l'exercici 1, així que el cost operatiu s'amortitza.
@Bean
RedisCacheManagerBuilderCustomizer configuracioPerCache() {
return builder -> builder
.withCacheConfiguration("estacions", base().entryTtl(Duration.ofMinutes(10)))
.withCacheConfiguration("usuaris", base().entryTtl(Duration.ofMinutes(5)));
}
@Component
class InvalidadorCacheEstacions {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alModificar(EstacioModificada esdeveniment) {
cacheManager.getCache("estacions").evict(esdeveniment.id()); // esborra per a les tres
}
}Amb l'evict a AFTER_COMMIT i una sola còpia a Redis, la invalidació és global: la instància que atén la modificació esborra l'entrada per a totes, i la lectura següent de qualsevol de les tres repobla amb el nom nou. Es manté el TTL de 10 minuts com a xarxa de seguretat (apartat 12) i cal decidir el comportament davant d'una caiguda de Redis: amb timeout: 500ms i un CacheErrorHandler que registri la fallada en lloc de propagar-la, l'aplicació degrada a consultar la base de dades, que és lent però correcte. El contrari —que un Redis caigut enderroqui l'endpoint d'estacions— seria canviar un problema de coherència per un de disponibilitat.
Conclusió
CicloUrbana té ara la seva segona palanca de rendiment, i la té amb l'ordre correcte: primer s'arregla la consulta i només després es posa memòria cau, perquè una memòria cau sobre codi dolent compra silenci i amaga la causa. Saps decidir què mereix memòria cau amb les tres condicions —molt llegit, poc escrit, tolerant a estar lleugerament desactualitzat— i per què a Ribalta això significa desar el catàleg d'estacions i les tarifes, però mai la disponibilitat en temps real ni el lloguer en curs: es desa el catàleg, mai l'estat.
Domines l'abstracció de Spring —@EnableCaching, CacheManager, Cache i el CacheInterceptor— i per què el teu codi no esmenta mai Caffeine ni Redis. Coneixes les cinc anotacions amb els seus atributs, la diferència real entre @Cacheable i @CachePut, i què fan allEntries i beforeInvocation. Saps que les claus són la font més gran d'errors silenciosos —el mètode sense arguments, l'entitat com a clau, el Pageable que genera centenars d'entrades— i les declares amb SpEL o amb un KeyGenerator propi. Distingeixes condition d'unless pel moment en què s'avaluen, i tens identificat l'error de desar un null o un Optional.empty() sense haver-ho decidit. I has vist per tercera vegada el parany del proxy, amb la seva signatura inconfusible: una taxa d'encerts propera a zero.
Del costat de la infraestructura, saps per què ConcurrentMapCache no val en producció, configures Caffeine amb maximumSize, expireAfterWrite i recordStats afinant memòria cau a memòria cau, i muntes Redis amb TTL per memòria cau, serialització JSON i les tres cares del problema dels tipus —JavaTimeModule, ISO-8601 i el tipatge restringit per paquet—, amb el seu contenidor al docker-compose.yml de 07-04 i la seva prova amb Testcontainers. Tens la taula de local davant de distribuïda i, sobretot, la raó per la qual en escalar a tres instàncies una memòria cau local deixa de ser innocent. I has treballat la part difícil: invalidar a AFTER_COMMIT i mai dins de la transacció, TTL com a xarxa de seguretat al costat de la invalidació explícita, les mitigacions de l'estampida —sync, refreshAfterWrite, jitter i precalentament—, la comparació amb la memòria cau de segon nivell d'Hibernate i per què preferim l'explícita, i la memòria cau HTTP amb ETag i Cache-Control que no costa res.
Queda un cap solt que ha aparegut a gairebé tots els apartats: mesurar. La taxa d'encerts que distingeix una memòria cau útil d'una d'inútil, cache.evictions que delata una mida mal triada, i abans d'això la latència p95 de 09-01, les pauses de GC, les connexions en espera del pool. Tots aquests números ja existeixen dins del procés, publicats per l'Actuator que vam muntar a 07-01, i fins ara els hem consultat d'oïda. La lliçó següent, Monitoratge amb Spring Boot Actuator, els converteix en instrumentació de debò: Micrometer com a façana de mètriques, els tipus de mesurador, les mètriques que ja existeixen sense escriure codi, la regla d'or de la cardinalitat de les etiquetes, i les mètriques de negoci de la xarxa de Ribalta —lloguers iniciats per tarifa, bicicletes disponibles per estació, durada dels trajectes— amb percentils que es poden agregar entre instàncies.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
