Why @Scheduled Runs Twice in Spring Boot Multi-Instance Deployments and How to Fix It

This article explains why Spring Boot's @Scheduled annotation executes on every instance in a clustered deployment, causing duplicate tasks like coupon issuance, and demonstrates solutions using ShedLock distributed locks and business-level idempotency keys to ensure exactly-once execution.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Why @Scheduled Runs Twice in Spring Boot Multi-Instance Deployments and How to Fix It

Front-end developers often assume that a @Scheduled task runs once per cluster, but Spring's scheduler is per-instance. When a service scales from one pod to multiple pods in Kubernetes, each pod triggers the same cron job, leading to duplicate side effects such as sending two expiration notifications or issuing two coupons.

The Problem: @Scheduled Is Not Cluster-Aware

The author discovered the issue after scaling a member-expiry job from 1 to 2 Spring Boot replicas. Logs showed two executions milliseconds apart from different pods:

02:00:00.012
开始执行会员过期任务
02:00:00.018
开始执行会员过期任务
member-service-7d9cfb7b58-k8x2p
member-service-7d9cfb7b58-v7mzc

The SQL

UPDATE member SET status='EXPIRED' WHERE expire_time < NOW() AND status='ACTIVE'

appeared idempotent because the second run found no ACTIVE rows. However, downstream side effects — like sending notifications or issuing coupons — were duplicated.

Why the Misconception Exists

Developers mentally map @Scheduled(cron="0 0 2 * * *") to "the whole system runs once daily at 2 AM." In reality it means "this Spring Boot instance runs once daily at 2 AM." With replicas: 4, the task runs four times concurrently.

Solution 1: Single-Instance Deployment (Rejected)

Setting replicas: 1 avoids duplication but sacrifices high availability, load balancing, and rolling deployments — an unreasonable trade-off for a single scheduled job.

Solution 2: Conditional Enable via Property (Partial)

Using

@ConditionalOnProperty(name="job.enabled", havingValue="true")

on one pod works until that pod crashes; the other pod has JOB_ENABLED=false and never picks up the task, turning "runs twice" into "runs zero times."

Solution 3: Distributed Lock with ShedLock (Adopted)

ShedLock adds a lightweight distributed lock around existing @Scheduled methods. It creates a shedlock table:

CREATE TABLE shedlock (
  name VARCHAR(64) NOT NULL,
  lock_until TIMESTAMP NOT NULL,
  locked_at TIMESTAMP NOT NULL,
  locked_by VARCHAR(255) NOT NULL,
  PRIMARY KEY (name)
);

Configuration:

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfiguration {
  @Bean
  public LockProvider lockProvider(DataSource dataSource) {
    return new JdbcTemplateLockProvider(
      JdbcTemplateLockProvider.Configuration.builder()
        .withJdbcTemplate(new JdbcTemplate(dataSource))
        .usingDbTime()
        .build()
    );
  }
}

Task annotation:

@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "member-expire-job", lockAtMostFor = "10m")
public void expire() {
  memberService.expireMembers();
}

Both pods still trigger at 02:00, but only one acquires the lock; the other skips immediately.

Key Parameter: lockAtMostFor

This is a safety net, not a task timeout. If the lock holder crashes before releasing, the lock expires after lockAtMostFor. It must exceed the worst-case execution time (e.g., normal 1–2 min, worst 4 min → set 10 min). Setting it too low (e.g., 1 min) causes a second node to start while the first is still running.

Key Parameter: lockAtLeastFor

Prevents re-execution due to clock skew or fast restarts. Even if the task finishes in 100 ms, the lock is held for at least the specified duration (e.g., 30 s). Must be smaller than the task period; otherwise subsequent runs are skipped.

Distributed Locks Alone Are Not Enough: Idempotency Required

If the lock holder crashes after sending a coupon but before marking the task complete, the next run (or a retry) will send another coupon. The author separates concerns:

Scheduling layer: @SchedulerLock ensures single concurrent execution.

Business layer: Idempotency keys guarantee exactly-once effect.

Example: daily reward uses a unique business key DAILY_REWARD_2026-10-02_10086 with a unique database index:

CREATE UNIQUE INDEX uk_reward_biz_no ON user_reward(biz_no);
@Transactional
public void issue(Long userId, LocalDate date) {
  String bizNo = "DAILY_REWARD_" + date + "_" + userId;
  if (rewardRepository.existsByBizNo(bizNo)) return;
  rewardRepository.save(new UserReward(userId, bizNo));
  accountService.addReward(userId);
}

Now even if scheduling duplicates, service restarts, or manual replays occur, the business logic detects the existing key and skips.

Classify Tasks Before Applying Locks

Not every @Scheduled needs a global lock:

Per-node tasks (cache refresh, local cleanup, metrics logging) should run on every instance.

Singleton tasks (report generation, settlement, coupon issuance, refunds, expiry, billing, third-party sync) must run once per cluster.

When to Graduate to a Full Scheduler

If the system grows to hundreds of jobs needing retries, dependencies, manual compensation, execution logs, sharding, dynamic triggers, or a management UI, a dedicated scheduler (e.g., XXL-Job, PowerJob) is warranted. ShedLock's documentation explicitly states it is not a full distributed scheduler.

Decision Matrix

Simple periodic task
  ↓
@Scheduled

Multi-instance + must run once
  ↓
@Scheduled + distributed lock

Complex task ecosystem
  ↓
Independent scheduling platform

The fix for the original issue was minimal — adding @SchedulerLock — but the mindset shift was critical: Spring Boot service ≠ single machine . In production, 2, 4, or 8 pods are normal. HTTP endpoints scale horizontally with added capacity; scheduled tasks scale horizontally with added duplication risk. The first question when seeing @Scheduled should now be "How many instances run in production?" not "Is the cron expression correct?"

If your Spring Boot project recently moved from a single machine to Docker/Kubernetes multi-instance, search for @Scheduled — the real traps are often behind those annotations.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

KubernetesSpring Bootdistributed lockidempotencyscheduled tasksmulti-instanceShedLock@Scheduled
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.