CicloUrbana ya es observable y sabe en qué entorno vive, pero sigue siendo puramente reactiva: solo hace algo cuando alguien se lo pide. Nadie cierra de madrugada los alquileres que un ciudadano de Ribalta olvidó finalizar, nadie recalcula la ocupación de las cuatro estaciones para la aplicación móvil, y el correo de confirmación se envía en el mismo hilo que atiende la petición, obligando al ciudadano a esperar a que el servidor SMTP conteste.

Esta lección le da iniciativa propia. Son dos capacidades distintas que conviene no confundir: programar trabajo para que ocurra en momentos determinados, y ejecutar trabajo fuera del hilo que atiende la petición. Veremos ambas con sus trampas reales —el planificador de un solo hilo, la tarea que se ejecuta cuatro veces cuando escalas a cuatro instancias, la transacción que no viaja al otro hilo, el contexto de seguridad que se queda atrás— y terminaremos con los hilos virtuales de Java 21 y el apagado ordenado.

Tareas programadas Ejecución asíncrona
Anotaciones @EnableScheduling + @Scheduled @EnableAsync + @Async
Quién dispara el trabajo Un reloj interno Una llamada desde otro hilo
Pregunta que responde ¿Cuándo debe pasar esto? ¿Quién debe esperar a que pase?
Ejemplo en CicloUrbana Caducar alquileres cada 10 minutos Enviar el correo de confirmación
Ejecutor por defecto ThreadPoolTaskScheduler de 1 hilo SimpleAsyncTaskExecutor, sin pool

Contenido

  1. @EnableScheduling y los tres modos de @Scheduled
  2. Expresiones cron y zonas horarias
  3. Las tareas periódicas de CicloUrbana
  4. El planificador de un solo hilo
  5. Tareas programadas con varias instancias: ShedLock
  6. Probar tareas programadas y /actuator/scheduledtasks
  7. @EnableAsync y @Async
  8. El Executor: por qué el predeterminado es peligroso
  9. Asincronía y transacciones
  10. Propagar el contexto entre hilos
  11. Excepciones en métodos asíncronos
  12. Hilos virtuales de Java 21
  13. Apagado ordenado
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. @EnableScheduling y los tres modos de @Scheduled

La capacidad se activa con una anotación en cualquier clase de configuración:

@Configuration
@EnableScheduling
public class ConfiguracionTareas { }

A partir de ahí, cualquier método public void sin argumentos anotado con @Scheduled en un bean se registra en el planificador. Los tres modos:

Modo Cuándo dispara la siguiente ejecución Si una ejecución tarda más que el intervalo
fixedRate Cada N ms desde el inicio de la anterior Se acumula retraso; la siguiente arranca en cuanto termina la actual
fixedDelay N ms después del fin de la anterior Nunca se solapa; el ritmo real se degrada
cron Según la expresión, en instantes absolutos Se salta las ocurrencias perdidas

La diferencia entre los dos primeros no es teórica. Con fixedRate = 60_000 y una tarea que tarda 90 segundos, el planificador quiere ejecutarla cada minuto y no puede: con un solo hilo, las ejecuciones se encadenan sin pausa; con varios hilos, se solapan, y dos copias del recalculador de ocupación escribiendo a la vez son una condición de carrera. Con fixedDelay = 60_000 hay siempre un minuto de descanso entre el fin de una y el inicio de la siguiente, y nunca hay solapamiento.

La regla práctica: fixedDelay para trabajo cuya duración es variable o desconocida —que es casi todo lo que toca una base de datos—, fixedRate solo cuando la cadencia importa más que el solapamiento y la tarea es rápida y segura, y cron cuando el momento absoluto importa (medianoche, fin de mes, hora punta).

@Scheduled(fixedDelay = 600_000, initialDelay = 60_000)   // milisegundos
@Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT1M")   // ISO-8601
@Scheduled(fixedDelay = 10, initialDelay = 1, timeUnit = TimeUnit.MINUTES)

Las tres líneas hacen lo mismo. initialDelay importa más de lo que parece: sin él, todas las tareas arrancan a la vez en el momento en que el contexto está listo, justo cuando la aplicación está calentando cachés y creando conexiones. Escalonarlas con retrasos iniciales distintos evita una tormenta de trabajo en el peor momento.

Y lo más importante para el mantenimiento: las variantes ...String admiten sustitución de propiedades, lo que convierte la cadencia en configuración:

@Scheduled(fixedDelayString = "${ciclourbana.alquileres.caducador.intervalo:PT10M}")

Con eso, el intervalo se ajusta por entorno con los perfiles de 07-02 —cinco minutos en producción, un segundo en una prueba manual— sin recompilar. Es la forma recomendada para toda tarea de CicloUrbana.

  1. Expresiones cron y zonas horarias

El cron de Spring tiene seis campos, no cinco: a diferencia del cron de Unix, incluye los segundos.

 ┌─────────── segundo (0-59)
 │ ┌───────── minuto (0-59)
 │ │ ┌─────── hora (0-23)
 │ │ │ ┌───── día del mes (1-31)
 │ │ │ │ ┌─── mes (1-12 o JAN-DEC)
 │ │ │ │ │ ┌─ día de la semana (0-7 o MON-SUN)
 0 0 3 * * *
Expresión Significado
0 */5 * * * * Cada 5 minutos
0 0 3 * * * Todos los días a las 03:00
0 30 2 * * MON-FRI De lunes a viernes a las 02:30
0 0 8,14,20 * * * A las 8, a las 14 y a las 20
0 0 0 1 * * El día 1 de cada mes a medianoche
0 0 0 L * * El último día del mes (extensión de Spring)
0 0 6 * * SAT#2 El segundo sábado de cada mes a las 6

