Spring Data JPA: Contoh N+1 Query Problem
Mon. Jun 3rd, 2024 04:58 AM14 mins read
Spring Data JPA: Contoh N+1 Query Problem
Source: Bing Image Creator - N+1 problem database

Sejak gw pertama kali berniat ingin nge-blog lagi di tahun 2020, inilah salah satu tulisan yang ingin gw bahas. Gw selalu menulis daftar bahasan yang ingin gw tulis dan ini adalah judul paling lama berada di daftar tersebut tapi ga pernah sempat dibikin🤭. Agak ribet aja sih reproduce permasalahannya, akhirnya setiap mau nulis ini selalu ketendang dari daftar tulisan dan gw memilih judul lain yang lebih ringan. Setelah kurang lebih 4 tahun, mungkin sekarang saatnya gw bahas😎.

N+1 Problem

N+1 Problem adalah masalah pada ORM di mana saat kita melakukan query terhadap entity yang memiliki relasi dengan fetch type Lazy setiap diakses akan mengeksekusi query tambahan. Ini akan membuat performa aplikasi jadi lebih berat😨. Biar lebih jelas kita langsung praktek aja menggunakan Spring Data JPA yang udah include Hibernate di dalamnya.

Setup Project

Kita menggunakan contoh kasus database kompetisi tim sepak bola. Dependency-nya kurang lebih seperti ini:

Maven pom.xml
<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-parent</artifactId>
			<version>3.3.0</version>
			<scope>import</scope>
			<type>pom</type>
		</dependency>
	</dependencies>
</dependencyManagement>
<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>org.postgresql</groupId>
		<artifactId>postgresql</artifactId>
		<scope>runtime</scope>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<optional>true</optional>
	</dependency>
</dependencies>

Gw menggunakan Spring boot 3.3.0 di sini. Sebenarnya ga harus versi itu sih, pakai versi lama pun juga bisa. Tapi biar ga kudet sekalian aja nyobain versi terbaru😎. Gw pakai dependency Spring Data JPA, postgresql, dan lombok.

Untuk membuktikannya, kita harus setup JPA untuk print query di console dengan mengaktifkan property show-sql menjadi true di application.properties atau application.yml

Config application.yml
spring:
  jpa:
    show-sql: true
Config application.properties
spring.jpa.show-sql=true

Selanjutnya kita setup entity dan repository-nya.

Entity Club
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Club implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String name;

	@ManyToOne
	Nation nation;

	@ManyToMany(mappedBy = "clubs")
	Set<Competition> competitions = new HashSet<>();

	@OneToMany(mappedBy = "club")
	Set<Player> players = new HashSet<>();
}
Entity Player
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Player implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String firstName;

	String lastName;

	@ManyToOne
	Club club;
}
Entity Competition
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Competition implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String name;

	String description;

	@ManyToMany
	Set<Club> clubs = new HashSet<>();
}
Entity Nation
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Nation implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	@Column(length = 3)
	String shortName;

	String longName;

	String chairman;

	@OneToMany(mappedBy = "nation")
	Set<Club> clubs = new HashSet<>();
}
Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{}

Isi datanya kurang lebih seperti ini:

Table club
id name nation_id
1 RANS Nusantara 1
2 PSBS Biak 1
3 Persepolis FC 2
4 Gamba Osaka 3
5 Kashima Antlers 3
Table competition
id description name
1 Liga Champions Asia ACL
2 Liga Indonesia Liga 1
3 Liga Jepang J1
Table competition_clubs
competitions_id clubs_id
1 1
1 3
1 4
2 1
2 2
3 4
3 5
Table nation
id long_name short_name chairman
1 Indonesia IDN Udin
2 Iran IRN Ali
3 Japan JPN Tsubasa
Table player
id first_name last_name club_id
1 Nohara Shinosuke 1
2 Nobi Nobita 1
3 Bill Gates 2
4 Elon Musk 3
5 Peter Parker 4
6 Warren Buffet 5
7 Kylian Mbappe 5
8 Erling Haaland 5

