@Scheduled makes it quick and easy to set up periodic execution. But once you’re running it in a business system, you hit walls like “I want to keep job definitions in the DB,” “I want to change schedules without redeploying,” and “I want to prevent the same job from running twice across multiple instances.” Once you get here, @Scheduled alone starts to feel painful.
That’s where Quartz Scheduler comes in. In this article, we’ll walk through everything with code: introducing spring-boot-starter-quartz, persistence with the JDBC JobStore, dynamic job manipulation at runtime, and preventing duplicate execution in a cluster. At the end, we’ll also sort out how to choose between @Scheduled / ShedLock / Quartz.
Three scenarios where @Scheduled falls short
First, let’s put into words when you actually need Quartz.
- You want to persist job definitions and execution state in the DB.
@Scheduledis embedded in code and managed in memory, so it naturally resets when you restart. - You want to add, change, or remove jobs without redeploying. When cron expressions are written in code, every change requires a build and a deploy.
- You want to prevent duplicate execution in a cluster (multiple instances). If you run the same app on three nodes,
@Scheduledfires on all three.
Quartz provides all three as standard features. Note that the basics of @Scheduled itself are covered in How to implement periodic execution with @Scheduled, so we won’t dig into that here.
Introducing spring-boot-starter-quartz and a minimal setup
Let’s start by adding the dependency. With Maven, this is all you need.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
With Gradle, add implementation 'org.springframework.boot:spring-boot-starter-quartz'.
In this state, the JobStore runs in memory (RAMJobStore). Let’s start by writing a minimal Job.
import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;
@Component
public class SampleJob extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) {
System.out.println("ジョブ実行: " + context.getJobDetail().getKey());
}
}
Extending QuartzJobBean makes the Spring DI integration (discussed later) go smoothly. After that, just register the JobDetail and Trigger as Beans, and they’ll be scheduled automatically when the app starts.
The basics of Job, JobDetail, and Trigger
Quartz has three central concepts. Grasp these and everything that follows gets easier.
- Job is the actual processing. The
SampleJobabove is an example. - JobDetail is the definition and metadata of that job. It’s identified by a
JobKey(name / group). - Trigger is the definition of when to run. The two commonly used types are
SimpleTriggerandCronTrigger.
When you want to pass parameters to a job, use JobDataMap. The Trigger side also has a TriggerKey, which—just like JobDetail—is uniquely identified by the combination of name and group.
Defining schedules with CronTrigger and SimpleTrigger
Let’s actually register a JobDetail and a Trigger as Beans. Assembling them with builders is the standard pattern.
import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QuartzConfig {
@Bean
public JobDetail sampleJobDetail() {
return JobBuilder.newJob(SampleJob.class)
.withIdentity("sampleJob", "reports")
.storeDurably()
.build();
}
@Bean
public Trigger sampleJobTrigger(JobDetail sampleJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(sampleJobDetail)
.withIdentity("sampleTrigger", "reports")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?"))
.build();
}
}
The above is a cron example for “every day at 3 AM.” If you just want to run at a fixed interval, use SimpleScheduleBuilder.
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInMinutes(10)
.repeatForever())
Adding storeDurably() keeps the JobDetail even when there’s no Trigger. It matters for dynamic registration, so keep it in mind.
Integrating Quartz Jobs with Spring Beans / DI
A common gotcha here is DI. By default, Quartz news up Job instances itself, so @Autowired fields stay null.
If you’re using the Spring Boot starter, a mechanism equivalent to SpringBeanJobFactory works internally and injects dependencies via the AutowireCapableBeanFactory when the Job is created. In other words, if your job extends QuartzJobBean, you can inject a Service straightforwardly.
@Component
public class ReportJob extends QuartzJobBean {
private final ReportService reportService;
public ReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Override
protected void executeInternal(JobExecutionContext context) {
reportService.generateDailyReport();
}
}
If you don’t want the same job to overlap with its previous run, add @DisallowConcurrentExecution to the class to suppress concurrent execution. For managing transactions inside the Service, Transaction management in Spring Boot is also a helpful reference.
Persisting jobs to the DB with the JDBC JobStore
Now for persistence. Switch the JobStore to JDBC in application.properties.
spring.quartz.job-store-type=jdbc
spring.quartz.jdbc.initialize-schema=always
spring.quartz.properties.org.quartz.scheduler.instanceName=AppScheduler
spring.quartz.properties.org.quartz.jobStore.class=org.springframework.scheduling.quartz.LocalDataSourceJobStore
Quartz stores jobs, Triggers, lock information, and so on in a set of tables prefixed with QRTZ_. Setting initialize-schema=always makes the starter create the schema automatically, which is convenient during development.
In production, however, you need to be careful. Because always tries to run the initialization script on every startup, it risks destroying existing data. It’s safer to set it to never in production and create the tables in advance via migration.
The DataSource is shared with the application, and LocalDataSourceJobStore cooperates with Spring’s transaction management. The key point of the JDBC JobStore is that, once persisted, when you restart, the jobs and schedules remaining in the DB are restored and execution continues.
Registering, changing, and removing jobs dynamically at runtime
Once you have persistence, the next thing you’ll want is to “manipulate jobs without redeploying.” By injecting the Scheduler, you can add or swap jobs while running.
@Service
public class JobAdminService {
private final Scheduler scheduler;
public JobAdminService(Scheduler scheduler) {
this.scheduler = scheduler;
}
public void register(String name, String cron) throws SchedulerException {
JobKey jobKey = new JobKey(name, "dynamic");
if (scheduler.checkExists(jobKey)) {
return; // 二重登録を防ぐ
}
JobDetail job = JobBuilder.newJob(ReportJob.class)
.withIdentity(jobKey).build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(name, "dynamic")
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
scheduler.scheduleJob(job, trigger);
}
public void reschedule(String name, String cron) throws SchedulerException {
TriggerKey key = new TriggerKey(name, "dynamic");
Trigger newTrigger = TriggerBuilder.newTrigger()
.withIdentity(key)
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
scheduler.rescheduleJob(key, newTrigger);
}
public void remove(String name) throws SchedulerException {
scheduler.deleteJob(new JobKey(name, "dynamic"));
}
}
Use scheduleJob to register, rescheduleJob to swap the Trigger and change the schedule, and deleteJob to remove. If you just want to pause, you can also use pauseJob. Checking existence with checkExists helps you avoid duplicate registration.
When combined with the JDBC JobStore, all of these changes are reflected in the DB, so dynamically registered jobs survive a restart. If you call this service from a REST controller, you can even provide job operations from an admin screen.
Preventing duplicate execution in a cluster with isClustered
The main event when running multiple instances is clustering. The configuration is simple.
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.clusterCheckinInterval=15000
The mechanism is that each node shares the same DB and competes for a DB lock (QRTZ_LOCKS), which guarantees that “a single firing of a given job is handled by only one node.” Even if one node goes down, another node fails over and takes over.
There are three key points. Setting instanceId to AUTO automatically assigns each node a unique ID. It’s a prerequisite that all nodes point to the same DB. And in a cluster, the JDBC JobStore is mandatory—it doesn’t work in memory. clusterCheckinInterval is the interval for checking each node’s liveness, but fine-tuning it isn’t necessary at first.
Choosing between @Scheduled / ShedLock / Quartz
Let’s line up the three options and sort them out.
| Aspect | @Scheduled | ShedLock | Quartz |
|---|---|---|---|
| Persistence | None | None (lock only in DB) | Yes (per job definition) |
| Dynamic registration | Not possible | Not possible | Possible |
| Cluster duplicate control | Not possible | Strong suit | Possible |
| Execution history | None | None | Can be stored in DB |
| Adoption cost | Low | Low–Medium | Medium–High |
Here’s a rough guide for deciding. If you just need to run a fixed schedule lightly, @Scheduled is enough. If you only want to stop @Scheduled from duplicating across multiple instances, ShedLock is easy. If you want DB persistence of jobs, dynamic registration, and cluster execution all together, Quartz is the choice.
When in doubt, ask yourself “Do I want to add jobs without redeploying?” and “Do I want to keep execution history in the DB?” If either is a Yes, Quartz is a candidate. If full-blown batch processing itself is the goal, the Spring Batch article is also worth considering.
Common pitfalls
Here are the things you’ll frequently trip over when you actually adopt it.
When a Bean is null inside a Job, it’s usually because the class doesn’t extend QuartzJobBean or the Job is being created outside Spring’s management. First, check the inheritance and the DI path.
An error saying the QRTZ_ tables don’t exist is caused by an uninitialized schema. During development, review initialize-schema; in production, prepare the tables via migration.
A firing whose execution time passed while the app was stopped becomes a misfire. The behavior can be controlled with misfire instructions, so for cron it’s reassuring to make your intent explicit by specifying something like withMisfireHandlingInstructionFireAndProceed() on the Trigger. In a cluster, behavior gets erratic if the DB or time zone drifts between nodes, so don’t forget time synchronization either.
Summary
With Quartz, you get all at once what @Scheduled couldn’t reach: “persistence with the JDBC JobStore,” “dynamic job manipulation via the Scheduler,” and “prevention of duplicate execution with isClustered.” The adoption cost is real, but for job management in a business system it should be well worth it.
You won’t go far wrong if you choose along this axis: @Scheduled if your requirement is a fixed schedule, ShedLock if you just need to prevent duplication, and Quartz if you need persistence and dynamic registration. Start by trying to persist a single small Job.