Definition
- 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
@Versioncolumn instead of database locks. Hibernate checks the version at write time and throwsOptimisticLockExceptionif 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:
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();
}
}
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-- generatesSELECT ... 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-- generatesSELECT ... FOR SHARE. Multiple transactions can hold a shared lock simultaneously, but none of them can modify the row until all shared locks are released.
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);
}
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 |
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));
}
}
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:
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
}
}
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 |
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; }
}
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.
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
}
}
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.
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));
}
}
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.
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
}
}
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_COMMITTEDisolation 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 LOCKEDto pick up unlocked work items without waiting or deadlocking.
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();
}
}
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 UPDATEare 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 UPDATEon 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.timeoutto avoid waiting forever. A value of 5000 means the lock attempt fails after 5 seconds with aLockTimeoutException. - Connection holding -- pessimistic locks hold the database connection for the entire transaction. On high-throughput paths, prefer optimistic locking with
@Versionto avoid connection pool pressure.
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);
}
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
@Lockwithout@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_WRITEprevent SELECT without FOR UPDATE? No. A plain SELECT in READ_COMMITTED still reads the committed version of the row. Only otherFOR UPDATEorFOR SHAREqueries are blocked. - What happens if you combine
SERIALIZABLEisolation withPESSIMISTIC_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
CountDownLatchto coordinate timing. One thread acquires the lock, the other thread attempts to acquire it and should block until the first commits.
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"));
}
}
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
┌──────────────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────────────────┘
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.
---