Eager Fetch vs Lazy Fetch

Sebelum masuk ke inti permasalahannya, kita perlu tau dulu perbedaan Eager vs Lazy di ORM. Default fetch dari @OneToOne & @ManyToOne adalah Eager, dan default fetch dari @OneToMany & @ManyToMany adalah Lazy. Eager artinya saat kita memanggil query Club, maka akan otomatis mengeksekusi query select tambahan secara langsung dengan data Nation tepat setelah query pertama atau melakukan join dengan data Nation karena kita menggunakan anotasi @ManyToOne yang defaultnya adalah Eager. Jadi saat kita mengakses property nation, tidak perlu melakukan query lagi nanti. Sebaliknya, Lazy artinya saat kita memanggil query Club dia tidak melakukan join atau memanggil query select tambahan secara langsung dengan data Player & Competition karena kita menggunakan anotasi @OneToMany & @ManyToMany yang defaultnya adalah Lazy. Tapi nanti tiap mengakses property players atau competitions, ORM akan melakukan query lagi untuk data players atau competitions berdasarkan id club yang diakses.

Lazy Fetch Type Problem

Letak permasalahan N+1 Problem ini adalah saat kita menggunakan Fetch Lazy. Contohnya sebagai berikut:

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findAllById(Set.of(1L, 2L, 3L, 4L, 5L)).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club players = {}", club.getPlayers().stream().map(player -> player.getFirstName() + ' ' + player.getLastName()).collect(Collectors.joining(", ")));
		});
	}
}

Pada code di atas, kita memanggil list club berdasarkan ids. Saat kita looping masing-masing data club, kita akan mengakses club name dan competition names. Karena kita sudah setup show-sql jadi true, saat dieksekusi akan muncul query-nya di console.

Hibernate: select c1_0.id,c1_0.name,c1_0.nation_id from club c1_0 where c1_0.id in (?,?,?,?,?)
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id=?
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id=?
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id=?
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id=?
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id=?

Masalahnya, saat kita mengakses property players atau competitions, maka terlihat di console bahwa tiap pemanggilan data itu selalu ada query lagi tiap looping. Misalkan ada data 100 clubs, dan kita looping untuk manggil data players tiap club, maka akan terjadi eksekusi 100+1 query🤯. 1 query saat manggil data clubs, dan 100 query lagi untuk manggil masing-masing player tiap club. Itulah yang dinamakan N+1 Problem.

Eager Fetch Type Problem

Ada juga yang bilang di internet solusinya adalah mengganti semua fetch type-nya menjadi Eager seperti berikut:

Entity Club
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Club implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String name;

	@ManyToOne
	Nation nation;

	@ManyToMany(mappedBy = "clubs", fetch = FetchType.EAGER)
	Set<Competition> competitions = new HashSet<>();

	@OneToMany(mappedBy = "club", fetch = FetchType.EAGER)
	Set<Player> players = new HashSet<>();
}

Masalahnya, itu artinya setiap kita melakukan query terkait data club, maka akan otomatis melakukan select semua data relasi🤦. Bedanya dengan Lazy, kali ini semua query-nya dilakukan di awal. Eager Fetch hanya akan melakukan sekali query dengan join jika kita menggunakan method findById(). Kalau pakai generated method repository, JPQL, atau Criteria API dari JPA maka behaviornya akan melakukan query select semua relasi di awal. Untuk beberapa kasus kadang kita ga perlu query data semua relasi. Bisa saja untuk suatu kasus kita hanya butuh data club saja tanpa perlu data players, nation, atau competitions. Jadi, mengganti semua fetch type menjadi Eager juga bukan solusi yang tepat.

N+1 Solution

Untuk menghindari N+1 ada beberapa cara. Tapi sebelum itu, kita perlu set fetch type-nya menjadi Lazy untuk semua property yang berelasi.

Fetch Mode Subselect

Cara pertama yang paling gampang adalah dengan menggunakan anotasi @Fetch(FetchMode.SUBSELECT) pada property yang memiliki anotasi @OneToMany & @ManyToMany.

