CicloUrbana ja és observable i sap en quin entorn viu, però continua sent purament reactiva: només fa alguna cosa quan algú l'hi demana. Ningú no tanca de matinada els lloguers que un ciutadà de Ribalta va oblidar de finalitzar, ningú no recalcula l'ocupació de les quatre estacions per a l'aplicació mòbil, i el correu de confirmació s'envia al mateix fil que atén la petició, obligant el ciutadà a esperar que el servidor SMTP contesti.
Aquesta lliçó li dona iniciativa pròpia. Són dues capacitats diferents que convé no confondre: programar feina perquè passi en moments determinats, i executar feina fora del fil que atén la petició. Veurem totes dues amb els seus paranys reals —el planificador d'un sol fil, la tasca que s'executa quatre vegades quan escales a quatre instàncies, la transacció que no viatja a l'altre fil, el context de seguretat que es queda enrere— i acabarem amb els fils virtuals de Java 21 i l'aturada ordenada.
| Tasques programades | Execució asíncrona | |
|---|---|---|
| Anotacions | @EnableScheduling + @Scheduled |
@EnableAsync + @Async |
| Qui dispara la feina | Un rellotge intern | Una crida des d'un altre fil |
| Pregunta que respon | Quan ha de passar això? | Qui ha d'esperar que passi? |
| Exemple a CicloUrbana | Caducar lloguers cada 10 minuts | Enviar el correu de confirmació |
| Executor per defecte | ThreadPoolTaskScheduler d'1 fil |
SimpleAsyncTaskExecutor, sense pool |
Contingut
@EnableSchedulingi els tres modes de@Scheduled- Expressions cron i zones horàries
- Les tasques periòdiques de CicloUrbana
- El planificador d'un sol fil
- Tasques programades amb diverses instàncies: ShedLock
- Provar tasques programades i
/actuator/scheduledtasks @EnableAsynci@Async- L'
Executor: per què el predeterminat és perillós - Asincronia i transaccions
- Propagar el context entre fils
- Excepcions en mètodes asíncrons
- Fils virtuals de Java 21
- Aturada ordenada
- Errors Comuns i Consells
- Exercicis
@EnableScheduling i els tres modes de @Scheduled
@EnableScheduling i els tres modes de @ScheduledLa capacitat s'activa amb una anotació en qualsevol classe de configuració:
A partir d'aquí, qualsevol mètode public void sense arguments anotat amb @Scheduled en un bean es registra al planificador. Els tres modes:
| Mode | Quan dispara l'execució següent | Si una execució triga més que l'interval |
|---|---|---|
fixedRate |
Cada N ms des de l'inici de l'anterior | S'acumula retard; la següent arrenca tan bon punt acaba l'actual |
fixedDelay |
N ms després del final de l'anterior | No se solapa mai; el ritme real es degrada |
cron |
Segons l'expressió, en instants absoluts | Se salta les ocurrències perdudes |
La diferència entre els dos primers no és teòrica. Amb fixedRate = 60_000 i una tasca que triga 90 segons, el planificador la vol executar cada minut i no pot: amb un sol fil, les execucions s'encadenen sense pausa; amb diversos fils, se solapen, i dues còpies del recalculador d'ocupació escrivint alhora són una condició de cursa. Amb fixedDelay = 60_000 hi ha sempre un minut de descans entre el final d'una i l'inici de la següent, i no hi ha mai solapament.
La regla pràctica: fixedDelay per a feina la durada de la qual és variable o desconeguda —que és gairebé tot allò que toca una base de dades—, fixedRate només quan la cadència importa més que el solapament i la tasca és ràpida i segura, i cron quan el moment absolut importa (mitjanit, fi de mes, hora punta).
@Scheduled(fixedDelay = 600_000, initialDelay = 60_000) // mil·lisegons
@Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT1M") // ISO-8601
@Scheduled(fixedDelay = 10, initialDelay = 1, timeUnit = TimeUnit.MINUTES)Les tres línies fan el mateix. initialDelay importa més del que sembla: sense ell, totes les tasques arrenquen alhora en el moment en què el context està llest, justament quan l'aplicació està escalfant memòries cau i creant connexions. Esglaonar-les amb retards inicials diferents evita una tempesta de feina en el pitjor moment.
I el més important per al manteniment: les variants ...String admeten substitució de propietats, cosa que converteix la cadència en configuració:
Amb això, l'interval s'ajusta per entorn amb els perfils de 07-02 —cinc minuts en producció, un segon en una prova manual— sense recompilar. És la forma recomanada per a tota tasca de CicloUrbana.
- Expressions cron i zones horàries
El cron d'Spring té sis camps, no cinc: a diferència del cron d'Unix, inclou els segons.
┌─────────── segon (0-59) │ ┌───────── minut (0-59) │ │ ┌─────── hora (0-23) │ │ │ ┌───── dia del mes (1-31) │ │ │ │ ┌─── mes (1-12 o JAN-DEC) │ │ │ │ │ ┌─ dia de la setmana (0-7 o MON-SUN) 0 0 3 * * *
| Expressió | Significat |
|---|---|
0 */5 * * * * |
Cada 5 minuts |
0 0 3 * * * |
Cada dia a les 03:00 |
0 30 2 * * MON-FRI |
De dilluns a divendres a les 02:30 |
0 0 8,14,20 * * * |
A les 8, a les 14 i a les 20 |
0 0 0 1 * * |
El dia 1 de cada mes a mitjanit |
0 0 0 L * * |
L'últim dia del mes (extensió d'Spring) |
0 0 6 * * SAT#2 |
El segon dissabte de cada mes a les 6 |
Existeixen a més macros llegibles —@yearly, @monthly, @weekly, @daily (equival a 0 0 0 * * *) i @hourly— i l'extensió -, que deshabilita una tasca sense esborrar-la; combinada amb una propietat resulta molt pràctica: @Scheduled(cron = "${ciclourbana.informes.cron:-}") deixa l'informe desactivat tret dels entorns que defineixin l'expressió.
Les zones horàries són la font d'errors més silenciosa de tot l'apartat. Sense zone, l'expressió s'interpreta a la zona de la JVM, que en un contenidor sol ser UTC encara que l'equip sigui a Ribalta. Un informe programat a les 0 0 3 * * * es genera aleshores a les 5 del matí local a l'estiu, i ningú no ho nota fins que algú compara xifres. La forma correcta és @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid").
I amb la zona declarada apareix el problema del canvi d'hora:
| Data | Què passa | Efecte sobre 0 30 2 * * * |
|---|---|---|
| Últim diumenge de març | A les 02:00 el rellotge salta a les 03:00 | La tasca no s'executa: aquesta hora no existeix |
| Últim diumenge d'octubre | Les 02:00-03:00 passen dues vegades | La tasca s'executa una sola vegada, però desplaçada |
La defensa no és tècnica sinó de disseny: programa la feina crítica fora de la franja 01:00-03:00 —les 04:15 és una hora perfectament bona— i fes les tasques idempotents, de manera que executar-les dues vegades o cap no corrompi res. Una tasca que suma imports sobre un comptador acumulat és perillosa; una que recalcula el total des de les dades d'origen no ho és.
- Les tasques periòdiques de CicloUrbana
CaducadorLloguers tanca els lloguers que superen la durada màxima configurada a XarxaProperties (02-05). Un ciutadà que deixa la bicicleta i oblida finalitzar bloquejaria la matrícula RB-0142 indefinidament.
package com.ciclourbana.lloguers;
@Component
public class CaducadorLloguers {
private final LloguerService lloguerService;
private final XarxaProperties xarxa;
private final Clock rellotge; // constructor omès
@Scheduled(fixedDelayString = "${ciclourbana.lloguers.caducador.interval:PT10M}",
initialDelayString = "PT1M")
public void executar() {
int tancats = caducarLloguersVencuts();
if (tancats > 0) {
log.info("Caducats {} lloguers que superaven {}", tancats,
xarxa.duradaMaximaLloguer());
}
}
/** Lògica invocable directament des d'una prova, sense esperar el rellotge. */
public int caducarLloguersVencuts() {
Instant limit = Instant.now(rellotge).minus(xarxa.duradaMaximaLloguer());
return lloguerService.tancarPerCaducitat(limit);
}
}Quatre decisions. L'interval és una propietat amb valor per defecte, així que s'ajusta per entorn. La lògica és en un mètode públic a part, que és el que fa la tasca comprovable (apartat 6). S'injecta Clock, seguint la pràctica de 06-02: amb Clock.fixed la prova controla el temps. I es registra al log només quan hi ha feina, perquè una tasca que escriu cada deu minuts «no he fet res» converteix el log en soroll i amaga allò important.
La segona tasca, RecalculadorOcupacio, manté al minut el resum que consumeix l'aplicació mòbil amb @Scheduled(fixedDelayString = "${ciclourbana.estacions.ocupacio.interval:PT1M}") sobre una crida a estacioService.refrescarResumOcupacio(). Aquí fixedDelay torna a ser l'elecció correcta: si un dia la consulta triga 90 segons perquè la base de dades està carregada, l'últim que volem és una segona còpia recalculant a sobre de la primera.
- El planificador d'un sol fil
Aquest és el parany que sorprèn tothom la primera vegada. El TaskScheduler que Spring Boot configura per defecte té exactament un fil. Amb dues tasques, si CaducadorLloguers triga tres minuts perquè la base de dades va lenta, RecalculadorOcupacio no s'executa en aquests tres minuts: espera el seu torn. El símptoma és desconcertant —una tasca que «a vegades no s'executa»— i la causa és en una altra tasca diferent.
La solució bàsica és una propietat:
spring:
task:
scheduling:
pool:
size: 4
thread-name-prefix: tasca-ciclo-
shutdown:
await-termination: true
await-termination-period: 30sI quan cal control fi, un bean propi:
@Bean
TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler planificador = new ThreadPoolTaskScheduler();
planificador.setPoolSize(4);
planificador.setThreadNamePrefix("tasca-ciclo-");
planificador.setWaitForTasksToCompleteOnShutdown(true);
planificador.setAwaitTerminationSeconds(30);
planificador.setErrorHandler(t -> log.error("Fallada en tasca programada", t));
return planificador;
}Dos advertiments sobre la mida del pool. Més fils no sempre és millor: amb fixedRate i diversos fils, una tasca lenta es pot solapar amb ella mateixa, cosa que amb un sol fil era impossible. I el thread-name-prefix no és cosmètic: quan a 09-05 calgui buscar al log quin fil va bloquejar una connexió, un fil anomenat tasca-ciclo-2 diu molt més que pool-3-thread-1.
El setErrorHandler mereix atenció a part: una excepció no capturada en una tasca @Scheduled no atura l'aplicació, però sí que cancel·la les execucions següents d'aquesta tasca quan s'escapa del planificador. El resultat és una tasca que deixa d'executar-se en silenci. Un gestor d'errors explícit, o un try/catch dins del mètode, evita aquest final.
- Tasques programades amb diverses instàncies: ShedLock
Quan CicloUrbana passi a tres rèpliques a 08-04, cadascuna tindrà el seu propi planificador i cada tasca s'executarà tres vegades. Per a RecalculadorOcupacio això és feina malbaratada; per a una tasca que enviï un correu de resum als ciutadans, són tres correus; per a una que cobri recàrrecs, tres cobraments.
| Solució | Com funciona | Quan triar-la |
|---|---|---|
Un perfil planificador en una sola instància |
Només aquesta rèplica activa @EnableScheduling |
Senzill, però aquesta instància és un punt únic de fallada |
| Bloqueig en base de dades (ShedLock) | La primera que pren la fila executa; la resta se la salta | L'opció per defecte: sense infraestructura nova |
| Planificador extern | Un CronJob de Kubernetes crida un endpoint |
Feina pesada o que ha de sobreviure al cicle de l'aplicació |
graph LR
B1[Instància 1<br/>pren el bloqueig] -->|executa| BD[(taula shedlock<br/>a PostgreSQL)]
B2[Instància 2] -.->|no l'aconsegueix:<br/>se la salta| BD
B3[Instància 3] -.->|no l'aconsegueix:<br/>se la salta| BD
ShedLock es recolza en la PostgreSQL que ja tenim: dues dependències (shedlock-spring i shedlock-provider-jdbc-template, versió 5.16.0) i una taula creada amb una migració de Flyway, coherent amb 04-08:
-- V7__shedlock.sql
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class ConfiguracioTasques {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // fa servir l'hora del servidor de BD, no la de la JVM
.build());
}
}@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "informeDiariRibalta",
lockAtLeastFor = "PT5M", lockAtMostFor = "PT25M")
public void generarInformeDiari() { /* ... */ }Els tres paràmetres que cal entendre. lockAtMostFor és la xarxa de seguretat: si la instància que va prendre el bloqueig mor sense alliberar-lo, aquest caduca sol passat aquest temps, així que ha de ser més gran que la durada màxima raonable de la tasca o dues instàncies acabaran executant-la alhora. lockAtLeastFor manté el bloqueig un mínim encara que la tasca acabi abans, cosa que protegeix del cas en què els rellotges de dues instàncies estan lleugerament desfasats i la segona dispara la tasca dos segons després. I usingDbTime() fa que la referència temporal sigui el rellotge de PostgreSQL, eliminant d'arrel aquest desfasament.
Últim advertiment: ShedLock no és un mecanisme d'exclusió mútua per al negoci. Garanteix que la tasca no es dispari dues vegades des del planificador, no que dues rutes diferents del codi no toquin les mateixes dades. Per a això continuen existint les transaccions i els bloqueigs de 04-07.
- Provar tasques programades i
/actuator/scheduledtasks
/actuator/scheduledtasksProvar una tasca esperant que el rellotge la dispari és lent i fràgil. El patró correcte ja està aplicat a CaducadorLloguers: l'anotació queda en un mètode prim que només delega, i tota la lògica viu en un mètode públic que la prova invoca directament.
class CaducadorLloguersTest {
private final Clock rellotge = Clock.fixed(
Instant.parse("2026-09-01T23:00:00Z"), ZoneOffset.UTC);
@Test
void tancaElsLloguersQueSuperenLaDuradaMaxima() {
LloguerService servei = mock(LloguerService.class);
given(servei.tancarPerCaducitat(any())).willReturn(3);
int tancats = new CaducadorLloguers(servei, xarxaProperties(), rellotge)
.caducarLloguersVencuts();
assertThat(tancats).isEqualTo(3);
verify(servei).tancarPerCaducitat(Instant.parse("2026-09-01T21:00:00Z"));
}
}L'assert sobre l'Instant exacte és el que dona valor a la prova: comprova que el límit es calcula restant la durada màxima, que és la regla de negoci real. I no triga deu minuts a executar-se.
Per verificar que la tasca està registrada amb la cadència esperada hi ha dues vies. En proves, @SpringBootTest amb @MockitoBean sobre el servei i Awaitility esperant que s'invoqui, amb l'interval abaixat a PT0.1S per propietat. I en execució, l'endpoint de 07-01:
curl -s -u admin:*** http://localhost:8081/actuator/scheduledtasks | jq '.fixedDelay'
# [ { "runnable": { "target": "...CaducadorLloguers.executar" },
# "initialDelay": 60000, "interval": 600000 } ]És la forma més ràpida de respondre a «per què no s'ha executat l'informe?»: si la tasca no apareix en aquesta llista, no està registrada, i la causa sol ser un @EnableScheduling absent, un perfil que no va activar el bean o un mètode no public.
@EnableAsync i @Async
@EnableAsync i @AsyncLa segona meitat de la lliçó canvia de pregunta: ja no és quan passa la feina, sinó qui espera que passi.
Un mètode @Async retorna el control immediatament i el seu cos s'executa en un altre fil. Els tipus de retorn admesos:
| Retorn | Semàntica | Ús |
|---|---|---|
void |
Dispara i oblida; el cridant no sap si va acabar ni si va fallar | Notificacions, auditoria, enviament de correu |
CompletableFuture<T> |
El cridant pot compondre, esperar i capturar errors | Crides en paral·lel que cal combinar |
Future<T> |
Igual però amb l'API antiga, sense composició | Codi heretat |
@Async
public CompletableFuture<ResumEstacio> resum(Long estacioId) {
return CompletableFuture.completedFuture(estacioService.resum(estacioId));
}Fixa't que el mètode retorna un futur ja completat: no el completa ell, ho fa el proxy. És contraintuïtiu però correcte; el valor s'embolcalla en acabar el mètode, que ja s'està executant al fil de l'executor.
I aquí tornen exactament els mateixos paranys de proxy que a @Transactional (04-07), per la mateixa raó: @Async s'implementa amb un proxy que embolcalla el bean.
| Parany | Què passa | Solució |
|---|---|---|
| Autoinvocació | this.enviarCorreu() no passa pel proxy: s'executa síncron, sense cap avís |
Moure el mètode asíncron a un altre bean i injectar-lo |
Mètode private, final o static |
No és interceptable; s'executa síncron | Ha de ser public i no final |
Crida des del constructor o @PostConstruct |
El proxy encara no està muntat | Fer servir ApplicationReadyEvent (01-05) |
| Retornar un tipus qualsevol | El cridant rep null, perquè el valor real es calcula després |
Només void, Future o CompletableFuture |
La primera és, de bon tros, la més freqüent i la més difícil de detectar: el codi funciona, les proves passen i l'única cosa que passa és que l'asincronia no existeix. El símptoma en producció és una latència que no baixa per molts fils que s'hi afegeixin.
- L'
Executor: per què el predeterminat és perillós
Executor: per què el predeterminat és perillósSense configuració explícita, Spring Boot fa servir l'applicationTaskExecutor que crea l'autoconfiguració, un ThreadPoolTaskExecutor amb valors per defecte generosos. Però si aquest bean no existeix —algú defineix un Executor propi mal registrat, o es treballa sense Boot— el suport és SimpleAsyncTaskExecutor, i això és perillós: crea un fil nou per invocació i no els reutilitza. Sota càrrega, mil peticions per minut són mil fils, cadascun amb la seva pila d'un megabyte, i la JVM es queda sense memòria. No hi ha cua, no hi ha límit i no hi ha contrapressió.
spring:
task:
execution:
pool: { core-size: 8, max-size: 24, queue-capacity: 200, keep-alive: 60s }
thread-name-prefix: async-ciclo-
shutdown: { await-termination: true, await-termination-period: 30s }O com a bean, quan cal política de rebuig o decoradors:
@Bean("executorCorreu")
Executor executorCorreu() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("correu-ciclo-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}Es tria per nom amb @Async("executorCorreu"). Tenir un executor per tipus de feina és una forma de mampara (07-06): si el servidor de correu s'encalla, els seus fils s'exhaureixen sense arrossegar la resta de l'aplicació.
El comportament del pool no és intuïtiu i convé memoritzar-lo, perquè explica el noranta per cent de les sorpreses: mentre hi hagi menys fils que core-size, cada tasca en crea un de nou; assolit core-size, les tasques van a la cua; només quan la cua està plena es creen fils fins a max-size; i exhaurits tots dos, actua la política de rebuig. El corol·lari incòmode: una queue-capacity gran fa que max-size gairebé no es faci servir mai. Amb core-size: 8 i queue-capacity: 10000, el pool no passarà mai de vuit fils per molta càrrega que arribi; es limitarà a acumular deu mil tasques pendents. Si el que es vol és que creixi sota pressió, la cua ha de ser petita.
| Política de rebuig | Què fa | Efecte |
|---|---|---|
CallerRunsPolicy |
L'executa el fil que ha cridat | Contrapressió: qui produeix feina es frena sol |
AbortPolicy (per defecte) |
Llança RejectedExecutionException |
Falla sorollosament; cal capturar-la |
DiscardPolicy |
Descarta en silenci | Gairebé mai acceptable: perd feina sense avisar |
DiscardOldestPolicy |
Descarta la més antiga de la cua | Només allà on el més recent importa més |
Per al correu de CicloUrbana, CallerRunsPolicy és l'elecció raonable: si el sistema se satura, el correu s'envia al fil de la petició —el ciutadà espera una mica— en lloc de perdre's.
- Asincronia i transaccions
Aquí hi ha l'error conceptual més car del mòdul. @Async i @Transactional junts gairebé mai no fan el que un espera, perquè la transacció d'Spring viu en un ThreadLocal i no viatja al fil de l'executor. Amb les dues anotacions al mateix mètode, el fil cridant llança la tasca i continua, i el fil de l'executor obre una transacció completament nova: no veu les escriptures encara no confirmades del cridant i confirma o desfà pel seu compte. Si el cridant fa rollback, la feina asíncrona ja s'ha executat i confirmat igualment. I si el mètode asíncron rep una entitat gestionada com a argument, aquesta entitat pertany a un EntityManager d'un altre fil: LazyInitializationException garantida.
Les tres regles que resolen el problema:
- Passa identificadors, mai entitats gestionades. El mètode asíncron torna a carregar el que necessiti a la seva pròpia transacció.
- Dispara la feina asíncrona després del commit, no dins de la transacció.
- Que la transacció del mètode asíncron sigui seva, oberta dins del seu fil.
La combinació que aplica CicloUrbana uneix això amb @TransactionalEventListener de 04-07:
@Component
public class NotificadorLloguer {
private final ServeiCorreu correu; // constructor omès
@Async("executorCorreu")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alConfirmar(LloguerIniciat esdeveniment) {
correu.enviarConfirmacio(esdeveniment.lloguerId(), esdeveniment.correuCiutada());
}
}sequenceDiagram
participant C as Ciutadà
participant H as Fil HTTP
participant BD as PostgreSQL
participant E as executorCorreu
C->>H: POST /api/v1/lloguers
H->>BD: INSERT lloguer + COMMIT
H-->>C: 201 Created (42 ms)
Note over H,E: AFTER_COMMIT + @Async
H->>E: publica la tasca i deixa anar el fil
E->>E: envia el correu per SMTP (1,8 s)
Per què és la combinació correcta. AFTER_COMMIT garanteix que no s'anuncia un lloguer que després es desfà, el problema que ja vam identificar a 04-07. @Async garanteix que el ciutadà no espera el servidor SMTP: la resposta surt en desenes de mil·lisegons. I l'esdeveniment transporta dades planes —l'identificador i el correu—, no l'entitat Lloguer, evitant el problema de la sessió tancada.
Queda un matís honest: amb aquest esquema, si l'enviament falla, la resposta ja s'ha donat com a correcta. És una decisió deliberada —el lloguer és vàlid encara que el correu no arribi— i la contrapartida és que la fallada ha de quedar registrada i vigilada. Un @Async amb void no té reintents ni durabilitat: si el procés mor amb tasques a la cua de l'executor, aquestes tasques es perden. Quan aquesta pèrdua no és acceptable, la resposta és una cua de missatges real, que veurem a 07-05 i 07-06.
- Propagar el context entre fils
Dues peces de CicloUrbana viuen en ThreadLocal i no creuen soles al fil de l'executor: el SecurityContextHolder de 05-01 i el MDC del FiltreRastreig de 03-06. D'aquí surten dos símptomes clàssics: un @PreAuthorize que falla dins d'un mètode asíncron perquè no hi ha autenticació, i unes línies de log de la feina en segon pla que surten amb [sense-rastre] i no es poden relacionar amb la petició que les va originar.
Per a la seguretat, Spring Security porta un decorador d'executors: n'hi ha prou d'embolcallar el ThreadPoolTaskExecutor ja configurat en un DelegatingSecurityContextAsyncTaskExecutor abans de retornar-lo com a bean. Existeix també SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL), que fa que els fils fills heretin el context, però és una opció amb riscos: només actua en crear el fil, així que en un pool que reutilitza fils el context heretat és el de la primera feina que va crear aquest fil, no el de l'actual. Amb pools és pitjor que no fer res; només serveix per a fils creats a mà.
Per al MDC, un TaskDecorator propi copia el mapa de diagnòstic:
public class DecoradorMdc implements TaskDecorator {
@Override
public Runnable decorate(Runnable tasca) {
Map<String, String> context = MDC.getCopyOfContextMap(); // fil cridant
return () -> {
Map<String, String> previ = MDC.getCopyOfContextMap();
try {
if (context != null) MDC.setContextMap(context);
tasca.run();
} finally {
if (previ != null) MDC.setContextMap(previ); else MDC.clear();
}
};
}
}Es registra amb executor.setTaskDecorator(new DecoradorMdc()). La clau és en les dues meitats: la còpia es fa en decorar, és a dir, al fil que envia la tasca, i la restauració al finally és imprescindible per la mateixa raó que el MDC.remove() de 03-06 —sense ella, l'identificador de rastre es queda enganxat al fil del pool i contamina les tasques següents—.
Amb les dues peces, una línia de log de l'enviament de correu porta el mateix rastreId que la petició POST /api/v1/lloguers que la va originar, que és exactament el que caldrà per a la traçabilitat distribuïda de 09-06.
- Excepcions en mètodes asíncrons
Un mètode @Async que retorna CompletableFuture lliura la seva excepció al cridant quan aquest fa join() o get(). Però un que retorna void no té a qui lliurar-la: per defecte l'excepció es registra i es perd. Per tractar-la de manera centralitzada:
@Configuration
@EnableAsync
public class ConfiguracioAsincronia implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, metode, params) -> log.error(
"Fallada en tasca asíncrona {} amb arguments {}",
metode.getName(), Arrays.toString(params), ex);
}
}AsyncConfigurer permet a més retornar l'Executor per defecte a getAsyncExecutor(). El gestor només s'aplica als mètodes que retornen void; per als que retornen futurs, la responsabilitat és del cridant, amb handle o exceptionally. La conseqüència pràctica és una regla: si el resultat importa, retorna un futur; si no importa, assumeix que la fallada només arribarà al log i assegura't que aquest log es vigila.
- Fils virtuals de Java 21
Java 21 introdueix els fils virtuals: fils gestionats per la JVM, no pel sistema operatiu, tan barats que se'n poden crear milions. Spring Boot 3.2+ els adopta amb una sola propietat:
Amb ella, el servidor web atén cada petició en un fil virtual, i els executors d'@Async i del planificador passen a crear fils virtuals per tasca. El canvi de model és profund: ja no cal dimensionar pools per a càrregues bloquejants, perquè bloquejar un fil virtual no consumeix un fil del sistema operatiu.
| Tipus de càrrega | Convenen? | Motiu |
|---|---|---|
| E/S bloquejant: JDBC, HTTP, SMTP | Sí, és el seu cas ideal | Milers de tasques esperant sense exhaurir fils del sistema |
| Càlcul intensiu | No aporten res | El límit són els nuclis, no els fils |
| Aplicacions ja reactives (WebFlux) | Innecessaris | Ja resolen el problema per una altra via |
I tres advertiments que cal conèixer abans d'activar-los a Ribalta. El primer, el pinning: un fil virtual bloquejat dins d'un bloc synchronized fixa el fil portador i anul·la l'avantatge; cal revisar el codi propi i les biblioteques antigues i substituir synchronized per ReentrantLock allà on hi hagi bloqueig. El segon: el pool de connexions continua sent el límit real. Un milió de fils virtuals demanant connexions a un HikariCP de vint no accelera res; simplement mou la cua de lloc, i fa que el coll d'ampolla sigui menys visible. El tercer: el ThreadLocal continua funcionant, però amb un fil per tasca les memòries cau basades en ThreadLocal deixen de tenir sentit i es converteixen en fuites de memòria.
La recomanació per a CicloUrbana: activar-los a dev i pre, mesurar amb les mètriques de 09-03, i promocionar-los a prod quan el comportament estigui confirmat. És un canvi d'una línia, i precisament per això convé tractar-lo pel que és: un canvi de model d'execució.
- Aturada ordenada
A 01-05 vam veure l'aturada ordenada del servidor web: deixa d'acceptar peticions noves i espera que acabin les que estan en vol. Els executors necessiten la seva pròpia configuració equivalent, o la feina en cua es descarta en aturar el procés:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 40s
task:
execution:
shutdown:
await-termination: true
await-termination-period: 30s
scheduling:
shutdown:
await-termination: true
await-termination-period: 30sLa regla de coherència: timeout-per-shutdown-phase ha de ser més gran que els await-termination-period, o el context es tancarà mentre els executors encara esperen. I tot plegat ha de cabre dins del termini de gràcia que doni l'orquestrador abans del SIGKILL (30 segons per defecte a Kubernetes, 08-04).
Tot i així, una aturada ordenada no converteix una tasca en cua en una tasca garantida: si el procés mor de manera abrupta, es perd. Aquesta és, un cop més, la frontera entre @Async i una cua de missatges real.
Errors Comuns i Consells
Autoinvocar un mètode @Async o @Scheduled. El proxy no intervé i el mètode s'executa síncron, sense cap error. És la fallada més freqüent d'aquesta lliçó.
Oblidar que el planificador té un sol fil. Una tasca lenta bloqueja totes les altres, i el símptoma apareix a la tasca equivocada.
Fer servir fixedRate amb feina de durada variable. Amb diversos fils la tasca se solapa amb ella mateixa; amb un s'acumula el retard. fixedDelay per defecte.
Programar tasques crítiques entre la 1 i les 3 de la matinada. El canvi d'hora farà que un dia no s'executin i un altre s'executin desplaçades.
Escalar a diverses instàncies sense coordinar les tasques. Tres rèpliques són tres execucions: ShedLock, un perfil dedicat o un planificador extern.
Combinar @Async i @Transactional al mateix mètode. La transacció no viatja a l'altre fil: se n'obre una de nova i independent, i les entitats gestionades no creuen.
Comptar que el SecurityContext o el MDC siguin al fil asíncron. No hi són: calen DelegatingSecurityContextAsyncTaskExecutor i un TaskDecorator.
Configurar una cua enorme creient que augmenta el paral·lelisme. Amb una cua gran, el pool no creix mai més enllà de core-size.
Consell: extreu la lògica de tota tasca programada a un mètode públic invocable, que és el que la fa comprovable en mil·lisegons en lloc d'en minuts, i fes les tasques idempotents: amb reintents, canvis d'hora i diverses instàncies, executar dues vegades és una possibilitat real.
Consell: posa prefix de nom a tots els teus fils. correu-ciclo-3 en un bolcat de fils val per mitja hora d'investigació.
Consell: fes configurable cada cadència amb fixedDelayString = "${...}". Poder abaixar un interval a un segon a dev i desactivar una tasca amb - en un entorn costa zero.
Exercicis
Exercici 1: tasca nocturna de manteniment de la flota
Escriu RevisorFlota, una tasca que cada dia a les 04:15 hora de Ribalta marqui com a MANTENIMENT les bicicletes el nivell de bateria de les quals estigui per sota del llindarBateria de XarxaProperties o que portin més de 300 lloguers des de la seva última revisió. Ha de ser configurable, comprovable sense esperar el rellotge, segura amb tres instàncies en execució i no ha de deixar de funcionar si una execució falla. Escriu també la prova unitària de la seva lògica.
Exercici 2: correu de confirmació asíncron, complet
Munta l'enviament del correu de confirmació del lloguer: l'executor dedicat amb la seva política de rebuig, la propagació del MDC i del context de seguretat, l'enllaç amb @TransactionalEventListener(AFTER_COMMIT) i el tractament d'excepcions. Explica què veu el ciutadà a cada pas i què passa si el servidor SMTP triga deu segons, si està caigut i si l'aplicació s'atura amb correus en cua.
Exercici 3: diagnosticar tres comportaments inexplicables
Un company reporta tres problemes a CicloUrbana. Diagnostica cadascun i proposa'n la correcció.
@Service
public class MantenimentService {
@Scheduled(fixedRate = 60_000)
public void revisar() {
List<Bicicleta> bicis = repositori.findAll();
for (Bicicleta b : bicis) {
this.processar(b); // (A) "no va en paral·lel"
}
}
@Async
@Transactional
public void processar(Bicicleta bicicleta) {
bicicleta.setUltimaRevisio(LocalDate.now());
repositori.save(bicicleta);
notificador.avisarTaller(bicicleta.getMatricula());
}
@Scheduled(cron = "0 0 2 * * *") // (B) "alguns dies se salta"
public void informeNocturn() {
informeService.generar(); // (C) "va deixar d'executar-se fa un mes"
}
}Solucions
Solució 1
package com.ciclourbana.bicicletes;
@Component
public class RevisorFlota {
private static final int LLOGUERS_ENTRE_REVISIONS = 300;
private final BicicletaRepositori repositori;
private final XarxaProperties xarxa; // constructor omès
@Scheduled(cron = "${ciclourbana.flota.revisio.cron:0 15 4 * * *}", zone = "Europe/Madrid")
@SchedulerLock(name = "revisioFlotaRibalta",
lockAtLeastFor = "PT2M", lockAtMostFor = "PT20M")
public void executar() {
try {
int marcades = revisarFlota();
if (marcades > 0) {
log.info("Enviades a manteniment {} bicicletes de Ribalta", marcades);
}
} catch (Exception e) {
log.error("La revisió nocturna de la flota ha fallat", e);
}
}
/** Lògica pura, invocable des d'una prova. Retorna quantes bicicletes ha canviat. */
@Transactional
public int revisarFlota() {
List<Bicicleta> candidates = repositori.cercarPerRevisio(
xarxa.llindarBateria(), LLOGUERS_ENTRE_REVISIONS);
for (Bicicleta bicicleta : candidates) {
bicicleta.setEstat(EstatBicicleta.MANTENIMENT); // idempotent
}
return candidates.size();
}
}Els cinc requisits, un a un. Configurable: l'expressió cron és una propietat amb valor per defecte, així que un entorn la pot avançar o desactivar amb -. A l'hora correcta: zone = "Europe/Madrid" fixa la referència, i les 04:15 queden fora de la franja perillosa del canvi d'hora. Comprovable: revisarFlota() és públic i no depèn del rellotge del planificador. Segur amb tres instàncies: @SchedulerLock amb lockAtMostFor de vint minuts, folgadament per damunt de la durada esperada. No deixa de funcionar després d'una fallada: el try/catch impedeix que l'excepció s'escapi al planificador i cancel·li les execucions futures.
Dos detalls addicionals. El filtre és en una consulta del repositori i no en un stream sobre findAll(): portar tota la flota a memòria per descartar el 95 % és l'antipatró de 04-06. I no hi ha save(): les entitats estan gestionades dins de la transacció i el dirty checking de 04-07 genera els UPDATE en el commit. Assignar l'estat a una bicicleta que ja era en manteniment no canvia res, cosa que dona la idempotència de franc.
@Test
void marcaLesBicicletesQueNecessitenRevisioAmbElLlindarConfigurat() {
BicicletaRepositori repositori = mock(BicicletaRepositori.class);
XarxaProperties xarxa = new XarxaProperties("Ribalta", 8, 20,
Duration.ofHours(2), List.of("Plaça Major"), false);
Bicicleta bateriaBaixa = bicicleta("RB-0142", EstatBicicleta.DISPONIBLE);
given(repositori.cercarPerRevisio(20, 300)).willReturn(List.of(bateriaBaixa));
int marcades = new RevisorFlota(repositori, xarxa).revisarFlota();
assertThat(marcades).isEqualTo(1);
assertThat(bateriaBaixa.getEstat()).isEqualTo(EstatBicicleta.MANTENIMENT);
}L'assert sobre cercarPerRevisio(20, 300) verifica que el llindar surt de XarxaProperties i no d'una constant escrita a foc, que era la lliçó de 02-05.
Solució 2
@Configuration
@EnableAsync
public class ConfiguracioAsincronia implements AsyncConfigurer {
@Bean("executorCorreu")
Executor executorCorreu() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("correu-ciclo-");
executor.setTaskDecorator(new DecoradorMdc());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, metode, params) -> log.error("Fallada asíncrona a {} amb {}",
metode.getName(), Arrays.toString(params), ex);
}
}L'escoltador és el NotificadorLloguer de l'apartat 9: @Async("executorCorreu") al costat de @TransactionalEventListener(AFTER_COMMIT).
Què veu el ciutadà. Envia POST /api/v1/lloguers; el fil HTTP obre la transacció, insereix el lloguer, marca la bicicleta com a llogada i confirma; a l'AFTER_COMMIT es publica la tasca a l'executor i el fil HTTP queda lliure; el ciutadà rep el seu 201 Created en desenes de mil·lisegons. Després, en un fil correu-ciclo-N, amb el mateix rastreId al MDC gràcies al decorador i amb la seva autenticació disponible gràcies a l'executor delegat, s'envia el correu.
Els tres escenaris de l'enunciat. Si el SMTP triga deu segons, al ciutadà no l'afecta: la seva resposta va sortir fa estona. El que es consumeix és un fil del pool durant deu segons; amb core-size: 4, a partir de la cinquena confirmació simultània les tasques s'encuen, i només amb cinc-centes a la cua actuaria la política de rebuig, que les executaria al fil cridant. Si el servidor està caigut, l'excepció puja fins a l'AsyncUncaughtExceptionHandler, que la registra amb el mètode i els arguments; el lloguer continua sent vàlid i el ciutadà no se n'assabenta, cosa que és una decisió de negoci conscient: la confirmació per correu és una comoditat, no el contracte. Si l'aplicació s'atura amb correus en cua, l'aturada ordenada de l'apartat 13 li dona fins a trenta segons per buidar-la; el que no càpiga en aquest termini, o qualsevol tasca pendent si el procés mor de cop, es perd sense rastre. Si aquesta pèrdua fos inacceptable —una factura, per exemple—, @Async seria l'eina equivocada i caldria persistir la intenció (patró outbox, 07-05) o fer servir una cua de missatges.
Solució 3
(A) «No va en paral·lel». Autoinvocació: this.processar(b) no passa pel proxy, així que @Async i @Transactional s'ignoren del tot i tot s'executa síncron al fil del planificador, sense cap transacció. Hi ha un segon problema a sobre: encara que es corregís movent processar a un altre bean, s'estaria passant una entitat gestionada a un altre fil, amb LazyInitializationException esperant al primer accés mandrós. La correcció és moure el mètode a un altre component i passar l'identificador:
@Component
public class ProcessadorBicicleta {
@Async("executorManteniment")
public void processar(Long bicicletaId) {
transaccio.execute(status -> { // o un mètode @Transactional propi
Bicicleta bicicleta = repositori.findById(bicicletaId).orElseThrow();
bicicleta.setUltimaRevisio(LocalDate.now(rellotge));
return null;
});
notificador.avisarTaller(bicicletaId); // crida externa fora de la transacció
}
}La notificació al taller queda fora de la transacció, per la raó de 04-07: no retenir una connexió durant una crida de xarxa.
(B) «Alguns dies se salta». L'expressió 0 0 2 * * * s'executa a les 2 de la matinada a la zona de la JVM, i cau justament a la franja del canvi d'hora: l'últim diumenge de març aquesta hora no existeix i la tasca no s'executa. La correcció és doble: declarar zone = "Europe/Madrid" perquè l'hora sigui l'esperada, i moure l'execució fora de la franja crítica, per exemple a 0 15 4 * * *.
(C) «Va deixar d'executar-se fa un mes». És el símptoma d'una excepció que es va escapar del mètode: quan una tasca programada llança i l'excepció arriba al planificador, les execucions futures d'aquesta tasca es cancel·len, mentre la resta de l'aplicació continua funcionant amb normalitat. Es confirma en dos passos: buscar al log de fa un mes l'excepció d'informeService.generar(), i comprovar a /actuator/scheduledtasks (07-01) que la tasca ja no apareix registrada. La correcció té tres capes: un try/catch dins del mètode, un ErrorHandler al TaskScheduler com a xarxa de seguretat global, i una alerta sobre el fet que la tasca no s'executa —perquè el mode de fallada silenciosa de les tasques programades és precisament que ningú no les troba a faltar—.
Hi ha a més un quart problema que l'enunciat no menciona: fixedRate = 60_000 sobre un findAll() de tota la flota. Si la revisió triga més d'un minut, amb un planificador de diversos fils se solapa amb ella mateixa i dues execucions marquen les mateixes bicicletes alhora. Ha de ser fixedDelay, i la consulta ha de filtrar a la base de dades.
Conclusió
CicloUrbana ja té iniciativa pròpia. Saps activar la programació amb @EnableScheduling i triar amb criteri entre fixedRate, fixedDelay i cron, entenent què passa quan una execució dura més que el seu interval, i saps fer configurable qualsevol cadència amb les variants ...String i una propietat, inclòs el truc de desactivar una tasca amb -. Domines els sis camps del cron d'Spring, les seves macros i les seves extensions, i tens gravades les dues lliçons sobre el temps: declara sempre la zona horària i programa la feina crítica fora de la franja del canvi d'hora, amb tasques idempotents que sobrevisquin a executar-se dues vegades o cap. CaducadorLloguers i RecalculadorOcupacio funcionen, estan escrites per poder-se provar en mil·lisegons i apareixen a /actuator/scheduledtasks quan cal comprovar per què alguna cosa no s'ha executat.
Coneixes els dos paranys grans del costat de les tasques: el planificador d'un sol fil, que fa que una tasca lenta bloquegi totes les altres i que el símptoma aparegui a la tasca equivocada, i la multiplicació en escalar, que converteix tres rèpliques en tres cobraments. Saps resoldre el primer amb spring.task.scheduling.pool.size o un TaskScheduler propi amb el seu ErrorHandler, i el segon amb ShedLock sobre la PostgreSQL que ja teníem, entenent lockAtMostFor, lockAtLeastFor i per què usingDbTime() elimina el desfasament entre rellotges.
Del costat asíncron, saps que @Async pateix exactament els mateixos paranys de proxy que @Transactional —i que l'autoinvocació simplement fa desaparèixer l'asincronia sense ni un sol avís—, i per què el SimpleAsyncTaskExecutor sense pool és perillós. Configures ThreadPoolTaskExecutor sabent que la cua s'omple abans que el pool creixi, tries política de rebuig amb criteri i fas servir executors separats per tipus de feina com a primera mampara. Tens clara la regla més valuosa del capítol: la transacció no viatja a l'altre fil, així que es passen identificadors i no entitats, i la feina asíncrona es dispara a AFTER_COMMIT —la combinació amb què el ciutadà de Ribalta rep el seu 201 en quaranta mil·lisegons mentre el correu surt per un altre fil—. Saps propagar el SecurityContext amb DelegatingSecurityContextAsyncTaskExecutor i el MDC amb un TaskDecorator, capturar les fallades dels mètodes void amb AsyncUncaughtExceptionHandler, valorar els fils virtuals de Java 21 amb els seus tres advertiments, i aturar els executors de manera ordenada. I saps on és el límit: @Async no dona durabilitat ni reintents, i quan perdre feina no és acceptable la resposta és una cua de missatges, que apareixerà a 07-05 i 07-06.
Amb tot això, CicloUrbana és observable, sap adaptar-se al seu entorn i treballa pel seu compte. I continua sent un JAR que algú ha d'arrencar a mà en una màquina amb Java 21 instal·lat, la zona horària correcta i les variables d'entorn ben posades. Aquest «algú» i aquestes condicions són l'última baula artesanal de tot el projecte: la causa que funcioni en un servidor i no en un altre, i que el desplegament depengui d'una llista de passos al cap d'una persona. La lliçó següent, Spring Boot amb Docker, ho elimina empaquetant l'aplicació, el seu JRE i la seva configuració en una imatge reproduïble: capes que aprofiten la memòria cau, un usuari sense privilegis, un docker-compose.yml complet amb PostgreSQL i els seus healthcheck enganxats a les sondes de 07-01, i la porta oberta a Kubernetes.
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
