Tratando tarefas agendadas com @Scheduled no Spring Boot
1. Visão geral
Muitas aplicações precisam executar tarefas de acordo com um cronograma: limpar pedidos expirados, sincronizar estoque a cada 5 minutos, enviar relatórios às 8 da manhã. O Spring suporta isso através da anotação @Scheduled sem precisar de bibliotecas externas.
Neste artigo, você vai habilitar o scheduling e diferenciar entre fixedRate, fixedDelay, cron. Você também vai mover o cronograma para um arquivo de configuração, ajustar o thread pool e escrever testes para a tarefa. Por fim, veremos os casos em que não deve usar @Scheduled.
2. Habilitando o scheduling
@Scheduled já vem em spring-context. Você apenas precisa do starter básico do Spring Boot:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
Em seguida, adicione @EnableScheduling em uma classe de configuração:
@SpringBootApplication
@EnableScheduling
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@EnableScheduling ausente é o erro mais comum. A aplicação ainda inicia normalmente, sem reportar erros, mas nenhuma tarefa é executada.
Observação: O método anotado com
@Scheduleddeve não ter parâmetros. Deve retornarvoid, pois o valor de retorno será ignorado. A classe que o contém deve ser um bean, por exemplo, com@Componentou@Service.
3. Executando em intervalos de tempo fixo
3.1. fixedRate
fixedRate calcula o período a partir do início da execução anterior:
@Component
public class InventoryJob {
private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);
@Scheduled(fixedRate = 5000)
public void syncInventory() {
log.info("Sincronizar estoque lúc {}", LocalTime.now().withNano(0));
}
}
A unidade padrão é milissegundos. A tarefa é executada assim que a aplicação está pronta, depois a cada 5 segundos:
INFO [ scheduling-1] InventoryJob : Sincronizar estoque lúc 09:00:00
INFO [ scheduling-1] InventoryJob : Sincronizar estoque lúc 09:00:05
INFO [ scheduling-1] InventoryJob : Sincronizar estoque lúc 09:00:10
Se uma execução levar mais de 5 segundos, a próxima não será executada em paralelo. Ela começa assim que a anterior termina, porque por padrão há apenas uma thread (scheduling-1).
3.2. fixedDelay
fixedDelay calcula o período a partir do fim da execução anterior. Esse tipo é adequado para tarefas que não devem ser sobrepostas, como chamar APIs externas ou processar lotes de dados:
@Component
public class EmailQueueJob {
private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);
@Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
public void sendPendingEmails() throws InterruptedException {
log.info("Iniciar envio de emails");
Thread.sleep(2000); // simula uma tarefa que leva 2 segundos
log.info("Envio concluído");
}
}
Explicação:
initialDelay = 10: aguardar 10 segundos após a inicialização antes de executar a primeira vez, para evitar desacelerar a inicialização.timeUnit = TimeUnit.SECONDS: muda a unidade parafixedDelayeinitialDelay.- Cada execução leva 2 segundos, depois descansa por 3 segundos. Os pontos de início de duas execuções consecutivas estão separados por 5 segundos:
09:00:10 Iniciar envio de emails
09:00:12 Envio concluído
09:00:15 Iniciar envio de emails
09:00:17 Envio concluído
4. Agendamento com cron
Quando você precisa executar em um horário específico, use cron. A expressão cron do Spring tem 6 campos, um a mais que o cron Unix porque inclui um campo de segundos no início:
┌─ segundo (0-59)
│ ┌─ minuto (0-59)
│ │ ┌─ hora (0-23)
│ │ │ ┌─ dia do mês (1-31)
│ │ │ │ ┌─ mês (1-12 ou JAN-DEC)
│ │ │ │ │ ┌─ dia da semana (0-7 ou MON-SUN)
* * * * * *
Por exemplo, enviar relatórios de vendas às 8 da manhã de segunda a sexta, de acordo com o horário do Vietnã:
@Component
public class SalesReportJob {
private final ReportService reportService;
public SalesReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
public void sendDailyReport() {
reportService.sendYesterdaySales();
}
}
O atributo zone é muito importante quando o servidor executa em UTC, que é comum com containers. Se omitido, o relatório chegará às 15h em vez de 8h.
Algumas expressões comuns:
0 */15 * * * * a cada 15 minutos
0 0 2 * * * 2h da manhã todos os dias
0 0 0 1 * * meia-noite no primeiro dia de cada mês
0 30 9 * * SAT,SUN 9:30 sábado e domingo
@daily equivalente a 0 0 0 * * *
@hourly equivalente a 0 0 * * * *
Observação: Se você copiar uma expressão de 5 campos do crontab Linux, por exemplo
0 8 * * *, o Spring lançaráIllegalStateExceptiondurante a inicialização. Você precisa adicionar um campo de segundos no início.
5. Movendo o cronograma para o arquivo de configuração
Codificar o cronograma forçá-o a reconstruir toda vez que mudar. Use placeholders. Para atributos numéricos, use a variante ...String:
@Component
public class OrderCleanupJob {
private final OrderRepository orderRepository;
private final AtomicInteger runCount = new AtomicInteger();
public OrderCleanupJob(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
public void removeExpiredOrders() {
orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
runCount.incrementAndGet();
}
public int getRunCount() {
return runCount.get();
}
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI
O valor após : é o padrão, usado quando a propriedade não é declarada. Para cron, você escreve @Scheduled(cron = "${jobs.sales-report.cron}").
Se você quiser desabilitar uma tarefa cron sem modificar o código, você define o valor como -:
jobs.sales-report.cron=-
O Spring reconhece - (a constante Scheduled.CRON_DISABLED) e não registra essa tarefa. Isso é útil quando você quer desabilitar a tarefa em ambientes de dev ou teste.
6. Configurando o thread pool
Por padrão, o Spring Boot cria um ThreadPoolTaskScheduler com exatamente uma thread. Todas as tarefas compartilham essa thread. Se SalesReportJob travar por 10 minutos, InventoryJob também terá que esperar 10 minutos.
Você pode aumentar o tamanho do pool:
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-
Após aumentar, tarefas diferentes podem ser executadas em paralelo:
INFO [ shop-job-1] InventoryJob : Sincronizar estoque lúc 09:00:05
INFO [ shop-job-2] EmailQueueJob : Iniciar envio de emails
A partir do Spring Boot 3.2, quando executando Java 21, você pode usar virtual threads:
spring.threads.virtual.enabled=true
Neste caso, o Boot usa SimpleAsyncTaskScheduler, e cada execução da tarefa roda em sua própria virtual thread. O atributo pool.size não tem mais efeito.
Observação: Aumentar o tamanho do pool não faz a mesma tarefa ser executada sobre si mesma. Cada método
@Scheduledainda é executado sequencialmente. Se você quiser que as execuções se sobreponham, você deve adicionar@Async(junto com@EnableAsync). Você quase nunca deve fazer isso, porque é fácil causar processamento duplicado de dados.
7. Tratando exceções
Quando uma tarefa lança uma exceção com fixedRate ou fixedDelay, o Spring registra o erro e ainda assim a executa novamente na próxima vez. Cron também. A tarefa não para completamente, mas o erro pode ser facilmente ignorado se você não monitorar os logs.
A forma mais segura é capturar a exceção diretamente na tarefa e tratá-la intencionalmente:
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
try {
inventoryClient.pullLatestStock();
} catch (RestClientException ex) {
log.warn("Não foi possível chamar o sistema de estoque, tentaremos novamente na próxima vez: {}", ex.getMessage());
alertService.notifyOps("inventory-sync-failed");
}
}
Se você quiser um lugar centralizado para tratar erros de todas as tarefas, você registra um ErrorHandler através de um customizer do Boot:
@Configuration
public class SchedulingConfig {
@Bean
ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
return scheduler -> scheduler.setErrorHandler(ex ->
alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
}
}
Este customizer só se aplica a ThreadPoolTaskScheduler. Ele não tem efeito quando você habilita virtual threads.
8. Escrevendo testes para tarefas
Separe o teste em duas camadas. A primeira camada verifica a lógica de negócio. A segunda camada verifica se o agendador realmente chama a tarefa.
A lógica de negócio deve ser testada como um método comum, chamar diretamente e sem precisar esperar:
class OrderCleanupJobUnitTest {
@Test
void shouldDeleteOrdersOlderThan24Hours() {
OrderRepository repo = mock(OrderRepository.class);
OrderCleanupJob job = new OrderCleanupJob(repo);
job.removeExpiredOrders();
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(repo).deleteExpiredBefore(captor.capture());
assertThat(captor.getValue())
.isBefore(Instant.now().minus(Duration.ofHours(23)));
assertThat(job.getRunCount()).isEqualTo(1);
}
}
Para verificar o agendamento, você usa Awaitility. Você não deve usar Thread.sleep, porque o teste será lento e instável. A versão do Awaitility já é gerenciada pelo Spring Boot, então você não precisa especificar a versão:
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {
@MockitoBean
OrderRepository orderRepository;
@Autowired
OrderCleanupJob job;
@Test
void shouldRunRepeatedly() {
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
}
}
Este teste reduz o período para 100 ms via properties. Em seguida, espera no máximo 2 segundos até que a tarefa seja executada pelo menos 3 vezes. Awaitility verifica repetidamente e retorna assim que a condição é atendida, então o teste geralmente termina em cerca de 300 ms. @MockitoBean está disponível a partir do Spring Boot 3.4. Com versões anteriores, você usa @MockBean.
Em outros testes de integração, você deve desabilitar o agendamento para que as tarefas não sejam executadas em segundo plano e corrompam os dados. Existem duas formas. A primeira é definir cron como -. A segunda é mover @EnableScheduling para uma classe de configuração separada com uma condição:
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }
Neste caso, o teste apenas precisa declarar jobs.enabled=false.
9. Quando NÃO usar @Scheduled
@Scheduled é simples, mas tem limitações claras:
- Executar em múltiplas instâncias. Com 3 pods do Kubernetes, a tarefa será executada 3 vezes simultaneamente, e o relatório será enviado 3 vezes. Você precisa de um lock distribuído, por exemplo, a biblioteca ShedLock usa uma tabela no banco de dados, ou separar a tarefa para um serviço que roda sozinho.
- Precisar salvar o estado da tarefa. Se a aplicação desligar exatamente às 8h, essa execução será perdida e não será executada novamente. Quando você precisa de misfire handling, histórico de execução ou agendamento dinâmico em runtime, use Quartz.
- A tarefa é muito pesada ou é executada por horas. Você deve mudar para Spring Batch ou para um sistema de fila para ter retry, particionamento em lotes e rastreamento de progresso.
- Agendamento criado pelo usuário, por exemplo, “lembre-me às 15:00”. A anotação só é adequada para cronogramas fixos no tempo de inicialização. Cronogramas dinâmicos devem ser salvos em um banco de dados e usar
TaskSchedulerde forma programática, ou usar Quartz.
Alguns outros erros comuns:
- Anotar um método
privatecom@Scheduled, ou um método de uma classe que não é um bean. - Esquecer de
zone, fazendo a tarefa executar na hora errada em um servidor UTC. - Colocar
@Schedulede@Transactionalno mesmo método pode funcionar. No entanto, separar a lógica para um serviço com@Transactionalserá mais fácil de testar e ler.
10. Conclusão
Neste artigo, você:
- habilitou o agendamento com
@EnableScheduling; - diferenciou entre
fixedRate, calculado a partir do início, efixedDelay, calculado a partir do fim; - escreveu expressões cron de 6 campos com fuso horário;
- moveu o cronograma para um arquivo de configuração e desabilitou tarefas com
-; - aumentou o thread pool ou usou virtual threads para que as tarefas não se bloqueiem;
- tratou exceções e escreveu testes com Awaitility.
Para uma aplicação executando uma única instância e com cronogramas fixos, @Scheduled é suficiente. Quando o sistema se expande para múltiplas instâncias ou precisa de maior confiabilidade, considere ShedLock ou Quartz antes de encontrar problemas em produção.