Lock by the resource you protect — a global lock serializes the wrong things
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.
- 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
// 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)- ·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.
- 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).
- 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).
- 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 (한국어로 이해 → 영어로 말하기)
- 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
조율이 없으면 둘 다 실행한다 → 작업이 두 번 돈다. 돈 정산이 두 번 되거나 번호가 꼬인다.
⚖️ 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
기본 해결: 둘 다 보는 공유 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
여기서 진짜 사고가 났다. 헝가리 공장 작업을 새로 추가했는데, 이게 한 번도 안 돌았다.
🔧 도구: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
원인: 두 공장 작업이 락 이름 하나('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
결과: 중국이 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
이름을 나눠도 한 가지 더: 스프링 기본 스케줄러는 스레드가 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
안전장치 두 개: 작업 도는 노드가 죽으면 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단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.