Sii Polska

SII UKRAINE

SII SWEDEN

  • Szkolenia
  • Kariera
Dołącz do nas Kontakt
Wstecz

Sii Polska

SII UKRAINE

SII SWEDEN

Wstecz

20.10.2025

Spring x MongoDB – jak zabezpieczyć swoje transakcje

20.10.2025

Spring x MongoDB – jak zabezpieczyć swoje transakcje

Niedawno zespół Coma wrócił po latach, by zagrać kilka koncertów. Wskrzeszona popularność muzyków rozgrzewa fanów do czerwoności. W końcu wybija godzina zero. Tysiące fanów odświeża stronę. Bilety znikają błyskawicznie – jak tostery na Black Friday. Został jeden, który widzą Adam i Ewa. Oboje szybko klikają „Rezerwuję i płacę”. Klik! Strona przeładowuje się, pojawia się kółko ładowania… a w tle, w serwerowni, rozgrywa się dramat, od którego zależy, czy to Adam, czy Ewa lub którekolwiek z nich będzie mogło śpiewać „Leszka Żukowskiego” pod sceną… I czy w ogóle uda się im o tym dowiedzieć.

W tym ułamku sekundy system musi odjąć bilet ze stanu, utworzyć rezerwację i przetworzyć płatność. A co, jeśli oba żądania zdążą zobaczyć, że ostatni bilet jest dostępny? Dwie osoby mają być obciążone płatnością? Albo jeśli zewnętrzny operator płatności odpowie z błędem lub po prostu upłynie jego czas na odpowiedź?

Jeśli bilet zostały już zarezerwowany, ale płatność się nie powiodła, ani Adam ani Ewa nie dostaną biletu, ale nikt inny też ich nie kupi. Zostają zamrożone w cyfrowej próżni. To scenariusz, który spędza sen z powiek każdemu – nie tylko fanom Comy, ale też nam – deweloperom.

Jak więc zaprojektować system, który z gracją obsługuje takie sytuacje? Odpowiedzią jest połączenie gwarancji transakcyjnych nowoczesnych baz danych z wzorcami projektowymi stworzonymi dla świata systemów rozproszonych.

W tym artykule, analizując kod demonstracyjnego projektu, zobaczymy, jak to zrobić dobrze.

Po lekturze tego artykułu będziesz wiedzieć

  • Jakie są fundamentalne mechanizmy transakcyjne w MongoDB.
  • Jak radzić sobie z konfliktami współbieżności, używając izolacji migawkowej.
  • Jak łączyć silne gwarancje atomowości (ACID) ze wzorcami zapewniającymi ostateczną spójność w jednym procesie biznesowym.
  • Czym jest wzorzec Transactional Outbox i jak rozwiązuje problem zawodnej komunikacji w systemach rozproszonych.
  • Jak zarządzać wieloetapowymi procesami za pomocą wzorca Saga w modelu choreografii, aby zarządzać długotrwałymi procesami biznesowymi.
  • Jak zapewnić poprawne i wydajne przetwarzanie zadań w tle w środowisku wieloinstancyjnym.
  • Czym jest ostateczna spójność i dlaczego jest to świadomy, architektoniczny kompromis.

Architektura

Zacznijmy od omówienia z lotu high levelowego architektury, która będzie realizowana w ramach tej demonstracji. Cały kod jest dostępny do pobrania i własnoręcznego uruchomienia.

Architektura
Ryc. 1 Architektura

Na pierwszy rzut oka widzimy trzy główne akty tego procesu:

  • Atomowe złożenie zamówienia: Wszystko zaczyna się od akcji klienta, która inicjuje jedną, niepodzielną transakcję. W ramach tej operacji system jednocześnie rezerwuje zasoby (np. bilety), tworzy dokument rezerwacji i – co kluczowe – zapisuje zadanie płatności w bazie danych. Ten ostatni krok to serce wzorca Outbox, który zostanie omówiony szerzej w dalszej części.
  • Asynchroniczne przetwarzanie płatności: W tle działa niezależny komponent, Payment Scheduler. Działa on jak listonosz, który regularnie sprawdza bazę danych w poszukiwaniu zaplanowanych zadań. Gdy znajdzie takie zadanie, podejmuje próbę wykonania płatności, komunikując się z zewnętrznym systemem. Po otrzymaniu odpowiedzi publikuje zdarzenie adekwatne do rezultatu: PaymentSucceededEvent lub PaymentFailedEvent.
  • Finalizacja lub Kompensacja: System w sposób reaktywny nasłuchuje na opublikowane zdarzenia. Sukces płatności uruchamia transakcję potwierdzającą, która finalizuje rezerwację. Porażka z kolei inicjuje transakcję kompensującą, która „sprząta” po nieudanej operacji – na przykład anuluje rezerwację i zwalnia zablokowane wcześniej bilety, przywracając system do spójnego stanu – to miejsce, w którym mruga do nas filozofia Wzorca Sagi.

