36 min read
Engineering7,781 words

Locking

What this is: a deep, practical reference for every kind of locking a backend engineer actually reaches for — JVM locks, database row/table/advisory locks, optimistic vs pessimistic concurrency control, and distributed locks across nodes — with the trade-offs, failure modes, and "how to sound Staff+" framing an interviewer is really probing for. All code is Kotlin + Java on Spring Boot. This is the coordination companion to [[concurrency-thread-safety-study-guide]] (which covers the JMM, synchronized, atomics, virtual threads, coroutines) and [[databases-and-data-storage-study-guide]] (isolation levels, MVCC) — I cross-reference rather than repeat those.

Version anchor (say the year — these move): Java 25 LTS (Sep 2025), Kotlin 2.x, Spring Boot 3.5 / 4.0, Spring Framework 6.2 / 7.0, Hibernate 6.x (JPA 3.1 / jakarta.persistence), PostgreSQL 17, MySQL 8.4, Redisson 3.3x, ShedLock 6.x. JPA lock-mode and exception names are stable across Hibernate 5→6; the jakarta.* package rename (from javax.*) landed with Jakarta EE 9 / Spring Boot 3. Postgres SKIP LOCKED exists since 9.5 (NOWAIT is far older, ~8.1); MySQL got SKIP LOCKED/NOWAIT in 8.0.1. Pin these when you cite them.


0. The mental model (everything below is a corollary of this)#

One sentence carries the topic:

A lock exists to make operations that conflict appear to happen one at a time. Every locking decision is you answering three questions — and the classic Staff-level mistakes are all answers to the wrong question.

Question 1 — WHERE does the contended state live? (This decides which lock can even work.)

  • In one JVM's heap → a JVM lock (synchronized, ReentrantLock, Mutex) is sufficient and correct.
  • In one database, touched by many app instances → you need a database lock (row lock, advisory lock) or a DB-enforced invariant (unique constraint, version column). A JVM lock is a no-op here — it only serializes the threads inside one process, and you have N processes.
  • Across many services / nodes / no shared transaction → you need a distributed lock (or, better, a design that removes the need for one).

The single most common Staff-level "gotcha": a local lock is worthless across nodes. synchronized fixed it on your laptop and in the one-pod test env; in prod with 3 replicas the invariant breaks again because there is no shared monitor across JVMs. Get Question 1 wrong and nothing else matters.

Question 2 — PREVENT the conflict, or DETECT it? (This is optimistic vs pessimistic — a bet on the contention rate.)

  • Pessimistic = prevent. Acquire the lock before touching the data; everyone else waits. You pay latency (waiting) and liveness risk (deadlock) to guarantee no one ever sees a conflict. Bet: "conflicts are likely; colliding is expensive."
  • Optimistic = detect. Take no lock; proceed assuming you're alone; check at commit whether anyone changed the data under you (a version/timestamp compare-and-set). If they did, you abort and retry. You pay nothing when uncontended, but pay a full redo on conflict. Bet: "conflicts are rare; the happy path should be free."

Optimistic and pessimistic are the same guarantee (no lost updates / serializable effect) bought two different ways. The entire choice is: how often will two writers actually collide, and what does a collision cost me?

Question 3 — GRANULARITY and COST. (Finer = more concurrency but more complexity; coarser = simpler but less throughput.)

  • Row lock vs page vs table; one hot counter row vs a sharded counter; a lock per key vs one global lock. Finer-grained locking buys parallelism at the price of deadlock surface and bookkeeping. Correctness first (coarsest safe lock), then reduce scope only where profiling shows contention.

Everything in this guide — @Version, SELECT … FOR UPDATE, SKIP LOCKED, Redlock, fencing tokens, advisory locks — is a specific answer to these three questions. Keep them in your head and you can derive the right tool live.


1. The taxonomy — the three layers of locking (draw this map first)#

When an interviewer says "tell me about locking," open with the map, so they see you know the space is layered, not one thing:

LayerScope of protectionMechanismsReleased by
In-process (JVM)Threads inside one JVMsynchronized, ReentrantLock, ReadWriteLock, StampedLock, Semaphore, CAS/atomics, Kotlin Mutexunlock / block exit / GC
In-databaseAll clients of one DBSELECT … FOR UPDATE (row), shared/exclusive locks, table locks, advisory locks, @Version (optimistic)COMMIT/ROLLBACK (mostly)
DistributedMany processes / services / DBsRedis (SET NX/Redlock), ZooKeeper/etcd, Redisson RLock, Spring Integration LockRegistry, ShedLocklease TTL expiry / explicit release

Two orthogonal axes cut across all three layers — an interviewer loves it when you name them:

  • Optimistic ↔ Pessimistic (detect vs prevent). Exists at every layer: JVM StampedLock's optimistic read mode, the DB version column, and a distributed compare-and-set on a version are the same idea at three scopes. Pessimistic equivalents: ReentrantLock, SELECT FOR UPDATE, a Redis mutex.
  • Shared ↔ Exclusive (read-lock vs write-lock). Many readers or one writer. ReadWriteLock (JVM), FOR SHARE vs FOR UPDATE (DB).

The rest of the guide walks the layers, but the optimistic-vs-pessimistic decision (Section 2) is the one you'll be asked about by name, so it comes first.


2. Optimistic vs Pessimistic — the core dichotomy (know this cold)#

2.1 The mechanism, side by side#

Pessimistic — lock, then read-modify-write, then commit. The lock is held for the whole transaction:

BEGIN; SELECT balance FROM account WHERE id = 42 FOR UPDATE; -- ← blocks any other writer NOW -- ... compute new balance in app ... UPDATE account SET balance = :new WHERE id = 42; COMMIT; -- ← lock released here

Optimistic — read (no lock), remember the version, and make the write conditional on the version being unchanged:

SELECT balance, version FROM account WHERE id = 42; -- ← no lock; version = 7 -- ... compute new balance in app (maybe across a long HTTP round-trip) ... UPDATE account SET balance = :new, version = 8 WHERE id = 42 AND version = 7; -- ← 0 rows updated ⇒ someone else won ⇒ retry

The optimistic UPDATE … WHERE version = 7 is a compare-and-set (CAS) — exactly the CPU CAS from the concurrency guide, lifted to a database row. Zero rows affected = the CAS failed = conflict detected.

2.2 The decision — six questions that pick the winner#

