What Is the N+1 Problem?
This is the most common and critical performance issue in JPA. When querying one parent entity, additional queries are fired for each associated child entity, resulting in N+1 total queries.
Think of it like a food delivery app. To check menus from 10 restaurants, you make 1 API call for the restaurant list + 10 separate API calls for each restaurant’s menu = 11 API calls total. It would be far more efficient to request “restaurants and menus together” in a single call.
Entity Setup and N+1 Reproduction
// Team.java / Member.java — Entities to reproduce the N+1 problem
package com.example.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// Default fetch strategy: LAZY (lazy loading)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
public Team() {}
public Team(String name) { this.name = name; }
// getters/setters omitted
public Long getId() { return id; }
public String getName() { return name; }
public List<Member> getMembers() { return members; }
}
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY) // LAZY recommended
@JoinColumn(name = "team_id")
private Member team;
public Member() {}
public Member(String username, Team team) {
this.username = username;
this.team = team;
}
// getters omitted
public String getUsername() { return username; }
public Team getTeam() { return team; }
}
// Code that triggers N+1
// List<Team> teams = teamRepository.findAll();
// Query executed: SELECT * FROM team (1 time)
//
// for (Team team : teams) {
// team.getMembers().size(); // Additional query per team
// // SELECT * FROM member WHERE team_id = 1 (N times)
// // SELECT * FROM member WHERE team_id = 2
// // SELECT * FROM member WHERE team_id = 3 ...
// }
// With 100 teams, a total of 101 queries are executed!
Solution 1: Fetch Join (JPQL)
// TeamRepository.java — Solving N+1 with Fetch Join
package com.example.repository;
import com.example.entity.Team;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TeamRepository extends JpaRepository<Team, Long> {
// Fetch Join — Load Team + Member in a single query
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// Query executed: SELECT t.*, m.* FROM team t
// INNER JOIN member m ON t.id = m.team_id
// -> Completed in 1 query!
// Note: Fetch Joining 2+ collections causes MultipleBagFetchException
// Solution: Use Set or combine with @BatchSize
}
// Using in the service layer
// @Service
// @Transactional(readOnly = true)
// public class TeamService {
// private final TeamRepository teamRepository;
//
// public List<TeamDto> getAllTeamsWithMembers() {
// return teamRepository.findAllWithMembers()
// .stream()
// .map(TeamDto::from)
// .toList();
// // Result: 1 query, all teams + members loaded
// }
// }
Solution 2: @BatchSize and EntityGraph
// Using BatchSize and EntityGraph
package com.example.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.BatchSize;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// @BatchSize — Batch query using IN clause
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
// Query change with BatchSize:
// Before (N+1): SELECT * FROM member WHERE team_id = ? (100 times)
// BatchSize: SELECT * FROM member WHERE team_id IN (1,2,...,100) (1 time)
// -> 101 queries reduced to 2!
}
// EntityGraph — Specify fetch strategy via annotation
// TeamRepository.java
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
// @EntityGraph(attributePaths = {"members"})
// @Query("SELECT t FROM Team t")
// List<Team> findAllWithMembersGraph();
// // Same effect as Fetch Join, applied without modifying JPQL
//
// // Dynamic EntityGraph
// @EntityGraph(attributePaths = {"members", "members.tasks"})
// List<Team> findByNameContaining(String name);
// }
Solution 3: DTO Projection
// TeamSummaryDto.java — Query only required data with DTO Projection
package com.example.dto;
// Interface-based projection (Spring Data JPA)
public interface TeamSummary {
String getName();
int getMemberCount();
}
// Class-based projection
public record TeamDetailDto(
Long id,
String name,
long memberCount
) {}
// Using in Repository
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
// // Interface projection — proxy generation
// @Query("SELECT t.name AS name, SIZE(t.members) AS memberCount FROM Team t")
// List<TeamSummary> findTeamSummaries();
//
// // Constructor projection — using new operator
// @Query("SELECT new com.example.dto.TeamDetailDto(t.id, t.name, COUNT(m)) " +
// "FROM Team t LEFT JOIN t.members m GROUP BY t.id, t.name")
// List<TeamDetailDto> findTeamDetails();
//
// // Result: Maps directly to DTO instead of entity
// // -> No persistence context management cost
// // -> SELECT only required columns
// }
Second-Level Cache Configuration
// Second-level cache setup — Ehcache 3 based
// build.gradle
// implementation 'org.hibernate.orm:hibernate-jcache'
// implementation 'org.ehcache:ehcache:3.10.8'
// application.properties
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=jcache
// spring.jpa.properties.hibernate.cache.use_query_cache=true
// spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE
// Team.java — Cache-enabled entity
package com.example.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Enable second-level cache
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Collection cache
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// Cache behavior:
// 1st query: Executes DB query, stores result in second-level cache
// 2nd query: Returns immediately from second-level cache (no DB query)
// Entity update: Cache automatically invalidated
public Team() {}
public Long getId() { return id; }
public String getName() { return name; }
public List<Member> getMembers() { return members; }
}
// Cache strategy selection guide:
// READ_ONLY -> Immutable code tables (countries, categories)
// READ_WRITE -> General entities with many reads and few writes
// NONSTRICT_READ_WRITE -> Low likelihood of concurrent modifications
// TRANSACTIONAL -> Transactional cache guarantee in JTA environments
Performance Monitoring Configuration
// application.properties — Query analysis settings
// spring.jpa.show-sql=true // Print SQL
// spring.jpa.properties.hibernate.format_sql=true // Format SQL
// spring.jpa.properties.hibernate.generate_statistics=true // Collect statistics
// logging.level.org.hibernate.SQL=DEBUG // SQL logging
// logging.level.org.hibernate.orm.jdbc.bind=TRACE // Bind parameters
// Statistics output example:
// Session Metrics {
// 1234 nanoseconds spent acquiring 1 JDBC connections;
// 5678 nanoseconds spent executing 2 JDBC statements;
// 0 nanoseconds spent executing 0 JDBC batches;
// 12345 nanoseconds spent performing 3 L2C hits;
// 0 nanoseconds spent performing 0 L2C misses;
// }
Practical Tips
Here is the priority order for JPA performance optimization.
- Priority 1: Detect N+1 — Always check query counts during development with
spring.jpa.show-sql=true. If more queries than expected are fired, suspect N+1 - Priority 2: Fetch strategy — Set associations to LAZY by default, and use Fetch Join or EntityGraph for eager loading only where needed
- Priority 3: BatchSize — Set a global
default_batch_fetch_sizebetween 100 and 1000 to prevent N+1 across the board - Priority 4: DTO Projection — For read-only APIs, map directly to DTOs instead of entities to eliminate persistence context overhead
- Priority 5: Second-level cache — Selectively apply to entities with infrequent changes and frequent reads
- Apply
@Transactional(readOnly = true)to read methods to skip Hibernate snapshot comparison and improve performance