Popołudniowy pik użytkowników, Twoja apka potrzebuje coraz więcej czasu na odpowiedź. Sytuacja powtarza się codziennie, prosta diagnostyka pokazuje, że bottleneckiem jest baza danych. Jeśli jeszcze nie używasz Hibernate Cache, to może być najlepszy moment, aby o tym pomyśleć 🙂
Kod wykorzystany w tym artykule zawiera to repozytorium
Poziomy cache w Hibernate
Cache w Hibernate przechowuje ostatnio zwrócone obiekty w pamięci, gdzie są szybko i łatwo dostępne bez ponownego odpytywania bazy danych. Must-have w aplikacjach obsługujących duży ruch, w których obciążenie bazy zaczyna być problemem. Są 2 poziomy cache w Hibernate, istnieje też trzeci rodzaj cache, Query Cache. Gdy aplikacja żąda zasobu o danym id, Hibernate sprawdza najpierw L1 cache, potem L2 cache (jeśli istnieje), i dopiero jeśli w nich nie znajdzie zasobu, odpytuje bazę danych. Przy bardziej złożonym query Hibernate zajrzy do Query Cache, które mapuje zapytania na id obiektów, które będą ich wynikami.

L1 Cache
Z pierwszego poziomu cache korzystamy często, nawet o tym nie wiedząc – jest domyślnie włączony przez Hibernate i nie da się go wyłączyć. Wyniki zapytań żyją tu dokładnie tyle, co sesja Hibernate i są przechowywane w obiekcie EntityManagera. W aplikacji opartej na Spring Boot, sesja jest domyślnie tworzona przez mechanizm OSIV (Open Session In View). Sesja trwa od początku do końca requestu.
Załóżmy, że nasza apka to prosty portal społecznościowy. Udostępnia operacje tworzenia i edycji postów:
@RestController @RequestMapping("/posts") @RequiredArgsConstructor public class PostController { private final PostService postService; private final SecurityService securityService; @PostMapping public ResponseEntity<String> createPost( @RequestHeader UUID userId, @RequestBody CreatePostDto dto) { final UUID postId = postService.createPost(userId, dto); return ResponseEntity.status(CREATED).body(postId.toString()); } @PutMapping("/{postId}") public ResponseEntity<Void> editPost( @RequestHeader UUID userId, @PathVariable UUID postId, @RequestBody EditPostDto dto) { securityService.checkPostEditAccess(userId, postId); postService.editPost(postId, dto); return ResponseEntity.ok().build(); } // … }
Przyjrzyjmy operacji edycji. SecurityService musi odczytać Post z bazy danych, aby sprawdzić czy użytkownik jest jego właścicielem.
@Service @RequiredArgsConstructor public class SecurityService { private final PostRepository postRepository; public void checkPostEditAccess(UUID userId, UUID postId) { Post post = postRepository.findById(postId) .orElseThrow(NotFoundException::new); if (!post.getAuthor().equals(userId)) { throw new InvalidAccessException(); } } }
PostService odpytuje o Post o tym samym ID:
@Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; public void editPost(UUID postId, EditPostDto dto) { final Post post = postRepository.findById(postId) .orElseThrow(NotFoundException::new); post.edit(dto.getText()); postRepository.save(post); } // … }
Czy to znaczy, że gdy PostService zażąda posta do edycji, Hibernate znów odpyta bazę danych?
Dzięki L1 cache, nie odpyta (jeśli obie operacje będą w tej samej sesji). Ale żeby nie być gołosłownym – przetestujmy to! W tym celu dodajmy logowanie zapytań sql w application.yml:
logging.level.org.hibernate.SQL: DEBUG
W naszym teście utwórzmy nowy post a potem spróbujmy go edytować:
@SpringBootTest @AutoConfigureMockMvc public class TestingWebApplicationTest { @Autowired private MockMvc mockMvc; private String userId = UUID.randomUUID().toString(); @Test public void shouldEditPost() throws Exception { final String postId = this.mockMvc.perform( post("/posts") .header("userId", userId) .content("{\"text\": \"Sample post\"}") .contentType(MediaType.APPLICATION_JSON)) .andReturn().getResponse().getContentAsString(); this.mockMvc.perform( put("/posts/" + postId) .header("userId", userId) .content("{\"text\": \"Edited post\"}") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); } }
Po wykonaniu testu zajrzyjmy w logi:
org.hibernate.SQL: insert into post (author, text, id) values ... org.hibernate.SQL: select post0_.id as id1_0_0_, post0_.author... org.hibernate.SQL: update post set author=?, text=? where id=?
Jak widać, Hibernate odpytał bazę o post tylko raz. Spróbujmy więc skonfigurować aplikację tak, żeby metoda checkPostEditAccess() dostała inną sesję Hibernate, niż metoda editPost(). Wtedy encja Post powinna przestać być cache’owana pomiędzy tymi dwiema operacjami. Aby to osiągnąć, wyłączmy mechanizm OSIV dodając w application.yml:
spring.jpa.open-in-view: false
Teraz musimy już tylko dodać adnotację @Transactional nad definicjami każdej z tych metod, aby zdefiniować nowe konteksty persystencji, w których będą funkcjonowały oddzielne sesje Hibernate.
Odpalmy test jeszcze raz i zajrzyjmy w log:
org.hibernate.SQL: insert into post (author, text, id) values ... org.hibernate.SQL: select post0_.id as id1_0_0_, post0_.author... org.hibernate.SQL: select post0_.id as id1_0_0_, post0_.author... org.hibernate.SQL: update post set author=?, text=? where id=?
I udało się, widzimy 2 zapytania select, jedno w checkPostEditAccess(), a drugie w editPost().
L2 Cache
Żeby dalej zoptymalizować zapytania w naszej apce, włączmy cache drugiego poziomu. Wrzucamy konfigurację do application.yml:
spring.jpa.hibernate.cache.use_second_level_cache: true
Teraz nad klasą encji Post, której obiekty chcemy cache’ować dodajemy adnotacje:
@Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Post implements Persistable<UUID> { // …
Możemy wybrać z kilku wartości CacheConcurrencyStrategy:
- READ_ONLY – przy próbie edycji, cache wyrzuca wyjątek. Dlatego używamy go do encji, które nigdy się nie zmieniają.
- NONSTRICT_READ_WRITE – update cache’a następuje, gdy zakończy się transakcja zmieniająca dane. Zapewnia słabą spójność danych pomiędzy bazą a cache. Poza tym, pomiędzy zapisem do bazy a aktualizacją cache są serwowane nieaktualne dane.
- READ_WRITE – na czas trwania update zakłada na obiekt w cache soft lock. To znaczy, że każde zapytanie o tak zablokowany obiekt będzie omijało cache. Dzięki temu zostanie zapewniona mocna spójność danych.
- TRANSACTIONAL – każdy update cache odbywa się w rozproszonej transakcji z bazą danych. Zapewnia największą spójność, za to największym kosztem.
Tutaj użyliśmy opcji CacheConcurrencyStrategy.READ_ONLY – więcej o tej strategii możesz przeczytać tutaj.
Ponieważ Hibernate sam nie dostarcza L2 Cache, a jedynie definiuje interfejs, musimy jeszcze zaimportować providera. Dla potrzeb prezentacji najłatwiej będzie użyć EhCache:
<dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.6.3</version> <scope>runtime</scope> </dependency>
Istnieją też inni providerzy, np. jeśli chcemy, aby nasza aplikacja była skalowalna poziomo, możemy użyć redisson-hibernate, który zezwala na przechowywanie cache w bazie Redis.
Odpalmy nasz test jeszcze raz, żeby zobaczyć efekt działania cache. Widzimy w logach:
org.hibernate.SQL: insert into post (author, text, id) values ... org.hibernate.SQL: update post set author=?, text=? where id=?
A więc pomiędzy insertem a updatem nie odpytaliśmy bazy ani razu! Post został zapisany do cache i stamtąd podniesiony w celu edycji. Cache drugiego poziomu najwyraźniej działa.
Query Cache
Hibernate Query Cache przydaje się, gdy nasza aplikacja bardzo często wykonuje pewne query, przy czym encje, o które odpytujemy bazę, nie powinny zmieniać się zbyt często. Każda zmiana w tabeli, którą odpytujemy, unieważnia nasz cache – nawet, gdy aktualizowana encja nie znajduje się w wyniku zapytania.
Query Cache nie przechowuje obiektów – mapuje tylko zapytania na listy id obiektów które są jego wynikiem. Aby Query Cache miało więc sens, powinniśmy użyć też L2 Cache.
Aby włączyć Query Cache dodajemy do application.yml:
spring.jpa.properties.hibernate.cache.use_query_cache: true
Musimy też oznaczyć query, którego wynik chcemy cache’ować:
public interface PostRepository extends JpaRepository<Post, UUID> { @QueryHints({@QueryHint(name= HINT_CACHEABLE, value = "true")}) List<Post> findAll(); }
Napiszmy też prosty test, który będzie symulował pobieranie wszystkich postów kilka razy:
@Test public void shouldGetAllPosts() throws Exception { this.mockMvc.perform( post("/posts") .header("userId", userId) .content("{\"text\": \"Sample post\"}") .contentType(MediaType.APPLICATION_JSON)); for (int i = 0; i < 4; i++) { this.mockMvc.perform( get("/posts")) .andExpect(status().isOk()); } }
Logi testu:
org.hibernate.SQL: insert into post (author, text, id) values (?, ?, ?) org.hibernate.SQL: select post0_.id as id1_0_, post0_.author as author2_0_, post0_.text...
Mimo, że test odpytuje o wszystkie posty 4 razy, w logach widzimy tylko jeden select. Jeśli wyłączymy Query Cache, select na pewno pojawi się 4 razy.
Podsumowanie
Hibernate Cache może bardzo zmniejszyć ilość zapytań do bazy danych i zoptymalizować zużycie zasobów naszej aplikacji. Powinniśmy jednak zawsze pamiętać, że nie ma nic za darmo i za oczywiste zyski z używania cache płacimy cenę w postaci zużycia zasobów i ryzyka operowania na przestarzałych danych. Aby podejmować świadome decyzje o używaniu cache, warto monitorować jego działanie, np. za pomocą odpowiednich metryk.