Bazy danych i JPA: kiedy podręcznikowa normalizacja cię zdradza

„Duplikacja danych to absolutne zło”

Na uczelnianych zajęciach z baz danych profesor podkreślał to z takim zapałem, że aż pryskała ślina. „Pierwsza postać normalna, druga postać normalna, trzecia postać normalna… zduplikowane dane marnują miejsce i psują spójność. Dzielić, a potem dzielić jeszcze bardziej!”

Ja naprawdę wiernie stosowałem się do tej nauki. W projekcie dyplomowym dzieliłem tabele na 10, potem 20 części. 'User’, 'Address’, 'City’, 'Zipcode’… Mój projekt bazy danych był tak perfekcyjnie „podręcznikowy”, że zapisanie jednego adresu wymagało aż trzech tabel.

Ale w prawdziwej pracy ten idealny projekt zamienił się w katastrofę. Chciałem tylko pobrać jedną listę klientów, a musiałem napisać pięć JOIN-ów. Zapytanie stawało się złożone, wydajność spadała, a co najgorsze, przenoszenie tych danych do obiektów Java było potwornie bolesne.

„Jak to możliwe, że chcę pobrać tylko jeden wiersz danych do wyświetlenia na ekranie, a kod robi się aż tak skomplikowany?”

Dopiero wtedy to zrozumiałem. Na studiach uczono nas optymalizacji zapisu. W praktyce znacznie ważniejsza jest efektywność odczytu. A pomiędzy językiem obiektowym takim jak Java a relacyjną bazą danych płynie rzeka znacznie trudniejsza do przekroczenia, niż się wydaje.

Utrzymanie porządku dzięki normalizacji to nie to samo, co przygotowanie danych pod wygodne i szybkie odczyty.

Niezgodność paradygmatów: kwadraty i koła

Głęboką przyczyną tego bólu jest tak zwany konflikt paradygmatów, czyli Impedance Mismatch.

Na studiach na siłę spinałem te dwa światy, pisząc SQL ręcznie. Rozbijałem obiekty Java na części, zapisywałem je do bazy przez INSERT, potem wyciągałem z niej dane przez SELECT, czytałem je wiersz po wierszu z ResultSet i mozolnie przekładałem do kolekcji Java takich jak Set czy List. Czułem się nie jak programista, lecz jak tłumacz danych.

Technologią, która pojawiła się po to, by rozwiązać tę nużącą powtarzalność, było właśnie JPA (Java Persistence API), czyli świat ORM (Object-Relational Mapping).

JPA i ORM: obsługa bazy danych przez obiekty

ORM to dosłownie technologia, która łączy obiekty z relacyjnymi bazami danych. Jak sama nazwa wskazuje, sedno polega na tym, by definiować i traktować tabele w bazie tak, jakby były obiektami Java.

Nie musimy już ręcznie pisać zapytań CREATE TABLE. Zamiast tego tworzymy klasę Java i przyklejamy do niej etykietę o nazwie @Entity. Wtedy JPA, standard ORM w świecie Javy, patrzy na tę klasę i myśli: „Aha, czyli potrzebujesz tabeli o takim kształcie”, po czym tworzy ją automatycznie w bazie danych.

Również zapisywanie danych przestaje oznaczać ręczne pisanie SQL. Wystarczy coś w rodzaju repository.save(member), niemal jak dodawanie elementu do kolekcji Java. Programista zostaje całkowicie po stronie myślenia obiektowego, a brudna robota tłumaczenia na SQL zostaje zrzucona na JPA.

Ale, jak uczyliśmy się już w serii Re: Booting, wygoda zawsze ma swoją cenę. I właśnie dlatego, że za bardzo zaufałem temu automatycznemu tłumaczowi o nazwie JPA, zasadziłem w swoim kodzie bombę z opóźnionym zapłonem: problem N+1.

[Code Verification] Problem N+1, czyli bomba z zapytań

To problem, na który trafia praktycznie każdy junior, gdy pierwszy raz zaczyna pracować z JPA. Sytuacja jest prosta: „Wyświetl wszystkich członków wraz z nazwą zespołu, do którego należą.”

// 1. Pobierz wszystkich czlonkow (1 zapytanie)
List<Member> members = memberRepository.findAll();

for (Member member : members) {
    // 2. Wypisz nazwe druzyny kazdego czlonka
    // Przy 100 czlonkach leci 100 dodatkowych zapytan o informacje o druzynach!
    System.out.println(member.getTeam().getName());
}

Zapytanie, którego się spodziewaliśmy: SELECT * FROM Member JOIN Team ... (dokładnie raz)

Zapytania, które faktycznie zostały wykonane:

Jeśli członków jest 100, aplikacja wyśle 101 zapytań: 1 + N. A jeśli jest ich 10 000? Wtedy do bazy uderza 10 001 zapytań. To właśnie słynny problem N+1, czyli typ błędu, który potrafi położyć serwer. JPA chciało być wygodne i ładować powiązane dane „dopiero wtedy, gdy są potrzebne”, w trybie lazy. I właśnie ta wygoda zamieniła się w katastrofę.

To, co można pobrać za jednym razem, zostaje rozbite na sto osobnych kursów. Na tym polega nieefektywność problemu N+1.

Praktyczna rada: pragmatyczne projektowanie bazy danych

Co więc robić w praktyce? Trzeba znaleźć równowagę między podręcznikową normalizacją a wygodą oferowaną przez JPA.

Na koniec: więcej wygody wymaga większej wiedzy

JPA bez wątpienia jest rewolucją. Uwolniło nas od nużącego powtarzania wciąż tych samych fragmentów SQL. Ale myślenie: „skoro używam JPA, to już nie muszę znać SQL” jest niebezpieczne.

JPA nie jest magikiem. To tylko sekretarka, która pisze SQL za ciebie. Jeśli wydasz tej sekretarce złe instrukcje, przez błędne mapowania, ładowanie EAGER i tym podobne, ona spokojnie odpali sto zapytań i zabije bazę danych. Żeby kontrolować i stroić wydajność SQL generowanego przez JPA, paradoksalnie trzeba znać SQL jeszcze lepiej. Wygoda zawsze niesie za sobą odpowiedzialność.

Teraz już wiemy, jak umieszczać dane w obiektach. Ale czy możemy te obiekty, czyli entity, po prostu wysłać wprost do frontendu, na przykład do Vue.js? A co jeśli encja User zawiera hasło? I co stanie się z bezpieczeństwem, jeśli przekażemy frontendowi informacje, których nigdy nie powinien zobaczyć?

Następnym razem porozmawiamy o DTO, czyli Data Transfer Object, oraz o projektowaniu REST API, a więc o technikach bezpiecznego pakowania i dostarczania danych.

Dodaj komentarz