Skip to content
Forward Engineering
Go back

[JPA + Spring Mastery 01] L1 Cache · flush · Transaction Lifecycle — what readOnly really shaves off, dirty checking's true cost

- views

Table of contents

Open Table of contents

Preface

I’d already measured raw JDBC against five JPA variants (method name / @Query JPQL / Native SQL / QueryDSL / JdbcTemplate) running the same SELECT against orders_w2 (10M rows) — state='CONFIRMED' ORDER BY created_at DESC LIMIT 20, same index, same LIMIT. The numbers:

Methodavg(ms) (warm)p99(ms)
raw JDBC + JdbcTemplate0.7361.018
Spring Data JPA — derived method name0.9941.467
Spring Data JPA — @Query JPQL0.8563.558
Spring Data JPA — Native SQL0.9801.439
QueryDSL — Q-class0.9081.539

The gap between raw JDBC (0.74 ms) and JPA Native SQL (0.98 ms) is roughly +0.4 ms. Same SQL, same rows, same LIMIT — the difference isn’t server-side (DB latency); it’s the client-side cost of mapping a row into an entity object and registering it with JPA’s lifecycle.

What is that +0.4 ms, and where does it come from? PersistenceContext. JPA materializes rows into entities, registers them in the first-level cache, takes a snapshot for dirty checking, and registers transaction-synchronization hooks. At 1M calls/day, that’s about 6.7 minutes of cumulative latency.

You can’t simply remove that cost — JPA’s whole model rests on PersistenceContext. You can either not use JPA or trim it (e.g., set readOnly = true to disable dirty checking). Kakao Pay reported a measurable case in JPA Transactional 잘 알고 쓰고 계신가요? — turning on readOnly cut MySQL Com_set_option round-trips and lifted QPS by 58%.

This article walks the PersistenceContext from Identity Map (Fowler PoEAA, 2002) through ActionQueue (4 lists), AutoFlush, and Dirty Checking (reflection vs bytecode enhancement) — citing Hibernate 6 source. On top of that I unpack Kakao Pay’s readOnly + set_option report into its three real layers: Hibernate flush mode, Spring transaction marker, and MySQL Com_set_option round-trips.

  1. PersistenceContext = Identity Map — Fowler PoEAA’s pattern
  2. EntityManager vs Hibernate Session — the limits of the JPA standard
  3. The four ActionQueues — SQL emission ordering for INSERT / UPDATE / DELETE / collection
  4. AutoFlush — exactly what triggers a flush before a query
  5. Two dirty-checking strategies — reflection (default) vs bytecode enhancement
  6. One pass through the transaction lifecycle — BEGIN → flush → COMMIT
  7. readOnly’s three-layer effect — Hibernate / Spring / MySQL, with the Kakao Pay incident as case study
  8. Decomposing the +0.4 ms baseline — where the cost lives, line by line
  9. Operations checklist

The headline:

The shorthand “L1 cache is fast, so it’s good” is the half answer. Below is the rest of it, line by line.


1. PersistenceContext = Identity Map (Fowler PoEAA, 2002)

1.1 The pattern

Fowler’s Patterns of Enterprise Application Architecture (2002) defines Identity Map as:

“An Identity Map keeps a record of all objects that have been read from the database in a single business transaction. Whenever you want an object, you check the Identity Map first to see if you already have it.” — Martin Fowler, Patterns of Enterprise Application Architecture, 2002

So:

  1. Within a single business transaction,
  2. every object read from the database is
  3. kept in a Map, so that
  4. a subsequent lookup by the same ID returns the same Java object.

That is exactly what JPA’s PersistenceContext is. EntityManager carries a Map<EntityKey, Object> first-level cache; calling findById(1L) twice in the same transaction returns the same Java object.

1.2 Java-level identity guarantee

@Transactional
public void example() {
    Order o1 = repo.findById(1L);  // first read — DB
    Order o2 = repo.findById(1L);  // second — L1 hit

    assertThat(o1).isSameAs(o2);   // ✅ same object (==)
}