Existen además macros legibles —@yearly, @monthly, @weekly, @daily (equivale a 0 0 0 * * *) y @hourly— y la extensión -, que deshabilita una tarea sin borrarla; combinada con una propiedad resulta muy práctica: @Scheduled(cron = "${ciclourbana.informes.cron:-}") deja el informe desactivado salvo en los entornos que definan la expresión.

Las zonas horarias son la fuente de errores más silenciosa de todo el apartado. Sin zone, la expresión se interpreta en la zona de la JVM, que en un contenedor suele ser UTC aunque el equipo esté en Ribalta. Un informe programado a las 0 0 3 * * * se genera entonces a las 5 de la mañana local en verano, y nadie lo nota hasta que alguien compara cifras. La forma correcta es @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid").

Y con la zona declarada aparece el problema del cambio de hora:

Fecha Qué ocurre Efecto sobre 0 30 2 * * *
Último domingo de marzo A las 02:00 el reloj salta a las 03:00 La tarea no se ejecuta: esa hora no existe
Último domingo de octubre Las 02:00-03:00 ocurren dos veces La tarea se ejecuta una sola vez, pero desplazada

La defensa no es técnica sino de diseño: programa el trabajo crítico fuera de la franja 01:00-03:00 —las 04:15 es una hora perfectamente buena— y haz las tareas idempotentes, de modo que ejecutarlas dos veces o ninguna no corrompa nada. Una tarea que suma importes sobre un contador acumulado es peligrosa; una que recalcula el total desde los datos de origen no lo es.

  1. Las tareas periódicas de CicloUrbana

CaducadorAlquileres cierra los alquileres que superan la duración máxima configurada en RedProperties (02-05). Un ciudadano que deja la bicicleta y olvida finalizar bloquearía la matrícula RB-0142 indefinidamente.

package com.ciclourbana.alquileres;

@Component
public class CaducadorAlquileres {

    private final AlquilerService alquilerService;
    private final RedProperties red;
    private final Clock reloj;                    // constructor omitido

    @Scheduled(fixedDelayString = "${ciclourbana.alquileres.caducador.intervalo:PT10M}",
               initialDelayString = "PT1M")
    public void ejecutar() {
        int cerrados = caducarAlquileresVencidos();
        if (cerrados > 0) {
            log.info("Caducados {} alquileres que superaban {}", cerrados,
                     red.duracionMaximaAlquiler());
        }
    }

    /** Lógica invocable directamente desde una prueba, sin esperar al reloj. */
    public int caducarAlquileresVencidos() {
        Instant limite = Instant.now(reloj).minus(red.duracionMaximaAlquiler());
        return alquilerService.cerrarPorCaducidad(limite);
    }
}

Cuatro decisiones. El intervalo es una propiedad con valor por defecto, así que se ajusta por entorno. La lógica está en un método público aparte, que es lo que hace la tarea comprobable (apartado 6). Se inyecta Clock, siguiendo la práctica de 06-02: con Clock.fixed la prueba controla el tiempo. Y se registra en el log solo cuando hay trabajo, porque una tarea que escribe cada diez minutos «no hice nada» convierte el log en ruido y esconde lo importante.

La segunda tarea, RecalculadorOcupacion, mantiene al minuto el resumen que consume la aplicación móvil con @Scheduled(fixedDelayString = "${ciclourbana.estaciones.ocupacion.intervalo:PT1M}") sobre una llamada a estacionService.refrescarResumenOcupacion(). Aquí fixedDelay es de nuevo la elección correcta: si un día la consulta tarda 90 segundos porque la base de datos está cargada, lo último que queremos es una segunda copia recalculando encima de la primera.

  1. El planificador de un solo hilo

Esta es la trampa que sorprende a todo el mundo la primera vez. El TaskScheduler que Spring Boot configura por defecto tiene exactamente un hilo. Con dos tareas, si CaducadorAlquileres tarda tres minutos porque la base de datos va lenta, RecalculadorOcupacion no se ejecuta en esos tres minutos: espera su turno. El síntoma es desconcertante —una tarea que «a veces no se ejecuta»— y la causa está en otra tarea distinta.

La solución básica es una propiedad:

spring:
  task:
    scheduling:
      pool:
        size: 4
      thread-name-prefix: tarea-ciclo-
      shutdown:
        await-termination: true
        await-termination-period: 30s

Y cuando hace falta control fino, un bean propio:

@Bean
TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler planificador = new ThreadPoolTaskScheduler();
    planificador.setPoolSize(4);
    planificador.setThreadNamePrefix("tarea-ciclo-");
    planificador.setWaitForTasksToCompleteOnShutdown(true);
    planificador.setAwaitTerminationSeconds(30);
    planificador.setErrorHandler(t -> log.error("Fallo en tarea programada", t));
    return planificador;
}

Dos advertencias sobre el tamaño del pool. Más hilos no es siempre mejor: con fixedRate y varios hilos, una tarea lenta puede solaparse consigo misma, algo que con un solo hilo era imposible. Y el thread-name-prefix no es cosmético: cuando en 09-05 haya que buscar en el log qué hilo bloqueó una conexión, un hilo llamado tarea-ciclo-2 dice mucho más que pool-3-thread-1.

El setErrorHandler merece atención aparte: una excepción no capturada en una tarea @Scheduled no detiene la aplicación, pero sí cancela las siguientes ejecuciones de esa tarea cuando escapa del planificador. El resultado es una tarea que deja de ejecutarse en silencio. Un manejador de errores explícito, o un try/catch dentro del método, evita ese final.

  1. Tareas programadas con varias instancias: ShedLock