Entity Club
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class Club implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String name;

	@ManyToOne(fetch = FetchType.LAZY)
	Nation nation;

	@ManyToMany(mappedBy = "clubs")
	@Fetch(FetchMode.SUBSELECT)
	Set<Competition> competitions = new HashSet<>();

	@OneToMany(mappedBy = "club")
	@Fetch(FetchMode.SUBSELECT)
	Set<Player> players = new HashSet<>();
}

Fetch Mode Subselect ini artinya dia hanya akan melakukan masing-masing 1 query tambahan aja dengan cara subselect berdasarkan semua id club yang di-select.

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findAllById(Set.of(1L, 2L, 3L, 4L, 5L)).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club players = {}", club.getPlayers().stream().map(player -> player.getFirstName() + ' ' + player.getLastName()).collect(Collectors.joining(", ")));
			log.info("club competitions = {}", club.getCompetitions().stream().map(Competition::getName).collect(Collectors.joining(", ")));
		});
	}
}

Setelah dieksekusi hasil query-nya nanti seperti ini:

Hibernate: select c1_0.id,c1_0.name,c1_0.nation_id from club c1_0 where c1_0.id in (?,?,?,?,?)
Hibernate: select p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from player p1_0 where p1_0.club_id in (select c1_0.id from club c1_0 where c1_0.id in (?,?,?,?,?))
Hibernate: select c2_0.clubs_id,c2_1.id,c2_1.description,c2_1.name from competition_clubs c2_0 join competition c2_1 on c2_1.id=c2_0.competitions_id where c2_0.clubs_id in (select c1_0.id from club c1_0 where c1_0.id in (?,?,?,?,?))

Pada saat pemanggilan query data club, query yang di-generate hanyalah dari tabel club saja. Saat masing-masing property yang berelasi diakses pertama kali, maka akan dilakukan subselect dengan where clause semua id club. Saat looping selanjutnya maka tidak ada query tambahan lagi karena semua data udah didapat dari akses yang pertama.

Kekurangannya adalah di sini tetap ada query tambahan. Walaupun tambahannya hanya 1x, ga tiap looping dan lebih baik daripada sebelumnya. Secara performa masih belum efisien. Ini juga ga bisa digunakan pada property @ManyToOne atau @OneToOne.

Custom Query

Cara kedua adalah dengan menambahkan method baru pada repository dan melakukan JPQL query dengan keyword “join fetch”.

Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{
	@Query("select c from Club c left join fetch c.players left join fetch c.nation left join fetch c.competitions where c.id in (:ids)")
	List<Club> findWithJpqlJoinFetchAll(@Param("ids") Collection<Long> ids);

	@Query("select c from Club c left join fetch c.players where c.id in (:ids)")
	List<Club> findWithJpqlJoinFetchPlayers(@Param("ids") Collection<Long> ids);
}

Pada code di atas kita menambahkan 2 method baru pada repository, yang pertama untuk fetch semua relasi entity, yang kedua untuk fetch entity players doang.

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findWithJpqlJoinFetchPlayers(Set.of(1L, 2L, 3L, 4L, 5L)).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club players = {}", club.getPlayers().stream().map(player -> player.getFirstName() + ' ' + player.getLastName()).collect(Collectors.joining(", ")));
		});
	}
}

Hasil query yang di-generate seperti ini:

Hibernate: select c1_0.id,c1_0.name,c1_0.nation_id,p1_0.club_id,p1_0.id,p1_0.first_name,p1_0.last_name from club c1_0 left join player p1_0 on c1_0.id=p1_0.club_id where c1_0.id in (?,?,?,?,?)

Query yang dieksekusi sekarang benar-benar 1x😎. Saat kita menggunakan findWithJpqlJoinFetchPlayers() maka itu akan join data players saat itu juga dan ga ada query tambahan saat property players diakses. Begitu juga saat menggunakan findWithJpqlJoinFetchAll(), maka itu akan join semua data saat itu juga dan ga ada query tambahan tiap akses.