In Fowler’s framing: “object identity within a business transaction”. Break the guarantee — get two different objects for the same ID — and changes on one won’t reflect on the other (a split entity class of bug).

1.3 Pairing with Unit of Work

Identity Map travels with another Fowler pattern: Unit of Work.

“A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you’re done, it figures out everything that needs to be done to alter the database as a result of your work.” — Fowler, PoEAA

So Unit of Work tracks every change during the transaction and flushes them as one batch at commit time.

JPA’s PersistenceContext implements both:

The combination is JPA’s runtime model.

1.4 L1 cache hit / miss in practice

@Transactional
public void cacheBehavior() {
    Order o1 = repo.findById(1L);  // miss → SELECT
    Order o2 = repo.findById(1L);  // hit → cache (no SQL)

    em.clear();                      // wipe L1

    Order o3 = repo.findById(1L);  // miss → SELECT
    assertThat(o1).isNotSameAs(o3);  // different identity (cache cleared)
}

em.clear(), em.detach(o1), or transaction termination empties the L1 cache. PersistenceContext lifetime = transaction lifetime.


2. EntityManager vs Hibernate Session — limits of the JPA spec

2.1 The JPA-spec abstraction

JPA 2.2 (JSR 338)‘s EntityManager is the vendor-neutral standard:

public interface EntityManager {
    void persist(Object entity);          // INSERT
    <T> T find(Class<T> entityClass, Object primaryKey);  // SELECT
    <T> T merge(T entity);                // UPDATE (detached → managed)
    void remove(Object entity);            // DELETE
    void flush();                           // force flush
    void clear();                           // wipe L1
    void detach(Object entity);             // detach one entity
    Query createQuery(String jpql);
    // ...
}

It’s enough that in theory you can swap providers (Hibernate, EclipseLink, DataNucleus). In practice most production code reaches for vendor-specific features and the abstraction leaks.

2.2 Hibernate Session — what it adds

The Hibernate User Guide defines Session as a subtype of EntityManager with extras:

public interface Session extends EntityManager {
    void replicate(Object entity, ReplicationMode replicationMode);
    void update(Object entity);             // saveOrUpdate
    void saveOrUpdate(Object entity);
    Statistics getStatistics();
    Filter enableFilter(String filterName);
    // ...
}

In production those extras matter often enough — features like setFlushMode(FlushMode.MANUAL) aren’t part of the JPA spec — that vendor neutrality stays a design ideal. When you need them, EntityManager#unwrap(Session.class) gets you a Session.

2.3 Why this article says “PersistenceContext”

From here on, when we mean the shared data structure inside EntityManager / Session, we use the term PersistenceContext. Hibernate’s source uses the same name:

// hibernate-core/src/main/java/org/hibernate/engine/spi/PersistenceContext.java
public interface PersistenceContext {
    void addEntity(EntityKey key, Object entity);
    Object getEntity(EntityKey key);
    EntityEntry getEntry(Object entity);
    void clear();
    // ...
}

The default impl is StatefulPersistenceContext — it holds the entity-by-EntityKey map, EntityEntry (per-entity metadata), collection-by-key, and so on.


3. The four ActionQueues — INSERT / UPDATE / DELETE emission order

3.1 ActionQueue’s data shape

Where does the PersistenceContext put modified entities while it’s acting as a Unit of Work? Hibernate’s ActionQueue:

// hibernate-core/src/main/java/org/hibernate/engine/spi/ActionQueue.java
public class ActionQueue {
    private ExecutableList<AbstractEntityInsertAction> insertions;
    private ExecutableList<EntityDeleteAction> deletions;
    private ExecutableList<EntityUpdateAction> updates;

    private ExecutableList<CollectionRecreateAction> collectionCreations;
    private ExecutableList<CollectionUpdateAction> collectionUpdates;
    private ExecutableList<CollectionRemoveAction> collectionRemovals;
    private ExecutableList<QueuedOperationCollectionAction> collectionQueuedOps;

