Strona główna » Hibernate problem n+1 zapytań

Hibernate problem n+1 zapytań

by Grzegorz Sowa

Często gdy apka zwalnia, obwiniamy bazę. Dokładamy instancji, zakładamy indeksy. Czasem to pomaga, ale w większości przypadków najlepszym wyjściem będzie sprawdzić, co nasz ORM wyprawia gdy nie patrzymy. Problem n+1 zapytań to jeden z najczęstszych błędów wydajnościowych aplikacji. Przyjrzyjmy się więc, na czym polega i jak można sobie z nim radzić.

Kod wykorzystany w tym artykule znajdziecie tutaj

Problem N+1 zapytań o zakupy

Przyjrzyjmy się przykładowej aplikacji, przechowującej historię zamówień ShopOrder użytkownika. Każde zamówienie składa się z kilku pozycji OrderPosition, czyli produktu i jego ilości.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class ShopOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private UUID clientId;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private Set<OrderPosition> positions;

    static ShopOrder newInstance() {
        final ShopOrder shopOrder = new ShopOrder();
        shopOrder.positions = new HashSet<>();
        return shopOrder;
    }

    static ShopOrder of(UUID clientId, OrderPosition... positions) {
        final ShopOrder shopOrder = newInstance();
        shopOrder.clientId = clientId;
        for (OrderPosition position : positions) {
            shopOrder.addPosition(position);
        }
        return shopOrder;
    }

    void addPosition(OrderPosition orderPosition) {
        this.positions.add(orderPosition);
    }

    Set<OrderPosition> getPositions() {
        return Collections.unmodifiableSet(positions);
    }

}

Pozycja w zamówieniu:

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class OrderPosition implements Persistable<UUID> {

    @Id
    private UUID id;
    @Transient
    private boolean isNew;
    private String product;
    private Integer quantity;

    static OrderPosition of(String product, Integer quantity) {
        final OrderPosition position = new OrderPosition();
        position.id = UUID.randomUUID();
        position.isNew = true;
        position.product = product;
        position.quantity = quantity;
        return position;
    }

}

Aby użytkownik mógł pobrać historię swoich zamówień, musimy zdefiniować odpowiednią metodę w repozytorium:

interface ShopOrderRepository extends CrudRepository<ShopOrder, Long> {

    Set<ShopOrder> findAllByClientId(UUID clientId);

}

Oraz endpoint do pobierania zamowień użytkownika:

    @GetMapping("/clients/{id}/orders")
    ResponseEntity<Set<ShopOrderDto>> getOrderWithId(@PathVariable UUID id) {
        final Set<ShopOrderDto> result = repository.findAllByClientId(id)
                .stream()
                .map(ShopOrderDto::of)
                .collect(Collectors.toSet());
        return ResponseEntity.ok(result);
    }

Właśnie tutaj kończy się zabawa a zaczyna problem N+1 zapytań. Hibernate odpytuje bazę najpierw o zamówienia użytkownika X. Gdy otrzyma N zamówień, dla każdego zamówienia odpytuje o jego pozycje, czyli dodatkowe N razy. Stąd bierze się nazwa problemu, tutaj jest N+1 zapytań.

problem-n-plus-1-zapytań

Testowanie problemu N+1 zapytań

Możemy w prosty sposób przetestować, czy nasza aplikacja nie ma problemów wydajnościowych przy pobieraniu zamówień. Jednak aby sprawdzić liczbę zapytań w teście, potrzebujemy dodatkowej biblioteki, pozwalającej założyć proxy na DataSource:

<dependency>
	<groupId>net.ttddyy</groupId>
	<artifactId>datasource-proxy</artifactId>
	<version>1.4.1</version>
</dependency>

Teraz, za pomocą post-processora musimy „złapać” beana DataSource i owinąć go w proxy. Zdefiniujmy go dla profilu integration-test, żeby nie mieć go już w uruchomionej aplikacji:

@Component
@Profile("integration-test")
@RequiredArgsConstructor
public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor {

    private final CountingQueryExecutionListener queryExecutionListener;

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof DataSource dataSource) {
            return ProxyDataSourceBuilder.create(dataSource)
                    .listener(queryExecutionListener)
                    .build();
        } else {
            return bean;
        }
    }
}

Jak widać, do proxy jest podłączany listener, który ma na celu zliczać zapytania wysyłane do bazy danych. Oto przykładowy kod takiego listenera, z metodami pozwalającym na pobranie liczby zapytań i reset ich licznika:

@Component
@Profile("integration-test")
@Slf4j
@RequiredArgsConstructor
public class CountingQueryExecutionListener implements QueryExecutionListener {

    @Getter
    private Integer count = 0;

    @Override
    public void beforeQuery(ExecutionInfo executionInfo, List<QueryInfo> list) {

    }

    @Override
    public void afterQuery(ExecutionInfo executionInfo, List<QueryInfo> list) {
        count = count + 1;
    }

    public void resetCount() {
        count = 0;
    }
}

Teraz możemy już napisać test:

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class HibernateNPlusOneDemoApplicationTests {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ShopOrderRepository repository;

    @Autowired
    private CountingQueryExecutionListener queryExecutionListener;

    private final UUID clientId = UUID.randomUUID();

    @Test
    void shouldLoadOrdersWithOneQuery() throws Exception {

        clientWithTwoOrders();
        queryExecutionListener.resetCount();

        getOrders()
                .andExpect(status().isOk());

        assertEquals(1, queryExecutionListener.getCount());

    }

// helper methods...
}

