JPA N+1 Query Problem Sinhala Guide | Hibernate Performance Optimization | N+1 ප්‍රශ්නය විසඳමු

JPA N+1 Query Problem Sinhala Guide | Hibernate Performance Optimization | N+1 ප්‍රශ්නය විසඳමු

කොහොමද යාලුවනේ! අද අපි කතා කරන්න යන්නේ software development වලදි අපිට නිතරම වගේ මුනගැහෙන, ඒ වගේම අපේ application එකේ performance එකට ලොකුවටම බලපාන්න පුළුවන් ප්‍රශ්නයක් ගැන. ඒ තමයි N+1 Query Problem එක. විශේෂයෙන්ම අපි JPA (Java Persistence API) හරි Hibernate වගේ ORM (Object-Relational Mapping) frameworks පාවිච්චි කරනකොට මේ ප්‍රශ්නය එනවා වැඩියි. මේක හරියට stealth bomber වගේ, silent killer කෙනෙක් වගේ, අපි නොදැනුවත්වම අපේ application එක slow කරන්න පුළුවන්.

හිතන්නකෝ, ඔයා හදපු web application එක මුලින් හොඳට වැඩ කළා. හැබැයි users වැඩි වෙන්න වැඩි වෙන්න, database එකේ data වැඩි වෙන්න වැඩි වෙන්න, application එක respond කරන speed එක අඩු වෙනවා. සමහරවිට ඔයා හිතයි server එකේ resources මදිද? Code එකේ logic එකේ අවුලක්ද? කියලා. හැබැයි ගොඩක් වෙලාවට මේ වගේ අවස්ථාවලට හේතුවක් වෙන්න පුළුවන් මේ N+1 Query Problem එක. අද අපි මේක මොකක්ද, ඇයි මේක එන්නේ, ඒ වගේම මේක fix කරගන්නේ කොහොමද කියලා විස්තරාත්මකව බලමු.

N+1 Query Problem එක කියන්නේ මොකක්ද?

සරලවම කියනවා නම්, N+1 Query Problem එක කියන්නේ database එකට අනවශ්‍ය විදිහට queries ගොඩක් යැවීම. මේක වෙන්නේ relational data load කරනකොට. අපි උදාහරණයකින් බලමු.

හිතන්න අපිට Blog Post එකක් සහ ඒකට අයිති Comments ටිකක් තියෙනවා කියලා. සාමාන්‍යයෙන් database එකේ Post table එකක් සහ Comment table එකක් ඇති. Post එකක් එක්ක comments ගොඩක් තියෙන්න පුළුවන් (One-to-Many relationship).

අපි Post entities ටිකක් load කරගන්නවා කියලා හිතමු. JPA/Hibernate වලදී default behaviour එක තමයි associated collections (එක Post එකකට අයිති Comments වගේ) LAZY විදිහට load කරන එක. ඒ කියන්නේ, ඔයා Post එක load කරගත්තාට පස්සේ, ඒ Post එකේ Comments ටික load වෙන්නේ නැහැ. ඒක load වෙන්නේ ඔයා ඒ Comments collection එකට access කරනකොට විතරයි.

මේක තේරුම් ගන්න මේ Scenario එක බලන්න:

  1. මුලින්ම, ඔයා database එකෙන් Post entities සියල්ලම load කරගන්නවා. (මෙතනදී එක SQL query එකක් execute වෙනවා).
  2. දැන් ඔයාට Post entities 'N' ගණනක් තියෙනවා.
  3. දැන් ඔයා මේ N Post entities ටික iterate කරලා, එකින් එක Post එකේ comments ටිකට access කරනවා.

මොකද වෙන්නේ කියලා දන්නවද? පළවෙනි Post එකේ comments ටික load කරන්න database එකට වෙනම SQL query එකක් යනවා. දෙවෙනි Post එකේ comments ටික load කරන්න තව වෙනම SQL query එකක් යනවා. මේ විදිහට N වෙනි Post එකේ comments ටික load කරන්න තවත් වෙනම SQL query එකක් යනවා.

එතකොට මුළු Queries ගණන:
1 (Posts ටික load කරන්න) + N (එක් එක් Post එකේ comments ටික load කරන්න) = N+1 Queries.

හිතන්න, ඔයාගේ blog එකේ Posts 100ක් තියෙනවා නම්, Post එකයි, comments ටිකයි දෙකම display කරන්න database එකට queries 101ක් යනවා! මේක තමයි N+1 Query Problem එක. මේක database එකට අනවශ්‍ය බරක් (load) දානවා, network latency වැඩි කරනවා, ඒ වගේම application එකේ performance එක හොඳටම පහල දානවා.

කෝ මේ N+1 වෙන්නේ?

මේක වෙන්න ප්‍රධාන හේතුව තමයි ORM එකේ lazy loading default behaviour එක. Lazy loading එක හොඳයි ගොඩක් වෙලාවට, මොකද අපිට අවශ්‍ය නැති data load කරලා memory නාස්ති කරන එක නවත්තනවා. හැබැයි සමහර වෙලාවට අපිට related data ටික අවශ්‍ය වෙනවා. එතකොට lazy loading නිසා queries ගොඩක් යනවා. අපි ඒක code වලින් බලමු.

උදාහරණයක් විදිහට Post සහ Comment entities:

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) // Default is LAZY for @OneToMany
    private List<Comment> comments = new ArrayList<>();

    // Getters and Setters
    // ...
}

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String text;

    @ManyToOne(fetch = FetchType.LAZY) // Default is LAZY for @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;

    // Getters and Setters
    // ...
}

දැන් හිතන්න, අපේ Spring Data JPA repository එක මෙහෙමයි කියලා:

public interface PostRepository extends JpaRepository<Post, Long> {
}

අපේ service layer එකේ අපි posts ටික load කරලා ඒවගේ comments ගණන print කරනවා කියලා හිතමු:

@Service
public class PostService {

    @Autowired
    private PostRepository postRepository;

    public void displayPostsWithCommentCount() {
        List<Post> posts = postRepository.findAll(); // 1 query to fetch all Post entities
        System.out.println("Fetched " + posts.size() + " posts.");

        for (Post post : posts) {
            // Accessing comments collection will trigger a separate query for EACH post
            System.out.println(post.getTitle() + " has " + post.getComments().size() + " comments.");
        }
    }
}

මේ `displayPostsWithCommentCount()` method එක call කරපුවහම, `postRepository.findAll()` එකෙන් Post entities ටික load කරගන්න එක SQL query එකක් යවයි. ඊට පස්සේ `for` loop එක ඇතුලේ `post.getComments().size()` කියන statement එක execute වෙනකොට, ඒ එක් එක් Post එකට අදාළ comments ටික load කරන්න වෙනම SQL query එකක් database එකට යවනවා. Post entities 100ක් තියෙනවා නම්, SQL queries 101ක් යයි! මේක තමයි අපේ N+1 Query Problem එක. ඔයාට මේ SQL queries ටික console එකේ බලන්න ඕන නම්, `application.properties` file එකේ `spring.jpa.show-sql=true` සහ `spring.jpa.properties.hibernate.format_sql=true` දාලා බලන්න පුළුවන්.

N+1 Query Problem එක Fix කරමු

වාසනාවකට වගේ, මේ N+1 Query Problem එකට විසඳුම් කිහිපයක් තියෙනවා. අපි එකින් එක බලමු.

Solution 1: JPQL / HQL JOIN FETCH භාවිතය

මේක තමයි N+1 Problem එකට තියෙන හොඳම සහ බහුලවම භාවිතා කරන විසඳුම. `JOIN FETCH` කියන්නේ අපිට අවශ්‍ය related entities ටික එක SQL query එකකින්ම load කරගන්න පුළුවන් ක්‍රමයක්. ඒ කියන්නේ, Post එක load කරනකොටම ඒකේ comments ටිකත් එකටම load වෙනවා.

අපි PostRepository එකට මේ වගේ custom query එකක් එකතු කරමු:

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("SELECT p FROM Post p JOIN FETCH p.comments")
    List<Post> findAllWithComments();

    // You can also use WHERE clauses with JOIN FETCH
    @Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.title LIKE %:titleKeyword%")
    List<Post> findByTitleContainingWithComments(@Param("titleKeyword") String titleKeyword);
}

දැන් අපේ service layer එකේ අපි මේ method එක call කරනකොට:

@Service
public class PostService {

    @Autowired
    private PostRepository postRepository;

    public void displayPostsWithCommentCountOptimized() {
        // This will execute only 1 query to fetch all Post entities and their associated Comment entities
        List<Post> posts = postRepository.findAllWithComments(); 
        System.out.println("Fetched " + posts.size() + " posts.");

        for (Post post : posts) {
            // Comments are already loaded, no extra query will be triggered
            System.out.println(post.getTitle() + " has " + post.getComments().size() + " comments.");
        }
    }
}

මේ වෙලාවේදී, database එකට යන්නේ එකම SQL query එකක් විතරයි. ඒ query එක `LEFT OUTER JOIN` එකක් පාවිච්චි කරලා Post සහ Comment table දෙකෙන්ම data එකටම load කරගන්නවා. මේක තමයි හොඳම විසඳුම, මොකද queries ගණන එකකට අඩු වෙනවා, ඒ වගේම network round trips අඩු වෙනවා. හැබැයි මෙතනදී එක දෙයක් මතක තියාගන්න ඕනේ, `JOIN FETCH` එකක් පාවිච්චි කරනකොට, `One-to-Many` or `Many-to-Many` relationship එකක් නම්, data duplication වෙන්න පුළුවන් (cartesian product). ඒක වළක්වන්න `DISTINCT` keyword එක පාවිච්චි කරන්න පුළුවන්. උදාහරණයක් විදිහට `SELECT DISTINCT p FROM Post p JOIN FETCH p.comments`.

Solution 2: EntityGraph භාවිතය

EntityGraph කියන්නේ JPA 2.1 වලින් හඳුන්වා දුන්නු feature එකක්. මේකෙන් අපිට entity එකක් load කරනකොට, ඒකත් එක්ක මොන related entities ටිකද load කරන්න ඕන කියලා declarative විදිහට define කරන්න පුළුවන්. මේක `JOIN FETCH` වලට වඩා හොඳයි, මොකද queries define නොකර, entity level එකෙන්ම load plan එකක් කියන්න පුළුවන්.

මුලින්ම Post entity එකට `@NamedEntityGraph` එකක් එකතු කරමු:

@Entity
@NamedEntityGraph(name = "post-with-comments", 
                  attributeNodes = @NamedAttributeNode("comments"))
public class Post {
    // ... existing fields and methods
}

දැන් PostRepository එකේදී මේ EntityGraph එක පාවිච්චි කරමු:

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(value = "post-with-comments", type = EntityGraph.EntityGraphType.LOAD)
    List<Post> findAll(); // This will use the defined EntityGraph

    @EntityGraph(attributePaths = {"comments"})
    Optional<Post> findById(Long id);
}

මෙහිදී `EntityGraph.EntityGraphType.LOAD` කියන්නේ default fetching behaviour එක තියාගෙන, අපිට අවශ්‍ය `attributeNodes` (මෙහිදී `comments`) විතරක් eagerly load කරන්න කියන එක. `EntityGraph.EntityGraphType.FETCH` කියන්නේ අනිත් හැම දේම ignore කරලා, EntityGraph එකේ define කරපු දේවල් විතරක් load කරන්න කියන එක.

EntityGraph භාවිතයත් `JOIN FETCH` වගේම එක SQL query එකක් මගින් අවශ්‍ය data load කරගන්නවා. මේක JPQL queries වලට වඩා maintain කරන්න පහසුයි, විශේෂයෙන්ම complex fetch paths තියෙනකොට.

Solution 3: Batch Fetching / @BatchSize භාවිතය

මේක `JOIN FETCH` හෝ `EntityGraph` වගේ එක query එකකින්ම හැමදේම load කරන්නේ නැහැ. හැබැයි N queries ගණන N/BATCH_SIZE දක්වා අඩු කරනවා. ඒ කියන්නේ queries ගොඩක් අඩු වෙනවා, හැබැයි එක query එකක් නෙමෙයි.

මේක හොඳටම ගැලපෙන්නේ `JOIN FETCH` එකක් පාවිච්චි කරනකොට cartesian product එකක් එනවා නම් (අනවශ්‍ය data duplication) හෝ එකට load කරන්න බැරි තරම් data ප්‍රමාණයක් තියෙනවා නම්. අපි `comments` collection එකට `@BatchSize` annotation එක එකතු කරමු:

@Entity
public class Post {
    // ...
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    @BatchSize(size = 10) // Fetch 10 comments at a time
    private List<Comment> comments = new ArrayList<>();
    // ...
}

දැන්, මුලින් Post entities ටික load වෙනවා. ඊට පස්සේ, පළවෙනි Post එකේ `getComments()` call කරනකොට, Hibernate එකෙන් Post ID 10ක comments ටික එක SQL query එකකින් load කරගන්නවා. ඊළඟ 10ට තවත් එක query එකක්, මේ විදිහට යනවා. ඒ කියන්නේ, Post entities 100ක් තියෙනවා නම්, queries 1 (Posts) + 10 (Comments in batches of 10) = 11 queries විතරයි යන්නේ! N+1 (101) එක්ක බලද්දි මේක ලොකු improvement එකක් නේද?

මේකට `application.properties` එකේ global setting එකක් විදිහට `hibernate.default_batch_fetch_size` කියලත් set කරන්න පුළුවන්:

spring.jpa.properties.hibernate.default_batch_fetch_size=10

මේක entity level එකේ @BatchSize එකට වඩා priority අඩුයි. ගොඩක් වෙලාවට entity level එකේ @BatchSize එක පාවිච්චි කරන එක තමයි recommended. මේක lazy loading එක්ක හොඳටම ගැලපෙනවා.

FetchType.EAGER භාවිතයෙන් Optimization?

User request එකේ තිබ්බා `FetchType.EAGER` පාවිච්චි කරලා optimize කරන්න කියලා. හැබැයි, `FetchType.EAGER` කියන්නේ N+1 Query Problem එකට හොඳ විසඳුමක් නෙමෙයි, සමහරවිට ඒක තව ප්‍රශ්න ඇති කරන්නත් පුළුවන්.

`FetchType.EAGER` කියන්නේ entity එකක් load කරනකොටම ඒකත් එක්ක අදාළ වෙන අනිත් entity collection (හෝ single entity) එකත් අනිවාර්යයෙන්ම load කරන්න කියන එක. `Many-to-One` සහ `One-to-One` relationships වල default එක `EAGER` වුණාට, `One-to-Many` සහ `Many-to-Many` relationships වල default එක `LAZY`.

අපි අපේ Post entity එකේ comments collection එක `EAGER` කරමු කියලා හිතමු:

@Entity
public class Post {
    // ...
    @OneToMany(mappedBy = "post", fetch = FetchType.EAGER) // Be careful with this!
    private List<Comment> comments = new ArrayList<>();
    // ...
}

දැන් අපේ service layer එකේ මෙහෙම call කරොත්:

List<Post> posts = postRepository.findAll();
for (Post post : posts) {
    System.out.println(post.getTitle() + " has " + post.getComments().size() + " comments.");
}

`FetchType.EAGER` පාවිච්චි කළා කියලා, මේ N+1 Problem එක සම්පූර්ණයෙන්ම fix වෙන්නේ නැහැ, විශේෂයෙන්ම `findAll()` වගේ method එකක් පාවිච්චි කරනකොට. මොකද, `OneToMany` relationships for `EAGER` fetching Hibernate එකට `findAll()` වගේ method එකකදී single query එකකින් load කරන්න අමාරුයි. ඒක `JOIN` එකක් දාලා එක query එකකින් load නොකර, Post ටික load කරලා, ඊට පස්සේ comments ටික load කරන්න වෙනම queries set එකක් යවනවා (N+1 වගේම behaviour එකක්) නැත්නම් batch fetching කරගෙන යනවා. හැබැයි, `findById()` වගේ single entity load කරනකොට, Hibernate එකෙන් `JOIN` එකක් දාලා Post එකයි, ඒකේ comments ටිකයි එකට load කරනවා.