    private ExecutableList<OrphanRemovalAction> orphanRemovals;
    // ...
}

Seven ExecutableLists — entity INSERT/UPDATE/DELETE plus collection create / update / remove / queued-ops plus orphan removals.

3.2 The emission ordering it guarantees

If you call em.persist(orderItem) then em.persist(order) in that order, Hibernate still issues the parent INSERT first. ActionQueue dictates the order.

Default order:

  1. OrphanRemovalAction — cascade ORPHAN_REMOVAL deletions
  2. EntityInsertAction — new-entity INSERTs
  3. EntityUpdateAction — existing-entity UPDATEs
  4. CollectionRemoveAction — collection removals
  5. CollectionUpdateAction — collection updates
  6. CollectionRecreateAction — collection recreations
  7. EntityDeleteAction — entity DELETEs

This sequence is what keeps FK constraints satisfied — parent INSERT → child INSERT → child UPDATE → parent UPDATE → child DELETE → parent DELETE.

3.3 hibernate.order_inserts = true — group by entity type

When order_inserts is on, INSERTs of the same entity type emit contiguously, which lets JDBC batch insert kick in.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true   # contiguous emission
        order_updates: true   # same for UPDATE

Why it matters — JDBC batch insert only fires for contiguous identical SQL. With order_inserts=false, Hibernate may interleave INSERTs of different entity types, breaking the batch. (See series article 5 — saveAll IDENTITY trap — for the full picture.)

3.4 Operational trap — changing the ordering

You don’t set the ordering yourself; Hibernate decides. Practical levers when you need a different ordering:

  1. Call em.flush() explicitly to force a partial flush
  2. Split into separate @Transactional methods so commits happen mid-flow
  3. Drop into native SQL to bypass ActionQueue

For most workloads Hibernate’s default order is right; in cascade chains 3+ levels deep or with complex collection mutations you may see surprises — em.flush() is the diagnostic lever.


4. AutoFlush — exactly what triggers a flush before a query

4.1 The three FlushModes

JPA’s FlushModeType defines two modes; Hibernate adds one more, totaling three:

FlushModeBehavior
AUTO (default)flush before queries and at commit
COMMITflush only at commit, not before queries
MANUAL (Hibernate-only)flush only on explicit em.flush()

4.2 What triggers AUTO

FlushModeType.AUTO is the default. It flushes before queries — but not all queries blindly. Hibernate flushes only entities that could affect the query about to run.

Vlad Mihalcea lays it out:

@Transactional
public void autoFlushExample() {
    Order order = new Order(...);
    em.persist(order);  // INSERT enqueued (not in DB yet)

    // Before the query Hibernate decides: do I need to flush?
    List<Order> all = em.createQuery("SELECT o FROM Order o").getResultList();
    // ↑ Order entity → ActionQueue's Order INSERT could affect the result
    //   → flush triggers → INSERT runs → query runs
    //   → 'all' includes the new order
}

The principle: flush only when the query’s entities and the queue’s entities overlap. Otherwise skip.

4.3 The native-query trap

For a native SQL query Hibernate cannot tell which entities it touches. To stay safe, it flushes the entire ActionQueue by default:

@Transactional
public void nativeQueryFlush() {
    Order order = new Order(...);
    em.persist(order);

    // Native SQL — Hibernate can't analyze it
    em.createNativeQuery("SELECT COUNT(*) FROM customer").getSingleResult();
    // ↑ Order INSERT flushed (even though Customer is unrelated)
}

Vlad’s caution — native queries flush everything to avoid stale-read risk; you may emit INSERTs you didn’t intend at that moment.

Workaround: em.createNativeQuery(sql).unwrap(NativeQuery.class).addSynchronizedEntityClass(Order.class) flushes only the entity you name.

4.4 When FlushMode.COMMIT makes sense

COMMIT skips the pre-query flush. Useful for:

  1. Read-only transactions (@Transactional(readOnly=true))
  2. Bulk batches — many INSERT/UPDATE then one commit
  3. Domains where you need explicit flush control

Trade-off: in-flight changes are not visible to subsequent queries within the same transaction. If you persist and then immediately select, the new entity won’t appear. Some domains tolerate that, others don’t.

4.5 What Spring’s readOnly = true actually does

When Spring sees readOnly = true, it switches Hibernate to FlushMode.MANUAL. AutoFlush is off. Details in §7.


5. Dirty checking — reflection vs bytecode enhancement

5.1 What dirty checking is, fundamentally

@Transactional
public void dirtyCheckingExample() {
    Order order = repo.findById(1L);  // SELECT — entity is managed
    order.setStatus(Status.CONFIRMED); // mutation — DB not yet aware

    // At transaction end:
    // 1. Hibernate compares the entity's *current* state with its *snapshot* (read-time)
    // 2. Differences detected → enqueue UPDATE in ActionQueue
    // 3. flush → emit UPDATE
    // 4. commit
}

The mechanism is snapshot comparison — store the entity’s read-time state, compare current state at commit, UPDATE only changed columns.

5.2 The two strategies

Vlad’s article summarizes both.

(1) Reflection (default) — at runtime Hibernate compares each field byte-by-byte using reflection.

At SELECT:
  Order order = ... (managed)
  PersistenceContext deep-copies the entity into a snapshot
  → snapshot = { id: 1, status: PENDING, amount: 100, ... }

At commit:
  for each field of order:
    if (currentField != snapshot[field]):
      mark dirty
  if (any dirty):
    enqueue UPDATE in ActionQueue

Costs:

(2) Bytecode enhancement — at build time Hibernate rewrites entity setters to intercept writes.

// What you wrote
public class Order {
    private Status status;
    public void setStatus(Status s) { this.status = s; }
}

// What Hibernate has after enhancement
public class Order implements PersistentAttributeInterceptable {
    private Status status;
    private PersistentAttributeInterceptor interceptor;

    public void setStatus(Status s) {
        this.status = interceptor.writeObject(this, "status", this.status, s);
        // ↑ interceptor records the change automatically
    }
}

Costs:

5.3 The 10k-row update measurement

(This will be measured in EXP-13 — W4. The article will be re-released as v2 with the numbers.)

EXP-13 will compare:

Vlad reports roughly 10× faster with bytecode enhancement at 50,000 entities. EXP-13 will validate that pattern in our learning environment.

5.4 Turning bytecode enhancement on

Spring Boot + Hibernate 6:

// build.gradle.kts
plugins {
    id("org.hibernate.orm") version "6.6.0"
}

hibernate {
    enhancement {
        enableLazyInitialization = true
        enableDirtyTracking = true   // ← dirty-check enhancement
        enableAssociationManagement = true
    }
}

After this, every @Entity is enhanced automatically. Build time grows slightly (~+2s for 100 entities).

5.5 Side effects to know

IssueMechanism
Conflict with Lombok @Data@Data’s generated equals/hashCode uses a different model from enhancement — entity equality breaks
Custom serialization breaksEnhancement adds a hidden field (interceptor) — Java serialization picks it up
”Surprises” in the debuggerSetters now contain extra logic — breakpoint stack traces grow

In practice Vlad’s recommendation is to turn it on. With Lombok, prefer @Getter @Setter instead of full @Data.


6. One pass through the lifecycle — BEGIN → flush → COMMIT

6.1 The full sequence

What happens when one @Transactional method runs:

sequenceDiagram
    participant App as Application
    participant TI as TransactionInterceptor
    participant TM as HibernateTransactionManager
    participant Session as Hibernate Session
    participant AQ as ActionQueue
    participant DB as MySQL

    App->>TI: deductOrder() (proxy)
    TI->>TM: getTransaction(definition)
    TM->>Session: openSession() / reuse
    TM->>DB: BEGIN transaction
    DB-->>TM: connection acquired
    TI->>App: invoke raw target

    App->>Session: em.find(Order.class, 1L)
    Session->>DB: SELECT ... — L1 miss
    DB-->>Session: row
    Session->>Session: snapshot, register in L1
    Session-->>App: Order entity

    App->>App: order.setStatus(CONFIRMED)
    App-->>TI: return

    TI->>TM: commit(status)
    TM->>Session: flush()
    Session->>AQ: process all actions
    AQ->>AQ: dirty check → status changed
    AQ->>AQ: enqueue EntityUpdateAction
    AQ->>DB: UPDATE order SET status='CONFIRMED' WHERE id=1
    DB-->>AQ: 1 row
    Session->>DB: COMMIT
    DB-->>Session: ok
    TM-->>TI: ok
    TI-->>App: ok

6.2 Source landmarks per stage

StageSource
1. BEGINAbstractPlatformTransactionManager#getTransaction
2. Session.openSessionHibernateTransactionManager#doBegin
3. em.find (L1 miss)EntityManager#findLoadEventDefaultLoadEventListener
4. snapshotStatefulPersistenceContext#addEntity
5. dirty checkDefaultFlushEventListener#onFlush
6. flushSession#flushFlushEventDefaultFlushEventListener
7. COMMITJdbcConnection#commit

6.3 PersistenceContext teardown

At transaction end the PersistenceContext is cleared. Every entity becomes detached.

@Transactional
public void example() {
    Order o = repo.findById(1L);  // managed
    return o;
}
// transaction ends — o is now detached

// Elsewhere, accessing a lazy field:
o.getCustomer().getName();  // ← LazyInitializationException (with OSIV=false)

That behavior is the entry point for series article 3 — OSIV. With OSIV=true the PersistenceContext lives until the view renders and lazy fields stay accessible. With OSIV=false (the recommended default) you reach for DTO projections or fetch joins.


7. The three-layer effect of readOnly — Kakao Pay decoded

7.1 The Kakao Pay report

카카오페이 — JPA Transactional 잘 알고 쓰고 계신가요? reports:

“Applying @Transactional(readOnly = true) carefully reduced set autocommit round-trips, and QPS rose by 58%.” — tech.kakaopay.com

That number is the result of three distinct effects layered together. Below, line by line.

7.2 Layer 1 — Hibernate FlushMode flips

When Spring sees readOnly=true, it calls Session#setHibernateFlushMode(FlushMode.MANUAL). AutoFlush is off.

Effects:

// Spring 6 — HibernateTransactionManager
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
    // ...
    if (definition.isReadOnly()) {
        session.setHibernateFlushMode(FlushMode.MANUAL);
    }
    // ...
}

Even on its own, this layer cuts the per-entity snapshot cost (deep copy) to zero — a 10k-row read can drop ~30% in latency, per Vlad’s measurements.

7.3 Layer 2 — Spring’s transaction marker

Spring also records readOnly=true as a marker on the transaction status. That marker enables:

7.4 Layer 3 — MySQL Com_set_option round-trips (Kakao Pay’s headline)

This is the layer Kakao Pay’s report centers on. Round-tripping autocommit on/off per transaction is itself expensive.

Default behavior:

  1. Tx start — JDBC setAutoCommit(false) → MySQL set autocommit=0
  2. Tx end — setAutoCommit(true) → MySQL set autocommit=1

Two round-trips per transaction. At 1 ms RTT that’s 2 ms/tx. At 1000 QPS, that’s 2 seconds of round-trip cost per second.

With readOnly = true, MySQL Connector/J omits the set autocommit toggle on read-only transactions. The Com_set_option round-trip disappears.

-- Watch the metric
SHOW STATUS LIKE 'Com_set_option';

Kakao Pay’s QPS +58% is the direct effect of that omission — for read-only APIs, throughput rose by exactly that round-trip overhead.

