Two app servers (S1, S2) run the SAME scheduled job — a nightly price refresh — off the same cron tick. With no coordination both wake, both run, and the job executes twice (duplicate price writes, double work). The ShedLock fix is one shared `job_lock` row: whichever server acquires it runs; the other's lock attempt returns false, so it SKIPS. The naive lock() always grants, so both servers run and emit the same run number — a duplicate.
résumé · SK AX — eliminated cross-server duplicate execution of a scheduled job with a distributed lock (ShedLock / job_lock table).
You'll predict the failure first, then write the fix and watch your own code drive the behavior.
서버가 2대(이중화). 둘 다 같은 시각(밤 2시)에 깨서 같은 작업을 돌린다.
@Scheduled(cron = "0 0 2 * * *") // both S1 and S2 fire at 2am
public void nightlyPriceRefresh() {
refreshPrices();
}“Two servers wake on the same schedule and run the same job.”
checking microphone…
조율이 없으면 둘 다 실행한다 → 작업이 두 번 돈다 (중복 처리, 중복 청구).
⚖️ Trade-off: 그냥 두면 자원 낭비 + 데이터 충돌.
“With no coordination, both servers run it, so the job executes twice.”
checking microphone…
자바 lock(synchronized)은 왜 안 되나? 그건 한 JVM(한 프로세스) 안에서만 통한다.
⚖️ Trade-off: 서버가 별개 프로세스라 서로의 메모리 락을 못 본다 → 앱 단 락은 무용.
🔧 도구:synchronizedReentrantLock (한 JVM 전용 — 여기선 X)
// in-process only: each JVM has its own lock object
private final ReentrantLock lock = new ReentrantLock();
lock.lock(); // S1's lock is invisible to S2's separate JVM
try { run(); } finally { lock.unlock(); }“A Java lock only works inside one process — separate servers can't see it.”
checking microphone…
해결: 둘 다 보는 공용 장소에 락을 건다. 공유 DB의 한 행(row)을 락으로 쓴다. 먼저 잡은 서버만 실행, 나머지는 skip.
⚖️ Trade-off: 락 저장소에 매 실행마다 왕복 1번 (조금 느림).
✅ Fix: 어차피 밤 배치라 그 정도 비용은 무시 가능 → DB 락이면 충분.
🔧 도구:ShedLock (@SchedulerLock + DB 락 테이블)Redis SETNXZooKeeper
boolean lock(String owner, String key) {
if (held.containsKey(key)) return false; // already held -> refuse
held.put(key, owner);
return true; // only the winner runs
}“I put the lock in a shared place both servers see — a row in the DB. Only the winner runs; the others skip.”
checking microphone…
트레이드오프 ①: 락 잡은 서버가 작업 도중에 죽으면? 락이 안 풀려서 내일 작업도 막힌다.
✅ Fix: TTL을 건다 — 일정 시간 지나면 락 자동 해제 (그래서 작업은 멱등이어야 안전).
🔧 도구:ShedLock lockAtMostFor
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "job_lock", lockAtMostFor = "PT10M")
public void nightlyPriceRefresh() { refreshPrices(); }“If the holder crashes, a TTL frees the lock so the next run isn't blocked.”
checking microphone…
트레이드오프 ②: 작업이 너무 빨리 끝나면? 락이 바로 풀려서 다음 틱이 또 돌 수 있다.
✅ Fix: 최소 보유 시간을 준다 — 일정 시간은 락을 붙잡고 있게.
🔧 도구:ShedLock lockAtLeastFor
@SchedulerLock(name = "job_lock",
lockAtMostFor = "PT10M",
lockAtLeastFor = "PT1M") // hold >=1m so a fast run can't double-fire“lockAtLeastFor holds the lock a minimum time so a fast run can't double-fire.”
checking microphone…
왜 Redis 말고 DB로 했나? 이미 DB가 있으니 새 인프라를 안 늘린다. DB 시간을 기준으로 해서 서버 간 시계 차이 문제도 피한다.
⚖️ Trade-off: Redis가 더 빠르지만 운영 부담 + 단일 Redis는 장애 시 위험(그땐 Redlock).
🔧 도구:DB(ShedLock)RedisRedlock / ZooKeeper (강한 합의)
@Bean
LockProvider lockProvider(DataSource ds) {
return new JdbcTemplateLockProvider(ds); // DB-backed: no new infra, uses DB time
}“I used the DB, not Redis — no new infra, and DB time avoids server clock skew.”
checking microphone…
7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
A scheduled job fires on every app server at the same cron tick. With no coordination it runs N times. You need exactly-once across separate JVMs.
// the lab's version: a Map / synchronized block
synchronized boolean lock(String key) {
if (held.contains(key)) return false;
held.add(key);
return true;
}Use when: Coordinating THREADS inside one process. Never for multiple servers.
Cost / limit: Useless across JVMs — each server has its own heap, so both 'win'.
-- one shared lock table
CREATE TABLE shedlock(
name VARCHAR PRIMARY KEY, -- the lock key (the PK = the mutex)
lock_until TIMESTAMP, -- TTL: when it auto-frees
locked_at TIMESTAMP,
locked_by VARCHAR -- which instance holds it
);
// you don't hand-write lock(): you annotate, the library does INSERT/UPDATE on the PK
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "priceRefresh",
lockAtMostFor = "30m", // crash safety: frees the lock if the holder dies
lockAtLeastFor = "5m") // a fast run can't double-fire the next tick
public void run() { ... }Use when: You already run a DB and have a periodic/nightly job — most cases.
Cost / limit: One row write per tick. Uses DB time, so no node clock-skew. Tune lockAtMostFor/AtLeastFor.
// acquire: atomic set-if-absent + TTL + a unique token
SET lock:priceRefresh <token> NX PX 30000
// release: only if it's STILL my token (atomic, via Lua — don't free someone else's)
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
endUse when: Many fast locks per second, you already run Redis, DB round-trip too slow.
Cost / limit: Another component to operate. A single Redis isn't safe under failover → that's what Redlock addresses.
// Redlock: acquire on a MAJORITY of N independent Redis nodes within a time bound // ZooKeeper/etcd: create an EPHEMERAL node; lowest-sequence node holds the lock, // others WATCH it; the node vanishes if the holder's session dies.
Use when: Split-brain double-execution would corrupt money/inventory and you need real consensus.
Cost / limit: Heavy — operational + latency. Overkill for one nightly job; the DB lock is almost always enough.
Know the ladder — start at the simplest that meets the need, and say what you'd reach for next and why.
Say it out loud in your own words — hit these terms — then check against the gold answer.
Say it in your own words — but hit these: concurrency · distributed lock · shedlock · scheduled job
checking microphone…