Cuando CicloUrbana pase a tres réplicas en 08-04, cada una tendrá su propio planificador y cada tarea se ejecutará tres veces. Para RecalculadorOcupacion eso es trabajo desperdiciado; para una tarea que envíe un correo de resumen a los ciudadanos, son tres correos; para una que cobre recargos, tres cobros.

Solución Cómo funciona Cuándo elegirla
Un perfil planificador en una sola instancia Solo esa réplica activa @EnableScheduling Sencillo, pero esa instancia es un punto único de fallo
Bloqueo en base de datos (ShedLock) La primera que toma la fila ejecuta; el resto se salta La opción por defecto: sin infraestructura nueva
Planificador externo Un CronJob de Kubernetes llama a un endpoint Trabajo pesado o que debe sobrevivir al ciclo de la aplicación
graph LR
    B1[Instancia 1<br/>toma el bloqueo] -->|ejecuta| BD[(tabla shedlock<br/>en PostgreSQL)]
    B2[Instancia 2] -.->|no lo consigue:<br/>se salta| BD
    B3[Instancia 3] -.->|no lo consigue:<br/>se salta| BD

ShedLock se apoya en la PostgreSQL que ya tenemos: dos dependencias (shedlock-spring y shedlock-provider-jdbc-template, versión 5.16.0) y una tabla creada con una migración de Flyway, coherente con 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 ConfiguracionTareas {

    @Bean
    LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()          // usa la hora del servidor de BD, no la de la JVM
                .build());
    }
}
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "informeDiarioRibalta",
               lockAtLeastFor = "PT5M", lockAtMostFor = "PT25M")
public void generarInformeDiario() { /* ... */ }

Los tres parámetros que hay que entender. lockAtMostFor es la red de seguridad: si la instancia que tomó el bloqueo muere sin liberarlo, este caduca solo pasado ese tiempo, así que debe ser mayor que la duración máxima razonable de la tarea o dos instancias acabarán ejecutándola a la vez. lockAtLeastFor mantiene el bloqueo un mínimo aunque la tarea termine antes, lo que protege del caso en que los relojes de dos instancias están ligeramente desfasados y la segunda dispara la tarea dos segundos después. Y usingDbTime() hace que la referencia temporal sea el reloj de PostgreSQL, eliminando de raíz ese desfase.

Última advertencia: ShedLock no es un mecanismo de exclusión mutua para el negocio. Garantiza que la tarea no se dispare dos veces desde el planificador, no que dos rutas distintas del código no toquen los mismos datos. Para eso siguen estando las transacciones y los bloqueos de 04-07.

  1. Probar tareas programadas y /actuator/scheduledtasks

Probar una tarea esperando a que el reloj la dispare es lento y frágil. El patrón correcto ya está aplicado en CaducadorAlquileres: la anotación queda en un método fino que solo delega, y toda la lógica vive en un método público que la prueba invoca directamente.

class CaducadorAlquileresTest {

    private final Clock reloj = Clock.fixed(
            Instant.parse("2026-09-01T23:00:00Z"), ZoneOffset.UTC);

    @Test
    void cierraLosAlquileresQueSuperanLaDuracionMaxima() {
        AlquilerService servicio = mock(AlquilerService.class);
        given(servicio.cerrarPorCaducidad(any())).willReturn(3);

        int cerrados = new CaducadorAlquileres(servicio, redProperties(), reloj)
                .caducarAlquileresVencidos();

        assertThat(cerrados).isEqualTo(3);
        verify(servicio).cerrarPorCaducidad(Instant.parse("2026-09-01T21:00:00Z"));
    }
}

El aserto sobre el Instant exacto es el que da valor a la prueba: comprueba que el límite se calcula restando la duración máxima, que es la regla de negocio real. Y no tarda diez minutos en ejecutarse.

Para verificar que la tarea está registrada con la cadencia esperada hay dos vías. En pruebas, @SpringBootTest con @MockitoBean sobre el servicio y Awaitility esperando a que se invoque, con el intervalo bajado a PT0.1S por propiedad. Y en ejecución, el endpoint de 07-01:

curl -s -u admin:*** http://localhost:8081/actuator/scheduledtasks | jq '.fixedDelay'
# [ { "runnable": { "target": "...CaducadorAlquileres.ejecutar" },
#     "initialDelay": 60000, "interval": 600000 } ]

Es la forma más rápida de responder a «¿por qué no se ha ejecutado el informe?»: si la tarea no aparece en esa lista, no está registrada, y la causa suele ser un @EnableScheduling ausente, un perfil que no activó el bean o un método no public.

  1. @EnableAsync y @Async

La segunda mitad de la lección cambia de pregunta: ya no es cuándo ocurre el trabajo, sino quién espera a que ocurra.

@Configuration
@EnableAsync
public class ConfiguracionAsincronia { }

Un método @Async devuelve el control inmediatamente y su cuerpo se ejecuta en otro hilo. Los tipos de retorno admitidos:

Retorno Semántica Uso
void Dispara y olvida; el llamante no sabe si terminó ni si falló Notificaciones, auditoría, envío de correo
CompletableFuture<T> El llamante puede componer, esperar y capturar errores Llamadas en paralelo que hay que combinar
Future<T> Igual pero con la API antigua, sin composición Código heredado
@Async
public CompletableFuture<ResumenEstacion> resumen(Long estacionId) {
    return CompletableFuture.completedFuture(estacionService.resumen(estacionId));
}

Nótese que el método devuelve un futuro ya completado: no lo completa él, lo hace el proxy. Es contraintuitivo pero correcto; el valor se envuelve al terminar el método, que ya se está ejecutando en el hilo del ejecutor.

Y aquí vuelven exactamente las mismas trampas de proxy que en @Transactional (04-07), por la misma razón: @Async se implementa con un proxy que envuelve el bean.

