Back to Isolation Levels & MVCC
Q3Question 3 of 3

Isolation level vs @Lock: how do they interact?

Key Concepts
  • Isolation level is a database-wide setting that controls what data a transaction can see (committed, snapshot, or serial)
  • @Lock is a row-level directive on a specific JPA query that tells the database to lock particular rows
  • Isolation controls visibility (what you SEE), locking controls exclusive access (what you HOLD)
  • They are orthogonal and used together: READ_COMMITTED isolation plus PESSIMISTIC_WRITE on a balance query
  • @Lock(PESSIMISTIC_WRITE) generates SELECT ... FOR UPDATE, holding the row until the transaction commits
  • Optimistic locking via @Version avoids database locks and detects conflicts at write time
  • For hot rows with frequent contention use pessimistic locking; for rare conflicts use optimistic locking

Definition

Isolation level controls what data a transaction can see (database-wide), while @Lock controls which rows a transaction holds exclusively (row-level) -- they are complementary and used together.
  • Isolation level -- a database-wide setting on the transaction that determines what data queries can see: only committed rows, a frozen snapshot, or a fully serialized view.
  • @Lock annotation -- a JPA directive placed on a repository query that tells the database to acquire a row-level lock (pessimistic) or check a version column (optimistic) for the selected rows.
  • Visibility vs access -- isolation controls what you SEE, locking controls what you HOLD. They solve different problems and do not compete.
  • PESSIMISTIC_WRITE -- generates SELECT ... FOR UPDATE, which locks the selected rows so no other transaction can modify them until the current transaction commits.
  • PESSIMISTIC_READ -- generates SELECT ... FOR SHARE, allowing concurrent readers but blocking writers.
  • Optimistic locking -- uses a @Version column instead of database locks. Hibernate checks the version at write time and throws OptimisticLockException if another transaction modified the row.

At the National Bank of India, the isolation level is the bank's policy on what balance figures a teller can see. A READ_COMMITTED policy means the teller only sees balances that are finalized, never balances that are still being processed. The @Lock annotation is a physical padlock that the teller places on Rajesh's account file so nobody else can touch it while the teller works on a transfer. You can have both the policy and the padlock at the same time -- the policy governs what you see, the padlock governs who can touch the file.

Understanding the Concepts

What is a transaction isolation level?

A transaction isolation level is a database-wide setting that controls what data the queries inside a transaction can see. When two transactions run at the same time, isolation determines whether one transaction can see uncommitted changes, committed changes, or a frozen snapshot from the other transaction.

Spring exposes four standard levels through the Isolation enum:

  • READ_UNCOMMITTED -- allows dirty reads (seeing uncommitted data from other transactions).
  • READ_COMMITTED -- only sees data that other transactions have committed. This is the default in PostgreSQL and Oracle.
  • REPEATABLE_READ -- takes a snapshot at the start of the transaction. All reads return the same data even if another transaction commits changes. This is the default in MySQL InnoDB.
  • SERIALIZABLE -- transactions behave as if they run one at a time. The strictest and slowest level.

You set the isolation level on the @Transactional annotation:

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class AccountQueryService {

    private final AccountRepository accountRepository;

    public AccountQueryService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public BigDecimal getCommittedBalance(String accountId) {
        // Only sees balances that are fully committed
        return accountRepository.findById(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId))
                .getBalance();
    }
}
TEXT
Isolation level set on the transaction applies to ALL queries in that transaction.
The query above will never see a balance that another transaction has not yet committed.

What is @Lock in JPA?

@Lock is a JPA annotation placed on a specific repository query method. It tells the database to acquire a lock on the rows returned by that query. Unlike isolation, which is a blanket policy for the entire transaction, @Lock targets specific rows.

The two most common lock modes are:

  • LockModeType.PESSIMISTIC_WRITE -- generates SELECT ... FOR UPDATE. The selected rows are exclusively locked until the transaction commits. Any other transaction that tries to read-for-update or modify these rows must wait.
  • LockModeType.PESSIMISTIC_READ -- generates SELECT ... FOR SHARE. Multiple transactions can hold a shared lock simultaneously, but none of them can modify the row until all shared locks are released.
JAVA
package com.bank.repository;

import com.bank.model.Account;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(String id);

    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForShare(String id);
}
TEXT
SQL generated by findByIdForUpdate:
  SELECT * FROM accounts WHERE id = 'SAV2001' FOR UPDATE