Implementacja

Wszystko zaczyna się od prostego żądania HTTP, które trafia do naszego kontrolera. Widzimy tu standardowy endpoint, który przyjmuje żądanie i deleguje całą pracę do serwisu. Zwróć uwagę na status HttpStatus.ACCEPTED. To subtelna wskazówka dla klienta, że jego prośba została przyjęta do przetworzenia, ale proces jeszcze się nie zakończył.

Zarezerwuj wszystko albo nic

@RestController
@RequestMapping("/reservations")
class ReservationController(private val reservationService: ReservationService) {
    // ...
    @PostMapping
    fun createReservation(@RequestBody request: ReservationRequest): ResponseEntity<ReservationResponse> {
        logger.info("Received reservation request: $request")
        return ResponseEntity.status(HttpStatus.ACCEPTED) // Przyjęto do realizacji
            .body(reservationService.createReservation(request).toReservationResponse())
    }
}

Stąd przechodzimy do serwisu, który zaczyna sekwencję atomowych kroków:

class ReservationService(...) {

   @Transactional
   @Retryable(value = [DataIntegrityViolationException::class], maxAttempts = 3, backoff = Backoff(delay = 2000, multiplier = 2.0))
    fun createReservation(reservationRequest: ReservationRequest){

        val requestedEvent = eventRepository.findById(reservationRequest.eventId)
            .orElseThrow { EventNotFound() }
            .toModel()

        if (requestedEvent.areTicketsInsufficient(reservationRequest.ticketCount)) {
            logger.error("Not enough tickets available for event: ${requestedEvent.eventId}")
            throw NotEnoughTicketException()
        }

        val reservedTickets = requestedEvent.reserveTickets(reservationRequest.ticketCount)
        eventRepository.save(requestedEvent.toDocument())
        logger.info("Reserved tickets: $reservedTickets for event: ${requestedEvent.eventId}")

        val reservation = reservationRepository.save(
            ReservationDocument(
                eventId = reservationRequest.eventId,
                userId = reservationRequest.userId,
                ticketCount = reservationRequest.ticketCount,
                reservedAt = Instant.now(clock),
                reservationStatus = Reservation.ReservationStatus.PENDING.name
            )
        ).toModel()

        logger.info("Created reservation: $reservation for user: ${reservationRequest.userId}")

        paymentOutbox.save(
            PaymentOutboxTask(
                paymentStatus = PaymentStatus.SCHEDULED.name,
                paymentRequest = reservation.toPaymentRequest(reservationRequest, reservedTickets),
                createdAt = Instant.now(clock),
            )
        )
        logger.info("Payment outbox task scheduled for reservation: ${reservation.reservationId}")
    }

Adnotacja @Transactional ze Spring Data MongoDB to nasza obietnica dla systemu: wszystkie operacje zapisu do bazy danych wewnątrz tej metody (eventRepository.save, reservationRepository.save, paymentOutbox.save) muszą powieść się jako jedna, niepodzielna całość. Jeśli na którymkolwiek etapie wystąpi błąd (np. konflikt zapisu, bo ktoś inny kupił te bilety w tym samym momencie), cała transakcja zostanie wycofana. Stan bazy danych wróci do punktu wyjścia – tak, jakby żądanie Adama lub Ewy nigdy nie nadeszło. To jest nasza atomowość w pierwszej odsłonie.

Adnotacja @Retryable

Kluczowa także jest adnotacja @Retryable, która instruuje Springa, by w przypadku specyficznych błędów (np. jednoczesnej próby zapisu przez Adama i Ewę) automatycznie ponowił całą transakcję. To prosta, ale potężna technika zwiększająca odporność systemu na chwilowe konflikty. Aby w pełni zrozumieć działający tu mechanizm, musimy zajrzeć pod maskę transakcji w MongoDB. Działają one w oparciu o tzw. izolację migawkową (ang. snapshot isolation).

Zobaczmy, co dzieje się, gdy Adam i Ewa klikają przycisk w tej samej milisekundzie, walcząc o ostatni bilet.