7.5 Applied in commerce-comment-platform-be

@Service
@Transactional(readOnly = true)  // class default = readOnly
public class OrderQueryService {
    
    public List<Order> findRecent() {
        return repo.findTop20ByStateOrderByCreatedAtDesc();
    }
}

@Service
@Transactional  // class default = read-write
public class OrderCommandService {
    
    public Order create(OrderRequest req) {
        return repo.save(new Order(req));
    }
}

This pairs naturally with CQRS — query services default to readOnly, only the write methods declare @Transactional.


8. Decomposing the +0.4 ms baseline

8.1 The numbers again

Methodavg(ms) (warm)p99(ms)rows
raw JDBC + JdbcTemplate0.7361.01820
Spring Data JPA — derived method name0.9941.46720
Spring Data JPA — @Query JPQL0.8563.55820
Spring Data JPA — Native SQL0.9801.43920
QueryDSL — Q-class0.9081.53920

Difference between raw JDBC (0.74) and the JPA variants (0.86–0.99): ~+0.4 ms.

8.2 What makes up the +0.4 ms

Four contributors:

StepCostMechanism
1. ResultSet → entity hydration~0.15 msreflection / proxy creation
2. L1 cache registration~0.05 msEntityKey hashing + Map.put
3. Snapshot creation (for dirty checking)~0.15 msper-entity deep copy
4. Transaction-sync hook registration~0.05 msTransactionSynchronizationManager.register
Total~0.40 ms(LIMIT 20 → 20 rows × per-row cost)

Apply readOnly = true:

→ With readOnly, the +0.4 ms drops to roughly +0.25 ms — a measurable shift.

8.3 The derived-method-name p99 8.5 ms outlier

In the first round of W3 measurements the derived method name’s p99 came in at 8.496 ms — even after warmup, 5× the other variants.

Likely contributors:

Operational guidance:

8.4 What to use where

Query typeRecommendation
Single-row PK lookupSpring Data JPA findById() (convenient)
Simple WHERE (1–2 cols)derived method name — until the query gets complicated
Complex WHERE / JOIN / dynamic conditionsQueryDSL (compile-time safety)
Read-only multi-column dashboard queries@Query JPQL or Native SQL with readOnly=true
Bulk batch / write-heavyraw JDBC + JdbcTemplate (skip JPA cost)
p99 < 1 ms targetraw JDBC

→ JPA’s real gain is on the write side — @Transactional + dirty checking + cascade simplify business logic. For read-only queries the cost-benefit deserves a second look.


9. Conclusion — PersistenceContext through a senior lens

9.1 Layered understanding

LayerUnderstanding
L1 surface”L1 cache is fast, so it’s good”
L2 mechanismIdentity Map (Fowler 2002) + Unit of Work — EntityManager implements both
L2.5 sourceStatefulPersistenceContext (L1) + ActionQueue’s 7 ExecutableLists + DefaultFlushEventListener
L3 measurementJPA 5 ways — raw JDBC 0.74 vs JPA 0.86–0.99 ms (+0.4 ms baseline). Derived method name p99 = 8.5 ms
L4 opsKakao Pay readOnly + set_option QPS +58% — three layers (Hibernate flush mode + Spring marker + MySQL Com_set_option round-trips)
L5 academicFowler PoEAA (Identity Map / Unit of Work) — JPA’s pattern origin

9.2 Operations checklist

9.3 What the next articles cover


10. References

Academic / books (L5)

Official documentation (primary)

Hibernate 6 source (cited directly)

Vlad Mihalcea (Hibernate Steering Committee)

Korean tech-blog incident reports

Author’s own measurements

Future expansion (after EXP-13 in W4)


Share this post on:

Next Post
[JPA + Spring Mastery 08] Transaction Split Patterns — Saga / Outbox / REQUIRES_NEW, from academic origins to a 9-scenario EXP-09b measurement