Posts
A collection of everything I've written so far.
-
Why saveAll() Becomes 10K INSERTs — IDENTITY and Hibernate's Structural Batch Disablement
hibernate.jdbc.batch_size=50 is set, yet saveAll() of 10,000 rows fires 10,000 INSERTs. GenerationType.IDENTITY needs LAST_INSERT_ID() per INSERT, which Statement.RETURN_GENERATED_KEYS cannot deliver in batch — Hibernate disables batching structurally. Application-managed IDs (TABLE strategy simulation) restore batching at ~200 SQL. Raw JDBC batchUpdate with rewriteBatchedStatements=true rewrites them as multi-value INSERT (~10 SQL) — the fastest path. The DZone "IDENTITY → SEQUENCE 100x" post is PostgreSQL-specific. MySQL has no native SEQUENCE (it falls back to TABLE), so the real options on MySQL are UUID, TableGenerator pooled-lo, Snowflake/TSID, or raw JDBC batch.
-
JPA N+1 and the Four JOIN FETCH Traps — MultipleBagFetchException, Pagination OOM, OneToOne LAZY
In a 4-depth domain (owner→merchant→rule→history) findAll + child traversal yields 121 SQL. JOIN FETCH collapses it to 1 (12× faster). Fetching two collections at once raises MultipleBagFetchException — Hibernate refuses the cartesian of two Bags. JOIN FETCH + setMaxResults emits HHH000104 and applies pagination *in memory* — silent OOM at scale. Non-owning @OneToOne LAZY is *always fetched* because the proxy cannot tell whether the value is null. **`default_batch_fetch_size: 10` reduces N+1 from 121 → 13 prep (9.3× drop) — but does NOT fix @OneToOne non-owning LAZY (still 1201 prep)**, because batch fetch only batches collection LAZY triggers, not row-by-row ToOne SELECTs. The fetch traps come from Bag/List/Set semantics, proxy limitations, and Hibernate's cartesian handling — JOIN FETCH alone is half the answer.
-
The Real Cost of JPA Dirty Checking — readOnly, @DynamicUpdate, and Query Plan Cache Leaks
Hibernate's dirty checking copies an entity snapshot at load time and compares it against the current state at flush. With 10,000 rows, readOnly=true skips the snapshot copy (memory savings). @DynamicUpdate emits SQL with only the changed columns — but generates a fresh SQL string per update pattern, increasing Query Plan Cache usage (a permanent heap leak if hibernate.query.plan_cache_max_size is unset). @Modifying bulk JPQL is fastest but leaves the persistence context inconsistent — clearAutomatically=true is the standard. The clear() pattern (flush + clear every 50 inserts) keeps memory bounded for large insert batches. The trap in JPA is never one feature; it is the interaction of flush, cache, and snapshot lifecycle.
-
JPA Optimistic Lock and the Retry Stampede Trap — 6 Scenarios @Version Cannot Cover Alone
100 workers each increment the same rule's priority by +1. Without @Version, the final priority < 100 (Lost Update). With @Version, you only get OptimisticLockException — handling is the caller's responsibility, so only some succeed. @Retryable(3) with backoff=0 produces **retry stampede** — retries pile up at the same instant, colliding again. Exponential backoff with full jitter spreads retries out and reaches priority=100. Plus the **self Lost Update** trap discovered along the way — same transaction, two SELECTs returning different objects (JDBC) vs the same instance (JPA first-level cache `==`). Different category from distributed Lost Update. The piece also covers @Transactional + @Retryable AOP ordering and the AWS Architecture Blog rationale for full jitter.