Back to SQL Basics: SELECT, WHERE, ORDER BY
Q4Question 4 of 8

Ensuring Safe Concurrent Updates in Real-Time Systems

Key Concepts
  • The core danger is the lost update: two actors read the same value, each computes a new value, and the second write silently overwrites the first.
  • Pessimistic locking (`SELECT ... FOR UPDATE`) locks the row up front so others block until commit.
  • Optimistic locking reads a version column and detects conflicts at commit time, retrying on mismatch.
  • Atomic compare-and-swap (CAS) with `AtomicInteger` or `AtomicReference` provides lock-free thread safety.
  • MVCC with proper isolation levels (READ COMMITTED, REPEATABLE READ, SERIALIZABLE) prevents anomalies at the database layer.
  • Distributed locks like Redis `SET key NX PX ttl` coordinate across multiple service instances with TTL for crash safety.
  • Always design updates to be idempotent and retryable because every optimistic or serializable approach requires retry on conflict.

Definition

Prevent lost updates by choosing the right concurrency control – pessimistic locks, optimistic version checks with retry, atomic CAS, database MVCC with isolation, or distributed Redis locks – according to contention and scope.
  • Lost update – The core race condition where two actors read the same value, each computes a new result, and the second write silently overwrites the first.
  • Pessimistic locking – SELECT ... FOR UPDATE locks the row so other transactions block until the holder commits or rolls back.
  • Optimistic locking – Reads a version column with the row and writes WHERE version = :read_version, detecting conflicts at commit and retrying on mismatch.
  • Compare-and-swap (CAS) – In-memory atomic operations like AtomicInteger.compareAndSet that update only if the value still equals the expected one.
  • MVCC – Multi-Version Concurrency Control lets readers see consistent snapshots without blocking writers; the isolation level determines which anomalies are prevented.
  • Distributed lock – A Redis lock (SET key NX PX ttl) or Redlock pattern that coordinates across multiple service instances with a TTL for crash safety.

If two tellers at National Bank of India try to debit the same account at the same time, both might read the balance as one lakh, both subtract fifty thousand, and one debit is lost. Pessimistic locking is like one teller locking the account drawer so the other waits. Optimistic locking is like both working on photocopies stamped with a version number – whoever saves second notices the version changed and redoes the work. Atomic CAS is a single indivisible "only update if the balance is still what I read" operation. A distributed Redis lock is a shared ticket across branches so only one branch acts at a time.

Understanding the Concepts

What is the lost update problem?

The lost update is the central danger of concurrent writes. Two actors read the same value, each computes a new value from it, and the second write overwrites the first, silently losing a change. In a banking system, this means money disappears.

SQL
-- Lost update scenario at National Bank of India
-- Account SAV2001 has balance = 100000.00

-- Teller A reads balance
SELECT balance FROM accounts WHERE account_id = 'SAV2001';  -- 100000.00

-- Teller B reads balance (same value)
SELECT balance FROM accounts WHERE account_id = 'SAV2001';  -- 100000.00

-- Teller A debits 30000
UPDATE accounts SET balance = 100000.00 - 30000.00 WHERE account_id = 'SAV2001';
-- balance = 70000.00

-- Teller B debits 20000 (uses stale read of 100000)
UPDATE accounts SET balance = 100000.00 - 20000.00 WHERE account_id = 'SAV2001';
-- balance = 80000.00  ← Teller A's debit is LOST!
-- Should be 50000.00

How does pessimistic locking work?

Pessimistic locking assumes conflicts are likely and locks the data up front. In SQL, SELECT ... FOR UPDATE acquires an exclusive row lock that is held until the transaction commits or rolls back. Other transactions that try to lock, update, or delete the same row must wait.

SQL
-- Pessimistic locking: safe concurrent debit
BEGIN;

SELECT balance FROM accounts
WHERE account_id = 'SAV2001'
FOR UPDATE;  -- row is locked until COMMIT

-- Other transactions trying to read FOR UPDATE on SAV2001 block here

UPDATE accounts
SET balance = balance - 30000.00
WHERE account_id = 'SAV2001';

COMMIT;  -- lock released, next transaction proceeds
DIAGRAM
Behavior under concurrency:
Teller A: BEGIN → SELECT FOR UPDATE (locks SAV2001) → UPDATE → COMMIT
Teller B: BEGIN → SELECT FOR UPDATE (BLOCKS waiting for A) → UPDATE → COMMIT
Result: both debits applied correctly, no lost update

How does optimistic locking work?

