Techniki snapshotowania i dlaczego nie warto ich używać
Cześć!
Sprawdziłem ostatnio z ciekawości, jakie są najpopularniejsze frazy wyszukiwane w dokumentacji EventStoreDB. Okazuje się, że w czołówce plasuje się zapytanie o to, jak robić snapshoty. Nie jest to dla mnie wielkim zaskoczeniem, bo jest to też pytanie, które słyszę regularnie.
Tak jak napisałem w poprzednim mailu w Event Sourcing każda operacja biznesowa kończy się nowym zdarzeniem. Zdarzenie jest dodane do event store. Gdy chcemy wykonać logikę biznesową, to najpierw powinniśmy pobrać zdarzenia dla danego obiektu biznesowego (strumienia). Następnie odtworzyć jego stan aplikując wszystkie zdarzenia jednym po drugim. Dopiero wtedy, na bazie aktualnego stanu weryfikujemy i wykonujemy logikę biznesową.
Zwykle nie jest to problemem. Pobranie nawet kilkunastu czy kilkudziesięciu małych zdarzeń nie jest wielkim narzutem czasu. Jest to jednak niezaprzeczalnie więcej niż pobranie jednego rekordu. W artykule “Why a bank account is not the best example of Event Sourcing?” przedstawiłem następujące wyliczenie:
Przyjmijmy, że założyłem konto bankowe w wieku 18 lat. Licząc, że dokonuję 3 transakcje dziennie. Jeśli przemnożymy te liczby (3 x 17 x 365), to wyjdzie nam 18 615 transakcji. Czy do wyliczenia stanu naszego konta potrzebujemy pobierać zawsze te wszystkie transakcje? Pierwszą myślą, która może przyjść nam do głowy, jest zapisywanie stanu aktualnego gdzieś “na boku”. Zamiast pobierać te wszystkie zdarzenia, moglibyśmy po prostu pobierać jeden rekord i na nim bazować. To jest właśnie Snapshot. Jak każda optymalizacja, gdy jest przedwczesna, jest źródłem wszystkiego zła. Dlaczego? O tym za chwilę.
W swoich artykułach i wystąpieniach mam jeden ulubiony przykład: kasa w Biedronce. Czy dla kasy w Biedronce liczymy stan wszystkich transakcji od początku, kiedy dana sklep został otwarty? Nie, zwykle stan w kasie wyliczany jest na koniec zmiany. Pracownik, który kończy zmianę, robi podsumowanie. Weryfikuje czy stan w systemie zgadza się z faktycznym stanem w kasie. Drukowany jest raport. Kolejny pracownik przychodzi do punktu i zaczyna swoją zmianę, na której koniec robione jest osobne podsumowanie. Podobnie jest w koncie bankowym. Regularnie co jakiś czas zamykany jest okres rozliczeniowy, na którego koniec archiwizowane są stare dane i rozpoczynamy ponownie od podsumowanego bilansu.
Każdy model przechowywania danych ma swoją specyfikę. Bazy relacyjne mają normalizację, bazy dokumentowe denormalizację, bazy klucz-wartość taktyki budowy klucza. Event Sourcing ma również swoją specyfikę. Najważniejszą i najbardziej charakterystyczną jest wpływ czasu na przechowywane dane. Tradycyjnie, nie musimy zwracać uwagi, na to, jaka ilość operacji na danym obiekcie wpływa na jego późniejsze użycie. W Event Sourcing dzięki historii zdarzeń zyskujemy audytowalność i łatwiejszą diagnostykę. Zyskujemy też dodatkowy aspekt, który musimy uwzględnić przy modelowaniu danych: cykl życia w czasie.
Wracając do naszej kasy w Biedronce, zamiast modelować nasz strumień (agregat) jako wszystkie zdarzenia go dotyczące (np. transakcje, paragony, rozpoczęcie i zakończenie zmian) moglibyśmy go rozbić na mniejsze, krócej żyjące byty. Jak? Mógłby to być np.:
- dzień rozliczeniowy (czyli od otwarcia do zamknięcia sklepu),
- zmiana kasjerki/a,
- każdy paragon osobno.
Jeżeli dopytamy się “biznesu” to może się okazać, że tak to też działa w rzeczywistości. Bardzo często nasze techniczne założenia są nadmiernymi uproszczeniami w stosunku do rzeczywistości. Dlatego warto drążyć i prosić Biznes, żeby przynosił nam problemy, a nie rozwiązania.
Modelując strumień jako zdarzenia, które zaszły na danej zmianie, wiele może nam się uprościć. Zdarzeń będzie dużo mniej, łatwiej będzie nimi zarządzać, a wydajność będzie lepsza. Cykl życia strumienia nie tylko wpływa na wydajność, ale przede wszystkim na łatwość w utrzymaniu. Pisałem o tym w artykule “How to (not) do event versioning”. Jeżeli nasz strumień będzie żył krótko, to mniej będziemy musieli się martwić o jego wersjonowanie. Rzadko interesujemy się rekordami, które są usunięte/zarchiwizowane. Dlatego też, gdy mamy wdrożenie i mamy jakieś istniejące zdarzenia ze starym schematem to będziemy musieli je wspierać co najwyżej przez cykl życia ich strumienia. Dzięki temu możemy rozbić nasze wdrożenie na “dwa takty”. Najpierw wdrażamy wersję, która wspiera obydwa schematy i oznaczamy stary jako nieaktualny. Następnie przy kolejnym wdrożeniu pozbywamy się kodu odpowiadającego za stare zdarzenia, bo nie ma już żadnych aktywnych instancji, które go mają.
Jakie są wady rozbijania strumieni na mniejsze? Czasem może się okazać to sztuczne. Jeśli byśmy rozbili nasz strumień, nie tylko na zmianę, ale też na godzinę to może się okazać, że nie odzwierciedla to faktycznego przepływu biznesowego. Zbyt małe strumienie wnoszą też większy narzut zarządczy. Czasem wymagania wydajnościowe mogą też powodować, że musimy ciąć wszystko, co nadmiarowe.
W tej sytuacji Snapshoty mogą pomóc. Sugerowałbym jednak, traktować to jako ostateczność, gdy nic innego nie pomaga. Czasem też może się okazać po czasie, że źle zaprojektowaliśmy model. Wtedy musimy się jakoś ratować, zanim uda nam się poprawić nasz model. Robiąc Snapshoty, musimy też pamiętać, że wpadamy w problem ich wersjonowania. Z racji, że nasz obiekt będzie żył długo (bo nie skróciliśmy jego cyklu życia) to ryzyko zmian schematu danych jest dużo większe. Jak zapewne wiesz, migracje nigdy nie są przyjemne.
Kluczowym aspektem jest też ocena, kiedy robić Snapshot. Popularne sposoby to:
- Po każdym zdarzeniu. Wtedy wiemy, że Snapshot ma zawsze ostatni stan, możemy na nim bazować bez pobierania dodatkowo zdarzeń.
- Po zdarzeniu z danym typem, np. zakończenie zmiany w kasie w biedronce.
- Snapshot co N zdarzeń. Wtedy wiemy, że będziemy musieli pobrać snapshot plus maksymalnie N zdarzeń, które zaszły po nim, żeby uzyskać stan aktualny,
- Co wybrany okres - np. raz dziennie. Zwany również wzorcem “End of Booking Day” lub “Passage of time”.
Gdzie i jak zapisywać Snapshoty? Tutaj ogranicza Cię już tylko Twoja wyobraźnia. No i używane w projekcie technologie. Możesz je zapisać np. jako:
- zdarzenia w tym samym lub osobnym strumieniu.
- w osobnej bazie danych.
- w pamięci (popularne w systemach opartych na aktorach).
- w cache jak np. Redis.
Zyskujemy wtedy możliwość ustawienia czasu życia (TTL), czyli np. że Snapshot będzie żył 1d, potem zostanie usunięty i trzeba go będzie odbudować i zapisać ponownie.
W zeszłym tygodniu rozpocząłem prace nad przykładami tych technik w EventStoreDB. Do zobaczenia tutaj: https://github.com/oskardudycz/EventSourcing.NodeJS/pull/11. Prace są jeszcze mocno robocze, ale liczę, że w kolejnym tygodniu będę miał większą aktualizację na ten temat.
Moja rada, więc jest, aby unikać Snapshotów, jeśli tylko można. Potrzeba ich użycia bardzo często oznacza, że popełniliśmy wcześniej błąd w modelowaniu. Warto wtedy wrócić do tablicy i przeanalizować nasze rozwiązanie. Jeśli nie mamy wyjścia, ratujmy się Snapshotami. Pamiętajmy jednak, że nie będzie to bezbolesne. Wprowadza to zdecydowanie dużo więcej skomplikowania, niż nam się to wydaje na początku.
Co o tym sądzisz?
Pozdrawiam!
Oskar
P.S. Zachęcam do lektury mojego ostatniego artykułu na blogu “How using events helps in a teams’ autonomy” oraz nowego Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#3rd-may-2021.