Trampa Qué pasa Solución
Autoinvocación this.enviarCorreo() no pasa por el proxy: se ejecuta síncrono, sin ningún aviso Mover el método asíncrono a otro bean e inyectarlo
Método private, final o static No es interceptable; se ejecuta síncrono Debe ser public y no final
Llamada desde el constructor o @PostConstruct El proxy aún no está montado Usar ApplicationReadyEvent (01-05)
Devolver un tipo cualquiera El llamante recibe null, porque el valor real se calcula después Solo void, Future o CompletableFuture

La primera es, con diferencia, la más frecuente y la más difícil de detectar: el código funciona, las pruebas pasan y lo único que ocurre es que la asincronía no existe. El síntoma en producción es una latencia que no baja por mucho que se añadan hilos.

  1. El Executor: por qué el predeterminado es peligroso

Sin configuración explícita, Spring Boot usa el applicationTaskExecutor que crea la autoconfiguración, un ThreadPoolTaskExecutor con valores por defecto generosos. Pero si ese bean no existe —alguien define un Executor propio mal registrado, o se trabaja sin Boot— el respaldo es SimpleAsyncTaskExecutor, y eso es peligroso: crea un hilo nuevo por invocación y no los reutiliza. Bajo carga, mil peticiones por minuto son mil hilos, cada uno con su pila de un megabyte, y la JVM se queda sin memoria. No hay cola, no hay límite y no hay contrapresión.

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 como bean, cuando hace falta política de rechazo o decoradores:

@Bean("ejecutorCorreo")
Executor ejecutorCorreo() {
    ThreadPoolTaskExecutor ejecutor = new ThreadPoolTaskExecutor();
    ejecutor.setCorePoolSize(4);
    ejecutor.setMaxPoolSize(8);
    ejecutor.setQueueCapacity(500);
    ejecutor.setThreadNamePrefix("correo-ciclo-");
    ejecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    ejecutor.setWaitForTasksToCompleteOnShutdown(true);
    ejecutor.setAwaitTerminationSeconds(30);
    ejecutor.initialize();
    return ejecutor;
}

Se elige por nombre con @Async("ejecutorCorreo"). Tener un ejecutor por tipo de trabajo es una forma de mamparo (07-06): si el servidor de correo se atasca, sus hilos se agotan sin arrastrar al resto de la aplicación.

El comportamiento del pool no es intuitivo y conviene memorizarlo, porque explica el noventa por ciento de las sorpresas: mientras haya menos hilos que core-size, cada tarea crea uno nuevo; alcanzado core-size, las tareas van a la cola; solo cuando la cola está llena se crean hilos hasta max-size; y agotados ambos, actúa la política de rechazo. El corolario incómodo: una queue-capacity grande hace que max-size casi nunca se use. Con core-size: 8 y queue-capacity: 10000, el pool nunca pasará de ocho hilos por mucha carga que llegue; se limitará a acumular diez mil tareas pendientes. Si lo que se quiere es que crezca bajo presión, la cola debe ser pequeña.

Política de rechazo Qué hace Efecto
CallerRunsPolicy La ejecuta el hilo que llamó Contrapresión: el que produce trabajo se frena solo
AbortPolicy (por defecto) Lanza RejectedExecutionException Falla ruidosamente; hay que capturarla
DiscardPolicy Descarta en silencio Casi nunca aceptable: pierde trabajo sin avisar
DiscardOldestPolicy Descarta la más antigua de la cola Solo donde lo reciente importa más

Para el correo de CicloUrbana, CallerRunsPolicy es la elección razonable: si el sistema se satura, el correo se envía en el hilo de la petición —el ciudadano espera un poco— en lugar de perderse.

  1. Asincronía y transacciones

Aquí está el error conceptual más caro del módulo. @Async y @Transactional juntos casi nunca hacen lo que uno espera, porque la transacción de Spring vive en un ThreadLocal y no viaja al hilo del ejecutor. Con las dos anotaciones en el mismo método, el hilo llamante lanza la tarea y sigue, y el hilo del ejecutor abre una transacción completamente nueva: no ve las escrituras aún no confirmadas del llamante y confirma o deshace por su cuenta. Si el llamante hace rollback, el trabajo asíncrono ya se ejecutó y confirmó igualmente. Y si el método asíncrono recibe una entidad gestionada como argumento, esa entidad pertenece a un EntityManager de otro hilo: LazyInitializationException garantizada.

Las tres reglas que resuelven el problema:

  1. Pasa identificadores, nunca entidades gestionadas. El método asíncrono recarga lo que necesite en su propia transacción.
  2. Dispara el trabajo asíncrono después del commit, no dentro de la transacción.
  3. Que la transacción del método asíncrono sea suya, abierta dentro de su hilo.

La combinación que aplica CicloUrbana une esto con @TransactionalEventListener de 04-07:

@Component
public class NotificadorAlquiler {

    private final ServicioCorreo correo;                  // constructor omitido

    @Async("ejecutorCorreo")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void alConfirmarse(AlquilerIniciado evento) {
        correo.enviarConfirmacion(evento.alquilerId(), evento.correoCiudadano());
    }
}
sequenceDiagram
    participant C as Ciudadano
    participant H as Hilo HTTP
    participant BD as PostgreSQL
    participant E as ejecutorCorreo
    C->>H: POST /api/v1/alquileres
    H->>BD: INSERT alquiler + COMMIT
    H-->>C: 201 Created (42 ms)
    Note over H,E: AFTER_COMMIT + @Async
    H->>E: publica la tarea y suelta el hilo
    E->>E: envía el correo por SMTP (1,8 s)