SQL generated by findByIdForShare:
  SELECT * FROM accounts WHERE id = 'SAV2001' FOR SHARE

Why are they different? Isolation vs Lock comparison

Isolation and locking solve fundamentally different problems. Isolation is about visibility -- what data can a query see. Locking is about access -- who can touch a specific row. Here is a side-by-side comparison:

| Aspect | Isolation Level | @Lock |
|---|---|---|
| Scope | Database-wide, applies to all queries in the TX | Row-level, applies to one specific query |
| Controls | What data you can SEE | What rows you HOLD exclusively |
| Set on | @Transactional(isolation = ...) | @Lock(LockModeType....) on a repository method |
| Prevents | Dirty reads, non-repeatable reads, phantom reads | Lost updates, concurrent modifications |
| SQL generated | Sets transaction characteristics at session level | Adds FOR UPDATE or FOR SHARE to the SELECT |
| Performance | Higher isolation = more overhead, potential serialization | Locks held until TX commits, can cause wait or deadlock |

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Isolation: only see committed data
    // Lock: hold the row exclusively while we modify the balance
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transfer(String fromId, String toId, BigDecimal amount) {
        // Lock both rows with FOR UPDATE
        var from = accountRepository.findByIdForUpdate(fromId)
                .orElseThrow(() -> new AccountNotFoundException(fromId));
        var to = accountRepository.findByIdForUpdate(toId)
                .orElseThrow(() -> new AccountNotFoundException(toId));

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(fromId, amount);
        }

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
    }
}
TEXT
Thread-1 reads SAV2001 balance = 500000 with FOR UPDATE. Row is locked.
Thread-2 tries to read SAV2001 with FOR UPDATE. Thread-2 waits.
Thread-1 deducts 10000, commits. Row unlocked.
Thread-2 now reads SAV2001 balance = 490000. Deducts 10000. Commits.
Final balance = 480000 (correct).

Why does isolation alone not prevent lost updates?

A lost update happens when two transactions read the same value, compute a result independently, and both write back, so one write overwrites the other. READ_COMMITTED isolation prevents dirty reads but does not prevent lost updates because both transactions read the committed balance at the same moment.

Consider Rajesh and Priya both transferring money from account SAV2001 at the same time. Without locking:

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class UnsafeTransferService {

    private final AccountRepository accountRepository;

    public UnsafeTransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // WRONG: isolation alone, no lock
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transferWithoutLock(String fromId, String toId, BigDecimal amount) {
        // No FOR UPDATE -- both threads read the same balance
        var from = accountRepository.findById(fromId)
                .orElseThrow(() -> new AccountNotFoundException(fromId));
        var to = accountRepository.findById(toId)
                .orElseThrow(() -> new AccountNotFoundException(toId));

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // Both threads read 500000, both subtract 10000
        // Last commit wins: final balance = 490000 instead of 480000
    }
}
TEXT
Thread-1 reads SAV2001 balance = 500000 (no lock)
Thread-2 reads SAV2001 balance = 500000 (no lock)
Thread-1 writes 500000 - 10000 = 490000, commits
Thread-2 writes 500000 - 10000 = 490000, commits (overwrites Thread-1)
Final balance = 490000 -- lost 10000 rupees!

Adding @Lock(PESSIMISTIC_WRITE) on the query makes the second thread wait until the first commits, which gives the correct result.

How does optimistic locking differ from pessimistic locking?

Optimistic locking does not hold any database lock. Instead, Hibernate adds a @Version column to the entity. When it issues an UPDATE, it includes WHERE version = ? in the statement. If another transaction already incremented the version, the update affects zero rows and Hibernate throws OptimisticLockException.

| Aspect | Pessimistic (@Lock) | Optimistic (@Version) |
|---|---|---|
| Lock held | Yes, SELECT ... FOR UPDATE | No database lock |
| Conflict detection | At read time (second reader waits) | At write time (version mismatch) |
| Best for | High contention, hot rows | Low contention, rare conflicts |
| Throughput | Lower (threads wait) | Higher (no waiting) |
| Failure mode | Deadlock possible | OptimisticLockException, must retry |

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    private String id;

    private String holderName;

    private BigDecimal balance;

    @Version
    private Long version; // Optimistic locking column

    // Getters and setters
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getHolderName() { return holderName; }
    public void setHolderName(String holderName) { this.holderName = holderName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    public Long getVersion() { return version; }
}
TEXT
SQL generated on update with @Version:
  UPDATE accounts SET balance = 490000.00, version = 2
  WHERE id = 'SAV2001' AND version = 1

