Three replicas of one application against one PostgreSQL database, proving duplicate @Scheduled execution and then removing it with ShedLock: 24 executions where 8 were due, then 7 for 7 ticks. Also measured: @SchedulerLock without @EnableSchedulerLock does nothing and warns about nothing; spring.task.scheduling.pool.size=1 does not starve a fixedRate job but delays it and fires 35 of 40 executions in a burst; and a node whose clock is 40 seconds fast takes a live lock unless the provider uses usingDbTime().
2.7 KiB
prev: Clock skew · README
5. When not to use a lock
ShedLock is small, it has one dependency on your database, and it solves the stated problem. It is still worth asking whether the problem should exist.
Make the job idempotent instead. A lock is a way of avoiding a second execution. Idempotence is a way of not caring about one. If the job's work can be keyed — "mark invoices dated 2026-09-01 as sent, where they are not already marked" — then three replicas running it produce one outcome and you have removed a distributed-systems dependency rather than adding one. This is almost always the better engineering, and it is almost never what the article you searched for suggests.
A lock is not a guarantee of exactly-once. It is a guarantee of at-most-one-per-lock-window, which is different. If the holder dies halfway, the job did not complete and nothing retries it — ShedLock has no notion of failure, only of expiry. Pairing it with a job table that records completion is what makes "exactly once" true, and at that point the job table is doing most of the work.
Consider the scheduler you already have. A Kubernetes CronJob runs one pod per schedule and
needs no lock, no library and no table; it costs you a pod start per run and the schedule lives
outside the application. Quartz in clustered mode owns misfire policy, persistence and recovery,
at the price of eleven tables and a great deal of configuration. ShedLock sits between them: it
adds a lock to the scheduler you already have, and deliberately adds nothing else.
A lock provider needs its store to be consistent. The JDBC provider is safe because a
conditional UPDATE on one row is atomic in every relational database. The Redis provider is
safe on a single instance and, as its own documentation notes, is subject to the well-known
argument about locks over Redis replication. If your store is eventually consistent, your lock
is too.
A short checklist for the version you ship
@EnableSchedulerLockpresent, and aLockProviderbean present — without both, the annotation does nothing (chapter 1)usingDbTime()on (chapter 4)lockAtMostForlonger than the worst-case run time, and you have accepted the gap it implies after a crashlockAtLeastFornon-zero for any job shorter than the interval between two replicas' timersspring.task.scheduling.pool.sizeraised above 1 (chapter 3)- the lock names are unique per job — two jobs sharing a name share a lock, and one of them stops running
- the method carrying
@SchedulerLockis called from outside its own class, and is notfinal