Por qué es la combinación correcta. AFTER_COMMIT garantiza que no se anuncia un alquiler que después se deshace, el problema que ya identificamos en 04-07. @Async garantiza que el ciudadano no espera al servidor SMTP: la respuesta sale en decenas de milisegundos. Y el evento transporta datos planos —el identificador y el correo—, no la entidad Alquiler, evitando el problema de la sesión cerrada.

Queda un matiz honesto: con este esquema, si el envío falla, la respuesta ya se dio como correcta. Es una decisión deliberada —el alquiler es válido aunque el correo no llegue— y la contrapartida es que el fallo debe quedar registrado y vigilado. Un @Async con void no tiene reintentos ni durabilidad: si el proceso muere con tareas en la cola del ejecutor, esas tareas se pierden. Cuando esa pérdida no es aceptable, la respuesta es una cola de mensajes real, que veremos en 07-05 y 07-06.

  1. Propagar el contexto entre hilos

Dos piezas de CicloUrbana viven en ThreadLocal y no cruzan solas al hilo del ejecutor: el SecurityContextHolder de 05-01 y el MDC del FiltroTraza de 03-06. De ahí dos síntomas clásicos: un @PreAuthorize que falla dentro de un método asíncrono porque no hay autenticación, y unas líneas de log del trabajo en segundo plano que salen con [sin-traza] y no se pueden relacionar con la petición que las originó.

Para la seguridad, Spring Security trae un decorador de ejecutores: basta envolver el ThreadPoolTaskExecutor ya configurado en un DelegatingSecurityContextAsyncTaskExecutor antes de devolverlo como bean. Existe también SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL), que hace que los hilos hijos hereden el contexto, pero es una opción con riesgos: solo actúa al crear el hilo, así que en un pool que reutiliza hilos el contexto heredado es el del primer trabajo que creó ese hilo, no el del actual. Con pools es peor que no hacer nada; sirve solo para hilos creados a mano.

Para el MDC, un TaskDecorator propio copia el mapa de diagnóstico:

public class DecoradorMdc implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable tarea) {
        Map<String, String> contexto = MDC.getCopyOfContextMap();   // hilo llamante
        return () -> {
            Map<String, String> previo = MDC.getCopyOfContextMap();
            try {
                if (contexto != null) MDC.setContextMap(contexto);
                tarea.run();
            } finally {
                if (previo != null) MDC.setContextMap(previo); else MDC.clear();
            }
        };
    }
}

Se registra con ejecutor.setTaskDecorator(new DecoradorMdc()). La clave está en las dos mitades: la copia se hace al decorar, es decir, en el hilo que envía la tarea, y la restauración en el finally es imprescindible por la misma razón que el MDC.remove() de 03-06 —sin ella, el identificador de traza se queda pegado al hilo del pool y contamina las tareas siguientes—.

Con las dos piezas, una línea de log del envío de correo lleva el mismo trazaId que la petición POST /api/v1/alquileres que lo originó, que es exactamente lo que hará falta para la trazabilidad distribuida de 09-06.

  1. Excepciones en métodos asíncronos

Un método @Async que devuelve CompletableFuture entrega su excepción al llamante cuando este hace join() o get(). Pero uno que devuelve void no tiene a quién entregársela: por defecto la excepción se registra y se pierde. Para tratarla de forma centralizada:

@Configuration
@EnableAsync
public class ConfiguracionAsincronia implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, metodo, params) -> log.error(
                "Fallo en tarea asíncrona {} con argumentos {}",
                metodo.getName(), Arrays.toString(params), ex);
    }
}

AsyncConfigurer permite además devolver el Executor por defecto en getAsyncExecutor(). El manejador solo se aplica a los métodos que devuelven void; para los que devuelven futuros, la responsabilidad es del llamante, con handle o exceptionally. La consecuencia práctica es una regla: si el resultado importa, devuelve un futuro; si no importa, asume que el fallo solo llegará al log y asegúrate de que ese log se vigila.

  1. Hilos virtuales de Java 21

Java 21 introduce los hilos virtuales: hilos gestionados por la JVM, no por el sistema operativo, tan baratos que se pueden crear millones. Spring Boot 3.2+ los adopta con una sola propiedad:

spring:
  threads:
    virtual:
      enabled: true

Con ella, el servidor web atiende cada petición en un hilo virtual, y los ejecutores de @Async y del planificador pasan a crear hilos virtuales por tarea. El cambio de modelo es profundo: ya no hay que dimensionar pools para cargas bloqueantes, porque bloquear un hilo virtual no consume un hilo del sistema operativo.

Tipo de carga ¿Convienen? Motivo
E/S bloqueante: JDBC, HTTP, SMTP Sí, es su caso ideal Miles de tareas esperando sin agotar hilos del sistema
Cálculo intensivo No aportan El límite son los núcleos, no los hilos
Aplicaciones ya reactivas (WebFlux) Innecesarios Ya resuelven el problema por otra vía

Y tres advertencias que hay que conocer antes de activarlos en Ribalta. La primera, el pinning: un hilo virtual bloqueado dentro de un bloque synchronized fija el hilo portador y anula la ventaja; hay que revisar el código propio y las bibliotecas antiguas y sustituir synchronized por ReentrantLock donde haya bloqueo. La segunda: el pool de conexiones sigue siendo el límite real. Un millón de hilos virtuales pidiendo conexiones a un HikariCP de veinte no acelera nada; simplemente mueve la cola de sitio, y hace que el cuello de botella sea menos visible. La tercera: el ThreadLocal sigue funcionando, pero con un hilo por tarea las cachés basadas en ThreadLocal dejan de tener sentido y se convierten en fugas de memoria.

La recomendación para CicloUrbana: activarlos en dev y pre, medir con las métricas de 09-03, y promocionarlos a prod cuando el comportamiento esté confirmado. Es un cambio de una línea, y precisamente por eso conviene tratarlo como lo que es: un cambio de modelo de ejecución.

  1. Apagado ordenado