If…PreferWhy
Conflicts are rare (low contention)OptimisticHappy path takes no lock, so it's cheaper and more scalable; you rarely pay the retry.
Conflicts are frequent (hot row)PessimisticOptimistic degenerates into a retry storm (redo work repeatedly); waiting once is cheaper.
There's user/network think-time between read and write (HTTP edit form, multi-step UI)OptimisticYou must not hold a DB lock across a user's think-time or an external call — it pins a connection and invites pile-ups. Optimistic holds nothing between read and write.
The transaction is short and in one place (server-side read-modify-write)Pessimistic is fineLock held for microseconds; no think-time; simplest correctness.
The operation must not fail/redo (some financial postings, "decrement stock exactly once")PessimisticDeterministic — no "sorry, conflict, try again"; but see idempotency below.
You're scaling writes horizontally and want no coordination on the read pathOptimisticNo shared lock manager contention; fits stateless services.

One-liner for the interview: "Optimistic vs pessimistic is a bet on the contention rate. Low contention or any think-time between read and write → optimistic with a version column and a retry. High contention on a short server-side transaction → pessimistic SELECT … FOR UPDATE. And I never hold a pessimistic lock across a network call or user think-time."

2.3 The rule that catches people: you cannot hold a pessimistic lock across think-time#

The canonical example: an "edit product" screen. User GETs the product at 10:00, edits for four minutes, PUTs at 10:04. If you SELECT … FOR UPDATE in the GET and hope to keep it locked until the PUT, you'd hold a DB transaction (and a pooled connection) open for four minutes per editing user — the connection pool is exhausted by a dozen users, and two of them deadlock trivially. This is why web read-modify-write is almost always optimistic: the version travels to the client (as a field or an HTTP ETag) and comes back on the write, where the conditional UPDATE detects any interleaving edit. (More on the HTTP/ETag mapping in §3.4.)


3. Optimistic locking in depth#

3.1 The version column and JPA @Version — the 90% case#

You add a version column; the persistence layer (Hibernate) automatically appends AND version = ? to every UPDATE and bumps the version. On a mismatch (0 rows updated) it throws — you never write the CAS by hand.

Kotlin (JPA entity + Spring Data):

