Problem bir ssenaridə

Endpoint ən son 50 sifarişi hər sifarişin müştəri adı ilə birlikdə qaytarır. Kod sadə görünür: repository metodu sifarişləri yükləyir, mapper hər birini DTO-ya çevirir və içində order.getCustomer().getName() çağırılır. Beş sətirlik lokal bazada hər şey sürətlidir. Cədvəl böyüdükcə endpoint yavaşlayır, halbuki Java kodu dəyişməyib.

Bu hadisəyə N+1 sorğu problemi deyilir. Hibernate sifarişləri yükləmək üçün bir sorğu göndərdi ("1"), sonra isə hər sifariş üçün lazy customer əlaqəsini yükləmək məqsədilə əlavə bir sorğu göndərdi ("N"). Əlli sifariş əlli bir sorğu deməkdir. Hər sorğunun sabit qiyməti var: şəbəkə gecikməsi, statement-in təhlili, pool-dan götürülmüş connection. Bu qiymət səhifə ölçüsü ilə düzxətli artır və kodda görünmür, çünki əlavə sorğuları sadə getter çağırışı işə salır.

Aşkar etmək

Təxmin etməyin, SQL-ə baxın. Development mühitində sorğuların loglanmasını açın:

spring:  jpa:    properties:      hibernate:        generate_statistics: truelogging:  level:    org.hibernate.SQL: debug

N+1 olan sorğuda eyni select ... from customer where id=? ifadəsi dönə-dönə çap olunur. generate_statistics isə sessiya üzrə statement sayını da göstərir. Daha davamlı yol integration testdə statement sayını yoxlamaqdır (datasource proxy kitabxanası və ya Hibernate Statistics obyekti ilə). Beləcə reqressiya yavaş endpoint haqqında xəbərdarlıq gözləmədən build-i sındırır.

Default fetch tipi vacibdir. Jakarta Persistence spesifikasiyasında @ManyToOne və @OneToOne default olaraq eager, @OneToMany və @ManyToMany isə lazy-dir. Eager "join ilə" demək deyil: entity-ləri JPQL ilə yüklədikdə Hibernate eager əlaqələr üçün yenə əlavə select-lər göndərir. Ən təhlükəsiz yol əlaqələri fetch = FetchType.LAZY elan edib hər use case üçün nəyin yüklənəcəyini ayrıca qərara almaqdır.

Həll 1: fetch join

Use case əlaqəyə ehtiyac duyursa, bunu sorğuda açıq yazın:

@Query(""    select o from Order o    join fetch o.customer    where o.status = :status    "")List<Order> findWithCustomer(@Param("status") OrderStatus status);

Hibernate indi join ilə tək select yaradır və müştəri artıq initialize olunmuş olur. Fetch join açıq və oxunaqlıdır, amma yalnız həmin sorğu metoduna bağlıdır.

Həll 2: @EntityGraph

Derived query metodlarına üstünlük verirsinizsə, entity graph JPQL yazmadan fetch planını əlavə edir:

@EntityGraph(attributePaths = {"customer"})List<Order> findByStatus(OrderStatus status);

Nəticə fetch join-ə oxşayır, üstəlik Spring Data-nın metod adlandırması və Specification ilə rahat işləyir. Graph-ları kiçik saxlayın: beş əlaqəni çəkən graph çox vaxt həmin use case üçün ayrıca read model lazım olduğunu göstərir.

Həll 3: batch fetching

Bəzən join istədiyiniz şey deyil, məsələn kolleksiya yükləyirsiniz və join sətirləri çoxaldır. Hibernate lazy əlaqələri tək-tək yox, qruplarla yükləyə bilər:

spring:  jpa:    properties:      hibernate:        default_batch_fetch_size: 50

İlk lazy müştəriyə toxunulanda Hibernate gözləyən 50-yə qədər proxy üçün müştəriləri in (...) ilə yükləyir. Əlli bir sorğu ikiyə enir. Bu, yaxşı qlobal təhlükəsizlik şəbəkəsidir: yazdığınız sorğuları dəyişmir, yalnız kimsə fetch planını unutduqda zərəri azaldır. Düşünülmüş fetch planını əvəz etmir, çünki əlavə sorğular hələ də var.

Həll 4: DTO projection

Çox vaxt endpoint-ə entity lazım olmur. Cavabda yalnız sifariş id-si, məbləğ və müştəri adı lazımdırsa, məhz bunu istəyin:

public record OrderRow(Long id, BigDecimal total, String customerName) {}@Query(""    select new com.example.OrderRow(o.id, o.total, c.name)    from Order o join o.customer c    where o.status = :status    "")List<OrderRow> findRows(@Param("status") OrderStatus status);

Projection daha az sütun oxuyur, persistence context və dirty checking-dən yan keçir və təsadüfən lazy loading işə sala bilməz. Oxuma yönümlü endpoint-lər üçün adətən ən yaxşı seçimdir. Entity isə dəyişdirib saxlamaq istədiyiniz hallarda düzgün alətdir.

Fetch join ilə səhifələmə

Kolleksiyanı fetch join etmək (join fetch o.items) və eyni zamanda Pageable istifadə etmək klassik tələdir. Join sətirləri çoxaldır, ona görə baza limit-i sifarişlərə tətbiq edə bilmir. Hibernate səhifələmənin yaddaşda tətbiq olunması barədə xəbərdarlıq yazır və səhifəni kəsməzdən əvvəl bütün nəticəni yükləyir. Daha təhlükəsiz variantlar:

  1. Əvvəlcə kök entity-nin id-ləri üzrə səhifələyin, sonra həmin id-lər üçün kolleksiyaları ikinci sorğu ilə yükləyin (where o.id in :ids).
  2. Səhifələnən sorğuda yalnız to-one əlaqələri fetch join edin, kolleksiyalar üçün default_batch_fetch_size istifadə edin.
  3. DTO projection istifadə edin və birləşdirməni tətbiqdə və ya ayrıca sorğuda aparın.

İki kolleksiyanı bir sorğuda join etmək başqa tələdir: Kartezian hasili yaranır, List tiplərində isə MultipleBagFetchException atıla bilər. Hər sorğuda bir kolleksiya yükləyin.

Tipik səhvlər

  • Lazy loading xətalarını "həll etmək" üçün bütün əlaqələri EAGER etmək. Bu, problemi hər sorğuya köçürür.
  • Transaction bitdikdən sonra mapper-də lazy əlaqəyə toxunub xətanı gizlətmək üçün open-session-in-view açmaq. Fetch planını service metodunun daxilində qərara alın.
  • Yaranan SQL-ə baxmadan hər yerə distinct və join əlavə etmək.
  • N-in 2 olduğu kiçik dataset ilə test etmək; belə halda heç nə səhv görünmür.

Güzəştlər

Fetch join bir sorğu verir, amma təkrarlanan ana sütunlarla daha geniş nəticə qaytarır. Batch fetching sorğuları kiçik saxlayır, amma bir neçə əlavə sorğu əlavə edir. Projection ən yaxşı nəzarəti verir, lakin saxlanmalı əlavə sinif deməkdir. Universal cavab yoxdur: hər endpoint üçün seçin və SQL logu ilə yoxlayın. Onu da unutmayın ki, daha az sorğu həmişə daha sürətli demək deyil; indekssiz join bir neçə indeksli axtarışdan yavaş ola bilər. Buna görə də indeks dizaynı eyni söhbətin hissəsidir.

Praktik qərar qaydası

Yeni endpoint yazarkən özünüzə üç sual verin. Cavabda entity-nin hamısı lazımdırmı, yoxsa bir neçə sahə? Yalnız bir neçə sahə lazımdırsa, projection seçin və məsələ bitir. Entity dəyişdiriləcəksə, to-one əlaqələr üçün fetch join və ya entity graph istifadə edin. Kolleksiyalar lazımdırsa və nəticə səhifələnirsə, əvvəl id-ləri səhifələyin, sonra kolleksiyaları ayrıca sorğu ilə yükləyin. Bu üç sualın cavabı demək olar ki, həmişə düzgün fetch planını müəyyən edir.

Review zamanı da eyni sualları verin. Mapper-də getX().getY() zənciri görürsünüzsə, bu əlaqənin sorğuda yüklənib-yüklənmədiyini yoxlayın. Hər dəfə SQL logunu açmaq yorucudur, ona görə kritik endpoint-lər üçün statement sayını yoxlayan test yazmaq daha sürətli ödənir: test bir dəfə yazılır, sonra hər build-də səssizcə qoruyur.

Yoxlama siyahısı

  • Əlaqələr güclü səbəb olmadıqca LAZY elan olunub.
  • Hər endpoint-in düşünülmüş fetch planı var: fetch join, entity graph və ya projection.
  • default_batch_fetch_size təhlükəsizlik şəbəkəsi kimi təyin olunub.
  • Kolleksiya fetch join-i Pageable ilə birləşdirilmir.
  • Kritik endpoint-lər üçün test SQL statement sayını yoxlayır.

Növbəti addım

Lazy loading yalnız açıq transaction daxilində işləyir, ona görə Spring transaction tələləri məqaləsini oxuyun. Statement sayı yoxlamasını test dəstinizə əlavə etmək üçün backend test strategiyasına baxın. Persistence mövzularında hazırlıq səviyyənizi yoxlamaq üçün bacarıq qiymətləndirməsini keçə bilərsiniz.