En 01-05 vimos el apagado ordenado del servidor web: deja de aceptar peticiones nuevas y espera a que terminen las que están en vuelo. Los ejecutores necesitan su propia configuración equivalente, o el trabajo en cola se descarta al parar el proceso:

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: 30s

La regla de coherencia: timeout-per-shutdown-phase debe ser mayor que los await-termination-period, o el contexto se cerrará mientras los ejecutores aún esperan. Y todo ello debe caber dentro del plazo de gracia que dé el orquestador antes del SIGKILL (30 segundos por defecto en Kubernetes, 08-04).

Aun así, un apagado ordenado no convierte una tarea en cola en una tarea garantizada: si el proceso muere de forma abrupta, se pierde. Esa es, otra vez, la frontera entre @Async y una cola de mensajes real.

Errores Comunes y Consejos

Autoinvocar un método @Async o @Scheduled. El proxy no interviene y el método se ejecuta síncrono, sin ningún error. Es el fallo más frecuente de esta lección.

Olvidar que el planificador tiene un solo hilo. Una tarea lenta bloquea a todas las demás, y el síntoma aparece en la tarea equivocada.

Usar fixedRate con trabajo de duración variable. Con varios hilos la tarea se solapa consigo misma; con uno se acumula el retraso. fixedDelay por defecto.

Programar tareas críticas entre la 1 y las 3 de la madrugada. El cambio de hora hará que un día no se ejecuten y otro se ejecuten desplazadas.

Escalar a varias instancias sin coordinar las tareas. Tres réplicas son tres ejecuciones: ShedLock, un perfil dedicado o un planificador externo.

Combinar @Async y @Transactional en el mismo método. La transacción no viaja al otro hilo: se abre una nueva e independiente, y las entidades gestionadas no cruzan.

Contar con que el SecurityContext o el MDC estén en el hilo asíncrono. No lo están: hacen falta DelegatingSecurityContextAsyncTaskExecutor y un TaskDecorator.

Configurar una cola enorme creyendo que aumenta el paralelismo. Con una cola grande, el pool nunca crece más allá de core-size.

Consejo: extrae la lógica de toda tarea programada a un método público invocable, que es lo que la hace comprobable en milisegundos en lugar de en minutos, y haz las tareas idempotentes: con reintentos, cambios de hora y varias instancias, ejecutar dos veces es una posibilidad real.

Consejo: pon prefijo de nombre a todos tus hilos. correo-ciclo-3 en un volcado de hilos vale por media hora de investigación.

Consejo: haz configurable cada cadencia con fixedDelayString = "${...}". Poder bajar un intervalo a un segundo en dev y desactivar una tarea con - en un entorno cuesta cero.

Ejercicios

Ejercicio 1: tarea nocturna de mantenimiento de la flota

Escribe RevisorFlota, una tarea que cada día a las 04:15 hora de Ribalta marque como MANTENIMIENTO las bicicletas cuyo nivel de batería esté por debajo del umbralBateria de RedProperties o que lleven más de 300 alquileres desde su última revisión. Debe ser configurable, comprobable sin esperar al reloj, segura con tres instancias en ejecución y no debe dejar de funcionar si una ejecución falla. Escribe también la prueba unitaria de su lógica.

Ejercicio 2: correo de confirmación asíncrono, completo

Monta el envío del correo de confirmación del alquiler: el ejecutor dedicado con su política de rechazo, la propagación del MDC y del contexto de seguridad, el enlace con @TransactionalEventListener(AFTER_COMMIT) y el manejo de excepciones. Explica qué ve el ciudadano en cada paso y qué ocurre si el servidor SMTP tarda diez segundos, si está caído y si la aplicación se apaga con correos en cola.

Ejercicio 3: diagnosticar tres comportamientos inexplicables

Un compañero reporta tres problemas en CicloUrbana. Diagnostica cada uno y propón la corrección.

@Service
public class MantenimientoService {

    @Scheduled(fixedRate = 60_000)
    public void revisar() {
        List<Bicicleta> bicis = repositorio.findAll();
        for (Bicicleta b : bicis) {
            this.procesar(b);                     // (A) "no va en paralelo"
        }
    }

    @Async
    @Transactional
    public void procesar(Bicicleta bicicleta) {
        bicicleta.setUltimaRevision(LocalDate.now());
        repositorio.save(bicicleta);
        notificador.avisarTaller(bicicleta.getMatricula());
    }

    @Scheduled(cron = "0 0 2 * * *")               // (B) "unos días se salta"
    public void informeNocturno() {
        informeService.generar();                  // (C) "dejó de ejecutarse hace un mes"
    }
}

Soluciones

Solución 1

package com.ciclourbana.bicicletas;

@Component
public class RevisorFlota {

    private static final int ALQUILERES_ENTRE_REVISIONES = 300;

    private final BicicletaRepositorio repositorio;
    private final RedProperties red;                  // constructor omitido

    @Scheduled(cron = "${ciclourbana.flota.revision.cron:0 15 4 * * *}", zone = "Europe/Madrid")
    @SchedulerLock(name = "revisionFlotaRibalta",
                   lockAtLeastFor = "PT2M", lockAtMostFor = "PT20M")
    public void ejecutar() {
        try {
            int marcadas = revisarFlota();
            if (marcadas > 0) {
                log.info("Enviadas a mantenimiento {} bicicletas de Ribalta", marcadas);
            }
        } catch (Exception e) {
            log.error("La revisión nocturna de la flota ha fallado", e);
        }
    }

