Transactions, Caching & Performance

Isolation Levels

18 min Lesson 3 of 13

Isolation Levels

When multiple transactions run concurrently, they can interfere with each other in well-defined ways. The SQL standard names these interference patterns concurrency anomalies and defines four isolation levels that progressively eliminate them — at the cost of progressively more locking or versioning overhead. Choosing the right isolation level is one of the most consequential performance decisions in any data-intensive service.

The Four Concurrency Anomalies

Before looking at the levels themselves, it helps to have a concrete mental model of what can go wrong.

  • Dirty Read: Transaction A reads a row that Transaction B has modified but not yet committed. If B rolls back, A was working with data that never actually existed.
  • Non-Repeatable Read: Transaction A reads a row, then reads it again later in the same transaction and sees different values — because B committed an update in between.
  • Phantom Read: Transaction A executes a range query twice. Between the two executions, Transaction B inserts or deletes rows that fall inside the range. The second query returns a different set of rows — "phantoms" appeared or vanished.
  • Lost Update: Two transactions read a value, both modify it, and the later write silently overwrites the earlier one. Neither transaction saw an error, but one update was discarded.
Lost updates are not in the SQL-92 standard's anomaly list but are arguably the most damaging in practice. Optimistic locking (covered in Lesson 5) is the standard JPA defence against them.

The Four Isolation Levels

The SQL standard defines the levels from weakest to strongest. Every major database supports at least the bottom three; most default to Read Committed.

READ UNCOMMITTED

A transaction can read uncommitted changes from other transactions. All four anomalies are possible. This level is almost never used in application code — it has niche uses in analytics dashboards that can tolerate stale data and need the absolute minimum of lock contention.

READ COMMITTED (PostgreSQL / Oracle default)

A transaction only sees data that has been committed. Dirty reads are eliminated. Non-repeatable reads and phantoms remain possible because a second read within the same transaction may see a different snapshot than the first.

REPEATABLE READ (MySQL / MariaDB InnoDB default)

The database ensures that if you read a row, subsequent reads of the same row within the same transaction return the same values. Dirty reads and non-repeatable reads are eliminated. Phantom reads are theoretically possible under the strict SQL-92 definition, but InnoDB's MVCC-based implementation prevents them in practice for most query patterns.

SERIALIZABLE

Transactions execute as if they were fully serialised — one after another. All anomalies are eliminated. The database achieves this with range locks (or predicate locks), which can cause significant contention and deadlocks under write-heavy workloads.

Setting the Isolation Level in Spring

You set the isolation level with the isolation attribute of @Transactional. The constant names mirror the SQL-standard names:

import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service; @Service public class AccountService { // Default — uses the database default (usually READ_COMMITTED) @Transactional public void transfer(long fromId, long toId, BigDecimal amount) { ... } // Prevent non-repeatable reads: same row always returns the same value @Transactional(isolation = Isolation.REPEATABLE_READ) public AccountSnapshot buildStatement(long accountId) { ... } // Maximum safety — use sparingly; causes the most contention @Transactional(isolation = Isolation.SERIALIZABLE) public void reserveSeat(long flightId, int seatNumber) { ... } }
Match the level to the anomaly you are actually defending against. Jumping to SERIALIZABLE everywhere to be "safe" will cause deadlocks and timeout errors under production load. Start at READ_COMMITTED, profile, and only escalate when you can demonstrate a concrete anomaly in your workload.

How Databases Implement Isolation

Understanding the implementation explains why some levels are "free" and others are expensive.

  • MVCC (Multi-Version Concurrency Control): PostgreSQL, MySQL InnoDB, and Oracle use versioned row images. Readers see a snapshot of committed data taken at the start of the transaction (or the start of the statement, depending on the level) without acquiring any locks. This makes reads fast even at higher isolation levels but uses more storage and CPU for garbage collection of old versions.
  • Shared and Exclusive Locks: SERIALIZABLE adds range/predicate locks on top of MVCC or uses two-phase locking. These block concurrent writers and can escalate to deadlocks.

Isolation in JPA / Hibernate

Hibernate introduces its own concept on top of the database level: the first-level cache (the persistence context). Within a single transaction, once Hibernate loads an entity it caches it in memory. Subsequent calls to entityManager.find() for the same primary key return the cached object, not a re-queried database row. This means Hibernate automatically provides repeatable reads for entity lookups, regardless of the database isolation level — but only for primary-key finds, not for JPQL range queries.

@Transactional(isolation = Isolation.READ_COMMITTED) public void demonstrateFirstLevelCache(long orderId) { // First find: hits the database Order order = orderRepository.findById(orderId).orElseThrow(); // Simulate some processing time during which another transaction commits processPayment(order); // Second find: returns the SAME in-memory object — no DB round-trip // Even though the DB may have a newer version at READ_COMMITTED level Order sameOrder = orderRepository.findById(orderId).orElseThrow(); // order == sameOrder (same reference) }
The first-level cache can hide stale data. If you need a fresh view of an entity mid-transaction (e.g. after calling a stored procedure that modifies the row), call entityManager.refresh(entity) to force a re-read from the database.

Practical Guidance by Use Case

  • Read-only reporting queries: READ_COMMITTED (or lower) with @Transactional(readOnly = true). Speed matters; phantoms in a report are tolerable.
  • Account balances / inventory counts: REPEATABLE_READ or optimistic locking. You must not calculate a derived value from a row that changed under you.
  • Seat / ticket reservations: SERIALIZABLE or pessimistic locking. The cost of an anomaly (double-booking) outweighs the performance penalty.
  • General CRUD operations: READ_COMMITTED (the default). Correct for the vast majority of business logic.

Summary

The four SQL isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE — form a spectrum that trades concurrency for safety. Spring exposes them directly through the isolation attribute of @Transactional. Most applications operate correctly at READ_COMMITTED; escalate only when a concrete anomaly — non-repeatable read, phantom, or lost update — is proven to exist in your workload. Hibernate's first-level cache silently provides repeatable entity reads regardless of the database level, which is helpful but can occasionally deliver stale data when you need freshness.