Topic 8.4
JPA and Hibernate: Mapping, Fetching and the N+1 Problem
In one line
JPA maps entities to tables and relationships to foreign keys, but default fetching can silently issue one query per row (N+1). Make associations LAZY, load what each use case needs with fetch joins, entity graphs or batch fetching, use DTO projections for reads, and apply @Version for optimistic locking.
Think of it like this
A waiter taking an order for a table of 20 by walking to the kitchen once per person instead of once for the whole table. Each trip is quick; 21 trips are not.
Key ideas
- 01
Mappings:
@ManyToOne(FK on this side),@OneToMany(mappedBy)(inverse side),@OneToOne,@ManyToMany(prefer an explicit join entity when the relationship has attributes). Owning side = the side with the FK. - 02
Defaults:
@ManyToOneand@OneToOneare EAGER by default in JPA, which is a common source of hidden queries. Setfetch = FetchType.LAZYeverywhere and fetch explicitly per use case. - 03
N+1: loading 100 orders and then touching
order.getCustomer().getName()triggers 100 more queries. Detect with SQL logging or Hibernate statistics in tests (assert query counts). - 04
Fixes:
JOIN FETCHin JPQL (one query, but don't fetch two collections at once, which creates a Cartesian product);@EntityGraph(attributePaths = ...)on repository methods;@BatchSize/hibernate.default_batch_fetch_size = 50(loads lazy associations in batches with IN lists); DTO projections for read-only screens. - 05
Locking:
@Versiongives optimistic locking (OptimisticLockExceptionon conflict);@Lock(LockModeType.PESSIMISTIC_WRITE)issuesSELECT ... FOR UPDATE. - 06
Batch writes: enable
hibernate.jdbc.batch_sizeandorder_inserts. Note thatGenerationType.IDENTITYdisables insert batching; use a sequence withallocationSize(pooled optimizer).
Code & diagrams
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Version
private long version;
}
public interface OrderRepository extends JpaRepository<Order, Long> {
// one query: orders + customers
@Query("select o from Order o join fetch o.customer where o.createdAt >= :since")
List<Order> findRecentWithCustomer(Instant since);
// read model: no entities, no dirty checking
@Query("select new com.shop.OrderRow(o.id, c.name, o.total) from Order o join o.customer c where c.id = :cid")
List<OrderRow> rowsForCustomer(Long cid);
}-- before: 1 + 100 queries
select o.id, o.customer_id, ... from orders o where o.created_at >= ?
select c.id, c.name from customer c where c.id = ? -- x100
-- after join fetch: 1 query
select o.id, ..., c.id, c.name from orders o join customer c on c.id = o.customer_id
where o.created_at >= ?Interview problem
The problem
An order list page issuing 1,201 queries
An order history page shows 200 orders with customer name, item count and shipping status. The logs show 1,201 queries per page load. Fix it.
When it breaks
JOIN FETCH on two collections with pagination
What you see
Hibernate warns HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory and loads every row into memory before paging, or throws MultipleBagFetchException.
Fix & prevent
Page on IDs first, then fetch associations for those IDs; use batch fetching for collections.
Explain it without notes
Why should to-one associations be LAZY by default?
Practice
Enable Hibernate SQL logging, reproduce an N+1, then fix it with an @EntityGraph.
Trade-offs
- ↔
ORMs speed up development and handle mapping; they hide SQL, so query counts and plans must be checked deliberately.
Done when you can
I can map relationships correctly and eliminate N+1 with fetch joins, entity graphs, batching or projections.