Optimistic locking assumes conflicts are rare. You read a row with a version column, do your computation, and update with a WHERE version = :read_version. If zero rows are affected, someone else changed the row first, so you retry.

SQL
-- Optimistic locking with a version column
ALTER TABLE accounts ADD COLUMN version INTEGER DEFAULT 0;

-- Step 1: read the row with its version
SELECT balance, version FROM accounts WHERE account_id = 'SAV2001';
-- balance = 100000.00, version = 5

-- Step 2: update only if version has not changed
UPDATE accounts
SET balance = balance - 30000.00, version = version + 1
WHERE account_id = 'SAV2001' AND version = 5;
-- If 1 row affected: success
-- If 0 rows affected: someone else updated first, RETRY
JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class AccountService {

    private static final int MAX_RETRIES = 3;

    public void debit(Connection conn, String accountId,
                      BigDecimal amount) throws SQLException {
        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
            conn.setAutoCommit(false);
            try (PreparedStatement read = conn.prepareStatement(
                    "SELECT balance, version FROM accounts WHERE account_id = ?")) {
                read.setString(1, accountId);
                ResultSet rs = read.executeQuery();
                if (!rs.next()) throw new IllegalArgumentException("Account not found");

                BigDecimal balance = rs.getBigDecimal("balance");
                int version = rs.getInt("version");

                if (balance.compareTo(amount) < 0) {
                    throw new IllegalStateException("Insufficient balance");
                }

                try (PreparedStatement update = conn.prepareStatement(
                        "UPDATE accounts SET balance = balance - ?, version = version + 1 " +
                        "WHERE account_id = ? AND version = ?")) {
                    update.setBigDecimal(1, amount);
                    update.setString(2, accountId);
                    update.setInt(3, version);
                    int rows = update.executeUpdate();

                    if (rows == 1) {
                        conn.commit();
                        return;  // success
                    }
                    // rows == 0 means version changed, retry
                    conn.rollback();
                }
            } catch (SQLException e) {
                conn.rollback();
                throw e;
            }
        }
        throw new IllegalStateException("Failed after " + MAX_RETRIES + " retries");
    }
}

How is pessimistic locking different from optimistic locking?

| Aspect | Pessimistic Locking | Optimistic Locking |
|---|---|---|
| Assumption | Conflicts are likely | Conflicts are rare |
| Mechanism | SELECT ... FOR UPDATE holds row lock | Version check on UPDATE, retry on mismatch |
| Concurrency | Reduced: others block while lock is held | High: no locks while computing |
| Deadlock risk | Higher (lock ordering matters) | None (no locks acquired) |
| Retry cost | None (waits, does not retry) | Must retry on conflict |
| Best for | Hot rows with frequent contention | Low-contention distributed systems |
| JPA support | LockModeType.PESSIMISTIC_WRITE | @Version annotation |

What are atomic operations and compare-and-swap?

In application memory, AtomicInteger, AtomicLong, and AtomicReference provide lock-free thread safety. The compareAndSet method updates a value only if it still equals the expected one. ConcurrentHashMap and LongAdder provide safe shared-state access without explicit synchronization.

JAVA
package com.bank.service;

import java.util.concurrent.atomic.AtomicLong;

public class TransactionCounter {
    private final AtomicLong counter = new AtomicLong(0);

    public long incrementAndGet() {
        return counter.incrementAndGet();
    }

    // CAS: only update if the value is still what we expect
    public boolean trySetIfExpected(long expected, long newValue) {
        return counter.compareAndSet(expected, newValue);
    }
}

What is MVCC and how does isolation level affect safety?

MVCC (Multi-Version Concurrency Control) lets readers see a consistent snapshot without blocking writers. The isolation level determines which anomalies are prevented.

SQL
-- SERIALIZABLE: strongest isolation, detects all conflicts
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT balance FROM accounts WHERE account_id = 'SAV2001';
UPDATE accounts SET balance = balance - 30000 WHERE account_id = 'SAV2001';
COMMIT;
-- If another transaction conflicted, this gets:
-- ERROR: could not serialize access due to concurrent update
-- Application must RETRY

What about distributed locks with Redis?

When the critical section spans multiple service instances or non-transactional resources, a distributed lock coordinates access. Redis provides SET key value NX PX ttl for mutual exclusion with a TTL so a crashed holder's lock expires.

JAVA
package com.bank.service;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;

public class DistributedLockService {

    private static final int LOCK_TTL_MS = 5000;

    public String acquireLock(Jedis jedis, String accountId) {
        String lockKey = "lock:account:" + accountId;
        String lockValue = UUID.randomUUID().toString();
        String result = jedis.set(lockKey, lockValue,
                SetParams.setParams().nx().px(LOCK_TTL_MS));
        return "OK".equals(result) ? lockValue : null;
    }

    public void releaseLock(Jedis jedis, String accountId, String lockValue) {
        String lockKey = "lock:account:" + accountId;
        // Only release if we still own the lock
        String current = jedis.get(lockKey);
        if (lockValue.equals(current)) {
            jedis.del(lockKey);
        }
    }
}

When should you use each approach?

The choice depends on contention level and scope.

  • Low contention, single database – Optimistic locking with a version column. Scales well, no lock overhead.
  • High contention, single database – Pessimistic SELECT ... FOR UPDATE for short critical sections.
  • In-process shared state – AtomicReference.compareAndSet and ConcurrentHashMap.
  • Cross-service coordination – Redis distributed lock with TTL.
  • Strongest correctness guarantee – SERIALIZABLE isolation with retry logic.

What should a senior engineer know?

A senior engineer knows to pick the mechanism by contention and scope, to make every update idempotent and retryable, to keep pessimistic lock transactions short to avoid deadlocks, and to never use FOR UPDATE on read-only queries where it adds contention for no correctness benefit.

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────────┐
│     Concurrency Control Decision Tree               │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌───────────────────────────┐                      │
│  │ Where is the shared state?│                      │
│  └─────────────┬─────────────┘                      │
│          ┌─────┴──────┐                             │
│          │            │                             │
│    In-process    Database / Cross-service            │
│          │            │                             │
│          ▼            ▼                             │
│  ┌──────────────┐  ┌───────────────────┐            │
│  │ AtomicLong   │  │ Contention level? │            │
│  │ CAS          │  └────────┬──────────┘            │
│  │ ConcurrentMap│      ┌────┴────┐                  │
│  └──────────────┘      │        │                   │
│                      Low      High                  │
│                        │        │                   │
│                        ▼        ▼                   │
│                ┌────────────┐ ┌──────────────┐      │
│                │ Optimistic │ │ Pessimistic  │      │
│                │ version    │ │ FOR UPDATE   │      │
│                │ + retry    │ │ (short txn)  │      │
│                └────────────┘ └──────────────┘      │
│                                                     │
│                ┌───────────────────┐                │
│                │ Cross-service?    │                │
│                └────────┬──────────┘                │
│                         ▼                           │
│                ┌──────────────────┐                 │
│                │ Redis SET NX PX  │                 │
│                │ with TTL + owner │                 │
│                └──────────────────┘                 │
│                                                     │
└─────────────────────────────────────────────────────┘

Key Takeaway

Safe concurrent updates mean guaranteeing correctness when many threads or transactions modify the same data at once. The central danger is the lost update: two actors read the same value, each computes a new value, and the second write silently overwrites the first. In a banking system this means money disappears.

The solution is to pick the right concurrency control for the contention level and scope. Pessimistic locking with SELECT ... FOR UPDATE locks the row up front so competing transactions block until the holder commits. It is simple and strongly correct but reduces concurrency and risks deadlocks, so Rajesh uses it only for short critical sections on hot accounts at National Bank of India. Optimistic locking reads a version column and detects conflicts at commit time with a WHERE version = :read_version check, retrying on mismatch. It scales well under low contention because no locks are held while computing. JPA's @Version implements exactly this pattern.

In application memory, compare-and-swap atomics like AtomicReference.compareAndSet provide lock-free thread safety, and ConcurrentHashMap handles shared-state access without explicit synchronization. At the database level, MVCC lets readers and writers avoid blocking each other, and the isolation level determines which anomalies are prevented. When the critical section spans multiple service instances, a Redis distributed lock with SET key NX PX ttl coordinates access with a TTL so a crashed holder's lock expires.

At National Bank of India, Pankaj's balance service suffered lost updates when two withdrawals hit the same account. Rajesh fixed it with optimistic version-checked debits that retry on conflict for the common case and pessimistic FOR UPDATE for a few hot settlement accounts. The rule is to pick the control by contention and scope and to make every update idempotent and retryable.

Remember one sentence: Prevent lost updates by choosing the right concurrency control – pessimistic locks, optimistic version checks with retry, atomic CAS, MVCC with proper isolation, or distributed Redis locks – according to contention and scope.

---

Share
Use ← → arrow keys to navigate