  1. Dwie równoległe rzeczywistości: System rozpoczyna dwie osobne transakcje, a każda z nich tworzy własną „migawkę” (snapshot) stanu bazy danych. W obu tych „migawkach” widnieje jeden dostępny bilet. Co ważne, jedna transakcja nie blokuje odczytu drugiej. Obie, działając na swoich kopiach rzeczywistości, dochodzą do tego samego wniosku: „Ekstra, ostatni wolny bilet – rezerwuję!”.
  2. Kto pierwszy, ten lepszy: Załóżmy, że transakcja Adama jest o ułamek sekundy szybsza i jako pierwsza wykonuje operację zapisu (eventRepository.save). W tym momencie, mimo że transakcja nie jest jeszcze zatwierdzona, MongoDB zakłada na modyfikowanym dokumencie tymczasową blokadę zapisu. Gdy transakcja Ewy, działająca na swojej (już nieaktualnej) migawce, również próbuje wykonać zapis na tym samym dokumencie, natychmiast napotyka tę blokadę.
  3. Fail Fast: Transakcja Ewy nie czeka do końca. Od razu wie, że inna operacja już „zaklepała” ten dokument, więc natychmiast przerywa transakcję Ewy, rzucając błąd konfliktu zapisu (WriteConflictException), który w świecie Springa jest często mapowany na DataIntegrityViolationException.
  4. Automatyczne samoleczenie: I właśnie w tym momencie do gry wkracza @Retryable. Zamiast zwrócić Ewie niejasny błąd techniczny, mechanizm ten „łapie” wyjątek konfliktu i mówi: „Spokojnie, to częsty problem przy dużym ruchu. Spróbujmy jeszcze raz!”. Cała metoda createReservation dla Ewy jest uruchamiana ponownie.
  5. Nowa, aktualna rzeczywistość: Podczas drugiej próby transakcja Ewy tworzy nową migawkę danych. Tym razem w bazie nie ma już dostępnych biletów – zostały poprawnie zarezerwowane przez Adama. Logika wewnątrz metody (if (areTicketsInsufficient…)) natychmiast to wykrywa i transakcja kończy się w sposób kontrolowany, z jasnym komunikatem biznesowym: „Niestety, biletów już nie ma”.

Dzięki @Retryable system samoleczy się z przejściowego problemu technicznego i przekształca go w jednoznaczną i prawdziwą odpowiedź biznesową: „Przepraszamy, ktoś był szybszy”.

To mechanizm, który:

  • Zapobiega nadsprzedaży i chaosowi w danych.
  • Gwarantuje spójność biznesową nawet pod dużym obciążeniem.
  • Zapewnia znacznie lepsze doświadczenie użytkownika, budując jego zaufanie do platformy.

W skrócie to most, który łączy techniczną strategię obsługi konfliktów współbieżności z płynnym i logicznym doświadczeniem dla klienta końcowego.

Niezawodny listonosz – wzorzec Outbox

Zauważ, że w powyższej transakcji nie ma bezpośredniego wywołania API płatności. To celowy zabieg. Wywołanie zewnętrznego serwisu w trakcie trwania transakcji bazodanowej to proszenie się o kłopoty. Taki serwis może odpowiadać wolno, blokując cenne zasoby bazy danych, albo może być niedostępny, co spowodowałoby wycofanie całej naszej transakcji, mimo że rezerwacja biletów była możliwa.

Zamiast tego stosujemy wzorzec Outbox. W ramach samej transakcji tworzymy wszystkie potrzebne rezerwacje i zapisy – a na końcu zapisujemy zadanie, które dopiero ma się wykonać po scomitowaniu transakcji. Dzięki temu unikamy opisanych wyżej problemów oraz mamy pewność, że klient zostanie obciążone płatnością – wyłącznie – w sytuacji, gdy uda się atomowo zarezerwować dla niego wszystkie niezbędne zasoby.

paymentOutbox.save(
    PaymentOutboxTask(
        paymentStatus = PaymentStatus.SCHEDULED.name,
        paymentRequest = reservation.toPaymentRequest(reservationRequest, reservedTickets),
        createdAt = Instant.now(clock),
    )
)

Ponieważ ten zapis jest częścią transakcji atomowej, mamy żelazną gwarancję: jeśli rezerwacja została utworzona, to zadanie płatności również istnieje. Zrzuciliśmy z siebie ciężar natychmiastowego przetworzenia płatności, zamieniając go na gwarantowane zadanie „do wykonania w przyszłości”.

Dla szerszego kontekstu zapraszam do dokumentu.

Przetwarzanie asynchroniczne oraz choreografia sagi

Teraz czas na PaymentOutboxScheduler. To komponent, który działa w tle i w regularnych odstępach czasu sprawdza naszą „skrzynkę nadawczą”.

@Component
class PaymentOutboxScheduler(
    private val paymentOutboxRepository: PaymentOutboxRepository,
    private val paymentService: PaymentService
) {

    private val logger = logger {}

    @Scheduled(fixedDelayString = "PT5S")
    fun processPaymentOutbox() {
        val startedTask = paymentOutboxRepository.findAndStartPaymentTask() ?: return
        logger.info("Started payment outbox task: $startedTask for processing")
        val processedTask = paymentService.process(startedTask)
        paymentOutboxRepository.save(processedTask)
    }

    @Scheduled(fixedDelayString = "PT90S")
    @SchedulerLock(name = "outboxPaymentTaskCleanupLock", lockAtMostFor = "5m", lockAtLeastFor = "30s")
    fun cleanupStuckTasks() {
        paymentOutboxRepository.resetStuckTasks()
        paymentOutboxRepository.findDeadTasks().forEach { paymentService.notifyPaymentResult(it) }
    }
}

Adnotacja @Scheduled sprawia, że Spring regularnie wykonuje metodę processPaymentOutbox, której zadaniem jest:

  • znalezienie gotowego do wykonania zadania,
  • podjęcie zadania,
  • faktyczna próba obciążenia klienta płatnością w paymentService.process(startedTask),
  • wyemitowanie do systemu odpowiedniego eventu,
  • następnie zapisanie zadania ze statusem zakończone.

Warto poświęcić chwilę na omówienie samej metody znajdującej zadania. Pod spodem na poziomie repozytorium używa ona metody findAndModify. Została ona zaprojektowana jako jedna atomowa akcja na znalezionym dokumencie – dlatego właśnie zabezpiecza przed sytuacją w której więcej niż jedna instancja mogłaby spróbować podjąć to samo zadanie z bazy.

Nota architektoniczna: Wzorzec „pollera” bazodanowego, który cyklicznie skanuje kolekcję, jest eleganckim i prostym rozwiązaniem. Warto jednak pamiętać, że w systemach o ekstremalnie wysokiej przepustowości (tysiące zadań na sekundę) takie skanowanie może stać się wąskim gardłem. W takich scenariuszach rozważa się użycie dedykowanych, wyspecjalizowanych systemów kolejkowych (np. Kafka, RabbitMQ). Dla wielu przypadków (szczególnie kodu demonstrującego) pokazane rozwiązanie stanowi idealny kompromis pomiędzy prostotą implementacji a wydajnością.

Dla kontrastu, inne podejście zostało zastosowane w drugiej metodzie – cleanupStuckTasks.

Jest to pewnego rodzaju nadzorca, którego zadaniem jest cykliczne uruchamianie się w dużo rzadszym interwale oraz znajdowanie zadań, które z jakiegoś powodu utknęły w stanie PROCESSING. Zostają one wtedy zresetowane do stanu SCHEDULED, przez co znowu mogą być podjęte. Jest to kluczowy mechanizm samonaprawczy, ale wymaga jednej fundamentalnej gwarancji od zewnętrznego systemu płatności – idempotentności.

Co, jeśli zadanie faktycznie zostało wykonane, płatność pobrana, ale nasz serwis uległ awarii, zanim zdążył zaktualizować status w outboxie? Ponowne przetworzenie zresetowanego zadania nie może prowadzić do podwójnego obciążenia klienta. Dlatego nasze żądanie płatności musi zawierać unikalny klucz idempotencji (idempotencyKey), który gwarantuje, że operator płatności przetworzy daną transakcję tylko raz, niezależnie od liczby prób. Dodatkowo w momencie ponownego ich wykonania zostanie zwiększona wersja zadania.

Ten fakt jest kluczowy dla drugiej metody “findDeadTasks”, której zadaniem jest zlokalizowanie tych zadań, których wersja jest większa niż 3 oraz nadal są w stanie „PROCESSING”. Oznacza to, że z jakiegoś powodu nie mogą być zrealizowane, przez co trzeba je finalnie uznać za nieudane oraz zwolnić powiązane z nimi zasoby poprzez wyemitowanie zdarzenia o błędzie płatności.

Różnica między tymi cyklicznymi procesami nie polega jednak tylko na różnych interwałach, ale na demonstracji innego podejścia, jak można też obsługiwać cykliczne zadania w środowisku wielu instancji. Drugi proces przeszukuje całe kolekcje i operuje na listach znalezionych dokumentów.

Co się stanie, gdy uruchomi się w tym samym czasie na wielu instancjach? Mongo nie zapewnia atomowej metody findManyAndUpdate, więc trzeba najpierw znaleźć wszystkie spełniające kryteria dokumenty, a następnie je zmodyfikować. Nie chcemy sytuacji, w której każda instancja przeszukuje te same kolekcje. Dlatego potrzebny jest mechanizm, który sprawi, że w tylko jedna instancja może podjąć takie zaplanowane zadanie, a pozostałe powinny w tym czasie je ignorować.

To częste wyzwanie w środowisku wielu instancji, dlatego też powstały gotowe rozwiązania jak ShedLock, które rozwiązują ten problem. Przy minimalnej konfiguracji zostanie nam udostępniona adnotacja @SchedulerLock, która, jak nazwa wskazuje, działa dokładnie jak mechanizm Locków, który możemy znać z zarządzania wielowątkowością albo mechanizmów blokowania z baz danych. Zakłada ona po prostu locka w specjalnej, nowej kolekcji bazy danych dla tej konkretnej instancji. Dzięki temu inne instancje, chcąc podjąć to samo zadanie, nie będą mogły tego zrobić, jeśli będzie założony już lock.

Daje nam to pewność, że raz wczytane dane w procesie cyklicznym będą wczytane tylko przez jedną instancję na cykl oraz dodatkowo zyskujemy oszczędność zasobów wiedząc, że baza danych nie procesuje niepotrzebnie wiele razy tych samym zapytań.

Werdykt – transakcja potwierdzająca lub kompensująca

Serwis ReservationService nasłuchuje na dwa typy zdarzeń, które są rezultatem próby płatności. Każde z nich inicjuje osobną, atomową transakcję, która stanowi ostatni akt w naszym procesie biznesowym.

Scenariusz pozytywny – płatność udana:

@EventListener
@Transactional
fun handleSuccessfulPaymentEvent(event: SuccessPaymentEvent) {
    reservationRepository.confirm(event.reservationId)
    eventRepository.confirm(event.eventId, event.ticketIds)
    logger.info("Payment successful...")
}

Gdy płatność się powiedzie, uruchamiana jest nowa transakcja, która finalizuje proces. Zmienia status rezerwacji z PENDING na CONFIRMED, a bilety na SOLD. Od tego momentu bilety oficjalnie należą do Adama lub Ewy.

Scenariusz negatywny – płatność nieudana:

@EventListener
@Transactional
fun handleFailedPaymentEvent(event: FailedPaymentEvent) {
    reservationRepository.cancel(event.reservationId)
    eventRepository.release(event.eventId, event.ticketIds)
    logger.info("Payment failed... tickets released")
}

Jeśli płatność się nie powiedzie, uruchamiana jest transakcja kompensująca. To kluczowy element, który przywraca porządek w systemie. Rezerwacja jest anulowana, a co najważniejsze, bilety są zwalniane (eventRepository.release) i wracają do ogólnodostępnej puli, gotowe do zakupu przez kogoś innego.

Wzorzec Saga

Dlaczego ten wieloetapowy proces nazywamy Sagą (mimo, że bardzo prostą) na potrzeby prezentacji. Wzorzec Saga to mechanizm zarządzania spójnością danych w systemach rozproszonych bez potrzeby stosowania długotrwałych, blokujących transakcji rozproszonych, które są często niepraktyczne i słabo się skalują.

Proces rezerwacji idealnie wpisuje się w definicję Sagi, ponieważ:

  1. Składa się z sekwencji lokalnych transakcji, które razem tworzą kompletny proces biznesowy:
    • Transakcja rezerwuje bilet i tworzy zadanie w outboxie.
    • Schedulowana operacja przetwarza płatność.
    • Transakcja potwierdza rezerwację lub ją anuluje.
  2. Ma transakcje kompensujące: Jeśli któryś z kroków po pierwszej transakcji zawiedzie (np. płatność się nie powiedzie), Saga uruchamia akcje kompensujące (handleFailedPaymentEvent), które cofają skutki wcześniejszych kroków. W naszym przypadku jest to zwolnienie biletów i anulowanie rezerwacji.
  3. Zapewnia spójność na poziomie biznesowym: Chociaż system nie jest spójny w każdej milisekundzie (istnieje stan PENDING), Saga gwarantuje, że cały proces biznesowy zakończy się w jednym z dwóch spójnych stanów: albo rezerwacja jest w pełni opłacona i potwierdzona, albo jest anulowana, a bilety wracają do puli. Nie ma możliwości, by system utknął w stanie pośrednim.

Koordynacja Sagi

Warto w tym miejscu wspomnieć o dwóch głównych sposobach koordynacji Sagi:

  • Choreografia: Model zastosowany w tym scenariuszu. Poszczególne serwisy (lub komponenty) subskrybują zdarzenia emitowane przez inne i reagują na nie, wykonując swoją część pracy. Nie ma centralnego zarządcy. Komunikacja jest zdecentralizowana, co świetnie sprawdza się w prostszych przepływach.
  • Orkiestracja: W tym modelu istnieje centralny komponent (orkiestrator), który dyryguje całym procesem, mówiąc poszczególnym serwisom, jakie kroki mają wykonać. Jest to rozwiązanie lepsze dla bardziej złożonych sag z wieloma krokami i skomplikowaną logiką warunkową.

Dla przypadku rezerwacji biletów model choreografii jest idealny ze względu na swoją prostotę i odporność. Warto jednak pamiętać, że w miarę wzrostu liczby kroków w procesie biznesowym, model orkiestracji często staje się łatwiejszy w zarządzaniu, monitorowaniu i debugowaniu.

Deal z czasem, czyli czym jest „ostateczna spójność”

Warto na chwilę się zatrzymać i zrozumieć istotny kompromis, który został wybrany. Od momentu, gdy użytkownik kliknął „Kupuję”, do chwili, gdy jego rezerwacja została ostatecznie potwierdzona lub anulowana, minęło trochę czasu. W tym okresie system był w stanie przejściowym: bilety były zarezerwowane, ale rezerwacja miała status PENDING.

To jest właśnie ostateczna spójność (ang. eventual consistency). Gwarantujemy, że system w końcu dojdzie do stanu spójnego, ale niekoniecznie stanie się to natychmiast. Dla biznesu oznacza to, że użytkownik na ekranie mógł zobaczyć komunikat „Przetwarzamy Twoją rezerwację, prosimy czekać…”, zamiast natychmiastowego potwierdzenia. To świadoma decyzja architektoniczna. W zamian za ten krótki okres niepewności zyskujemy system, który jest nieporównywalnie bardziej odporny na awarie i lepiej się skaluje.

OUTRO

Prześledziliśmy całą podróż żądania – od jednego kliknięcia, przez sieć transakcji i zdarzeń, aż do ostatecznego, spójnego stanu. Zamiast budować monolityczny, kruchy proces, stworzyliśmy odporny i elastyczny przepływ pracy.

Połączyliśmy twarde gwarancje atomowości transakcji MongoDB, aby zapewnić integralność danych w krytycznym momencie rezerwacji, z asynchroniczną niezawodnością wzorców Outbox i Saga. To podejście pozwala nam budować złożone procesy biznesowe, wiedząc, że chwilowa niedostępność zewnętrznego serwisu czy awaria naszej własnej aplikacji nie zrujnuje spójności naszych danych. To właśnie tak wyglądają fundamenty nowoczesnych, solidnych systemów backendowych.

oferty pracy

Scena po napisach

Bonus 1: Dlaczego transakcje wymagają Replica Set?

Odpowiedź jest prosta i elegancka: transakcje zostały zbudowane na fundamencie mechanizmu, który w MongoDB istniał od lat – replikacji. Sercem każdego Replica Set jest oplog (operations log), czyli chronologiczny dziennik wszystkich operacji zapisu. Węzły wtórne (ang. secondary) „śledzą” ten dziennik, aby powielać zmiany i utrzymywać spójność z węzłem głównym (ang. primary).

Inżynierowie MongoDB, projektując transakcje, wykorzystali ten istniejący, przetestowany w boju i niezwykle odporny mechanizm. Każdy krok transakcji, jej postęp i ostateczna decyzja (commit lub rollback) są zapisywane właśnie w oplogu. To on staje się jedynym, autorytatywnym źródłem prawdy o stanie transakcji.

Bonus 2: Jak transakcja ACID może być rozproszona?

Kluczem jest zrozumienie, że atomowość transakcji jest gwarantowana i koordynowana w jednym miejscu: na węźle primary. „Rozproszenie” w tym kontekście nie oznacza negocjowania wyniku transakcji między węzłami. Oznacza propagację spójnego, atomowo zatwierdzonego wyniku w celu zapewnienia wysokiej dostępności i trwałości danych.

Bonus 3: Pułapka readPreference i nadrzędność węzła primary

Aby zoptymalizować wydajność i zmniejszyć opóźnienia, w MongoDB często używa się ustawienia readPreference. Jest to instrukcja dla sterownika bazy danych, która mówi mu, z którego węzła w klastrze ma czytać dane. Popularnym wyborem jest „nearest”, co oznacza odpytywanie geograficznie najbliższego serwera, niezależnie od tego, czy jest to primary, czy secondary.

Tu jednak czai się pułapka. Załóżmy, że globalnie ustawiliśmy readPreference na „nearest”. Dlaczego więc operacje w naszej transakcji nagle przestają działać?

Odpowiedź znów leży w spójności. Transakcja, aby spełnić obietnicę ACID, musi operować na idealnie spójnej i aktualnej migawce danych. Węzły secondary z natury replikują dane z niewielkim opóźnieniem. Gdyby transakcja mogła odczytać dane z węzła secondary, ryzykowałaby operowanie na nieaktualnych danych, co mogłoby prowadzić do kosztownych błędów.

Dlatego silnik MongoDB narzuca żelazną zasadę: wszystkie operacje wewnątrz aktywnej transakcji, zarówno odczyty, jak i zapisy, muszą być kierowane do węzła primary. Globalne ustawienie readPreference jest na czas trwania transakcji ignorowane na rzecz absolutnej spójności. Sposób konfiguracji jest pokazany w załączonym repozytorium.

5/5
Ocena
5/5
Avatar

O autorze

Bartłomiej Drobczyk

Software Engineer z doświadczeniem w tworzeniu oprogramowania, projektowaniu architektury systemów, zagadnieniach DevOps oraz zastosowaniach sztucznej inteligencji. Interesuje go pełne spektrum tworzenia i rozwijania rozwiązań technologicznych – od kodu, przez infrastrukturę, aż po aspekty skalowalności i niezawodności. Ceni sobie przejrzysty kod, przemyślaną architekturę i efektywną współpracę zespołową. Po godzinach chętnie podróżuje i spędza czas na świeżym powietrzu – zazwyczaj w towarzystwie swojego psa. Kiedy tylko może, łączy pasję do technologii z zamiłowaniem do odkrywania nowych miejsc. Kawa to jego codzienny rytuał – zarówno w biurze, jak i w trasie

Wszystkie artykuły autora

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Dołącz do nas

Sprawdź oferty pracy

Pokaż wyniki
Dołącz do nas Kontakt

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?