JPA Performance: LAZY/EAGER, N+1, Fetch Joins & Locking
The "order history" page loaded in 80 milliseconds in every demo. In production, for the customer's account with 500 orders, it took 11 seconds. Nobody had changed the code between the demo and the launch — the difference was the data. The page loaded the orders with one query, then, for each order, lazily loaded its line items with another query. One plus five hundred: 501 round trips to the database, each fast, together catastrophic. The APM trace was almost comical — the same SELECT repeated 500 times with a different id.
This is the N+1 problem, and it is the single most-asked JPA topic in backend interviews — because it's the place where the ORM's convenience quietly becomes a performance bug. This post demonstrates it with real, countable queries, fixes it two ways (JOIN FETCH and @EntityGraph), settles LAZY vs EAGER, and then handles the other classic: two threads updating the same row, solved with optimistic locking.
The N+1, demonstrated and counted
Our order now has line items — a true composition, so the mapping uses cascade = ALL with orphanRemoval (an item cannot exist without its order):
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal total;
@Column(nullable = false, length = 20)
private String status = "NEW";
// Collections are LAZY by default — Hibernate loads the orders first and
// issues one extra SELECT per order when you touch getItems().
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Version
private long version;
// getters, addItem helper...
}
Now the innocent-looking service method that brought down the order history page:
@Transactional(readOnly = true)
public void printNewOrderSummaries() {
List<Order> newOrders = orders.findAll();
for (Order o : newOrders) {
System.out.println("order " + o.getId() + ": " + o.getItems().size() + " items");
}
}
Count the queries with me — this arithmetic is derivable straight from the code, no profiler needed. With 5 NEW orders in the database:
- 1 SELECT for
findAll()—SELECT … FROM orders. The items collections come back as uninitialized lazy proxies; no item data is loaded. - 5 more SELECTs — the first
getItems()call on each order faults in its lazy collection:SELECT … FROM order_items WHERE order_id = ?, once per order.
Total: 1 + 5 = 6 SELECTs. With 500 orders it would be 501. The Hibernate SQL log has this representative shape (your literals and aliases will differ — the shape is the point):
-- query 1: findAll()
select o1_0.id, o1_0.status, o1_0.total, o1_0.version from orders o1_0
-- queries 2-6: one per order, fired by getItems() inside the loop
select i1_0.order_id, i1_0.id, i1_0.qty, i1_0.sku, i1_0.unit_price
from order_items i1_0 where i1_0.order_id=?
-- ... repeated 4 more times, identical shape, different id
Decision rule: whenever you iterate a collection of entities and touch a lazy association inside the loop, count the queries: it's 1 + N. If N is bounded and tiny (a handful), it's fine. If N grows with your data, it's a production incident waiting for enough rows.
LAZY vs EAGER: the defaults and the trade-off
JPA's fetch defaults encode a sensible opinion, and you should know what it is before overriding it:
- To-one associations (
@ManyToOne,@OneToOne) default to EAGER — loading one extra row (usually via a join) is cheap, and a to-one reference is almost always needed. - To-many associations (
@OneToMany,@ManyToMany) default to LAZY — a collection can hold thousands of rows; loading it "just in case" is how you accidentally select the whole database.
The trade-off is simple: EAGER risks loading data you don't need; LAZY risks the N+1 when you do need it. EAGER on a to-one is usually harmless. EAGER on a collection is where the horror stories come from — Hibernate must choose between N+1 selects or one giant cartesian join, and with pagination the join silently returns wrong page sizes.
Decision rule: leave the defaults alone. Never flip a collection to EAGER "to fix" an N+1 — you've traded a countable problem for an uncontrollable one. Instead, declare per query what that query needs, with the two techniques below.
Fix 1: JOIN FETCH
A fetch join tells Hibernate: "load the orders and their items in one query." Same repository, one annotation, N+1 gone:
// One query, items loaded in the same round trip.
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(String status);
@Transactional(readOnly = true)
public void printNewOrderSummariesFixed() {
List<Order> newOrders = orders.findByStatusWithItems("NEW");
for (Order o : newOrders) {
// getItems() touches already-loaded collections — no further SELECTs.
System.out.println("order " + o.getId() + ": " + o.getItems().size() + " items");
}
}
Two caveats that interviewers love:
- Pagination + fetch join needs a separate count query. The join multiplies rows (one order × its items), so
Pageresults needcountQueryspecified explicitly, or the page sizes lie. - One collection per fetch join. Hibernate refuses to fetch-join two
List-valued collections in a single query (MultipleBagFetchException) — the cartesian product would be ambiguous. Fetch one collection; load the second with a separate query.
Fix 2: @EntityGraph
Sometimes you want the same derived query — same name, same conditions — but with one association loaded eagerly just for this call. Rewriting it as JPQL works, but @EntityGraph declares the fetch plan without touching the query:
// Keeps the derived-method naming AND declares
// "load items eagerly for this query only". No JPQL to maintain.
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(String status);
Which to choose? JOIN FETCH when the query itself is complex — conditions on the association, multiple joins, explicit control. @EntityGraph when the derived query is already right and you just want to add "…and bring the items along." A third option for the toolbox: @BatchSize on the collection keeps LAZY loading but fetches N collections per SELECT instead of one — turning 1 + 500 into 1 + 25 with a batch size of 20. It's the pragmatic middle ground when you can't rewrite the query.
Concurrent updates: optimistic locking with @Version
Reads are only half the performance story. The other classic: two operators refund the same order at the same moment. Both load order #7 at version 3. Operator A marks it REFUNDED and saves. Operator B — holding the stale copy — marks it REFUNDED and saves, silently overwriting A's audit fields. Without locking, the last writer wins and nobody is told.
Optimistic locking adds a version column and makes the database enforce the check. The mechanics are beautifully simple: Hibernate reads the version with the row; on flush it runs UPDATE … SET version = version + 1 WHERE id = ? AND version = ?. If another transaction committed first, the version no longer matches, zero rows update, and Hibernate throws instead of overwriting:
@Service
public class PaymentRaceService {
private final OrderRepository orders;
public PaymentRaceService(OrderRepository orders) {
this.orders = orders;
}
// Two operators refund the same order at the same moment. Both load
// order #7 at version 3; whoever commits second must lose.
public void raceTwoRefunds(long orderId) {
Thread a = new Thread(() -> refundOnce(orderId, "operator-A"));
Thread b = new Thread(() -> refundOnce(orderId, "operator-B"));
a.start();
b.start();
try {
a.join();
b.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Transactional
public void refundOnce(long orderId, String by) {
Order order = orders.findById(orderId).orElseThrow();
long seenVersion = order.getVersion();
order.setStatus("REFUNDED");
try {
orders.flush();
System.out.println(by + " refunded order " + orderId);
} catch (ObjectOptimisticLockingFailureException e) {
// UPDATE ... WHERE version=? matched zero rows — someone committed first.
// (JPA raises OptimisticLockException; Spring Data translates repository
// exceptions, so through a repository you catch this form.)
System.out.println(by + " lost the race (saw version " + seenVersion
+ "); re-read the order and retry the business decision");
throw e;
}
}
}
The loser doesn't retry blindly — it re-read the order and retries the business decision. Maybe the order is already REFUNDED and there's nothing to do; maybe the operator needs to see the new state before deciding. "Retry the transaction" without re-reading just replays a stale decision faster.
Decision rule: optimistic locking is the default for concurrent writes — cheap (one extra column, no locks held), correct under low-to-moderate contention. It assumes conflicts are rare and detects them; it does not prevent them.
A note on pessimistic locking
Sometimes conflicts aren't rare — they're the norm. Decrementing the same inventory row from a hundred threads, or debiting one wallet: optimistic retries would thrash, with nearly every attempt losing the race. For hot rows, take a pessimistic lock: the database holds a row lock until your transaction commits, and everyone else waits:
// The database holds a row lock until this transaction commits.
// Use when optimistic retries would thrash — not "just in case".
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdForUpdate(Long id);
The cost is real: lock waits serialize your throughput, a slow transaction blocks everyone behind it, and locks taken in different orders deadlock. Decision rule: optimistic by default; pessimistic only for demonstrably hot rows — and when you take it, keep the locked transaction as short as humanly possible.
The interview cheat sheet
- "What is the N+1 problem?" — 1 query for N parents + N queries for their associations = 1 + N round trips. Caused by touching lazy associations in a loop.
- "LAZY or EAGER?" — to-one defaults EAGER, to-many defaults LAZY; keep the defaults, declare per-query fetch plans instead.
- "How do you fix N+1?" —
JOIN FETCHfor complex queries,@EntityGraphfor derived queries,@BatchSizeas the middle ground. One collection per fetch join. - "Two users update the same row?" —
@Versionoptimistic locking: loser gets an exception, re-reads, retries the decision. PessimisticSELECT … FOR UPDATEonly for hot rows.
The N+1 is also the kind of bug that hides from unit tests and shows up under load — which is exactly what this track's Gatling post is built to surface. A load test with realistic data volumes turns "80ms in the demo" into "11 seconds with 500 orders" before your customers do it for you.
Field check before you move on: enable spring.jpa.show-sql=true, seed 5 orders with 2 items each, and run the N+1 loop — count the SELECTs in the log and confirm you get 6. Then switch to the JOIN FETCH version and confirm the count drops to 1. Finally, remove the @Version field, run the refund race, and observe the silent overwrite; put it back and watch the loser fail loudly. Three experiments, and you'll never hand-wave about JPA performance in an interview again.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment