Project

Project_온라인스터디_Chpater0?_Persistence Context(N+1 Test)

강용민 2023. 6. 30. 19:32

Problem

While conducting an online study project, I came across the N+1 issue, so I decided to test it. Firstly, our current database diagram is as follows. And the repository to test the N+1 issue is StudyRoom.

To perform this test, I wrote the following code.

@ActiveProfiles("test")
@ExtendWith(SpringExtension.class)
@DataJpaTest
public class StudyRoomRepositoryTest {
  @Autowired private StudyRoomRepository studyRoomRepository;
  @Autowired private UserRepository userRepository;

  private List<StudyRoom> studyRooms = new ArrayList<>();
  private List<User> users = new ArrayList<>();

  @BeforeEach
  public void setUp() {
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester@gmail.com").password("password").nickName("tester").build()));
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester2@gmail.com").password("password").nickName("tester2").build()));
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom1").constructor(users.get(0)).build()));
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom2").constructor(users.get(1)).build()));

  }
  
  @Test
  @DisplayName("N+1 문제 Test")
  public void UsersNPlusOneTest(){
    List<StudyRoom> findStudyRooms = studyRoomRepository.findAll();
    for(StudyRoom findStudyRoom: findStudyRooms){
      System.out.println(findStudyRoom.getConstructor());
    }
  }
}

As per my expectation, in order to fetch the information from the Constructor, a total of 3 SELECT queries should be executed (1 for StudyRoom and 2 for User). Let's execute it once and see the results.

 

Hibernate: 
    select
        studyroom0_.study_room_id as study_ro1_1_,
        studyroom0_.create_at as create_a2_1_,
        studyroom0_.is_deleted as is_delet3_1_,
        studyroom0_.update_at as update_a4_1_,
        studyroom0_.user_id as user_id7_1_,
        studyroom0_.room_name as room_nam5_1_,
        studyroom0_.room_uuid as room_uui6_1_ 
    from
        study_rooms studyroom0_
com.twoleader.backend.domain.user.entity.User@1317ac2c
com.twoleader.backend.domain.user.entity.User@7aef21bb

Contrary to my expectation, it was able to retrieve the data with only 1 SELECT query. In order to understand the reason behind this, I diligently searched and found that the cause lies in the Persistence Context.

 

Cause

Persistence Context

The Persistence Context refers to the environment where entities are permanently stored. It plays the role of a virtual database, storing objects between the application and the database. When entities are saved or retrieved through the Entity Manager, the Entity Manager stores and manages them in the Persistence Context. The structure of the Persistence Context is illustrated in the following diagram.

https://willseungh0.tistory.com/65

 

First level Cash

When executing methods that modify the database, such as the save() method, the database is not immediately updated. Instead, it goes through the cache in the Persistence Context, which we refer to as the first-level cache. Generally, the first-level cache remains valid from the beginning to the end of a transaction.

On the other hand, when executing methods that retrieve tuples from the database, such as the find() method, the Persistence Context first looks for the desired entity in the first-level cache. If the entity exists in the cache, it tis retrieved directly from there without accessing the database.

https://willseungh0.tistory.com/77

Furthermore, within a single transaction, there is a shared Persistence Context.

https://milenote.tistory.com/144

Therefore, regardless of which EntityManager is used, the same first-level cache is utilized. This means that when calling the 'StudyRoom findAll' method in the N+1 issue test, the entities are retrieved from the first-level cache rather than the actual database, thus avoiding the occurrence of the N+1 issue.

 

Resolution

In this case, we can resolve the issue by resetting the Persistence Context. The initialization can be done by calling the clear() method on the EntityManager.

By invoking the clear() method, all entities will transition to the detached state, and they will be fetched directly from the database. Let's make the necessary changes.

@ActiveProfiles("test")
@ExtendWith(SpringExtension.class)
@DataJpaTest
public class StudyRoomRepositoryTest {
  @Autowired private StudyRoomRepository studyRoomRepository;
  @Autowired private UserRepository userRepository;

  @PersistenceContext EntityManager em;

  private List<StudyRoom> studyRooms = new ArrayList<>();
  private List<User> users = new ArrayList<>();

  @BeforeEach
  public void setUp() {
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester@gmail.com").password("password").nickName("tester").build()));
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester2@gmail.com").password("password").nickName("tester2").build()));
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom1").constructor(users.get(0)).build()));
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom2").constructor(users.get(1)).build()));

  }
  
  @Test
  @DisplayName("users N+1 문제 Test")
  public void UsersNPlusOneTest(){
    em.clear();  //EntityManager 초기화
    List<StudyRoom> findStudyRooms = studyRoomRepository.findAll();
    for(StudyRoom findStudyRoom: findStudyRooms){
      System.out.println(findStudyRoom.getConstructor());
    }
  }
}

Now, let's see the test results.

Hibernate: 
    select
        studyroom0_.study_room_id as study_ro1_1_,
        studyroom0_.create_at as create_a2_1_,
        studyroom0_.is_deleted as is_delet3_1_,
        studyroom0_.update_at as update_a4_1_,
        studyroom0_.user_id as user_id7_1_,
        studyroom0_.room_name as room_nam5_1_,
        studyroom0_.room_uuid as room_uui6_1_ 
    from
        study_rooms studyroom0_
Hibernate: 
    select
        user0_.user_id as user_id1_2_0_,
        user0_.create_at as create_a2_2_0_,
        user0_.is_deleted as is_delet3_2_0_,
        user0_.update_at as update_a4_2_0_,
        user0_.email as email5_2_0_,
        user0_.nick_name as nick_nam6_2_0_,
        user0_.password as password7_2_0_,
        user0_.role as role8_2_0_,
        user0_.user_uuid as user_uui9_2_0_ 
    from
        users user0_ 
    where
        user0_.user_id=?
com.twoleader.backend.domain.user.entity.User@7a85dc58
Hibernate: 
    select
        user0_.user_id as user_id1_2_0_,
        user0_.create_at as create_a2_2_0_,
        user0_.is_deleted as is_delet3_2_0_,
        user0_.update_at as update_a4_2_0_,
        user0_.email as email5_2_0_,
        user0_.nick_name as nick_nam6_2_0_,
        user0_.password as password7_2_0_,
        user0_.role as role8_2_0_,
        user0_.user_uuid as user_uui9_2_0_ 
    from
        users user0_ 
    where
        user0_.user_id=?
com.twoleader.backend.domain.user.entity.User@4f5c757c

We can observe that a total of 3 query statements were executed. Now we are ready to test the N+1 issue.

 

Add

By the way, when the find() method is called, if the entity does not exist in the first-level cache, it is fetched from the database, stored in the first-level cache, and then sent to the client.

Therefore, if only one User has created the Constructor, the first User will be fetched from the database initially, but the second User will be retrieved from the first-level cache. This means that 2 SELECT queries will be executed.

@ActiveProfiles("test")
@ExtendWith(SpringExtension.class)
@DataJpaTest
public class StudyRoomRepositoryTest {
  @Autowired private StudyRoomRepository studyRoomRepository;
  @Autowired private UserRepository userRepository;

  @PersistenceContext EntityManager em;

  private List<StudyRoom> studyRooms = new ArrayList<>();
  private List<User> users = new ArrayList<>();

  @BeforeEach
  public void setUp() {
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester@gmail.com").password("password").nickName("tester").build()));
    users.add(userRepository.save(User.builder().userUuid(UUID.randomUUID()).email("tester2@gmail.com").password("password").nickName("tester2").build()));
    //user1이 StudyRoom1 생성
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom1").constructor(users.get(0)).build()));
    //user1이 StudyRoom2 생성
    studyRooms.add(
        studyRoomRepository.save(StudyRoom.builder().roomName("TestStudyRoom2").constructor(users.get(0)).build()));

  }

  @Test
  @DisplayName("users N+1 문제 Test")
  public void UsersNPlusOneTest(){
    //given
    em.clear();
    List<StudyRoom> findStudyRooms = studyRoomRepository.findAll();
    for(StudyRoom findStudyRoom: findStudyRooms){
      System.out.println(findStudyRoom.getConstructor());
    }
  }
}

 

Result

Hibernate: 
    select
        studyroom0_.study_room_id as study_ro1_1_,
        studyroom0_.create_at as create_a2_1_,
        studyroom0_.is_deleted as is_delet3_1_,
        studyroom0_.update_at as update_a4_1_,
        studyroom0_.user_id as user_id7_1_,
        studyroom0_.room_name as room_nam5_1_,
        studyroom0_.room_uuid as room_uui6_1_ 
    from
        study_rooms studyroom0_
Hibernate: 
    select
        user0_.user_id as user_id1_2_0_,
        user0_.create_at as create_a2_2_0_,
        user0_.is_deleted as is_delet3_2_0_,
        user0_.update_at as update_a4_2_0_,
        user0_.email as email5_2_0_,
        user0_.nick_name as nick_nam6_2_0_,
        user0_.password as password7_2_0_,
        user0_.role as role8_2_0_,
        user0_.user_uuid as user_uui9_2_0_ 
    from
        users user0_ 
    where
        user0_.user_id=?
com.twoleader.backend.domain.user.entity.User@7f9e6167
com.twoleader.backend.domain.user.entity.User@7f9e6167