    /** Lógica pura, invocable desde una prueba. Devuelve cuántas bicicletas cambió. */
    @Transactional
    public int revisarFlota() {
        List<Bicicleta> candidatas = repositorio.buscarParaRevision(
                red.umbralBateria(), ALQUILERES_ENTRE_REVISIONES);

        for (Bicicleta bicicleta : candidatas) {
            bicicleta.setEstado(EstadoBicicleta.MANTENIMIENTO);   // idempotente
        }
        return candidatas.size();
    }
}

Los cinco requisitos, uno a uno. Configurable: la expresión cron es una propiedad con valor por defecto, así que un entorno puede adelantarla o desactivarla con -. A la hora correcta: zone = "Europe/Madrid" fija la referencia, y las 04:15 quedan fuera de la franja peligrosa del cambio de hora. Comprobable: revisarFlota() es público y no depende del reloj del planificador. Seguro con tres instancias: @SchedulerLock con lockAtMostFor de veinte minutos, holgadamente por encima de la duración esperada. No deja de funcionar tras un fallo: el try/catch impide que la excepción escape al planificador y cancele las ejecuciones futuras.

Dos detalles adicionales. El filtro está en una consulta del repositorio y no en un stream sobre findAll(): traer toda la flota a memoria para descartar el 95 % es el antipatrón de 04-06. Y no hay save(): las entidades están gestionadas dentro de la transacción y el dirty checking de 04-07 genera los UPDATE en el commit. Asignar el estado a una bicicleta que ya estaba en mantenimiento no cambia nada, lo que da la idempotencia gratis.

@Test
void marcaLasBicicletasQueNecesitanRevisionUsandoElUmbralConfigurado() {
    BicicletaRepositorio repositorio = mock(BicicletaRepositorio.class);
    RedProperties red = new RedProperties("Ribalta", 8, 20,
            Duration.ofHours(2), List.of("Plaza Mayor"), false);
    Bicicleta bateriaBaja = bicicleta("RB-0142", EstadoBicicleta.DISPONIBLE);
    given(repositorio.buscarParaRevision(20, 300)).willReturn(List.of(bateriaBaja));

    int marcadas = new RevisorFlota(repositorio, red).revisarFlota();

    assertThat(marcadas).isEqualTo(1);
    assertThat(bateriaBaja.getEstado()).isEqualTo(EstadoBicicleta.MANTENIMIENTO);
}

El aserto sobre buscarParaRevision(20, 300) verifica que el umbral sale de RedProperties y no de una constante escrita a fuego, que era la lección de 02-05.

Solución 2

@Configuration
@EnableAsync
public class ConfiguracionAsincronia implements AsyncConfigurer {

    @Bean("ejecutorCorreo")
    Executor ejecutorCorreo() {
        ThreadPoolTaskExecutor ejecutor = new ThreadPoolTaskExecutor();
        ejecutor.setCorePoolSize(4);
        ejecutor.setMaxPoolSize(8);
        ejecutor.setQueueCapacity(500);
        ejecutor.setThreadNamePrefix("correo-ciclo-");
        ejecutor.setTaskDecorator(new DecoradorMdc());
        ejecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        ejecutor.setWaitForTasksToCompleteOnShutdown(true);
        ejecutor.setAwaitTerminationSeconds(30);
        ejecutor.initialize();
        return new DelegatingSecurityContextAsyncTaskExecutor(ejecutor);
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, metodo, params) -> log.error("Fallo asíncrono en {} con {}",
                metodo.getName(), Arrays.toString(params), ex);
    }
}

El escuchador es el NotificadorAlquiler del apartado 9: @Async("ejecutorCorreo") junto a @TransactionalEventListener(AFTER_COMMIT).

Qué ve el ciudadano. Envía POST /api/v1/alquileres; el hilo HTTP abre la transacción, inserta el alquiler, marca la bicicleta como alquilada y confirma; en el AFTER_COMMIT se publica la tarea al ejecutor y el hilo HTTP queda libre; el ciudadano recibe su 201 Created en decenas de milisegundos. Después, en un hilo correo-ciclo-N, con el mismo trazaId en el MDC gracias al decorador y con su autenticación disponible gracias al ejecutor delegado, se envía el correo.

Los tres escenarios del enunciado. Si el SMTP tarda diez segundos, al ciudadano no le afecta: su respuesta salió hace rato. Lo que se consume es un hilo del pool durante diez segundos; con core-size: 4, a partir de la quinta confirmación simultánea las tareas se encolan, y solo con quinientas en cola actuaría la política de rechazo, que las ejecutaría en el hilo llamante. Si el servidor está caído, la excepción sube hasta el AsyncUncaughtExceptionHandler, que la registra con el método y los argumentos; el alquiler sigue siendo válido y el ciudadano no se entera, lo cual es una decisión de negocio consciente: la confirmación por correo es una comodidad, no el contrato. Si la aplicación se apaga con correos en cola, el apagado ordenado del apartado 13 le da hasta treinta segundos para vaciarla; lo que no quepa en ese plazo, o cualquier tarea pendiente si el proceso muere de golpe, se pierde sin rastro. Si esa pérdida fuera inaceptable —una factura, por ejemplo—, @Async sería la herramienta equivocada y habría que persistir la intención (patrón outbox, 07-05) o usar una cola de mensajes.

Solución 3

(A) «No va en paralelo». Autoinvocación: this.procesar(b) no pasa por el proxy, así que @Async y @Transactional se ignoran por completo y todo se ejecuta síncrono en el hilo del planificador, sin ninguna transacción. Hay un segundo problema encima: aunque se corrigiera moviendo procesar a otro bean, se estaría pasando una entidad gestionada a otro hilo, con LazyInitializationException esperando en el primer acceso perezoso. La corrección es mover el método a otro componente y pasar el identificador:

@Component
public class ProcesadorBicicleta {