`FetchType.EAGER` වල තියෙන ප්‍රධානම ප්‍රශ්නය මොකක්ද?
`FetchType.EAGER` පාවිච්චි කරනකොට, ඔයාට related data අවශ්‍ය වුණත් නැතත්, ඒක හැමවෙලේම load වෙනවා. උදාහරණයක් විදිහට, ඔයාට Post එකේ `title` එක විතරක් load කරන්න ඕන වුණත්, `EAGER` නිසා comments ටිකත් load වෙනවා. මේකෙන් database එකෙන් අනවශ්‍ය data ගොඩක් load වෙනවා, memory waste වෙනවා, ඒ වගේම query execution time එක වැඩි වෙනවා.

ඒ නිසා, පොදුවේ `FetchType.EAGER` (විශේෂයෙන්ම collections වලට) පාවිච්චි කරන එක recommended නැහැ. Default `LAZY` behaviour එක තියාගෙන, අවශ්‍ය තැනට `JOIN FETCH` හෝ `EntityGraph` පාවිච්චි කරන එක තමයි හොඳම පුරුද්ද.

අවසන් සටහන සහ හොඳම පුරුදු

N+1 Query Problem එක කියන්නේ අපේ application එකේ performance එකට ලොකුවටම බලපාන, ඒ වගේම identify කරන්න ටිකක් අමාරු වෙන්න පුළුවන් ප්‍රශ්නයක්. මේකෙන් අපේ database එකට අනවශ්‍ය බරක් වැටෙනවා, application respond කරන speed එක අඩු වෙනවා, ඒ වගේම user experience එකත් බාල වෙනවා.

ඒත් අපි දැක්කා වගේ, JPA/Hibernate වල තියෙන `JOIN FETCH`, `EntityGraph`, සහ `@BatchSize` වගේ features පාවිච්චි කරලා මේ ප්‍රශ්නයට සාර්ථකව මුහුණ දෙන්න පුළුවන්. මතක තියාගන්න:

  • ගොඩක් වෙලාවට `LAZY` fetching තමයි default විදිහට හොඳ.
  • ඔයාට related data ටික අවශ්‍ය නම්, `JOIN FETCH` (JPQL/HQL) හෝ `EntityGraph` පාවිච්චි කරන්න. මේකෙන් queries ගණන එකකට අඩු වෙනවා.
  • `JOIN FETCH` එකක් පාවිච්චි කරනකොට `DISTINCT` keyword එක පාවිච්චි කරන්න, අනවශ්‍ය data duplication වළක්වා ගන්න.
  • අනවශ්‍ය `EAGER` fetching වලින් වැළකෙන්න. විශේෂයෙන්ම collections වලට `EAGER` පාවිච්චි කිරීමෙන් තව ප්‍රශ්න ඇති වෙන්න පුළුවන්.
  • `Batch Fetching` (Global `hibernate.default_batch_fetch_size` හෝ entity level `@BatchSize`) කියන්නේ `JOIN FETCH` එකක් පාවිච්චි කරන්න බැරි අවස්ථාවලදී හොඳ අතරමැදි විසඳුමක්.

මේ වගේ database performance issues detect කරන්න Hibernate Statistics, P6Spy, හෝ Spring Boot Actuator වගේ monitoring tools පාවිච්චි කරන්න පුළුවන්. ඒකෙන් ඔයාගේ application එකෙන් database එකට යන queries මොනවද, ඒවා execute වෙන්න කොච්චර වෙලාවක් යනවාද කියලා පැහැදිලිව බලාගන්න පුළුවන්.

ඉතින්, යාලුවනේ, ඔයාගේ application එකත් slow නම්, N+1 Query Problem එකක් තියෙනවද කියලා බලන්න. මේ ලිපියේ කියපු solutions ටික try කරලා බලන්න. ඔයා මේ ගැන තව දුරටත් මොනවා හරි දන්නවා නම්, නැත්නම් ඔයාට මේ වගේ issues ආපු experience එකක් තියෙනවා නම්, පහල comment section එකේ share කරන්න. මේ වගේ තව technical ලිපි ඕනෙ නම් ඒකත් කියන්න! බොහොම ස්තූතියි!