Kekurangannya, codenya jadi verbose karena harus dinulis manual query joinnya. Kalau joinnya banyak maka makin verbose. Misalkan saat ada yang menggunakan findWithJpqlJoinFetchPlayers() dan dia juga mengakses property seperti competitions, maka tetap akan terjadi N+1 Problem. Kita juga harus berpikir pragmatis untuk mencegah hal-hal kayak gini🧠.

Named Entity Graph

Cara selanjutnya adalah menggunakan anotasi @NamedEntityGraph pada entity club. Di dalamnya kita tulis nama Entity Graph dan Attribute Node sesuai nama property yang diinginkan.

Entity Club
@Entity
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@NamedEntityGraph(name = "Club.fetchAllEntities",
		attributeNodes = { @NamedAttributeNode("competitions"), @NamedAttributeNode("players"), @NamedAttributeNode("nation"), }
)
@NamedEntityGraph(name = "Club.fetchPlayers", attributeNodes = @NamedAttributeNode("players"))
public class Club implements Serializable{
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Id
	Long id;

	String name;

	@ManyToOne(fetch = FetchType.LAZY)
	Nation nation;

	@ManyToMany(mappedBy = "clubs")
	@Fetch(FetchMode.SUBSELECT)
	Set<Competition> competitions = new HashSet<>();

	@OneToMany(mappedBy = "club")
	@Fetch(FetchMode.SUBSELECT)
	Set<Player> players = new HashSet<>();
}
Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{
	@EntityGraph("Club.fetchAllEntities")
	@Query("select c from Club c where c.id in (:ids)")
	List<Club> findWithNamedEntityGraphsFetchAll(@Param("ids") Collection<Long> ids);

	@EntityGraph("Club.fetchPlayers")
	@Query("select c from Club c where c.id in (:ids)")
	List<Club> findWithNamedEntityGraphsFetchPlayers(@Param("ids") Collection<Long> ids);

	@EntityGraph("Club.fetchAllEntities")
	List<Club> findAllByIdBetween(Long startId, Long endId);
}

Pada ClubRepository kita juga perlu tambahkan anotasi @EntityGraph pada method dan isi valuenya harus sesuai nama Entity Graph yang kita buat tadi. Lalu kita execute:

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findAllByIdBetween(1L, 5L).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club players = {}", club.getPlayers().stream().map(player -> player.getFirstName() + ' ' + player.getLastName()).collect(Collectors.joining(", ")));
		});
	}
}

Keunggulan dari @NamedEntityGraph ini adalah kita bisa reuse value Entity Graph untuk beberapa method lain, termasuk method yang di-generate otomatis oleh Spring Data JPA maupun method yang menggunakan Custom Query. Hasil query-nya juga hanya sekali join. Codenya lebih rapi dan kita ga perlu bikin query join fetch manual lagi di tiap relasi.

Kekurangannya kurang lebih sama dengan Custom Query, yaitu saat ada yang memanggil query yang hanya fetch players saja tapi juga mengakses competitions. Sehingga kemungkinan terjadi N+1 Problem tetap ada. Lalu penulisannya juga sedikit verbose karena kita harus tulis nama-nama Entity Graph & atributnya di Entity Club dan itu harus unik.

Attribute Paths Entity Graph

Karena cara di atas masih verbose, ini ada cara simplenya dengan menambahkan anotasi @EntityGraph saja pada repository.

Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{
	@EntityGraph(attributePaths = {"nation", "players"})
	@Query("select c from Club c where c.id in (:ids)")
	List<Club> findWithAttributePathEntityGraphs(@Param("ids") Collection<Long> ids);
}

Kita hanya perlu tulis nama property yang diinginkan pada attributePaths di atas.

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findWithAttributePathEntityGraphs(Set.of(1L, 2L, 3L, 4L, 5L)).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club nation = {}", club.getNation().getLongName());
			log.info("club players = {}", club.getPlayers().stream().map(player -> player.getFirstName() + ' ' + player.getLastName()).collect(Collectors.joining(", ")));
		});
	}
}

