Kontynuując nasze rozważania, jak optymalnie zaimplementować wyszukiwanie tekstowe na poziomie pojedynczych pól w systemie, wykorzystamy tym razem typ kolumny specyficzny dla Postgres - kolumnę HStore. Następnie założymy na niej indeksy typu GIN / GiST opisane na początku niniejszej serii artykułów oraz zintegrujemy HStore z Hibernate oraz Spring Data JPA.
Spis treści
Ten wpis jest częścią serii artykułów o internacjonalizacji danych ze wsparciem dla wyszukiwania tekstowego:
- Indeksy dostepne w Postgres oraz antywzorce
- Wydajna internacjonalizacja w PostgreSQL + Hibernate / JPA:
- Tabela towarzysząca z tłumaczeniami
- Kolumna HStore z tłumaczeniami - obecnie czytasz
Klucze językowe w HStore
HStore to specjalny typ danych w Postgres, który umożliwia nam w każdym wierszu przechować mapę klucz ‑ wartość. Dzięki temu dla każdego pola (jak np. nazwa produktu) możemy dodać towarzyszącą kolumnę z mapą: kod języka - tekst w danym języku. Przykład tabeli wykorzystującej ten koncept:
W powyższym przykładzie zakładamy że w kolumnie Name przechowuję nazwę w domyślnym języku organizacji, a w NameTranslations mamy tłumaczenia dla wszelkich innych języków.
Żeby móc korzystać z HStore w danej bazie Postgres trzeba uprzednio włączyć odpowiednie rozszerzenie:
CREATE EXTENSION hstore;
Integracja HStore z JPA / Hibernate
Java Persistence API zgodnie ze swoją zasadą neutralności nie wspiera HStore, który jest typem specyficznym dla bazy danych Postgres. Można jednak bardzo łatwo rozszerzyć funkcjonalność Hibernate aby móc pracować z tym typem zarówno na poziomie JPQL, Criteria API, a nawet generacji DDL. Pierwsze co musimy zrobić do dodać nasz UserType dla HStore.
Następnie musimy też dodać funkcje do zapytań w Hibernate, która umożliwi nam pobieranie wartości po kluczy z kolumny typu HStore. W tym celu dodajemy implementacje SQLFunction:
Spinamy wszystko z dialekt Postgresa:
i ustawiamy nasz dialekt w konfiguracji Spring Boot:
spring.jpa.properties.hibernate.dialect=it.walczak.examples.in18jpa.ExpandedPostgresDialect
Model obiektowy w JPA
Po rozszerzeniu Hibernate o wsparcie dla HStore, możemy opracować model obiektowy, który będzie go wykorzystywał przy zapisie do bazy danych.
Z racji że tworzymy dosyć generyczny embeddable, który będzie wykorzystany dla wielu pól w każdej encji, to warto ustawić w Spring Boot strategię nazewniczą kolumn bazy danych uwzględniające zagnieżdżenie obiektów.
spring: jpa: hibernate: naming: implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
Bez tego Hibernate w domyślnej konfiguracji próbowałby stworzyć wiele kolumn o nazwie "value" w jednej tabeli. Natomiast z powyższą konfiguracją będzie robił kolumny o nazwach:
details_name_value text; details_name_translation_by_language hstore; details_description_value text; details_description_translation_by_language hstore;
Usługa w Spring wyszukująca po tekście
Przetestujemy teraz wykorzystanie modelu w praktyce poprzez zaimplementowanie usługi Springowej umożliwiającej poprzez REST API:
- dodać oraz pobrać tłumaczenia danego produktu;
- wyszukać produkty po nazwie wg. danego języka.
Obsługę zapytań SQL, na której będzie bazować owa usługa, zaimplementujemy natomiast z pomocą Spring Data JPA.
Indeksy w Postgres oraz testy wydajności
Biblioteka Hibernate na podstawie modelu z adnotacjami JPA wygeneruje nam tabele SQL, jednak nie zapewni optymalizacji. Samodzielnie musimy zadbać o dodanie do Postgres indeksów typu GIN, o których wspomnieliśmy w pierwszym artykule z serii.
Jak widać po powyższym przykładzie trzeba tworzyć oddzielny indeks per język. Ma to swoje plusy i minusy. Z jednej strony musimy robić wiele indeksów z wycinkiem danych. Z drugiej natomiast mamy nad tym większą kontrolę i możemy robić indeksy tylko dla najpopularniejszych języków pośród naszych użytkowników. Ponadto, wyszukiwanie zawsze dzieje się w obrębie jednego języka, więc też jednego dobrze wyspecjalizowanego indeksu związanego z tym że językiem.
Analogicznie jak w poprzednie koncepcji zasilimy sobie bazę danych przy pomocy skryptu Gatlingowego.
Upewnijmy się że nasze indeksy są wykorzystywane:
explain select product0_.id as id1_0_, product0_.details_code as details_2_0_, product0_.details_description_translationByLanguage as details_3_0_, product0_.details_description_value as details_4_0_, product0_.details_name_translationByLanguage as details_5_0_, product0_.details_name_value as details_6_0_, product0_.details_price as details_7_0_ from Product product0_ where lower(product0_.details_name_value) like ('%'||lower('aaa')||'%') or lower(product0_.details_name_translationByLanguage -> 'pl') like ('%'||lower('aaa')||'%')
zwraca nam
Bitmap Heap Scan on product product0_ (cost=175.84..5669.84 rows=15680 width=173) Recheck Cond: ((lower((details_name_value)::text) ~~ '%aaa%'::text) OR (lower((details_name_translationbylanguage -> 'pl'::text)) ~~ '%aaa%'::text)) -> BitmapOr (cost=175.84..175.84 rows=16000 width=0) -> Bitmap Index Scan on product_details_name_idx (cost=0.00..84.00 rows=8000 width=0) Index Cond: (lower((details_name_value)::text) ~~ '%aaa%'::text) -> Bitmap Index Scan on product_translated_name_idx (cost=0.00..84.00 rows=8000 width=0) Index Cond: (lower((details_name_translationbylanguage -> 'pl'::text)) ~~ '%aaa%'::text)
Korzysta z indeksu zgodnie z założeniami. Wykonajmy teraz żądanie HTTP do naszego API.
Czas wykonania od 5 do 30 ms, więc jesteśmy na dobrej drodze. Jeżeli usuniemy indeksy czas zwiększa się w okolice ~150 ms. Zróbmy zatem teraz pełen test wydajnościowy. W tym celu napiszemy dwa bardziej rozszerzone skrypty Gatlina:
- Just search - symulacja gdzie równolegle dzieją się tylko wyszukiwania
- Full simulation - symulacja gdzie równolegle dzieją się zarówno zapisy jak i wyszukiwania w proporcji 1:1
Skrypty te uruchomimy raz na bazie z indeksami i raz na bazie bez indeksów. Raz na bazie z 100'000 produktów, a raz z 500'000 produktów. Zestawienie czasów odpowiedzi zaprezentowane poniżej.
Wyniki dla 100'000 produktów
Just search simulation - BEZ indeksów
Just search simulation - Z indeksami
Wyniki dla 500'000 produktów
Just search simulation - BEZ indeksów
Just search simulation - Z indeksami
Wnioski
- koncepcja oparta na HStore okazała się o wiele łatwiejszą w integracji z JPA i Spring Data, aniżeli tabela towarzysząca z tłumaczeniami
- przy tabelach z 100'000 wierszami indeksy mają marginalny wpływ na wydajność, zarówno wyszukiwania po tekście jaki i zapisów przy obciążonym systemie,
- różnice dopiero widać przy 500'000 wierszach gdzie:
- średni czas wyszukiwania po tekście w systemie obciążonym jedynie wyszukiwaniem jest 8x krótszy jeżeli są indeksy,
- w scenariuszu gdzie jest bardzo dużo zapisów (jeden zapis na wyszukiwanie), zarówno wyszukiwanie jak i zapis jest szybszy o 20% z indeksami - co ciekawe o ile zapis ma więcej roboty z indeksem to zaoszczędzony czas na wyszukiwaniu przełożył się tu na ostatecznie szybsze działanie usługi.
Kody źródłowe zastosowane w tym artykule można znaleźć na naszym repozytorium bitbucket.
Niniejszy artykuł kończy nasza serię. Jeżeli chciałbyś przeczytać naszą analizę od początku to przejdź do artykułu Internacjonalizacja danych z wyszukiwaniem tekstowym - indeksy w Postgres oraz antywzorce
Ten artykuł jest wynikiem naszej współpracy z Nextbuy - firmą dostarczającą w modelu SaaS platformę zakupową i przetargową, która łączy kupców i dostawców. Świadczymy dla nich usługi doradcze oraz wsparcie w pracach programistycznych. Jesteśmy wdzięczni, że zgodzili się upublicznić część dokumentów projektowo-rozwojowych powstałych, w wyniku tego. Możecie sprawdzić ich świetną platformę na www.nextbuy24.com