If version was already changed by another TX, affected rows = 0
  -> Hibernate throws OptimisticLockException

What are common mistakes when combining isolation and locking?

The most frequent mistake is relying on isolation level alone to prevent concurrent modification. READ_COMMITTED isolation prevents dirty reads but does not prevent two transactions from reading and overwriting the same row.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class MistakeTransferService {

    private final AccountRepository accountRepository;

    public MistakeTransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // WRONG: developer thinks SERIALIZABLE alone prevents lost updates
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transferSerializable(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findById(fromId).orElseThrow();
        var to = accountRepository.findById(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // SERIALIZABLE does prevent lost updates in PostgreSQL (SSI)
        // but at a high cost: serialization failures, retries needed
    }

    // CORRECT: READ_COMMITTED + explicit row lock
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transferWithLock(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // Correct: explicit lock on hot rows, cheaper than SERIALIZABLE
    }
}
TEXT
SERIALIZABLE approach:
  + Prevents lost updates
  - High overhead, serialization failures require retry logic
  - Every query is locked, not just the ones that need it

READ_COMMITTED + @Lock approach:
  + Only locks the specific rows you are modifying
  + Lower overhead for queries that do not need locking
  + Explicit about which operations are critical

What about PESSIMISTIC_READ vs PESSIMISTIC_WRITE?

PESSIMISTIC_READ acquires a shared lock (FOR SHARE), which allows multiple transactions to read the row simultaneously but blocks any transaction from writing to it until all shared locks are released. PESSIMISTIC_WRITE acquires an exclusive lock (FOR UPDATE), which blocks both readers-for-update and writers.

Use PESSIMISTIC_READ when you need the value to remain stable during your transaction but do not plan to modify it. Use PESSIMISTIC_WRITE when you intend to update the row.

JAVA
package com.bank.service;

import jakarta.persistence.LockModeType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class StatementService {

    private final AccountRepository accountRepository;

    public StatementService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Read the balance for a statement -- no modification planned
    @Transactional
    public BigDecimal getBalanceForStatement(String accountId) {
        // FOR SHARE: other readers can also read, but no writer can change it
        var account = accountRepository.findByIdForShare(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId));
        return account.getBalance();
    }

    // Debit the account -- modification planned
    @Transactional
    public void debit(String accountId, BigDecimal amount) {
        // FOR UPDATE: exclusive lock, nobody else touches this row
        var account = accountRepository.findByIdForUpdate(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId));
        account.setBalance(account.getBalance().subtract(amount));
    }
}
TEXT
FOR SHARE (PESSIMISTIC_READ):
  Thread-1: SELECT ... FOR SHARE  -> OK, shared lock acquired
  Thread-2: SELECT ... FOR SHARE  -> OK, shared lock acquired
  Thread-3: UPDATE ...             -> BLOCKED until both shared locks released

FOR UPDATE (PESSIMISTIC_WRITE):
  Thread-1: SELECT ... FOR UPDATE  -> OK, exclusive lock acquired
  Thread-2: SELECT ... FOR UPDATE  -> BLOCKED until Thread-1 commits

What about thread safety and connection holding?

When using PESSIMISTIC_WRITE, the lock is held for the entire duration of the transaction. If the transaction does slow work after acquiring the lock, other threads pile up waiting. This is a throughput concern for hot rows like a settlement account that every transfer touches.

The best practice is to keep the transaction as short as possible: acquire the lock, do the calculation, commit. Move any non-database work (logging, notifications, HTTP calls) outside the transaction.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.ApplicationEventPublisher;
import java.math.BigDecimal;

@Service
public class OptimizedTransferService {

    private final AccountRepository accountRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OptimizedTransferService(AccountRepository accountRepository,
                                     ApplicationEventPublisher eventPublisher) {
        this.accountRepository = accountRepository;
        this.eventPublisher = eventPublisher;
    }

    // Short transaction: lock, compute, commit. Nothing else.
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(fromId, amount);
        }

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        // Publish event -- delivered AFTER commit via @TransactionalEventListener
        eventPublisher.publishEvent(
            new TransferCompletedEvent(fromId, toId, amount));
        // Lock released as soon as this method returns and TX commits
    }
}
TEXT
Lock hold time with slow work inside TX:
  Lock acquired -> slow HTTP call (3 sec) -> commit -> Lock released
  Other threads wait 3 seconds.