    @Async("ejecutorMantenimiento")
    public void procesar(Long bicicletaId) {
        transaccion.execute(status -> {           // o un método @Transactional propio
            Bicicleta bicicleta = repositorio.findById(bicicletaId).orElseThrow();
            bicicleta.setUltimaRevision(LocalDate.now(reloj));
            return null;
        });
        notificador.avisarTaller(bicicletaId);    // llamada externa fuera de la transacción
    }
}

La notificación al taller queda fuera de la transacción, por la razón de 04-07: no retener una conexión durante una llamada de red.

(B) «Unos días se salta». La expresión 0 0 2 * * * se ejecuta a las 2 de la madrugada en la zona de la JVM, y cae justo en la franja del cambio de hora: el último domingo de marzo esa hora no existe y la tarea no se ejecuta. La corrección es doble: declarar zone = "Europe/Madrid" para que la hora sea la esperada, y mover la ejecución fuera de la franja crítica, por ejemplo a 0 15 4 * * *.

(C) «Dejó de ejecutarse hace un mes». Es el síntoma de una excepción que escapó del método: cuando una tarea programada lanza y la excepción llega al planificador, las ejecuciones futuras de esa tarea se cancelan, mientras el resto de la aplicación sigue funcionando con normalidad. Se confirma en dos pasos: buscar en el log de hace un mes la excepción de informeService.generar(), y comprobar en /actuator/scheduledtasks (07-01) que la tarea ya no aparece registrada. La corrección tiene tres capas: un try/catch dentro del método, un ErrorHandler en el TaskScheduler como red de seguridad global, y una alerta sobre el hecho de que la tarea no se ejecuta —porque el modo de fallo silencioso de las tareas programadas es precisamente que nadie las echa de menos—.

Hay además un cuarto problema que el enunciado no menciona: fixedRate = 60_000 sobre un findAll() de toda la flota. Si la revisión tarda más de un minuto, con un planificador de varios hilos se solapa consigo misma y dos ejecuciones marcan las mismas bicicletas a la vez. Debe ser fixedDelay, y la consulta debe filtrar en la base de datos.

Conclusión

CicloUrbana tiene ya iniciativa propia. Sabes activar la programación con @EnableScheduling y elegir con criterio entre fixedRate, fixedDelay y cron, entendiendo qué pasa cuando una ejecución dura más que su intervalo, y sabes hacer configurable cualquier cadencia con las variantes ...String y una propiedad, incluido el truco de desactivar una tarea con -. Dominas los seis campos del cron de Spring, sus macros y sus extensiones, y tienes grabadas las dos lecciones sobre el tiempo: declara siempre la zona horaria y programa el trabajo crítico fuera de la franja del cambio de hora, con tareas idempotentes que sobrevivan a ejecutarse dos veces o ninguna. CaducadorAlquileres y RecalculadorOcupacion funcionan, están escritas para poder probarse en milisegundos y aparecen en /actuator/scheduledtasks cuando hay que comprobar por qué algo no se ejecutó.

Conoces las dos trampas grandes del lado de las tareas: el planificador de un solo hilo, que hace que una tarea lenta bloquee a todas las demás y que el síntoma aparezca en la tarea equivocada, y la multiplicación al escalar, que convierte tres réplicas en tres cobros. Sabes resolver la primera con spring.task.scheduling.pool.size o un TaskScheduler propio con su ErrorHandler, y la segunda con ShedLock sobre la PostgreSQL que ya teníamos, entendiendo lockAtMostFor, lockAtLeastFor y por qué usingDbTime() elimina el desfase entre relojes.

Del lado asíncrono, sabes que @Async sufre exactamente las mismas trampas de proxy que @Transactional —y que la autoinvocación simplemente hace desaparecer la asincronía sin un solo aviso—, y por qué el SimpleAsyncTaskExecutor sin pool es peligroso. Configuras ThreadPoolTaskExecutor sabiendo que la cola se llena antes de que el pool crezca, eliges política de rechazo con criterio y usas ejecutores separados por tipo de trabajo como primer mamparo. Tienes clara la regla más valiosa del capítulo: la transacción no viaja al otro hilo, así que se pasan identificadores y no entidades, y el trabajo asíncrono se dispara en AFTER_COMMIT —la combinación con la que el ciudadano de Ribalta recibe su 201 en cuarenta milisegundos mientras el correo sale por otro hilo—. Sabes propagar el SecurityContext con DelegatingSecurityContextAsyncTaskExecutor y el MDC con un TaskDecorator, capturar los fallos de los métodos void con AsyncUncaughtExceptionHandler, valorar los hilos virtuales de Java 21 con sus tres advertencias, y apagar los ejecutores de forma ordenada. Y sabes dónde está el límite: @Async no da durabilidad ni reintentos, y cuando perder trabajo no es aceptable la respuesta es una cola de mensajes, que aparecerá en 07-05 y 07-06.

Con todo esto, CicloUrbana es observable, sabe adaptarse a su entorno y trabaja por su cuenta. Y sigue siendo un JAR que alguien tiene que arrancar a mano en una máquina con Java 21 instalado, la zona horaria correcta y las variables de entorno bien puestas. Ese «alguien» y esas condiciones son el último eslabón artesanal de todo el proyecto: la causa de que funcione en un servidor y no en otro, y de que el despliegue dependa de una lista de pasos en la cabeza de una persona. La siguiente lección, Spring Boot con Docker, lo elimina empaquetando la aplicación, su JRE y su configuración en una imagen reproducible: capas que aprovechan la caché, un usuario sin privilegios, un docker-compose.yml completo con PostgreSQL y sus healthcheck enganchados a las sondas de 07-01, y la puerta abierta a Kubernetes.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

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

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados