Back to Scaling, Load Balancing & Performance
Q17Question 17 of 20

How do you implement concurrency control for the "last item / last ticket" problem as a progression

Key Concepts
  • The last-item problem is a check-then-act race: many requests see stock=1, all decrement, and you oversell.
  • Rung 1: In-JVM primitives (synchronized, AtomicInteger CAS) work only within one process.
  • Rung 2: DB locking (optimistic version column or pessimistic SELECT FOR UPDATE) works across instances sharing one DB.
  • Rung 3: Redis distributed lock (SET NX PX ttl) works across instances without a shared lockable row.
  • Rung 4: A single-consumer queue serializes requests so no lock is needed and no contention exists.
  • Use the cheapest technique whose scope matches your deployment; climb only when scaling out breaks the previous rung.
  • Prefer a database constraint over a Redis lock whenever the resource is a row you can constrain.

Definition

For the last unit, use in-JVM locks within one process, database locking across instances sharing a DB, a Redis SETNX lock across instances without a shared row, or a single-consumer queue that serializes requests so no lock is needed.
  • Check-then-act race – Multiple threads read stock=1, all decide to take it, all decrement, causing overselling.
  • In-JVM primitives – synchronized, ReentrantLock, AtomicInteger.compareAndSet; work only within one JVM.
  • DB optimistic locking – Version column guarded UPDATE; retry on conflict; good for rare contention.
  • DB pessimistic locking – SELECT ... FOR UPDATE blocks other transactions; good for high contention.
  • Redis distributed lock – SET key NX PX ttl ensures only one caller wins across distributed instances.
  • Single-consumer queue – All requests funnel through one consumer; eliminates contention by serialization.

National Bank of India is giving away one last festival gift voucher and a hundred customers reach for it at once. If one counter, the teller uses one hand. If many counters share one ledger, each teller checks and marks carefully. If counters do not share a ledger, they agree on a talking stick. Or the bank forms one line to one teller.

Understanding the Concepts

What is the check-then-act race?

Every request reads stock=1, all decide to take it, all decrement. The result: overselling to multiple buyers.

JAVA
package com.bank.concurrency;

public class RaceCondition {
    public static void main(String[] args) {
        System.out.println("The race:");
        System.out.println("  Thread A: reads stock=1 -> decides to buy");
        System.out.println("  Thread B: reads stock=1 -> decides to buy");
        System.out.println("  Thread A: stock = stock - 1 = 0");
        System.out.println("  Thread B: stock = stock - 1 = -1 (OVERSOLD!)");
        System.out.println();
        System.out.println("Fix: make check-and-decrement ATOMIC");
    }
}
TEXT
Expected output:
The race:
  Thread A: reads stock=1 -> decides to buy
  Thread B: reads stock=1 -> decides to buy
  Thread A: stock = stock - 1 = 0
  Thread B: stock = stock - 1 = -1 (OVERSOLD!)

Fix: make check-and-decrement ATOMIC

Rung 1: In-JVM primitives (single instance only)

AtomicInteger.compareAndSet atomically takes the last unit only if it is still one.

JAVA
package com.bank.concurrency;

import java.util.concurrent.atomic.AtomicInteger;

public class InJvmLocking {
    private static final AtomicInteger stock = new AtomicInteger(1);

    public static boolean tryBuy() {
        // Atomic: takes the last unit ONLY if stock is still 1
        return stock.compareAndSet(1, 0);
    }

    public static void main(String[] args) {
        System.out.println("Buyer 1: " + (tryBuy() ? "GOT IT" : "SOLD OUT"));
        System.out.println("Buyer 2: " + (tryBuy() ? "GOT IT" : "SOLD OUT"));
    }
}
TEXT
Expected output:
Buyer 1: GOT IT
Buyer 2: SOLD OUT

Limitation: works within ONE JVM only.
Two instances -> each has its own AtomicInteger -> both can sell.

Rung 2: DB optimistic locking (across instances)

A version column guards the UPDATE. If another transaction already decremented, the version no longer matches.

SQL
-- Optimistic locking: conditional update with version
UPDATE stock
SET qty = qty - 1, version = version + 1
WHERE product_id = 'FD-DIWALI-50' AND version = 3 AND qty > 0;
-- If another thread already decremented: version != 3 -> 0 rows updated
JAVA
package com.bank.concurrency;

public class DbOptimisticLocking {
    // Simpler: conditional atomic decrement (no version needed)
    // UPDATE stock SET qty = qty - 1 WHERE product_id = ? AND qty > 0
    // Only one thread succeeds when qty = 1

    public static void main(String[] args) {
        System.out.println("DB optimistic locking:");
        System.out.println("  Thread A: UPDATE SET qty=qty-1 WHERE qty>0 -> 1 row updated");
        System.out.println("  Thread B: UPDATE SET qty=qty-1 WHERE qty>0 -> 0 rows updated");
        System.out.println("  Thread B: 'SOLD OUT'");
        System.out.println();
        System.out.println("Works across ALL instances sharing one database");
    }
}
TEXT
Expected output:
DB optimistic locking:
  Thread A: UPDATE SET qty=qty-1 WHERE qty>0 -> 1 row updated
  Thread B: UPDATE SET qty=qty-1 WHERE qty>0 -> 0 rows updated
  Thread B: 'SOLD OUT'

Works across ALL instances sharing one database

Rung 3: Redis distributed lock (no shared DB row)

When the resource is not a database row, use SET NX PX so only one caller wins.

JAVA
package com.bank.concurrency;

import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.util.UUID;

public class RedisDistributedLock {

    public static boolean tryAcquire(StringRedisTemplate redis, String key) {
        String token = UUID.randomUUID().toString();
        Boolean acquired = redis.opsForValue()
                .setIfAbsent(key, token, Duration.ofSeconds(10));
        if (Boolean.TRUE.equals(acquired)) {
            System.out.println("Lock acquired: " + key);
            return true;
        }
        System.out.println("Lock NOT acquired: " + key + " (someone else has it)");
        return false;
    }

    public static void main(String[] args) {
        System.out.println("Redis SET NX PX:");
        System.out.println("  NX: set only if key absent -> only first caller wins");
        System.out.println("  PX 10000: TTL 10 seconds -> crashed holder's lock expires");
        System.out.println();
        System.out.println("Cautions:");
        System.out.println("  TTL too short -> expires mid-operation -> two holders");
        System.out.println("  Prefer DB constraint over Redis lock when possible");
    }
}
TEXT
Expected output:
Redis SET NX PX:
  NX: set only if key absent -> only first caller wins
  PX 10000: TTL 10 seconds -> crashed holder's lock expires

Cautions:
  TTL too short -> expires mid-operation -> two holders
  Prefer DB constraint over Redis lock when possible

Rung 4: Single-consumer queue (no locks needed)

Every request goes onto a queue. One consumer processes them one at a time. Zero contention.

JAVA
package com.bank.concurrency;

public class QueueSerialization {
    public static void main(String[] args) {
        System.out.println("Single-consumer queue:");
        System.out.println("  All purchase requests -> Kafka topic (1 partition)");
        System.out.println("  One consumer processes sequentially");
        System.out.println("  No contention, no locks, no races");
        System.out.println();
        System.out.println("Benefits:");
        System.out.println("  - Simplest correctness story");
        System.out.println("  - Natural fairness (first-come-first-served)");
        System.out.println("  - Backpressure and audit trail built in");
        System.out.println();
        System.out.println("Best for: extreme flash-sale contention");
        System.out.println("  Even DB locks thrash at 10,000 concurrent requests");
        System.out.println("  Queue absorbs the spike, consumer processes calmly");
    }
}
TEXT
Expected output:
Single-consumer queue:
  All purchase requests -> Kafka topic (1 partition)
  One consumer processes sequentially
  No contention, no locks, no races

How do you choose the right rung?

| Rung | Scope | When to use |
|---|---|---|
| In-JVM (AtomicInteger) | Single instance | One server, no scale-out |
| DB locking | Multiple instances, one DB | Shared database, moderate contention |
| Redis lock | Multiple instances, no shared row | External resource, non-DB entity |
| Queue | Extreme contention | Flash sale, thousands of concurrent claims |

JAVA
package com.bank.concurrency;

public class ChoosingRung {
    public static void main(String[] args) {
        System.out.println("Decision guide:");
        System.out.println("  Single instance? -> AtomicInteger CAS");
        System.out.println("  Multiple instances, shared DB? -> Conditional UPDATE");
        System.out.println("  No lockable DB row? -> Redis SET NX PX");
        System.out.println("  Extreme contention? -> Queue to single consumer");
        System.out.println();
        System.out.println("Principle: cheapest technique whose scope matches deployment");
        System.out.println("  Climb only when scaling out breaks the current rung");
    }
}
TEXT
Expected output:
Decision guide:
  Single instance? -> AtomicInteger CAS
  Multiple instances, shared DB? -> Conditional UPDATE
  No lockable DB row? -> Redis SET NX PX
  Extreme contention? -> Queue to single consumer

Principle: cheapest technique whose scope matches deployment

What is the WRONG pattern?

JAVA
package com.bank.concurrency;

public class WrongPattern {
    // WRONG: read-then-write (not atomic)
    // if (stock > 0) { stock--; } // Two threads both see stock > 0

    // WRONG: synchronized across multiple instances
    // synchronized (this) { stock--; } // Each JVM has its own lock!

    // CORRECT: AtomicInteger.compareAndSet for single JVM
    // CORRECT: UPDATE stock SET qty=qty-1 WHERE qty>0 for multi-instance

    public static void main(String[] args) {
        System.out.println("WRONG: if (stock > 0) stock-- // not atomic, race condition");
        System.out.println("WRONG: synchronized across JVMs // each JVM has own lock");
        System.out.println("CORRECT: atomic CAS or conditional DB UPDATE");
    }
}
TEXT
Expected output:
WRONG: if (stock > 0) stock-- // not atomic, race condition
WRONG: synchronized across JVMs // each JVM has own lock
CORRECT: atomic CAS or conditional DB UPDATE

What should a senior engineer know?

Prefer database constraints over Redis locks. The conditional UPDATE is often the simplest correct solution for database-backed resources.

TEXT
Senior guidelines:
  [x] Always make check-and-act ATOMIC (never read-then-write)
  [x] Single JVM: AtomicInteger.compareAndSet
  [x] Multi-instance: conditional UPDATE (cheapest, most robust)
  [x] Non-DB resource: Redis SET NX PX (with caution on TTL)
  [x] Extreme contention: single-consumer queue
  [x] Prefer DB constraint over Redis lock where possible
  [x] Monitor per-shard stock levels for early warning

Visual Overview

DIAGRAM
  Concurrency Control Progression:

  Rung 1: In-JVM (single instance)
  ┌────────────────────────────┐
  │ AtomicInteger stock = 1    │
  │ Thread A: CAS(1,0) = true  │ GOT IT
  │ Thread B: CAS(1,0) = false │ SOLD OUT
  └────────────────────────────┘

  Rung 2: DB Locking (multi-instance)
  ┌────────┐  ┌────────┐  ┌────────────────┐
  │ Inst-1 │  │ Inst-2 │  │    Database     │
  │ UPDATE │──┤ UPDATE │──┤ WHERE qty > 0   │
  │ 1 row  │  │ 0 rows │  │ (atomic guard)  │
  └────────┘  └────────┘  └────────────────┘

  Rung 3: Redis Lock (no shared row)
  ┌────────┐  ┌────────┐  ┌────────────────┐
  │ Inst-1 │  │ Inst-2 │  │    Redis       │
  │ SET NX │──┤ SET NX │──┤ NX: first wins │
  │ true   │  │ false  │  │ PX: TTL 10s    │
  └────────┘  └────────┘  └────────────────┘

  Rung 4: Queue (zero contention)
  ┌────────┐  ┌────────┐  ┌───────┐  ┌──────────┐
  │ Inst-1 │──│ Inst-2 │──│ Queue │──│ Consumer │
  │ publish│  │ publish│  │       │  │ (single) │
  └────────┘  └────────┘  └───────┘  └──────────┘

Key Takeaway

The last-item problem is a check-then-act race: many concurrent requests read stock=1, all decide to buy, and all decrement, causing overselling. Every technique in the progression makes the check-and-decrement atomic at a particular scope, and you climb one rung at a time as deployment grows.

In-JVM primitives like AtomicInteger.compareAndSet work within one process. Database locking with a conditional UPDATE works across instances sharing one database. A Redis distributed lock with SET NX PX works when there is no lockable database row. A single-consumer queue removes contention entirely by serializing all requests.

At National Bank of India, Pankaj walked this progression. A single-instance service sold the last fixed-deposit slot twice, so he used AtomicInteger CAS. When they scaled out, that broke because each instance had its own counter, so he moved to a conditional UPDATE. For a one-time festival coupon not in the database, he used a Redis lock. And during a flash sale when even database locks thrashed, he serialized claims through a Kafka queue.

Remember one sentence: use the cheapest concurrency technique whose scope matches your deployment: AtomicInteger CAS within one JVM, conditional DB UPDATE across instances, Redis SET NX for non-DB resources, or a single-consumer queue for extreme contention.

---

Share
Use ← → arrow keys to navigate