JPA/Hibernate Performance Optimization — From N+1 to Caching

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_size between 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

Was this article helpful?