Lock hold time with optimized TX:
  Lock acquired -> balance update (5 ms) -> commit -> Lock released
  Other threads wait 5 milliseconds.

When should you use isolation tuning vs explicit locking?

Use isolation level tuning when you need a blanket policy for all queries in the transaction. Use explicit @Lock when you need targeted protection for specific rows.

  • Read-heavy reporting queries -- use @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) to get a consistent snapshot without any row locks.
  • Balance updates -- use READ_COMMITTED isolation with @Lock(PESSIMISTIC_WRITE) on the balance query to prevent lost updates on the specific row.
  • Low-contention updates -- use @Version (optimistic locking) with retry logic to avoid holding any database locks.
  • Queue processing -- use SELECT ... FOR UPDATE SKIP LOCKED to pick up unlocked work items without waiting or deadlocking.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service
public class ReportingService {

    private final AccountRepository accountRepository;

    public ReportingService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Snapshot isolation for consistent report -- no locks needed
    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
    public List<Account> generateEndOfDayReport() {
        // All queries see the same snapshot, even if other TXs commit
        return accountRepository.findAll();
    }
}
TEXT
End-of-day report runs under REPEATABLE_READ:
  Query 1 reads total deposits = 50,00,000
  Meanwhile another TX commits a 1,00,000 deposit
  Query 2 reads total deposits = 50,00,000 (snapshot, not 51,00,000)
  Report is internally consistent.

What should a senior engineer know?

A senior engineer should understand these deeper points:

  • PostgreSQL uses MVCC (Multi-Version Concurrency Control) for READ_COMMITTED and REPEATABLE_READ. Readers never block writers and writers never block readers. Locks from FOR UPDATE are an additional layer on top of MVCC.
  • MySQL InnoDB uses gap locks under REPEATABLE_READ, which can cause unexpected blocking on ranges of rows, not just the specific row you queried.
  • Deadlock risk -- if two transactions lock rows in opposite order (FOR UPDATE on A then B, and B then A), the database detects the cycle and kills one transaction. Always lock rows in a consistent order (for example, sorted by account ID).
  • Lock timeout -- set jakarta.persistence.lock.timeout to avoid waiting forever. A value of 5000 means the lock attempt fails after 5 seconds with a LockTimeoutException.
  • Connection holding -- pessimistic locks hold the database connection for the entire transaction. On high-throughput paths, prefer optimistic locking with @Version to avoid connection pool pressure.
JAVA
package com.bank.repository;

import com.bank.model.Account;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000"))
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdateWithTimeout(String id);
}
TEXT
If the row is already locked by another TX and 5 seconds pass:
  -> LockTimeoutException is thrown instead of waiting indefinitely

What are common interview follow-up questions?

  • Can you use @Lock without @Transactional? No. The lock is held for the transaction duration. Without an active transaction, the lock is released immediately and has no effect.
  • Does PESSIMISTIC_WRITE prevent SELECT without FOR UPDATE? No. A plain SELECT in READ_COMMITTED still reads the committed version of the row. Only other FOR UPDATE or FOR SHARE queries are blocked.
  • What happens if you combine SERIALIZABLE isolation with PESSIMISTIC_WRITE? It works but is usually overkill. SERIALIZABLE already prevents lost updates (via SSI in PostgreSQL), and adding a row lock on top adds unnecessary contention.
  • How do you test pessimistic locking? Use two threads in an integration test with a CountDownLatch to coordinate timing. One thread acquires the lock, the other thread attempts to acquire it and should block until the first commits.
JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class TransferConcurrencyTest {

    @Autowired
    private TransferService transferService;

    @Autowired
    private AccountRepository accountRepository;

    @Test
    void twoTransfersFromSameAccountShouldNotLoseUpdate() throws Exception {
        // Setup: SAV2001 has balance 500000
        ExecutorService pool = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(2);
        AtomicReference<Exception> error = new AtomicReference<>();

        Runnable task = () -> {
            try {
                transferService.transfer("SAV2001", "CUR3001",
                    new BigDecimal("10000"));
            } catch (Exception e) {
                error.set(e);
            } finally {
                latch.countDown();
            }
        };

        pool.submit(task);
        pool.submit(task);
        latch.await();

        BigDecimal finalBalance = accountRepository.findById("SAV2001")
                .orElseThrow().getBalance();
        // With @Lock: 500000 - 10000 - 10000 = 480000 (correct)
        assertThat(finalBalance).isEqualByComparingTo(new BigDecimal("480000"));
    }
}
TEXT
Expected output:
  Test passed: final balance = 480000
  Both deductions applied correctly because @Lock(PESSIMISTIC_WRITE)
  forced the second thread to wait until the first committed.

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────┐
│             Transaction Isolation Level               │
│  (DB-wide: what ALL queries in this TX can SEE)       │
│                                                       │
│  READ_UNCOMMITTED  → sees uncommitted data            │
│  READ_COMMITTED    → sees only committed data         │
│  REPEATABLE_READ   → snapshot at TX start             │
│  SERIALIZABLE      → fully serial execution           │
└──────────────────────────────────────────────────────┘
                        +
┌──────────────────────────────────────────────────────┐
│                   @Lock (Row-Level)                    │
│  (Per-query: which ROWS this query HOLDS)             │
│                                                       │
│  PESSIMISTIC_WRITE → SELECT ... FOR UPDATE            │
│  PESSIMISTIC_READ  → SELECT ... FOR SHARE             │
│  OPTIMISTIC         → @Version check at write time    │
└──────────────────────────────────────────────────────┘
                        =
┌──────────────────────────────────────────────────────┐
│              Combined: Isolation + Lock                │
│                                                       │
│  @Transactional(isolation = READ_COMMITTED)            │
│     └─ All queries see only committed data             │
│  @Lock(PESSIMISTIC_WRITE) on findByIdForUpdate         │
│     └─ This specific row is held exclusively           │
│                                                       │
│  Visibility (what you SEE)                             │
│       + Access control (what you HOLD)                 │
│       = Correct concurrent balance updates             │
└──────────────────────────────────────────────────────┘
DIAGRAM
Timeline: Two threads updating SAV2001 balance

  Thread-1                          Thread-2
  ────────                          ────────
  BEGIN TX                          BEGIN TX
  SELECT ... FOR UPDATE (SAV2001)   │
  Row locked ✓                      SELECT ... FOR UPDATE (SAV2001)
  balance = 500000                  │  BLOCKED (waiting for lock)
  balance = 500000 - 10000          │  ...
  UPDATE balance = 490000           │  ...
  COMMIT                            │  Lock acquired
  Lock released                     balance = 490000 (reads updated)
                                    balance = 490000 - 10000
                                    UPDATE balance = 480000
                                    COMMIT

  Final balance: 480000 (correct)

Key Takeaway

Isolation level and @Lock annotation solve different problems and are designed to work together. The isolation level is a database-wide setting that controls what data your queries can see: READ_COMMITTED shows only committed data, REPEATABLE_READ gives a frozen snapshot, and SERIALIZABLE makes transactions behave as if they run one at a time. The @Lock annotation is a row-level directive on a specific JPA query: PESSIMISTIC_WRITE generates SELECT FOR UPDATE which locks the selected rows so no other transaction can modify them until your transaction commits. Isolation controls visibility, locking controls exclusive access.

In Rajesh's SIP processing service at the National Bank of India, READ_COMMITTED isolation alone does not prevent lost updates. Two threads can read the same balance of Rs.5,00,000, both deduct Rs.10,000, and the final balance ends up as Rs.4,90,000 instead of the correct Rs.4,80,000. Adding @Lock(PESSIMISTIC_WRITE) on the repository query makes the second thread wait until the first commits. The second thread then reads the updated balance and correctly deducts to Rs.4,80,000. For low-contention scenarios where conflicts are rare, optimistic locking with @Version avoids database locks entirely and relies on a version check at commit time, throwing OptimisticLockException if another transaction modified the row.

Pankaj, the architect, recommends this rule of thumb: use pessimistic locking for hot rows with frequent contention such as settlement accounts, use optimistic locking for rows with rare conflicts such as customer profile updates, and use isolation level tuning for reporting queries that need a consistent snapshot. The two mechanisms are orthogonal and complementary.

Remember one sentence: isolation controls what you SEE (database-wide), @Lock controls what you HOLD (row-level), and they combine to give both visibility safety and exclusive access.

---

Share
Use ← → arrow keys to navigate