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; thejakarta.*package rename (fromjavax.*) landed with Jakarta EE 9 / Spring Boot 3. PostgresSKIP LOCKEDexists since 9.5 (NOWAITis far older, ~8.1); MySQL gotSKIP LOCKED/NOWAITin 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.
synchronizedfixed 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:
| Layer | Scope of protection | Mechanisms | Released by |
|---|---|---|---|
| In-process (JVM) | Threads inside one JVM | synchronized, ReentrantLock, ReadWriteLock, StampedLock, Semaphore, CAS/atomics, Kotlin Mutex | unlock / block exit / GC |
| In-database | All clients of one DB | SELECT … FOR UPDATE (row), shared/exclusive locks, table locks, advisory locks, @Version (optimistic) | COMMIT/ROLLBACK (mostly) |
| Distributed | Many processes / services / DBs | Redis (SET NX/Redlock), ZooKeeper/etcd, Redisson RLock, Spring Integration LockRegistry, ShedLock | lease 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 SHAREvsFOR 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… | Prefer | Why |
|---|---|---|
| Conflicts are rare (low contention) | Optimistic | Happy path takes no lock, so it's cheaper and more scalable; you rarely pay the retry. |
| Conflicts are frequent (hot row) | Pessimistic | Optimistic 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) | Optimistic | You 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 fine | Lock held for microseconds; no think-time; simplest correctness. |
| The operation must not fail/redo (some financial postings, "decrement stock exactly once") | Pessimistic | Deterministic — no "sorry, conflict, try again"; but see idempotency below. |
| You're scaling writes horizontally and want no coordination on the read path | Optimistic | No 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):
import jakarta.persistence.*
class Account(
val id: Long,
var balance: BigDecimal,
) {
// 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)
}
class TransferService(private val accounts: AccountRepository) {
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):
class Account {
private Long id;
private BigDecimal balance;
// 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:
@Versionfields must beint/Integer/long/Long/short/Short/java.sql.Timestamp(orInstantvia 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
UPDATEandDELETE(Hibernate checks the version on delete too), but not forINSERT(nothing to conflict with) and not for bulk JPQLUPDATE/UPDATE … SET— a bulk update bypasses the version check unless you bumpversion = version + 1yourself. 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 modes — OPTIMISTIC vs OPTIMISTIC_FORCE_INCREMENT#
JPA has explicit lock modes you can request on a find/query even beyond the automatic behavior:
LockModeType | Meaning | Use 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.
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:
class InventoryService(private val repo: ItemRepository) {
// 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
}
// 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):
<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.
fun update(
id: Long,
ifMatch: String, // e.g. "\"7\""
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:
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:
LockModeType | SQL it generates (Postgres) | Semantics |
|---|---|---|
PESSIMISTIC_READ | SELECT … FOR SHARE | Shared lock: others can also read-lock, but nobody can write until you release. |
PESSIMISTIC_WRITE | SELECT … FOR UPDATE | Exclusive lock: nobody else may read-lock or write the row until you commit. The default "I'm going to modify this." |
PESSIMISTIC_FORCE_INCREMENT | SELECT … FOR UPDATE + bump @Version | Exclusive lock and increment the version — combine pessimistic + optimistic when other readers use optimistic checks. |
Kotlin — Spring Data @Lock:
interface AccountRepository : JpaRepository<Account, Long> {
// → SELECT ... FOR UPDATE
// wait ≤ 3s
fun findByIdForUpdate(id: Long): Account?
}
class TransferService(private val accounts: AccountRepository) {
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:
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.timeouthint is not honored by the Postgres JDBC driver as a per-statement lock timeout the way it is on Oracle. On Postgres you either useNOWAIT/SKIP LOCKED(below) or set the sessionlock_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):
| Clause | Strength | Blocks | Typical use |
|---|---|---|---|
FOR KEY SHARE | weakest | other txns' FOR UPDATE (not FOR NO KEY UPDATE) | FK integrity |
FOR SHARE | shared read | any writer | "read and be sure it doesn't change" |
FOR NO KEY UPDATE | exclusive-ish | other exclusive locks, but not FOR KEY SHARE | updates that don't touch the PK/unique key (less blocking than FOR UPDATE) |
FOR UPDATE | strongest | everything | "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 LOCKED— skip 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.
-- 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:
interface OutboxRepository : JpaRepository<OutboxRow, Long> {
fun claimBatch(batch: Int): List<OutboxRow> // call inside @Transactional; locks held to commit
}
Java — jOOQ, the same claim:
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
SELECTtakes 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 bareSELECTgives 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 forgottenFOR UPDATEtransaction 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):
| Level | What it prevents | How it's enforced |
|---|---|---|
| READ COMMITTED (Postgres default) | dirty reads | each 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 reads | transaction-level snapshot (Postgres: true snapshot isolation; also prevents phantoms). InnoDB adds gap/next-key locks to stop phantoms. |
| SERIALIZABLE | + phantoms, + write skew | Postgres: 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).
// Deadlock-safe transfer: always lock the two rows in a canonical order (by id).
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 onCOMMIT/ROLLBACK. Prefer this one — no leak risk.pg_try_advisory_lock(key)— non-blocking; returnsfalseimmediately if held (your "did I get leadership?" check).
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:
- Mutual exclusion (safety): at most one holder at a time.
- 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.
- 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:
- Client A acquires the lock, gets lease for 30s.
- A stops-the-world (GC pause / hits a breakpoint / the VM is descheduled) for 40s. A doesn't know time passed.
- The lease expires at 30s. The lock service grants the lock to client B.
- 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):
// 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:
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() }
}
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 lease —
lock()ortryLock(waitTime, unit). The moment you pass aleaseTime(the three-argtryLockabove), 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:
// 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:
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_revisionis 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
sessionIdmeans 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
@Scheduledreconciliation 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 / primitive | Type | Key property | Reach for it when |
|---|---|---|---|
synchronized (intrinsic monitor) | pessimistic, exclusive, reentrant | simplest; JMM edge on enter/exit; no timeout | short critical section, no need for tryLock |
ReentrantLock | pessimistic, exclusive, reentrant | tryLock(timeout), interruptible, fairness, conditions | need to back off, time out, or wait on conditions |
ReentrantReadWriteLock | pessimistic, shared/exclusive | many readers or one writer | read-heavy, long-ish critical sections |
StampedLock | optimistic read + pessimistic write | optimistic read = validate-a-stamp (no lock); not reentrant | read-mostly, ultra-low-overhead reads (validate, retry as read-lock) |
Semaphore | counting (not mutual exclusion) | N permits — bound concurrency, not serialize | limit concurrent access to a resource pool |
CAS / AtomicX | optimistic, lock-free | compare-and-set, spins on contention; ABA caveat | single-variable counters/flags/refs |
Object.wait/notify, Condition | coordination | thread signalling under a monitor | producer/consumer without a queue type |
Kotlin Mutex (coroutines) | pessimistic, suspending (non-blocking) | doesn't block a thread; not reentrant | shared mutable state in suspend code (never synchronized there) |
Kotlin Semaphore (coroutines) | counting, suspending | permits without blocking a thread | bound concurrent coroutines |
Reusable adjectives that show you understand the dimensions of a lock:
- Reentrant vs non-reentrant — can the holder re-acquire? (
synchronized/ReentrantLockyes;StampedLock/Mutexno → 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;
Mutexsuspends 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 useMutex/coroutineSemaphore, neversynchronized/ReentrantLock— a blocking JVM lock stalls the underlying dispatcher thread and defeats the coroutine model.
8. Best practices (the consolidated checklist)#
- 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.)
- 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
synchronizedacross 3 pods is a no-op. - 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 → pessimisticFOR UPDATE. When possible, avoid both with a single atomicUPDATE … WHERE. - 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.
- 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.
- Always bound the wait — never wait forever.
tryLock(timeout)(JVM),NOWAIT/SKIP LOCKEDorSET LOCAL lock_timeout(Postgres), a lease TTL (distributed). A lock with no timeout is a latent outage. - Always handle the failure path. Optimistic → bounded, jittered retry on a fresh read, with idempotent side effects. Pessimistic/deadlock → catch
CannotAcquireLockException/40P01and retry or fail fast. Serializable (SSI) → retry on40001. - 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. - Release deterministically. JVM:
unlock()infinally. 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), neverGET-then-DEL. - 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. - 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(conditionalUPDATE … WHERE version = ?), or pessimisticSELECT … FOR UPDATE, or best: one atomicUPDATE 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
ObjectOptimisticLockingFailureExceptionconstantly; 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:
@Scheduledruns independently on every instance; there's no shared lock. - Fix: ShedLock
@SchedulerLock(at-most-one node per execution), or apg_try_advisory_xact_lockguard, 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/
UPDATEbypassed the version check; theOptimisticLockExceptionwas caught and swallowed; the write went through a path (native SQL) that doesn't touchversion; or reads/writes are split across transactions so the check window is wrong. - Fix: bump
version = version + 1explicitly in bulk updates; never swallow the exception (retry or propagate); route all writes through the versioned path; considerOPTIMISTIC_FORCE_INCREMENTfor 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_lockwasn'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 infinallyon the same connection.
9.9 Serializable transaction randomly fails with 40001.
- Problem: under
SERIALIZABLEon Postgres, transactions abort withcould 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 ofLongAdder), 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
synchronizedthat 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 UPDATEexplicitly. AndSERIALIZABLEon Postgres is optimistic SSI: it aborts with40001rather than blocking, so serializable code needs a retry loop. On most other engines SERIALIZABLE is pessimistic 2PL." Very few candidates say this. - Have
SKIP LOCKEDready. 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_INCREMENTon 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:
| Dimension | Optimistic | Pessimistic |
|---|---|---|
| How | detect at commit (version CAS) | prevent at read (FOR UPDATE) |
| Lock held | none between read & write | from read to commit |
| Best when | low contention, think-time, web RMW | high contention, short server-side txn |
| Failure mode | conflict → retry (storms if hot) | deadlock, held connections, waits |
| Cost model | free happy path, expensive redo | pay latency always, no redo |
| JPA | @Version + ObjectOptimisticLockingFailureException | @Lock(PESSIMISTIC_WRITE) + CannotAcquireLockException |
JPA LockModeType → SQL → meaning:
| LockModeType | SQL (Postgres) | Use |
|---|---|---|
OPTIMISTIC | version check at commit | verify a read you depend on is still valid |
OPTIMISTIC_FORCE_INCREMENT | version bump even if row unchanged | aggregate-root invariant across child edits |
PESSIMISTIC_READ | FOR SHARE | shared lock; block writers |
PESSIMISTIC_WRITE | FOR UPDATE | exclusive; "I will modify this" |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + version bump | exclusive lock and version bump together |
Postgres locking quick-ref:
| Want | SQL |
|---|---|
| Exclusive row lock | SELECT … FOR UPDATE |
| …fail if already locked | SELECT … FOR UPDATE NOWAIT (55P03) |
| …skip locked rows (queue) | SELECT … FOR UPDATE SKIP LOCKED |
| Shared row lock | SELECT … FOR SHARE |
| App-defined lock, txn-scoped | pg_advisory_xact_lock(key) / pg_try_advisory_xact_lock(key) |
| Bound wait time | SET LOCAL lock_timeout = '3s' |
| Deadlock error code | 40P01 |
| Serialization failure (retry) | 40001 |
| Lock-not-available (NOWAIT) | 55P03 |
Distributed lock options:
| Option | Safety | Liveness | Use |
|---|---|---|---|
pg_advisory_xact_lock | good (single DB) | auto-release at commit | already have one Postgres |
SET NX PX (single Redis) | efficiency only | TTL lease | harmless dedup, not correctness |
| Redlock (N Redis) | contested (needs fencing) | TTL lease | Antirez vs Kleppmann — know the debate |
Redisson RLock | good + watchdog renewal | auto-renew lease | app-level mutex on Redis |
Spring Integration LockRegistry | backend-dependent | backend-dependent | uniform API over Redis/JDBC/ZK |
| ZooKeeper / etcd | strong (consensus) | session/lease | correctness-critical, leader election |
| ShedLock | one-node-per-run | lockAtMostFor 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)#
- State the three questions you ask before choosing any lock. Which one is the "works in test, breaks in prod" trap?
- Optimistic vs pessimistic: give the mechanism of each in SQL, and the two situations that force each choice.
- Why can't you hold a
SELECT … FOR UPDATEacross a user's edit-form think-time? What do you use instead, and how doesETag/If-Matchimplement it? - What does
@Versiongenerate onUPDATE? Name two ways a lost update still slips past a version column. - What is
OPTIMISTIC_FORCE_INCREMENTfor? Tie it to an aggregate root. - Write the
FOR UPDATE SKIP LOCKEDworker-queue query and explain why two workers never grab the same row. - Under MVCC, does a plain
SELECTtake a row lock? What are the consequences for "readers vs writers"? - How is
SERIALIZABLEimplemented on Postgres vs most other engines? What error must serializable code retry on, and why? - Walk the fencing-token argument: how does a GC pause defeat a TTL-based distributed lock, and how does a fencing token fix it?
- Why is a single-instance Redis
SET NX PXlock fine for efficiency but unsafe for correctness? Why must the release be a Lua check-and-delete? - Your
@Scheduledcron runs 3× on 3 pods. Give two fixes. - Recite the JVM lock taxonomy and, for each, whether it's reentrant, shared/exclusive, and optimistic/pessimistic. Which lock do you use inside
suspendcode, and which must you never use there? - Deadlock on a two-account transfer: root cause and the one-line design fix.
- 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]].