Distributed lock — granularity

Lock by the resource you protect — a global lock serializes the wrong things

Résumé bullet

Eliminated cross-server duplicate execution with a distributed lock (ShedLock/job_lock table), and decoupled the scheduler from execution via @Async so both plant closes run in parallel.

Deep-dive
  • Dual servers: the same @Scheduled job fires on both -> double-runs [financial post / inventory / numbering]
  • Fix (basic): ShedLock on a job_lock table in the existing DB -> one node runs, others skip
  • Real story: added a Hungary factory job -> it never ran
  • Cause: both jobs shared ONE lock name; ShedLock locks by name and SKIPS on miss (no retry)
  • China held name='factoryClose' at 7:00 for 10 min -> Hungary couldn't acquire -> skipped till next day
  • Fix: per-factory lock names; then scheduler pool >= 2 so they truly run in parallel
Code
// BUG: one lock name for two factories -> false mutual exclusion
@Scheduled(cron = "0 0 7 * * *")
@SchedulerLock(name = "factoryClose")   // China holds it 10m; Hungary fires 7:00, can't acquire -> SKIPPED
void closeAll() { ... }

// FIX: lock per resource (per factory) + a multi-thread scheduler
@Scheduled(cron = "0 0 7 * * *")
@SchedulerLock(name = "close-china",
    lockAtMostFor = "30m",   // node dies mid-run -> lock expires
    lockAtLeastFor = "5m")   // fast run -> next tick can't double-fire
void closeChina() { ... }

@Scheduled(cron = "0 0 7 * * *")
@SchedulerLock(name = "close-hungary", lockAtMostFor = "30m", lockAtLeastFor = "5m")
void closeHungary() { ... }
// spring.task.scheduling.pool.size >= 2  (default = 1 thread -> still sequential)
Say it (English)
  • ·Dual servers double-ran the same scheduled job — I added a distributed lock so only one node runs it.
  • ·Then I added a Hungary factory job and it never ran.
  • ·Both jobs shared one lock name. ShedLock locks by name and just skips if it can't get it — no retry.
  • ·China held the shared lock for ten minutes, so Hungary was locked out till the next day.
  • ·Fix: lock per factory, not one global lock — and a scheduler pool of two so they run in parallel.
Why it matters — Opendoor / as an engineer
  • Opendoor: any cron across app servers — and lock granularity matters (per-market, not one global).
  • Honest: the library is basics; the signal is diagnosing the granularity bug + lock scope.
  • Proves: lock by the resource you protect; understand failure modes (skip-no-retry, single-thread scheduler).
Trade-offs (what you give up)
  • ShedLock skips on miss — no queue, no retry; a missed run waits for the next tick.
  • DB-based lock adds a row write per run (fine — avoids a new component).
  • Per-resource locks + a thread pool add config you must get right (granularity, pool size).
Push it further
  • Tune lockAtMostFor / lockAtLeastFor to run time (crash-release + no double-fire).
  • DB lock over Redis: no new infra + DB clock avoids node clock skew.
  • For missed runs, a catch-up/retry instead of skip-till-tomorrow.
  • At more jobs, a clustered scheduler (Quartz) or a queue-driven worker.

Understand it step by step (한국어로 이해 → 영어로 말하기)

