Cześć, ten artykuł będzie krótki, a jego tematyka to optymalizacja JPA. Postaram się w nim zawrzeć kilka trików, które pomogą Ci pisać lepsze aplikacje oparte o Spring Data. Pozwolą też zwiększyć Twoją wiedzę, jak Hibernate działa „pod maską”. Jedziemy!
Kod wykorzystany w tym artykule znajdziecie tutaj
Implementuj Persistable<ID>
Ten tip przydaje się gdy mamy encję, której id nie generuje Hibernate, na przykład:
@Getter @NoArgsConstructor(access = PRIVATE) @Entity public class Post { @Id private UUID id; private UUID author; private String text; public static Post newInstance(UUID author, String text) { Post post = new Post(); post.id = UUID.randomUUID(); post.author = author; post.text = text; return post; } }
Wygląda dobrze? No to włączmy logowanie zapytań SQL i przyjrzyjmy się, co się dzieje kiedy zapisujesz nową instancję encji Post
. W tym celu dodajemy do application.yml
:
logging.level.org.hibernate.SQL: DEBUG
Po stworzeniu nowego Post
-a widzimy w konsoli:
org.hibernate.SQL: select post0_.id as id1_0_0_, post0_.author as author2_0_0_, ... org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?)
Zaraz, Hibernate robi dodatkowego SELECTa, przed każdym INSERTem nowego obiektu do bazy… Dlaczego to robi? Otóż gdy wywołujemy metodę save()
z encją, której id nie jest nullem, JPA „nie wie”, czy ma już dane o obiekcie w bazie. Dlatego nie może zdecydować, czy powinien użyć polecenia INSERT, czy UPDATE. Na szczęście możemy zapobiec temu dodatkowemu INSERTowi, przejmując na siebie ciężar odpowiedzialności za ustalenie, czy obiekt encji jest nowy. W tym celu implementujemy interfejs Persistable<T>
:
public interface Persistable<ID> { @Nullable ID getId(); boolean isNew(); }
Dzięki metodzie isNew()
możemy „powiedzieć” JPA, że jesteśmy pewni, że obiekt encji nie istnieje jeszcze w bazie danych. Zobaczmy przykładową implementację w klasie Post
:
@Getter @NoArgsConstructor(access = PRIVATE) @Entity public class Post implements Persistable<UUID>{ @Id private UUID id; @Transient private boolean isNew; private UUID author; private String text; public static Post newInstance(UUID author, String text) { Post post = new Post(); post.id = UUID.randomUUID(); post.isNew = true; post.author = author; post.text = text; return post; } }
I gotowe, Lombok utworzył getter isNew()
, wypełniając kontrakt interfejsu. Pole oznaczone adnotacją @Transient
nie jest zapisywane ani odczytywane z bazy, a jeśli nie inicjujemy pola boolean to będzie zawsze miało domyślną wartość false. Czyli isNew()
zwróci true
tylko, jeśli obiekt pochodzi z metody statycznej newInstance()
.
Po stworzeniu posta widzimy w logach:
org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?)
Hibernate nie wykonał dodatkowego zapytania. Wprawdzie wzrost wydajności może być różny w zależności od przypadku, ale koszt jego zastosowania jest właściwie zerowy, więc to świetny tip do optymalizacji JPA 🙂
Używaj właściwej strategii generowania id
Dla id zachowujących sekwencję, które muszą być generowane przez bazę danych ważną kwestią dla wydajności jest strategia ich generowania. Dostępne strategie są zdefiniowane w enumie GenerationType
:
IDENTITY
– generowanie id przez kolumnę IDENTITY w tabeli. Jest okej, chyba że chcemy używać JDBC batching, na który ta strategia nie pozwala.TABLE
– generowanie id przez oddzielną tabelę, definiowaną specjalnie do tego celu. Niestety, zakłada oddzielne połączenie do bazy tylko po to, aby pobrać kolejne id.SEQUENCE
– do generowania id zakłada sekwencję bazodanową. Trochę bardziej skomplikowane niżIDENTITY
, jednak bardziej elastyczne. Domyślnie również zakłada nowe połączenie, aby pobrać nowe id. Oferuje jednak optymalizacje, które pozwalają pobrać kilka id za jednym razem, redukując liczbę zapytań. Pobieranie zapasu nowych id pozwala też na JDBC batching (patrz następną wskazówkę).
Optymalna strategia generowania id
Załóżmy, że w encji Post
zaimplementowaliśmy id generowane przez Hibernate:
@Id @GeneratedValue(strategy = GenerationType.TABLE) private Long id;
Przy włączonym logowaniu SQL w Hibernate utwórzmy nowy Post
i zajrzyjmy w logi:
org.hibernate.SQL: select tbl.next_val from hibernate_sequences tbl where tbl.sequence_name=? for update org.hibernate.SQL: update hibernate_sequences set next_val=? where next_val=? and sequence_name=? org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?)
Czyli przy strategii TABLE
wykonujemy aż DWA dodatkowe zapytania do bazy, kiedy dodajemy do niej nową encję. Za to po zamienieniu strategii na IDENTITY
logi wyglądają już tak:
org.hibernate.SQL: insert into post (id, author, text) values (null, ?, ?)
Tutaj, baza używa do generowania id specjalnej kolumny IDENTITY
i nie potrzebuje dodatkowych zapytań.
Tylko IDENTITY? Cóż, nie zawsze
Przyjrzyjmy się jeszcze ostatniej strategii, czyli SEQUENCE
. Pozwala ona na dalsze optymalizacje JPA, jak np. batching, co opisałem w kolejnym punkcie. Po podmianie strategii w klasie Post
, logi po utworzeniu nowego posta wyglądają tak:
org.hibernate.SQL: call next value for hibernate_sequence org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?)
Niestety, znowu mamy dodatkowe zapytanie do bazy o id, poza tym nazwa hibernate_sequence nie brzmi zbyt unikalnie – nie chcemy chyba, aby id dla innych tabel były generowane przez ten sam sequence 😉 Gdzie tu optymalizacja JPA? Ewidentnie potrzeba tutaj trochę więcej konfiguracji:
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "post_sequence") @SequenceGenerator(name = "post_sequence", allocationSize = 5) private Long id;
Tutaj już poprawiliśmy nazwę sekwencji. Poza tym, jeśli użyjemy allocationSize
większego niż 1 to aplikacja zacznie pobierać po kilka id naraz, używając tzw. pooled optimizer. Więcej do poczytania o tym, jak działają optimizery i jakie są ich rodzaje, poczytasz tutaj.
Żeby zobaczyć efekty naszych zmian, będziemy musieli utworzyć kilka postów. W tym celu napiszmy test:
@SpringBootTest @AutoConfigureMockMvc class JpaTipsDemoApplicationTests { @Autowired private MockMvc mockMvc; private String userId = UUID.randomUUID().toString(); @Test void shouldCreateTenPosts() throws Exception { for (int i = 0; i < 10; i++) { this.mockMvc.perform( post("/posts") .header("userId", userId) .content(String.format("{\"text\": \"Sample post %d\"}", i)) .contentType(MediaType.APPLICATION_JSON)); } } }
Powyższy test utworzy 10 postów, a po jego wykonaniu widzimy w logach:
org.hibernate.SQL: call next value for post_sequence org.hibernate.SQL: call next value for post_sequence org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: call next value for post_sequence org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?)
Czyli sequence optimizer pobiera sobie z bazy danych 5 kolejnych id, i za każdym razem, gdy te 5 zapasowych id zostanie „naruszone”, pobiera sobie dodatkowe 5. Widać, że znacznie zmniejsza to liczbę połączeń do bazy. Oprócz tego sprawia, że aplikacja dysponuje teraz konkretną pulą id do wykorzystania, dzięki czemu może wysyłać kolejne INSERTy w grupach (batchach).
Używaj JDBC batch processing
Kolejna mała optymalizacja JPA to JDBC batch processing. Ta technologia pozwala na wysyłanie modyfikujących zapytań do bazy danych w grupach. Znacznie przyspiesza to działanie aplikacji. Można batchować nie tylko zapytania INSERT, ale tutaj właśnie na nich się skupimy. Żeby poruszyć JDBC batch processing najpierw musimy zrozumieć, czym jest persistence context – jest co coś w rodzaju „nakładki” na bazę danych, którą widzi aplikacja i spełnia dwie role:
- jest formą cache, do którego wpadają wszystkie obiekty, które podnieśliśmy z bazy danych w danej sesji – jest to cache pierwszego poziomu. Więcej o cache w Hibernate możesz poczytać w tym artykule.
- przechowuje obiekty, które chcieliśmy zapisać do bazy danych i dokonuje zapisu w momencie, gdy transakcja, w ramach której funkcjonuje, jest zsynchronizowana (czyli najczęściej na jej końcu). Domyślnie w aplikacji Spring Boot transakcja bazodanowa jest tworzona na okres od początku zapytania do zwrócenia odpowiedzi i w takim zakresie działa JDBC batching.
Żeby zaobserwować batching zapytań dodajmy uproszczoną możliwość oznaczania innych użytkowników w postach:
@Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; private final NotificationRepository notificationRepository; public UUID createPost(UUID userId, CreatePostDto dto) { final Post post = Post.newInstance(userId, dto.getText()); final UUID postId = postRepository.save(post).getId(); if (dto.getMentionedUsers() != null && !dto.getMentionedUsers().isEmpty()) { final List<Notification> notifications = dto.getMentionedUsers().stream() .map(user -> new Notification(user, "You are mentioned in a post!", String.format("https://localhost:8080/posts/%s", post.getId()))) .collect(Collectors.toList()); notificationRepository.saveAll(notifications); } return postId; } // … }
Jak widać, wyciągamy po prostu oznaczonych użytkowników z CreatePostDto
i tworzymy dla nich powiadomienia z linkiem do posta. Tak wygląda klasa Notification
:
@Entity @RequiredArgsConstructor class Notification { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "notification_sequence") @SequenceGenerator(name = "notification_sequence", allocationSize = 5) private Long id; private final UUID userId; private final String message; private final String url; }
Jeśli nie wiesz, dlaczego użyliśmy takiej strategii generowania id, zobacz poprzedni punkt. Napiszmy teraz test, w którym dodamy nowy post i oznaczymy w nim kilku użytkowników:
@Test void shouldCreatePostWithMentionedUsers() throws Exception { this.mockMvc.perform( post("/posts") .header("userId", userId) .content(""" {"text": "Sample post", "mentionedUsers": [ "5d6101d3-d7b2-4eab-aedf-9cacf19713bb", "90d32548-b10d-4746-bbfe-8494217e504d", "1ed15d33-74b3-4271-80f0-231762158790", "9ce98893-71fb-4061-ba63-4bbd5bcb5aab" ]} """) .contentType(MediaType.APPLICATION_JSON)); }
Niestety, logi z Hibernate nie wystarczą, żeby zaobserwować działanie batchowania naszych INSERTów. Aby to zrobić, musimy zaimportować dodatkową bibliotekę udostępniającą możliwość zdefiniowania proxy śledzącego użycie DataSource
:
<dependency> <groupId>net.ttddyy</groupId> <artifactId>datasource-proxy</artifactId> <version>1.4.1</version> </dependency>
Aby użyć naszego nowego importu i opakować DataSource
w proxy, możemy użyć customowy BeanPostProcessor
, który jest świetnym sposobem na złapanie zainicjalizowanego DataSource
i jego modyfikację.
@Component public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(final Object bean, final String beanName) throws BeansException { return bean; } @Override public Object postProcessAfterInitialization(final Object bean, final String beanName) throws BeansException { if (bean instanceof DataSource dataSourceBean) { return ProxyDataSourceBuilder.create(dataSourceBean) .name("BatchLogger") .asJson().countQuery() .logQueryBySlf4j(SLF4JLogLevel.INFO).build(); } return bean; } }
Teraz odpalmy napisany test i zajrzyjmy w logi:
n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["insert into post ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["call next value for notification_sequence"] ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["call next value for notification_sequence"] ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["insert into notification ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["insert into notification ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["insert into notification ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["insert into notification ...
Zapytania nie są batchowane, za każdym razem jest do bazy wrzucane jedno Notification
. Przez to jest generowane dużo niepotrzebnego ruchu między aplikacją a bazą – postarajmy się to zmienić. Mając już właściwą strategię generowania id w Notification
, dodajmy do application.yml
konfigurację:
spring.jpa.properties.hibernate.jdbc.batch_size: 5
Logi po powtórnym odpaleniu wcześniej napisanego testu:
n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":true, "querySize":1, "batchSize":1, "query":["insert into post ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["call next value for notification_sequence"] ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":false, "querySize":1, "batchSize":0, "query":["call next value for notification_sequence"] ... n.t.d.l.l.SLF4JQueryLoggingListener: {"name":"BatchLogger", [...] "batch":true, "querySize":1, "batchSize":4, "query":["insert into notification ...
Widać, że teraz zapytania dodające nowe Notification
są już batchowane, zamiast kilku oddzielnych zapytań został wysłany do bazy danych jeden batch.
Podsumowanie
Mam nadzieję, że te trzy tipy pozwoliły Wam chociaż trochę rozszerzyć wiedzę z JPA. Mimo, że nie zawsze stosowalne, często mogą prowadzić do sporych zysków w wydajności aplikacji. Optymalizacja JPA to często najłatwiejsza droga optymalizacji całej aplikacji.