W teście generujemy dwa zamówienia dla tego samego klienta oraz wywołujemy endpoint historii zamówień:

    void clientWithTwoOrders() {

        List<ShopOrder> orders = new ArrayList<>();

        orders.add(ShopOrder.of(clientId,
                OrderPosition.of("Milk", 2),
                OrderPosition.of("Butter", 1),
                OrderPosition.of("Egg", 10),
                OrderPosition.of("Paprika", 2),
                OrderPosition.of("Cucumber", 1),
                OrderPosition.of("Banana", 5)));

        orders.add(ShopOrder.of(clientId,
                OrderPosition.of("Beer", 6),
                OrderPosition.of("Crisps", 2),
                OrderPosition.of("Peanuts", 2)));

        repository.saveAll(orders);

    }

    ResultActions getOrders() throws Exception {
        return mockMvc.perform(
                MockMvcRequestBuilders.get(String.format("/clients/%s/orders", clientId))
        );
    }

Test oczywiście nie przejdzie w tym stanie, bo oczekuje on, że aplikacja wykona tylko jedno połączenie do bazy danych – otrzymujemy następujący błąd:

[ERROR]   HibernateNPlusOneDemoApplicationTests.shouldLoadOrdersWithOneQuery:44 expected: <1> but was: <3>

Czyli zamiast jednego zapytania, dla klienta o dwóch zamówieniach poszło ich 3, czyli dokładnie N+1. Zobaczmy teraz, jak sprawić, aby test zabłysnął na zielono. Istnieje kilka rozwiązań tego problemu.

Fix: JPQL Query

    @Query("""
            SELECT o FROM ShopOrder o
            LEFT JOIN FETCH o.positions
            """)
    Set<ShopOrder> findAllByClientId(UUID clientId);

Teraz test powinien już przejść, nie polegamy tu na generowaniu zapytania przez ORM, a bierzemy stery we własne ręce.

Fix: Entity Graph

Specjalnie do tego celu w JPA 2.1 pojawiło się nowe rozwiązanie – EntityGraph. Jest to chyba najprostsze rozwiązanie, aby osiągnąć nasz cel, jednak nadal niepozbawione ograniczeń. W tym celu dodajemy w repozytorium adnotację:

    @EntityGraph(attributePaths = {"positions"})
    Set<ShopOrder> findAllByClientId(UUID clientId);

To wystarczy, aby metoda nie wykonywała nadmiarowych zapytań przy pobieraniu użytkowników. Rozwiązanie nie powinno też przeszkadzać we wprowadzeniu pagingu. Jednak napotkamy problemy, jeśli nasze zależności staną się wielopoziomowe, gdy encje zależne będą miały swoje kolekcje innych encji. Wtedy musimy zacząć definiować grafy z użyciem @NamedEntityGraph bezpośrednio nad klasą encji. Przy dużej liczbie różnych projekcji bardzo zaciemni to kod klasy encji, utrudni też zastosowanie wzorca CQRS.

Fix: Criteria Query

Criteria query to rozwiązanie wymagające napisania o wiele więcej kodu, jednak pozwalające na całkowite oddzielenie tego kodu od klasy encji, a nawet od standardowego repozytorium. Aby użyć criteria query będziemy musieli stworzyć klasę repozytorium. Aby podłączyć ją pod istniejący interfejs springowy musimy najpierw zdefiniować dodatkowy interfejs z metodą, którą chcemy zaimplementować:

interface ShopOrderCriteriaRepository {
    Set<ShopOrder> findAllByClientId(UUID clientId);
}

Repozytorium, którego już używamy musi rozszerzać nowy interfejs:

interface ShopOrderRepository extends CrudRepository<ShopOrder, Long>, ShopOrderCriteriaRepository {

}

Teraz zaimplementujmy dodaną metodę w klasie, którą opatrzymy adnotacją @Repository:

@Repository
public class ShopOrderCriteriaRepositoryImpl implements ShopOrderCriteriaRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Set<ShopOrder> findAllByClientId(UUID clientId) {
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();

        CriteriaQuery<ShopOrder> query = criteriaBuilder.createQuery(ShopOrder.class);
        Root<ShopOrder> shopOrder = query.from(ShopOrder.class);
        shopOrder.fetch("positions", JoinType.LEFT);
        query.select(shopOrder);

        TypedQuery<ShopOrder> typedQuery = entityManager.createQuery(query);
        return typedQuery.getResultStream()
                .collect(Collectors.toSet());
    }
}

Dzięki adnotacji @PersistenceContext korzystamy zawsze dokładnie z tego EntityManagera, który jest używany w kontekście danej sesji. Dzięki niemu możemy zbudować CriteriaQuery w którym możemy dowolnie zdefiniować zapytanie. Teraz też test powinien przejść, aplikacja wykona tylko jedno zapytanie do bazy danych.

To rozwiązanie jest najbardziej elastyczne z opisanych, pozwala w dość łatwy sposób zrobić paging i multi-poziomowe JOINy. Co więcej, pozwala na definiowanie skomplikowanych reguł filtrowania encji.

Podsumowanie

Budując aplikacje małe i duże, warto być świadomym takich problemów wydajnościowych, jak opisany w artykule. Jeśli natomiast nasze zapytania stają się coraz bardziej złożone, warto też przenieść je w zupełnie inne miejsce w aplikacji, na przykład za pomocą wzorca CQRS. Jeśli chcesz wiedzieć więcej o optymalizacji JPA, zajrzyj do tego artykułu.

You may also like

Leave a Comment