서버 2대가 같은 예약 작업을 동시에 돈다. 락을 걸어 한 번만 돌게 했는데, 락 이름 하나를 두 공장이 같이 써서 헝가리 공장 작업이 아예 안 돌았다.
  1. 1

    서버가 2대(이중화). 둘 다 같은 시각에 깨서 같은 예약 작업을 돌린다. 정산/재고/번호 매기기 같은 작업이라 두 번 돌면 안 된다.

    🔧 도구:@Scheduled (Spring)

    @Scheduled(cron = "0 0 7 * * *")
    void closeFactory() { ... }   // fires on every app server
    🗣 영어로 말해

    Two servers wake on the same schedule and run the same job.

    checking microphone…

  2. 2

    조율이 없으면 둘 다 실행한다 → 작업이 두 번 돈다. 돈 정산이 두 번 되거나 번호가 꼬인다.

    ⚖️ Trade-off: 그냥 두면 데이터가 두 번 처리돼서 망가진다.

    🔧 도구:@Scheduled (Spring)

    // No lock at all: both servers enter and run the same job -> double-run
    @Scheduled(cron = "0 0 7 * * *")
    void closeFactory() { post(); }
    🗣 영어로 말해

    With no coordination, both servers run it, so the job executes twice.

    checking microphone…

  3. 3

    기본 해결: 둘 다 보는 공유 DB에 락 테이블(작은 표)을 둔다. 먼저 잡은 서버만 실행하고, 나머지는 그냥 건너뛴다(skip).

    ⚖️ Trade-off: 실행할 때마다 락 테이블에 한 줄 쓰는 비용이 생긴다.

    ✅ Fix: DB는 이미 있던 거라 새 장비를 안 늘려도 된다. 그 정도 쓰기 비용은 무시할 만하다.

    🔧 도구:ShedLock (@SchedulerLock + DB 락 테이블)Redis SETNXZooKeeper

    private final ReentrantLock lock = new ReentrantLock();
    void run() {
      lock.lock();          // only guards THIS JVM's threads
      try { closeFactory(); } finally { lock.unlock(); }
    }
    🗣 영어로 말해

    I put a lock in the shared DB so only one node runs the job; the others skip.

    checking microphone…

  4. 4

    여기서 진짜 사고가 났다. 헝가리 공장 작업을 새로 추가했는데, 이게 한 번도 안 돌았다.

    🔧 도구:ShedLock

    @Scheduled(cron = "0 0 7 * * *")
    @SchedulerLock(name = "close-china")  // row in job_lock DB table; one node wins, others skip
    void closeChina() { ... }
    🗣 영어로 말해

    Then I added a Hungary factory job, and it never ran.

    checking microphone…

  5. 5

    원인: 두 공장 작업이 락 이름 하나('factoryClose')를 같이 썼다. ShedLock은 '이름'으로 락을 거는데, 못 잡으면 그냥 건너뛴다 — 다시 시도(retry)를 안 한다.

    ⚖️ Trade-off: 공장마다 보호할 대상이 다른데 이름 하나로 묶으니, 서로 상관없는 작업이 서로를 막는다. 가짜 상호배제다.

    ✅ Fix: 락은 '막을 대상(공장)' 단위로 걸어야 한다. 중국 락, 헝가리 락을 따로 둔다. 그러면 서로 안 막는다.

    🔧 도구:ShedLock @SchedulerLock(name=...)

    @Scheduled(cron = "0 0 7 * * *")
    @SchedulerLock(name = "close-china", lockAtMostFor = "30m")  // crash -> lock expires after 30m
    void closeChina() { ... }
    🗣 영어로 말해

    Both jobs shared one lock name, and ShedLock just skips when it can't get it.

    checking microphone…

  6. 6

    결과: 중국이 7시에 그 공유 락을 10분 잡고 있어서, 같은 7시에 깬 헝가리는 락을 못 잡고 그냥 다음 날까지 건너뛰었다.

    ⚖️ Trade-off: 한 번 놓친 실행은 큐도 없고 재시도도 없어서 다음 주기(다음 날)까지 기다린다.

    ✅ Fix: 락 이름을 공장별로 나누니 서로 안 막힌다. 놓친 실행이 걱정되면 건너뛰기 대신 따라잡기(catch-up) 실행을 붙일 수도 있다.

    🔧 도구:ShedLock

    @Scheduled(cron = "0 0 7 * * *")
    @SchedulerLock(name = "close-china",
        lockAtMostFor = "30m", lockAtLeastFor = "5m")  // fast run can't double-fire next tick
    void closeChina() { ... }
    🗣 영어로 말해

    China held the shared lock for ten minutes, so Hungary was locked out till the next day.

    checking microphone…

  7. 7

    이름을 나눠도 한 가지 더: 스프링 기본 스케줄러는 스레드가 1개다. 둘 다 7시에 깨도 한 스레드라 줄 서서 차례로 돈다.

    ⚖️ Trade-off: 스레드가 하나면 락을 나눠도 진짜 동시에는 안 돈다.

    ✅ Fix: 스케줄러 풀 크기를 2 이상으로 키운다(spring.task.scheduling.pool.size >= 2). 그래야 둘이 진짜 병렬로 돈다.

    🔧 도구:spring.task.scheduling.pool.size

    @Bean
    LockProvider lockProvider(DataSource ds) {        // reuse existing DB; DB time avoids clock skew
      return new JdbcTemplateLockProvider(
          JdbcTemplateLockProvider.Configuration.builder()
              .withJdbcTemplate(new JdbcTemplate(ds))
              .usingDbTime().build());
    }
    🗣 영어로 말해

    I also set the scheduler pool to two so the per-factory jobs truly run in parallel.

    checking microphone…

  8. 8

    안전장치 두 개: 작업 도는 노드가 죽으면 lockAtMostFor로 락이 풀려 다음 노드가 잡는다. 작업이 너무 빨리 끝나면 lockAtLeastFor로 최소 시간은 잡아둬서 다음 타이밍에 겹쳐 또 도는 걸 막는다.

    ⚖️ Trade-off: 이 두 시간 값을 작업 길이에 안 맞게 잡으면, 너무 짧으면 중복 실행, 너무 길면 죽은 노드 때문에 다음 실행이 늦는다.

    ✅ Fix: 값을 실제 작업 시간에 맞춰 튜닝한다. 그리고 작업 자체를 멱등(두 번 돌아도 결과 같게)으로 만들어 둔다.

    🔧 도구:@SchedulerLock(lockAtMostFor, lockAtLeastFor)

    🗣 영어로 말해

    lockAtMostFor frees the lock if a node dies, and lockAtLeastFor stops a fast job from double-firing.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.