Keunggulannya adalah kita ga perlu lagi menambahkan @NamedEntityGraph, melainkan cukup tambahkan attribute paths pada masing-masing method. Joinnya langsung otomatis sesuai property path yang ditulis.

Kekurangannya juga masih sama seperti Named Entity Graph & Custom Query, tetap ada kemungkinan terjadi N+1 Problem pada method yang hanya fetch sebagian relasi saja.

Projection

Alternatif lainnya yaitu menggunakan Projection. Misalkan kita hanya ingin fetch tabel club saja dan kita ingin mencegah orang yang menggunakan method ini untuk mengakses property lain yang berelasi.

Interface ClubOnlyProjection
public interface ClubOnlyProjection{
	Long getId();
	String getName();
}
Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{
	<P> List<P> findAllByIdIn(Collection<Long> ids, Class<P> clazz);
}

Kita perlu bikin interface dengan getter dari property yang ingin kita select pada interface ClubOnlyProjection. Agar methodnya fleksibel pada repository, kita bisa menggunakan generic type untuk return type-nya. Jadi nanti ga hanya interface ClubOnlyProjection yang bisa digunakan.

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findAllByIdIn(Set.of(1L, 2L, 3L, 4L, 5L), ClubOnlyProjection.class).forEach(club -> {
			log.info("club name = {}, club id = {}", club.getName(), club.getId());
		});
	}
}

Dengan begini semua yang menggunakan method ini ga akan “terjebak” N+1 Problem lagi karena aksesnya saja ga ada😅. Keunggulan dari cara ini adalah kita bisa tentukan kolom apa saja yang ingin di-select. Pada contoh di atas kita hanya ingin select club name dan id saja. Kolom lain ga perlu di-select sama sekali.

Kekurangannya adalah ini hanya cocok kalau scope projectionnya ga meliputi property yang berelasi. Kalau di dalam projectionnya ada property yang berelasi, maka tetap bisa kena N+1 Problem.

Entity Graph + Custom Query + Projection💡

Kita bisa menggabungkan ketiganya untuk hasil yang lebih baik.

Interface ClubWithCompetitionAndNationProjection
public interface ClubWithCompetitionAndNationProjection{
	String getName();
	Set<Competition> getCompetitions();
	Nation getNation();
}
Interface ClubRepository
public interface ClubRepository extends JpaRepository<Club, Long>{
	@EntityGraph(attributePaths = {"competitions", "nation"})
	@Query("select c from Club c where c.id in (:ids)")
	List<ClubWithCompetitionAndNationProjection> findWithCompetitionProjection(@Param("ids") Collection<Long> ids);
}

Kita bikin projection baru yang memiliki property relasi dengan competitions & nation misalnya. Lalu kita bikin method baru di repository dengan Custom Query dan anotasi @EntityGraph dengan paths competitions & nation.

@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class NplusoneApplication implements CommandLineRunner{
	private final ClubRepository clubRepository;

	public static void main(String[] args){
		SpringApplication.run(NplusoneApplication.class, args);
	}

	@Transactional
	@Override
	public void run(String... args) throws Exception{
		clubRepository.findWithCompetitionProjection(Set.of(1L, 2L, 3L, 4L, 5L)).forEach(club -> {
			log.info("club name = {}", club.getName());
			log.info("club nation = {}", club.getNation().getLongName());
			log.info("club competitions = {}", club.getCompetitions().stream().map(Competition::getName).collect(Collectors.joining(", ")));
		});
	}
}

Sekarang kita terbebas dari N+1 Problem, query-nya hanya sekali join dan selection yang muncul hanyalah yang diperlukan saja tanpa khawatir ada query tambahan lagi😎.

Verdict

N+1 Problem ini bisa berdampak pada performa aplikasi karena ORM akan melakukan query tambahan setiap mengakses property yang Lazy. Masalah tersebut dapat diatasi dengan beberapa cara. Cara yang efektif menurut gw adalah dengan menggunakan gabungan Entity Graph + Custom Query + Projection😎.

© 2024 · Ferry Suhandri