kotlin
import jakarta.persistence.* @Entity class Account( @Id val id: Long, var balance: BigDecimal, ) { @Version // Hibernate manages this: reads it, checks it, increments it var version: Long = 0 // Long/Int (or java.sql.Timestamp, but a numeric counter is safer) } @Service class TransferService(private val accounts: AccountRepository) { @Transactional fun withdraw(id: Long, amount: BigDecimal) { val acc = accounts.findById(id).orElseThrow() // SELECT ... , version = 7 acc.balance -= amount // dirty-checked // on COMMIT Hibernate runs: UPDATE account SET balance=?, version=8 WHERE id=? AND version=7 // if 0 rows → OptimisticLockException → surfaces as ObjectOptimisticLockingFailureException } }

Java (same entity):

java
@Entity class Account { @Id private Long id; private BigDecimal balance; @Version // jakarta.persistence.Version private long version; // getters/setters ... }

The exception you actually catch: Hibernate throws JPA's jakarta.persistence.OptimisticLockException; Spring's @Transactional/@Repository translation re-wraps it as org.springframework.orm.ObjectOptimisticLockingFailureException (a subclass of the generic OptimisticLockingFailureException). Catch the Spring type in service code so you're not coupled to Hibernate.

Key facts to state:

  • @Version fields must be int/Integer/long/Long/short/Short/java.sql.Timestamp (or Instant via a converter). A numeric counter is preferred over a timestamp — timestamps can collide at clock resolution and are sensitive to clock skew.
  • It works for UPDATE and DELETE (Hibernate checks the version on delete too), but not for INSERT (nothing to conflict with) and not for bulk JPQL UPDATE/UPDATE … SET — a bulk update bypasses the version check unless you bump version = version + 1 yourself. This is a classic silent lost-update source.
  • Don't catch (Exception) and swallow it — that reintroduces the lost update you were preventing.

3.2 Optimistic lock modesOPTIMISTIC vs OPTIMISTIC_FORCE_INCREMENT#

JPA has explicit lock modes you can request on a find/query even beyond the automatic behavior:

LockModeTypeMeaningUse when
OPTIMISTIC (a.k.a. legacy READ)Verify at commit that the row's version is unchanged since you read it — even if you didn't modify it.You read A, decide based on it, and write B; you need A to still be valid at commit (guards against a stale read you depend on).
OPTIMISTIC_FORCE_INCREMENT (legacy WRITE)Bump the version even though you didn't change the row's own columns.Aggregate consistency: you modified a child, but the invariant lives on the aggregate root — force-increment the root's version so two concurrent child edits conflict. The DDD/aggregate move.

The FORCE_INCREMENT case is the Staff-level one and ties straight to your domain: an OCPI charging session aggregate whose root version should bump whenever any child session-update event is applied, so two concurrent updates to the same session are serialized at the root even though they touch different child rows.

kotlin
val session = em.find(ChargingSession::class.java, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT) // root version will bump on commit session.apply(updateEvent) // mutate a child; root still "changes" via forced version

3.3 Retrying on conflict — the other half of optimistic (don't skip it)#

Optimistic locking is only complete if you handle the conflict. The retry must re-read fresh data (the whole point — the old snapshot is stale) and should be idempotent and bounded with back-off.

Kotlin — Spring Retry, declarative:

kotlin
@Service class InventoryService(private val repo: ItemRepository) { @Retryable( retryFor = [ObjectOptimisticLockingFailureException::class], maxAttempts = 4, backoff = Backoff(delay = 50, multiplier = 2.0, random = true) // jittered exponential back-off ) @Transactional // NOTE: @Transactional must be INSIDE the retry — new tx per attempt fun reserve(itemId: Long) { val item = repo.findById(itemId).orElseThrow() // FRESH read each attempt require(item.available > 0) { "sold out" } item.available -= 1 } @Recover // called after attempts are exhausted fun recover(ex: ObjectOptimisticLockingFailureException, itemId: Long): Unit = throw ResponseStatusException(HttpStatus.CONFLICT, "Too much contention, try again") }

Java — manual loop (when you want explicit control):

java
<T> T withOptimisticRetry(int maxAttempts, Supplier<T> tx) { for (int attempt = 1; ; attempt++) { try { return tx.get(); // each call is its own @Transactional unit } catch (ObjectOptimisticLockingFailureException e) { if (attempt >= maxAttempts) throw e; sleepWithJitter(attempt); // exponential back-off + jitter } } }

Trap to name: the retry and the transaction boundary must nest correctly — the transaction has to commit/rollback and reopen per attempt, or you'll retry inside a transaction that's already marked rollback-only and just fail again. In Spring, put @Retryable on the outer call and @Transactional on the inner method (separate beans), or use a TransactionTemplate inside the loop. Also: retrying is only safe if the body is idempotent — re-running it must not double-charge; combine with an idempotency key if there are side effects (ties to [[inbox-outbox-pattern]]).

3.4 Optimistic concurrency at the API layer — HTTP ETag / If-Match#

The web-native form of the version column. The server returns the resource's version as an ETag; the client echoes it in If-Match on the write; the server rejects with 412 Precondition Failed if the version moved. This is optimistic locking with the "think-time" living on the user's screen — the correct pattern for the edit-form example in §2.3.

kotlin
@PutMapping("/products/{id}") fun update( @PathVariable id: Long, @RequestHeader("If-Match") ifMatch: String, // e.g. "\"7\"" @RequestBody body: ProductDto, ): ResponseEntity<ProductDto> { val current = repo.findById(id).orElseThrow() if ("\"${current.version}\"" != ifMatch) throw ResponseStatusException(HttpStatus.PRECONDITION_FAILED) // 412 — someone edited first // ... apply, save (the @Version gives a second line of defense at the DB) ... return ResponseEntity.ok().eTag("\"${current.version + 1}\"").body(/* ... */) }

Saying "I'd expose the version as an ETag and require If-Match, returning 412 on conflict" connects locking to REST design and reads as senior. (See [[API-Design-Best-Practices]].)

3.5 Manual optimistic with jOOQ / plain SQL — when you're not on JPA#

Not every write path is an entity. On a jOOQ/JDBC command path you write the CAS yourself and check the affected-row count:

kotlin
val updated = dsl.update(ACCOUNT) .set(ACCOUNT.BALANCE, newBalance) .set(ACCOUNT.VERSION, expectedVersion + 1) .where(ACCOUNT.ID.eq(id)) .and(ACCOUNT.VERSION.eq(expectedVersion)) // the CAS predicate .execute() // returns row count if (updated == 0) throw OptimisticLockException("account $id changed under us")

jOOQ also supports @Version-style optimistic locking automatically on UpdatableRecords when you enable Settings.withExecuteWithOptimisticLocking(true) and mark a version/timestamp column. (See [[jooq-typesafe-sql-study-guide]].)


4. Pessimistic locking in depth#

4.1 The mechanism — SELECT … FOR UPDATE and JPA lock modes#

Pessimistic locking acquires a database row lock at read time and holds it until the transaction ends. Anyone else who tries to lock the same row waits (or fails fast, if you asked). In JPA you request it with a LockModeType:

LockModeTypeSQL it generates (Postgres)Semantics
PESSIMISTIC_READSELECT … FOR SHAREShared lock: others can also read-lock, but nobody can write until you release.
PESSIMISTIC_WRITESELECT … FOR UPDATEExclusive lock: nobody else may read-lock or write the row until you commit. The default "I'm going to modify this."
PESSIMISTIC_FORCE_INCREMENTSELECT … FOR UPDATE + bump @VersionExclusive lock and increment the version — combine pessimistic + optimistic when other readers use optimistic checks.

Kotlin — Spring Data @Lock:

kotlin
interface AccountRepository : JpaRepository<Account, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) // → SELECT ... FOR UPDATE @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // wait ≤ 3s @Query("select a from Account a where a.id = :id") fun findByIdForUpdate(id: Long): Account? } @Service class TransferService(private val accounts: AccountRepository) { @Transactional fun withdraw(id: Long, amount: BigDecimal) { val acc = accounts.findByIdForUpdate(id) ?: error("no account") // row is now X-locked acc.balance -= amount // safe: no one else can touch it } // COMMIT releases the lock }

Java — EntityManager.find with lock mode + timeout hint:

java
Map<String, Object> hints = Map.of("jakarta.persistence.lock.timeout", 3000); // ms Account acc = em.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE, hints); acc.setBalance(acc.getBalance().subtract(amount));

The failure you catch: a lock-acquisition timeout / conflict surfaces as JPA jakarta.persistence.PessimisticLockException or LockTimeoutException, translated by Spring to PessimisticLockingFailureException / CannotAcquireLockException.

Postgres caveat worth volunteering: the standard jakarta.persistence.lock.timeout hint is not honored by the Postgres JDBC driver as a per-statement lock timeout the way it is on Oracle. On Postgres you either use NOWAIT/SKIP LOCKED (below) or set the session lock_timeout (e.g. SET LOCAL lock_timeout = '3s'). Knowing this DB-specific gap is a strong signal.

4.2 Postgres row-lock modes and the two flags that matter most#

Raw SQL gives finer control than JPA exposes. The four row-level lock strengths (weakest→strongest):

ClauseStrengthBlocksTypical use
FOR KEY SHAREweakestother txns' FOR UPDATE (not FOR NO KEY UPDATE)FK integrity
FOR SHAREshared readany writer"read and be sure it doesn't change"
FOR NO KEY UPDATEexclusive-ishother exclusive locks, but not FOR KEY SHAREupdates that don't touch the PK/unique key (less blocking than FOR UPDATE)
FOR UPDATEstrongesteverything"I will modify or delete this row"

The two flags that turn FOR UPDATE from a footgun into a tool:

  • NOWAIT — don't queue; if the row is already locked, fail immediately (55P03 lock_not_available). Use when waiting is pointless and you'd rather fail fast and tell the user.
  • SKIP LOCKEDskip rows currently locked by others and return only what you can lock. This is the foundation of a concurrent worker queue (next section) and is the single most impress-the-interviewer pessimistic feature.

4.3 SKIP LOCKED — the concurrent job-queue pattern (learn this, it's gold)#

"How do N workers pull jobs from a table without stepping on each other, without a message broker?" Answer: FOR UPDATE SKIP LOCKED. Each worker atomically grabs the next unlocked pending row, locks it, processes, marks it done — and other workers skip the row it holds. No double-processing, no external queue.

sql
-- One worker's "claim a batch" query: SELECT id, payload FROM outbox WHERE status = 'PENDING' ORDER BY created_at LIMIT 10 FOR UPDATE SKIP LOCKED; -- ← other workers skip these 10 rows -- ...process... then UPDATE outbox SET status='DONE' WHERE id = ANY(:ids); COMMIT;

Kotlin — Spring Data native query claiming rows:

kotlin
interface OutboxRepository : JpaRepository<OutboxRow, Long> { @Query( value = """ SELECT * FROM outbox WHERE status = 'PENDING' ORDER BY created_at LIMIT :batch FOR UPDATE SKIP LOCKED """, nativeQuery = true) fun claimBatch(batch: Int): List<OutboxRow> // call inside @Transactional; locks held to commit }

Java — jOOQ, the same claim:

java
Result<Record> claimed = dsl.selectFrom(OUTBOX) .where(OUTBOX.STATUS.eq("PENDING")) .orderBy(OUTBOX.CREATED_AT) .limit(10) .forUpdate().skipLocked() // ← jOOQ DSL for FOR UPDATE SKIP LOCKED .fetch();

This is exactly the drain pattern behind a transactional outbox poller ([[inbox-outbox-pattern]]): multiple relay instances share one outbox table and never publish the same event twice. It's also how lightweight job systems (e.g. the mechanism behind libraries like pg-boss, and how you'd hand-roll one) achieve competing consumers on Postgres.

4.4 When pessimistic is the right call#

Reach for pessimistic when: contention is high on a specific row (optimistic would thrash), the transaction is short and server-side (no think-time), you're doing SKIP LOCKED queueing, or you need deterministic "no redo" semantics (e.g. decrement-inventory-exactly-once where a retry is more disruptive than a brief wait). Its costs — deadlocks, held connections, reduced throughput — are the subject of §5 and §8.


5. Database locking internals (the "concurrency at the DB level" the interview wants)#

Your active screen explicitly probes concurrency at the database level. This is where you show it. (Isolation-level anomalies are covered fully in [[databases-and-data-storage-study-guide]]; here I frame them through locking.)

5.1 Shared vs exclusive, and lock granularity#

Databases lock at multiple granularities: row, page, table. Two basic modes — shared (S) for readers, exclusive (X) for writers; S-locks are compatible with each other, X is compatible with nothing. Intent locks (IS/IX) at the table level let the engine know "some row below is S/X-locked" so a table-level operation can check compatibility without scanning every row lock. Lock escalation (notably SQL Server; Postgres does not escalate row→table) is when too many row locks get promoted to a coarser table lock, tanking concurrency — a real gotcha on some engines.

5.2 MVCC — why in Postgres readers don't block writers (and vice versa)#

The single most important DB-concurrency fact for a Postgres shop: Postgres uses MVCC (Multi-Version Concurrency Control). A write creates a new version of the row rather than overwriting in place; readers see the version consistent with their snapshot. Consequences you should state plainly:

  • A plain SELECT takes no row locks and is never blocked by a writer, and a writer is never blocked by readers. This is why "readers don't block writers" is true on Postgres/Oracle/MySQL-InnoDB but was not on old lock-based engines.
  • Therefore, to get a pessimistic lock you must ask explicitly — SELECT … FOR UPDATE. A bare SELECT gives you a consistent snapshot, not exclusivity.
  • MVCC's cost is bloat/garbage — dead row versions must be reclaimed by VACUUM. A long-running transaction holds back the vacuum horizon, so a forgotten FOR UPDATE transaction not only blocks writers on that row but also bloats the whole DB. (Great "failure mode" to mention.)

5.3 Isolation levels are a locking strategy in disguise#

Isolation level and locking are two views of the same thing — the level dictates what the engine locks (or which snapshot it enforces):

LevelWhat it preventsHow it's enforced
READ COMMITTED (Postgres default)dirty readseach statement sees latest committed snapshot; writers take row X-locks; lost updates still possible on read-modify-write
REPEATABLE READ (MySQL/InnoDB default)+ non-repeatable readstransaction-level snapshot (Postgres: true snapshot isolation; also prevents phantoms). InnoDB adds gap/next-key locks to stop phantoms.
SERIALIZABLE+ phantoms, + write skewPostgres: SSI — Serializable Snapshot Isolation, an optimistic mechanism that aborts one txn with 40001 if the schedule wasn't serializable. Most other engines: strict two-phase locking (2PL)pessimistic.

The Staff-level nugget: "On PostgreSQL, SERIALIZABLE is implemented optimistically via SSI — it doesn't take extra locks; it monitors read/write dependencies and aborts a transaction with a serialization failure (40001), so you must wrap serializable transactions in a retry loop. On most other engines SERIALIZABLE is pessimistic 2PL that blocks instead. Same isolation guarantee, opposite mechanism." Very few candidates know this; it lands.

The lost update anomaly is the one locking is most often deployed against. Under READ COMMITTED, two read-balance → subtract → write transactions can both read 100, both write 80, losing one withdrawal. Three fixes, one per style: optimistic (@Version), pessimistic (SELECT … FOR UPDATE), or avoid the read-modify-write entirely with a single atomic statement: UPDATE account SET balance = balance - :amt WHERE id = :id AND balance >= :amt — the DB does the arithmetic under its own row lock, no app round-trip. Offer that third option; it's often the best one.

5.4 Deadlocks — detection and the fix#

Two transactions each hold a row the other wants → deadlock. Postgres auto-detects the cycle (after deadlock_timeout, default 1s) and kills one victim with 40P01 deadlock detected; the survivor proceeds. You don't prevent deadlocks by waiting harder — you prevent them by acquiring locks in a consistent global order (e.g. always lock the lower account id first in a transfer) and by keeping transactions short. Handle the victim's error with a bounded retry (same shape as optimistic retry).

kotlin
// Deadlock-safe transfer: always lock the two rows in a canonical order (by id). @Transactional fun transfer(from: Long, to: Long, amt: BigDecimal) { val (first, second) = listOf(from, to).sorted() // GLOBAL ordering prevents the A→B / B→A cycle val a = accounts.findByIdForUpdate(first)!! val b = accounts.findByIdForUpdate(second)!! val (src, dst) = if (from == first) a to b else b to a src.balance -= amt; dst.balance += amt }

5.5 Advisory locks — an application-defined mutex living in Postgres#

Sometimes you want to serialize work ("only one node may run this migration / rebuild / cron") without a natural row to lock. Postgres advisory locks are named locks the app controls, keyed by a 64-bit integer, that the database arbitrates — so they work across all app instances sharing that DB. This is the bridge from DB locking to distributed locking.

  • pg_advisory_lock(key)session-scoped: held until you explicitly unlock or the session ends. Danger in a connection pool: if you don't release it, the connection returns to the pool still holding the lock.
  • pg_advisory_xact_lock(key)transaction-scoped: auto-released on COMMIT/ROLLBACK. Prefer this one — no leak risk.
  • pg_try_advisory_lock(key) — non-blocking; returns false immediately if held (your "did I get leadership?" check).
kotlin
@Transactional fun runExclusiveRebuild(jobKey: Long) { val got = dsl.fetchValue("select pg_try_advisory_xact_lock(?)", jobKey) as Boolean if (!got) return // another instance holds it → skip this run rebuildProjections() // exactly one node runs this; lock auto-released at COMMIT }

Advisory locks are the pragmatic "distributed lock when you already have one shared Postgres" — no Redis/ZooKeeper needed. Spring Integration's JdbcLockRegistry generalizes this idea onto a lock table (§6.4).

5.6 MySQL/InnoDB contrast — be ready for "we use MySQL"#

If they run MySQL, the picture shifts: default isolation is REPEATABLE READ, and InnoDB prevents phantoms with gap locks and next-key locks (a next-key lock = a row lock plus the gap before it, locking a range so no one can insert a phantom into it). This means SELECT … FOR UPDATE on a range in MySQL can lock rows that don't exist yet, blocking inserts — a source of surprising deadlocks that Postgres (which uses predicate-ish SSI logic only at SERIALIZABLE) doesn't have at READ COMMITTED. MySQL 8.0+ also has FOR UPDATE SKIP LOCKED / NOWAIT, and older code uses LOCK IN SHARE MODE (now FOR SHARE). Knowing "gap locks / next-key locks" by name is the MySQL depth-check.


6. Distributed locking — the Staff+ centerpiece#

This is where the interview goes to separate senior from Staff. The headline you must deliver before any algorithm:

Distributed locks are a last resort, not a first reach. A distributed lock trades availability for consistency and adds a failure mode (the lock service). Before using one, I try to design the contention away: partition the work so one owner touches each key (Kafka partition key → single consumer; consistent hashing; sticky routing), make the operation idempotent (idempotency key + unique constraint so a duplicate is harmless), or use optimistic concurrency (a version/conditional write in the DB). I use a real distributed lock only for a genuinely non-idempotent, non-partitionable, mutually-exclusive side effect.

Say that, and then show you know how to build one correctly.

6.1 What a correct distributed lock actually requires#

Three properties, and the third is the one most people miss:

  1. Mutual exclusion (safety): at most one holder at a time.
  2. A lease / TTL (liveness): the holder can crash while holding the lock. Without an expiry, a dead holder deadlocks everyone forever. So every distributed lock has a time-bounded lease — which immediately creates problem #3.
  3. Fencing (safety under pauses): because the lock auto-expires, the original holder can be paused (a GC stop-the-world, a VM freeze, a network partition) past the lease, the lock gets granted to someone else, and now two processes believe they hold the lock. A TTL cannot prevent this. The only real fix is a fencing token.

6.2 Fencing tokens — the argument you must be able to make (Kleppmann vs Redlock)#

This is the distributed-locking discussion, from Martin Kleppmann's critique of the Redlock algorithm. Walk it as a story:

  1. Client A acquires the lock, gets lease for 30s.
  2. A stops-the-world (GC pause / hits a breakpoint / the VM is descheduled) for 40s. A doesn't know time passed.
  3. The lease expires at 30s. The lock service grants the lock to client B.
  4. A wakes up at 40s still believing it holds the lock, and writes to the protected resource. B is also writing. Mutual exclusion is violated — and no amount of clock-tuning or extra Redis nodes fixes it, because the pause happened inside the client.

The fix: the lock service hands out a monotonically increasing fencing token with each grant (A gets 33, B gets 34). Every write to the protected resource carries its token, and the resource itself rejects any token lower than the highest it has already seen. When paused-A finally writes with token 33, the storage sees it already accepted 34 and rejects 33. Safety is restored — but note it requires the protected resource to participate in fencing, which many can't. That limitation is exactly why the senior instinct is to avoid needing the lock at all (§6, opener).

Time → A: acquire(tok=33) ──[ 40s GC pause ]────────────► write(tok=33) ✗ rejected (< 34) lease expires ──► B: acquire(tok=34) ──► write(tok=34) ✓ accepted Storage remembers "highest token = 34" and fences off the stale writer.

The Redlock algorithm (Antirez/Redis) tries to make a lock safe across N independent Redis masters by requiring a majority (e.g. 3 of 5) and bounding on clock/lease time. Kleppmann's critique: it depends on bounded clock drift and bounded pauses, which don't hold in real systems, so it's unsafe for correctness-critical use without fencing; Antirez rebutted on the clock assumptions. You don't need to pick a side — you need to show you understand the trade-off and that fencing tokens are the robust answer. For most apps, a single-instance Redis lock is fine for efficiency (avoid two workers doing the same harmless work) but not for correctness (never rely on it alone to prevent double-spend).

6.3 The pragmatic single-Redis lock (and why the release must be atomic)#

The common real-world lock: SET key <unique-token> NX PX <ttl>. NX = set only if absent (acquire); PX = expiry (lease). Release must check-and-delete atomically so you never delete someone else's lock (yours may have expired and been re-acquired):

kotlin
// Acquire: SET lock:job "<uuid>" NX PX 30000 → "OK" means acquired val token = UUID.randomUUID().toString() val ok = redis.opsForValue().setIfAbsent("lock:job", token, Duration.ofSeconds(30)) // Release: delete ONLY if the value is still my token — atomically, via Lua (never GET-then-DEL) val unlock = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """.trimIndent() redis.execute(DefaultRedisScript(unlock, Long::class.java), listOf("lock:job"), token)

Why the Lua: a naive if get()==token then del() has a race — the lock can expire between the GET and the DEL, be acquired by someone else, and you'd delete their lock. The check-and-delete must be one atomic server-side step.

6.4 Production tooling — what you'd actually use (don't hand-roll)#

You would rarely write the above by hand. Name the libraries:

Redisson RLock — a batteries-included distributed lock over Redis with a watchdog that auto-renews the lease while you still hold it (default lease 30s, renewed every 10s), reentrancy, and fair-lock variants:

kotlin
val lock = redisson.getLock("lock:session:$sessionId") if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // wait ≤3s, EXPLICIT 30s lease → watchdog is OFF try { doExclusiveWork() } finally { lock.unlock() } }
java
RLock lock = redisson.getLock("lock:session:" + sessionId); if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // explicit lease ⇒ no auto-renewal; expires after 30s try { doExclusiveWork(); } finally { lock.unlock(); } }

Watchdog caveat to get right: the auto-renewing watchdog only kicks in when you acquire without an explicit leaselock() or tryLock(waitTime, unit). The moment you pass a leaseTime (the three-arg tryLock above), Redisson honors that fixed lease and does not renew it, so long-running work can still lose the lock mid-flight. Use the no-lease form for watchdog protection, or size the explicit lease above your worst-case work time.

Spring Integration LockRegistry — a uniform Lock abstraction with pluggable backends: RedisLockRegistry, JdbcLockRegistry (a DB lock table — great when your only shared infra is Postgres), ZookeeperLockRegistry, Hazelcast:

kotlin
// JdbcLockRegistry: a distributed lock backed by an INT_LOCK table — no Redis needed val lock = jdbcLockRegistry.obtain("session:$sessionId") if (lock.tryLock(3, TimeUnit.SECONDS)) { try { doExclusiveWork() } finally { lock.unlock() } }

ShedLock — for scheduled jobs specifically (extremely common, very presentable). The problem: a @Scheduled cron runs on every replica, so a nightly report fires 3× on a 3-pod deployment. ShedLock ensures at most one node runs each execution, using a shared store (JDBC/Redis/Mongo) — it is not a general mutex, exactly a scheduled-task lock:

kotlin
@Scheduled(cron = "0 0 2 * * *") @SchedulerLock(name = "nightlySettlement", // one node cluster-wide runs this lockAtMostFor = "10m", lockAtLeastFor = "1m") fun runNightlySettlement() { /* ... */ }

lockAtMostFor is the safety lease (released even if the node dies mid-run); lockAtLeastFor guards against a too-fast run letting a second node also fire. This one example — "we deployed to 3 pods and the cron ran 3 times; I fixed it with ShedLock" — is a perfect, concrete distributed-locking war story.

6.5 ZooKeeper / etcd — consensus-backed locks (when correctness matters)#

For correctness-critical coordination, a consensus system (ZooKeeper via ZAB, etcd via Raft) is safer than Redis because liveness is tied to a session/lease the server tracks, not a wall-clock TTL the client guesses:

  • ZooKeeper recipe: create an ephemeral sequential znode under a lock node; you hold the lock if your sequence number is the lowest; otherwise watch only your immediate predecessor (avoids the "herd effect" of everyone waking on every change). Ephemeral = the znode vanishes automatically when your session dies → crash-safe release without a TTL guess.
  • etcd: a lease attached to a key; the key auto-deletes when the lease isn't renewed; etcd's monotonic revision/mod_revision is a natural fencing token. This is why Kubernetes uses etcd for leader election.

Leader election is the structured cousin of the distributed lock: instead of every node grabbing a lock per task, one node is elected leader and does the exclusive work; the lock is "who is leader." Often cleaner than fine-grained locking for singleton background work.

6.6 How this maps to your domain (say this in the room)#

  • Temporal.io durable sagas (your contract/account-management work) largely sidestep distributed locks: a workflow is single-owner and durably executed, so "only one actor drives this saga" is guaranteed by the workflow engine, not a lock you manage. That's a design that removes the need for a lock — exactly the senior move.
  • Kafka partitioning (your OCPI pipeline): keying session-update events by sessionId means one partition → one consumer processes a given session's events in order — mutual exclusion by partitioning, no lock at all.
  • Outbox + idempotency keys: duplicate delivery is made harmless by a unique constraint, so you don't need a lock to prevent double-publish.
  • Where you'd still use one: e.g. ShedLock on a @Scheduled reconciliation sweep so only one pod runs it.

7. The JVM lock taxonomy — "all types of locking in Java/Kotlin" (compact; depth in the concurrency guide)#

The question literally asks for "all types of locking," so have the in-process catalog ready. (Mechanics, JMM, and happens-before are in [[concurrency-thread-safety-study-guide]] — here's the typology.)

Lock / primitiveTypeKey propertyReach for it when
synchronized (intrinsic monitor)pessimistic, exclusive, reentrantsimplest; JMM edge on enter/exit; no timeoutshort critical section, no need for tryLock
ReentrantLockpessimistic, exclusive, reentranttryLock(timeout), interruptible, fairness, conditionsneed to back off, time out, or wait on conditions
ReentrantReadWriteLockpessimistic, shared/exclusivemany readers or one writerread-heavy, long-ish critical sections
StampedLockoptimistic read + pessimistic writeoptimistic read = validate-a-stamp (no lock); not reentrantread-mostly, ultra-low-overhead reads (validate, retry as read-lock)
Semaphorecounting (not mutual exclusion)N permits — bound concurrency, not serializelimit concurrent access to a resource pool
CAS / AtomicXoptimistic, lock-freecompare-and-set, spins on contention; ABA caveatsingle-variable counters/flags/refs
Object.wait/notify, Conditioncoordinationthread signalling under a monitorproducer/consumer without a queue type
Kotlin Mutex (coroutines)pessimistic, suspending (non-blocking)doesn't block a thread; not reentrantshared mutable state in suspend code (never synchronized there)
Kotlin Semaphore (coroutines)counting, suspendingpermits without blocking a threadbound concurrent coroutines

Reusable adjectives that show you understand the dimensions of a lock:

  • Reentrant vs non-reentrant — can the holder re-acquire? (synchronized/ReentrantLock yes; StampedLock/Mutex no → self-deadlock if you try.)
  • Fair vs unfair — FIFO grant order vs throughput-optimized (unfair is the default and usually faster; fair prevents starvation).
  • Shared vs exclusive — read-lock vs write-lock.
  • Optimistic vs pessimistic — the same axis as the DB: StampedLock.tryOptimisticRead() is the in-memory twin of a version column.
  • Blocking vs non-blocking — a lock parks a thread; CAS spins; Mutex suspends a coroutine (frees the thread).
  • Spinlock — busy-waits instead of parking; only sane for extremely short holds (the JVM uses adaptive spinning internally before parking).

Kotlin runs on the JVM and obeys the same JMM, so every Java lock is available. The only Kotlin-specific rule that matters: inside suspend/coroutine code use Mutex/coroutine Semaphore, never synchronized/ReentrantLock — a blocking JVM lock stalls the underlying dispatcher thread and defeats the coroutine model.


8. Best practices (the consolidated checklist)#

  1. Design the contention away before you lock. Immutability, confinement (one owner per key), partitioning, and idempotency remove whole classes of locking. The best lock is the one you didn't need. (This is the senior instinct from [[concurrency-thread-safety-study-guide]] applied at every scope.)
  2. Match the lock's scope to the contention's scope (Question 1). In-JVM contention → JVM lock. Cross-instance contention on shared data → DB lock / version column. Cross-service → distributed lock. Never use a smaller-scope lock than the problem — a synchronized across 3 pods is a no-op.
  3. Pick optimistic vs pessimistic by contention rate and think-time (Question 2). Low contention or any think-time → optimistic @Version + retry. High contention, short server-side txn → pessimistic FOR UPDATE. When possible, avoid both with a single atomic UPDATE … WHERE.
  4. Never hold a lock across I/O, a network call, or user think-time. That's how you exhaust connection pools, pin virtual-thread carriers, and deadlock. Read-modify-write over HTTP is optimistic, full stop.
  5. Keep critical sections tiny and acquire multiple locks in a consistent global order. Sort by a canonical key (account id, resource name) to kill the A→B / B→A deadlock.
  6. Always bound the wait — never wait forever. tryLock(timeout) (JVM), NOWAIT/SKIP LOCKED or SET LOCAL lock_timeout (Postgres), a lease TTL (distributed). A lock with no timeout is a latent outage.
  7. Always handle the failure path. Optimistic → bounded, jittered retry on a fresh read, with idempotent side effects. Pessimistic/deadlock → catch CannotAcquireLockException/40P01 and retry or fail fast. Serializable (SSI) → retry on 40001.
  8. Distributed locks: last resort, always leased, prefer fencing. Require a TTL (holder can crash) and, for correctness-critical writes, a fencing token the resource enforces. Use a single-Redis lock only for efficiency, consensus (etcd/ZK) for correctness, ShedLock for @Scheduled.
  9. Release deterministically. JVM: unlock() in finally. DB: prefer transaction-scoped advisory locks (pg_advisory_xact_lock) over session-scoped so a pooled connection can't leak a held lock. Redis: atomic check-and-delete (Lua), never GET-then-DEL.
  10. Beware the hot row. A single global counter/row serializes everything. Shard it (N sub-counters summed on read — the DB analog of LongAdder) or move to an append-and-aggregate model.
  11. Test the pause/crash path, not just the happy path. Concurrency bugs are nondeterministic; a passing test proves little. Stress-test interleavings; for distributed locks, explicitly test "holder pauses past its lease."

9. Problems & how to solve them (Problem → Root cause → Fix)#

9.1 Lost update on a read-modify-write.

  • Problem: two requests read balance 100, each subtracts, both write 80; one withdrawal vanishes.
  • Root cause: READ COMMITTED doesn't prevent lost updates; the compound read-modify-write isn't atomic.
  • Fix: optimistic @Version (conditional UPDATE … WHERE version = ?), or pessimistic SELECT … FOR UPDATE, or best: one atomic UPDATE account SET balance = balance - :amt WHERE id=:id AND balance >= :amt.

9.2 Optimistic retry storm under high contention.

  • Problem: a hot row (e.g. a popular product's stock) throws ObjectOptimisticLockingFailureException constantly; throughput collapses as everyone redoes work.
  • Root cause: optimistic locking bet on low contention and lost the bet; every attempt conflicts and redoes.
  • Fix: switch that row to pessimistic FOR UPDATE, or serialize via a queue/SKIP LOCKED, or shard the counter so writers hit different rows. Optimistic is the wrong tool for a genuinely hot row.

9.3 Pessimistic deadlock.

  • Problem: two transfer transactions hang; Postgres kills one with 40P01 deadlock detected.
  • Root cause: inconsistent lock order — T1 locks A then B, T2 locks B then A.
  • Fix: acquire rows in a canonical global order (sort by id) and keep the transaction short; retry the victim with back-off.

9.4 SELECT … FOR UPDATE held across an external call → pool exhaustion.

  • Problem: latency spikes and "connection pool timeout" under load; a few slow calls freeze everything.
  • Root cause: a pessimistic lock (and its pooled connection) held open across an HTTP call / user think-time — connections and locks pile up.
  • Fix: never hold a lock across I/O. Do the external call outside the locked transaction; use optimistic concurrency for the long span; keep the locked section to the DB write only.

9.5 A @Scheduled job runs N times (once per pod).

  • Problem: nightly settlement fires 3× on a 3-replica deployment → duplicate side effects.
  • Root cause: @Scheduled runs independently on every instance; there's no shared lock.
  • Fix: ShedLock @SchedulerLock (at-most-one node per execution), or a pg_try_advisory_xact_lock guard, or leader election.

9.6 Distributed lock expired mid-work → double execution.

  • Problem: two workers both process the same job despite a Redis lock.
  • Root cause: the lease (TTL) expired while worker A was paused (GC/partition); the lock was re-granted to B → two holders. A TTL can't prevent this.
  • Fix: fencing token the resource enforces (reject stale tokens); make the operation idempotent so a double-run is harmless; use Redisson's watchdog lease-renewal; for correctness use consensus (etcd/ZK), not single-Redis.

9.7 @Version present but still a lost update.

  • Problem: the version column exists yet updates are still clobbered.
  • Root cause: one of — a bulk JPQL/UPDATE bypassed the version check; the OptimisticLockException was caught and swallowed; the write went through a path (native SQL) that doesn't touch version; or reads/writes are split across transactions so the check window is wrong.
  • Fix: bump version = version + 1 explicitly in bulk updates; never swallow the exception (retry or propagate); route all writes through the versioned path; consider OPTIMISTIC_FORCE_INCREMENT for aggregate-root invariants.

9.8 Advisory lock leaked into the connection pool.

  • Problem: after a while, a job never runs because "the lock is always held," even though no one is using it.
  • Root cause: a session-scoped pg_advisory_lock wasn't released; the connection returned to the pool still holding it.
  • Fix: use pg_advisory_xact_lock (auto-released at commit) instead of the session-scoped variant, or ensure explicit unlock in finally on the same connection.

9.9 Serializable transaction randomly fails with 40001.

  • Problem: under SERIALIZABLE on Postgres, transactions abort with could not serialize access due to read/write dependencies.
  • Root cause: that's SSI working as designed — it's optimistic and aborts non-serializable schedules rather than blocking.
  • Fix: wrap serializable transactions in a bounded retry loop on 40001. It's expected, not a bug; budget for it.

9.10 Hot single-row / global-counter contention.

  • Problem: a sequence/counter/"total" row becomes the bottleneck; everything serializes on it.
  • Root cause: all writers contend on one row's lock.
  • Fix: shard the counter into N rows (write to id % N, sum on read — DB analog of LongAdder), or use an append-only events table aggregated asynchronously.

10. Interview framing — how to sound Staff+#

  • Open with the three questions, not an API. "Locking is making conflicting operations appear serial. I ask three things: where does the contended state live — that decides whether a JVM, database, or distributed lock is even valid; do I prevent the conflict (pessimistic) or detect it (optimistic), which is a bet on contention rate; and what granularity, trading concurrency for complexity." That paragraph signals depth before any code.
  • Frame optimistic vs pessimistic as a bet. "Optimistic and pessimistic buy the same guarantee two ways. Low contention or any think-time between read and write → optimistic version column plus retry. High contention on a short server-side transaction → pessimistic FOR UPDATE. And I never hold a pessimistic lock across a network call or user think-time — that's the connection-pool killer."
  • Land the scope-mismatch insight. "The most common bug is a synchronized that works in test and breaks in prod because prod has three replicas — a JVM lock doesn't compose across processes. The fix is to push the invariant to shared state: a unique constraint, a version column, or a distributed lock with fencing." This one connects local thread-safety to distributed systems — the Staff move.
  • Own the distributed-lock trade-off. "Distributed locks are a last resort — they trade availability for consistency and add a failure mode. I first try to partition so one owner touches each key, make the op idempotent, or use optimistic concurrency. If I truly need one, it must be leased because holders crash, and for correctness it needs a fencing token because a GC pause can outlive the lease — that's Kleppmann's critique of Redlock."
  • Know the DB internals. "Postgres is MVCC, so readers don't block writers and a plain SELECT takes no lock — I have to ask for FOR UPDATE explicitly. And SERIALIZABLE on Postgres is optimistic SSI: it aborts with 40001 rather than blocking, so serializable code needs a retry loop. On most other engines SERIALIZABLE is pessimistic 2PL." Very few candidates say this.
  • Have SKIP LOCKED ready. It's the crispest "I've actually built concurrent systems" flex — N workers draining a table with no broker, the mechanism behind a transactional-outbox relay.
  • Tie it to your domain. OCPI session aggregate → optimistic @Version (+ FORCE_INCREMENT on the root); Kafka partition-by-sessionId → mutual exclusion by partitioning, no lock; Temporal durable workflows → single-owner execution that removes the need for a distributed lock; a 3-pod cron fixed with ShedLock. Concrete, lived examples beat textbook definitions.

Common wrong answers to avoid: "just use synchronized" (doesn't compose across nodes); "optimistic is always better" (retry storms on hot rows); "a TTL makes a distributed lock safe" (GC pause → two holders; you need fencing); "SELECT locks the row" (not under MVCC — you must ask); "hold the lock from the GET to the PUT" (pool exhaustion; use optimistic/ETag); "SERIALIZABLE just makes it safe" (on Postgres it aborts you — handle 40001); catching and swallowing OptimisticLockException.


11. Cheat-sheets#

Optimistic vs pessimistic — pick fast:

DimensionOptimisticPessimistic
Howdetect at commit (version CAS)prevent at read (FOR UPDATE)
Lock heldnone between read & writefrom read to commit
Best whenlow contention, think-time, web RMWhigh contention, short server-side txn
Failure modeconflict → retry (storms if hot)deadlock, held connections, waits
Cost modelfree happy path, expensive redopay latency always, no redo
JPA@Version + ObjectOptimisticLockingFailureException@Lock(PESSIMISTIC_WRITE) + CannotAcquireLockException

JPA LockModeType → SQL → meaning:

LockModeTypeSQL (Postgres)Use
OPTIMISTICversion check at commitverify a read you depend on is still valid
OPTIMISTIC_FORCE_INCREMENTversion bump even if row unchangedaggregate-root invariant across child edits
PESSIMISTIC_READFOR SHAREshared lock; block writers
PESSIMISTIC_WRITEFOR UPDATEexclusive; "I will modify this"
PESSIMISTIC_FORCE_INCREMENTFOR UPDATE + version bumpexclusive lock and version bump together

Postgres locking quick-ref:

WantSQL
Exclusive row lockSELECT … FOR UPDATE
…fail if already lockedSELECT … FOR UPDATE NOWAIT (55P03)
…skip locked rows (queue)SELECT … FOR UPDATE SKIP LOCKED
Shared row lockSELECT … FOR SHARE
App-defined lock, txn-scopedpg_advisory_xact_lock(key) / pg_try_advisory_xact_lock(key)
Bound wait timeSET LOCAL lock_timeout = '3s'
Deadlock error code40P01
Serialization failure (retry)40001
Lock-not-available (NOWAIT)55P03

Distributed lock options:

OptionSafetyLivenessUse
pg_advisory_xact_lockgood (single DB)auto-release at commitalready have one Postgres
SET NX PX (single Redis)efficiency onlyTTL leaseharmless dedup, not correctness
Redlock (N Redis)contested (needs fencing)TTL leaseAntirez vs Kleppmann — know the debate
Redisson RLockgood + watchdog renewalauto-renew leaseapp-level mutex on Redis
Spring Integration LockRegistrybackend-dependentbackend-dependentuniform API over Redis/JDBC/ZK
ZooKeeper / etcdstrong (consensus)session/leasecorrectness-critical, leader election
ShedLockone-node-per-runlockAtMostFor lease@Scheduled jobs across replicas

JVM lock types: synchronized (reentrant exclusive) · ReentrantLock (+timeout/interruptible/conditions/fairness) · ReadWriteLock (shared/exclusive) · StampedLock (optimistic read, non-reentrant) · Semaphore (counting) · AtomicX/CAS (lock-free optimistic) · Kotlin Mutex (suspending, non-reentrant).


12. Self-test (say the answers out loud)#

  1. State the three questions you ask before choosing any lock. Which one is the "works in test, breaks in prod" trap?
  2. Optimistic vs pessimistic: give the mechanism of each in SQL, and the two situations that force each choice.
  3. Why can't you hold a SELECT … FOR UPDATE across a user's edit-form think-time? What do you use instead, and how does ETag/If-Match implement it?
  4. What does @Version generate on UPDATE? Name two ways a lost update still slips past a version column.
  5. What is OPTIMISTIC_FORCE_INCREMENT for? Tie it to an aggregate root.
  6. Write the FOR UPDATE SKIP LOCKED worker-queue query and explain why two workers never grab the same row.
  7. Under MVCC, does a plain SELECT take a row lock? What are the consequences for "readers vs writers"?
  8. How is SERIALIZABLE implemented on Postgres vs most other engines? What error must serializable code retry on, and why?
  9. Walk the fencing-token argument: how does a GC pause defeat a TTL-based distributed lock, and how does a fencing token fix it?
  10. Why is a single-instance Redis SET NX PX lock fine for efficiency but unsafe for correctness? Why must the release be a Lua check-and-delete?
  11. Your @Scheduled cron runs 3× on 3 pods. Give two fixes.
  12. Recite the JVM lock taxonomy and, for each, whether it's reentrant, shared/exclusive, and optimistic/pessimistic. Which lock do you use inside suspend code, and which must you never use there?
  13. Deadlock on a two-account transfer: root cause and the one-line design fix.
  14. Name three ways to make a distributed lock unnecessary by design. (Partitioning, idempotency, optimistic concurrency, single-owner workflows.)

Lineage & sources: optimistic/pessimistic concurrency control — Bernstein/Goodman transaction theory; JPA locking & LockModeType — Jakarta Persistence 3.1 spec + Hibernate 6 ORM docs; PostgreSQL row-level locks, MVCC, SSI, advisory locks — PostgreSQL 17 manual (ch. "Explicit Locking", "Transaction Isolation"); SSI — Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (VLDB 2012); the distributed-lock/fencing argument — Martin Kleppmann, "How to do distributed locking" (2016) and Antirez's Redlock rebuttal; Kleppmann, Designing Data-Intensive Applications ch. 8–9. Tooling: Redisson RLock (watchdog lease), Spring Integration LockRegistry, ShedLock (Lukáš Krečan). Verify version-specific claims (JPA package names, Postgres/MySQL syntax, library defaults) against your target stack — see the version anchor at the top. Companion guides: [[concurrency-thread-safety-study-guide]], [[databases-and-data-storage-study-guide]], [[inbox-outbox-pattern]], [[jooq-typesafe-sql-study-guide]], [[API-Design-Best-Practices]].