Sześć senioralnych pytań o architekturę — liczy się cena, nie definicja
Dual-write, idempotencja, eventual consistency, granice kontekstów, CQRS, transakcje rozproszone. Sześć pytań z rozmowy, które oddzielają seniora od mida — bo sprawdzają nie znajomość terminu, lecz świadomość kompromisu.
Te sześć pytań wraca na rozmowach senioralnych. Nie sprawdzają, czy znasz słowo „outbox" albo „saga" — tego można się nauczyć na pamięć. Sprawdzają, czy widzisz cenę: każdy z tych wzorców kupuje jakąś własność systemu rozproszonego (niezawodność, luźne spięcie, skalę) i płaci za nią czymś konkretnym — opóźnieniem, złożonością, ostateczną spójnością, kosztem operacyjnym. Mid nazwie wzorzec. Senior nazwie cenę, zanim go o nią zapytają — i wie, kiedy po wzorzec nie sięgać.
Poniżej moja odpowiedź na każde z nich. Pytania 1, 2 i 6 różnicują najmocniej, bo to w praktyce ten sam problem oglądany z trzech stron.
1. Dual-write: baza commituje, publish pada
Pytanie: zapisujesz encję do bazy i publikujesz event na kolejkę. Baza commituje, publikacja pada. Co się dzieje i jak to naprawiasz?
To są dwa zapisy do dwóch systemów bez wspólnej transakcji — i to jest cała pułapka. Baza się zacommitowała, broker nie dostał eventu → masz dane, o których reszta systemu nigdy się nie dowie. Albo odwrotnie: opublikujesz najpierw, transakcja się cofnie → event o danych, których nie ma. Oba przypadki to cicha korozja spójności na poziomie całego systemu, nie jednej usługi.
Owinięcie publish w retry nie ratuje — proces może paść między commitem a publikacją.
Dopóki są to dwa zapisy do dwóch systemów, zawsze istnieje okno, w którym jeden się udał, a drugi nie.
Rozwiązanie: nie rób dwóch commitów. Zrób jeden — oba zapisy w tej samej
transakcji: obok encji wstaw wiersz do tabeli outbox:
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (id, type, payload, created_at)
VALUES (gen_random_uuid(), 'OrderPlaced', '{...}', now());
COMMIT;
Osobny proces czyta outbox — odpytując tabelę w pętli albo śledząc log transakcyjny
bazy — publikuje na broker i oznacza wiersz jako wysłany. Teraz jest atomowo: commit →
event trwale zakolejkowany w outboxie; rollback → nie ma ani encji, ani eventu. Publikacja
staje się at-least-once (ten proces po restarcie może wysłać event dwa razy) — i właśnie
dlatego istnieje pytanie 2.
To jest lustrzane odbicie zasady z poprzedniego wpisu „decyzja wyprzedza persystencję": tutaj event nie może wyprzedzić commitu.
2. Idempotencja: ten sam event dwa razy
Pytanie: konsument dostaje ten sam event dwukrotnie. Jak zabezpieczasz się przed podwójnym przetworzeniem?
Skoro outbox (i każdy broker at-least-once) potrafi dostarczyć duplikat, konsument musi
przetworzyć ten sam event dwa razy bez podwójnego efektu. Odruch seniora: dedup po
identyfikatorze wiadomości. Konsument trzyma zbiór przetworzonych message_id (tabela
inbox) i wstawia-i-sprawdza wynik w tej samej transakcji co efekt uboczny:
odbierz(msg):
BEGIN
INSERT INTO inbox(message_id) VALUES (msg.id) -- UNIQUE(message_id)
ON CONFLICT DO NOTHING -- duplikat → 0 wierszy
if wstawiono 0 wierszy: ROLLBACK; return -- już przetworzone, pomiń
zastosuj efekt biznesowy
COMMIT
Strażnikiem nie jest tu if, tylko unikalny indeks na message_id — samo „sprawdź,
potem wstaw" bez niego to race condition: przy dwóch konsumentach mielących ten sam duplikat
równolegle oba contains zwracają false (na READ COMMITTED) i efekt leci dwa razy. Dopiero
insert w unikalny indeks, który pada / zwraca 0 wierszy, jest realnym dedupem. (Tabela inbox
nie rośnie w nieskończoność — trzymasz okno deduplikacji z TTL rzędu maksymalnego czasu
redelivery brokera, starsze message_id czyścisz.) Całość zamienia
dostarczanie at-least-once w przetwarzanie effectively-once. Alternatywnie
— zrób operację naturalnie idempotentną (upsert po kluczu biznesowym, „ustaw", nie
„zwiększ"). Sedno: w systemie rozproszonym nie wyeliminujesz duplikatów, możesz tylko
uczynić ponowne dostarczenie tanim. Dlatego senior nie goni za „exactly-once delivery"
(mit — Kafkowe „exactly-once" to effectively-once processing wewnątrz pipeline'u, nie
delivery do dowolnego konsumenta) — robi konsumenta idempotentnym. Zastrzeżenie: dedup w transakcji działa, dopóki efekt
to zapis w tej samej bazie co inbox; gdy efektem jest wywołanie zewnętrznego systemu
(płatność, mail), transakcja go nie obejmuje — idempotencję przenosisz wtedy na jego granicę
(idempotency key u odbiorcy).
3. Spójność natychmiastowa vs ostateczna
Pytanie: czym różni się spójność natychmiastowa od ostatecznej? Gdzie świadomie wybierzesz tę drugą?
Natychmiastowa: po powrocie z zapisu każdy czytelnik widzi nową wartość — jedna granica transakcyjna. Ostateczna: czytelnik może przez chwilę zobaczyć stan sprzed zmiany, system zbiega do spójności. To, co kupujesz eventual consistency, to dostępność, luźne spięcie i skala — usługi nie blokują się nawzajem. Cena: okno nieświeżości, które biznes musi tolerować.
Przykład, w którym wybieram ostateczną świadomie: aktualizacja modelu odczytu / indeksu wyszukiwania / innego kontekstu po złożeniu zamówienia. Samo zamówienie musi zacommitować natychmiast (pieniądze), ale „licznik zamówień na dashboardzie" czy indeks rekomendacji może spóźnić się o sekundę. Wciśnięcie ich w transakcję zamówienia sprzęgłoby wszystko, pogorszyło latencję i sprawiło, że zamówienie padnie, gdy padnie indeks. Więc: natychmiastowa tam, gdzie niezmiennik jest naprawdę transakcyjny (saldo w obrębie jednego rejestru/agregatu), ostateczna w poprzek granic kontekstów. Ruch seniora: wybieram per niezmiennik, nie globalnie.
4. Granice bounded contextów i mikroserwisów
Pytanie: jak wyznaczasz granice? Po czym poznajesz, że przecięcie jest złe?
Tnę po zdolności biznesowej i języku, nie po warstwie technicznej. Granica jest dobra, gdy kontekst posiada swoje dane i może zmieniać się wewnątrz bez koordynacji z innymi. Granica kontekstu to granica modelu i języka, niekoniecznie osobny serwis — bounded context świetnie żyje jako moduł w monolicie; mikroserwis to dopiero decyzja o deploymencie nałożona na tę granicę. Sygnały, że przecięcie jest złe:
- chatty calls — dwie usługi wymieniają serię synchronicznych wywołań, żeby wykonać jedną operację (to naprawdę jeden kontekst),
- współdzielona encja, którą obie muszą trzymać w zgodzie,
- potrzeba transakcji rozproszonej, by utrzymać niezmiennik (niezmiennik przecina granicę → granica jest w złym miejscu),
- zmiana w jednej usłudze zawsze wymusza zmianę w drugiej (wysoki coupling).
Heurystyka: jeśli dwie rzeczy zmieniają się razem i z tego samego powodu biznesowego — należą do siebie. Podział techniczny („wszystkie kontrolery tu, wszystkie repozytoria tam") to klasyczne złe cięcie: każda zmiana biznesowa przecina wszystkie warstwy naraz — zero autonomii zmiany (złamane Common Closure Principle).
5. CQRS: kiedy się opłaca, a kiedy to overengineering
Pytanie: kiedy CQRS ma sens, a kiedy to przerost? Co realnie kosztuje?
Opłaca się, gdy odczyt i zapis są naprawdę asymetryczne: złożone modele odczytu, które nie pasują do modelu zapisu; bardzo różne skalowanie czytania i pisania; wiele kształtów odczytu z jednego modelu zapisu (raporty, wyszukiwanie, dashboardy). Wtedy rozdzielenie modelu komend i zapytań pozwala każdy zoptymalizować osobno.
Overengineering, gdy to CRUD o symetrycznych potrzebach — dokładasz drugi model bez korzyści. Ale uwaga na mit, który sam się tu wkrada: CQRS nie znaczy z automatu eventual consistency. W bazowej formie (osobne ścieżki/modele komend i zapytań nad tą samą bazą, read model jako widoki SQL albo projekcja w tej samej transakcji) odczyt jest w pełni spójny i tani. Nieświeżość wchodzi dopiero, gdy rozdzielasz też magazyny — osobna baza odczytu + asynchroniczna projekcja. CQRS to spektrum: od osobnych klas w jednej bazie (spójne, tanie) po osobne magazyny z projekcjami (skala, ale eventual consistency, „zapisałem i od razu czytam, a tego jeszcze nie ma", plus infrastruktura projekcji) — koszt rośnie z każdym krokiem, więc zatrzymaj się na najtańszym, który wystarcza. Najczęstszym uzasadnieniem są rozjeżdżające się potrzeby odczytu; sam zapis rzadko wystarcza za powód. I drobiazg, który zdradza mida: CQRS ≠ event sourcing.
6. Transakcje rozproszone bez klasycznej transakcji
Pytanie: operacja biznesowa spina kilka serwisów, każdy może paść. Jak zapewniasz spójność bez klasycznej transakcji?
W praktyce nie robisz 2PC przez granice usług (blokady, coupling, spadek dostępności). Rezygnujesz z atomowej transakcji i modelujesz operację jako sagę: ciąg lokalnych transakcji plus akcje kompensujące cofające wcześniejsze kroki, gdy późniejszy padnie; kolejny krok wyzwala event (choreografia) albo komenda koordynatora (orkiestracja). To rollback semantyczny, nie bazodanowy — nie „od-obciążysz" karty, robisz zwrot. I pamiętaj o cenie, o której się milczy: saga nie ma „I" z ACID — między krokami inne transakcje widzą stan pośredni, więc bronisz się środkami zaradczymi (semantic lock, wersjonowanie), a niektórych kroków (pivot) nie da się już skompensować.
Dwa style:
- choreografia — każda usługa reaguje na eventy, brak centralnego koordynatora. Proste, ale przepływ jest emergentny, trudny do zobaczenia w całości.
- orkiestracja — usługa-koordynator prowadzi kroki. Przepływ jawny i „posiadany", kosztem centralnego komponentu.
Choreografia przy kilku krokach i luźnym spięciu; orkiestracja, gdy przepływ jest złożony i musisz go widzieć. Klocki: każdy krok musi być idempotentny (pytanie 2) i niezawodnie opublikowany (pytanie 1, outbox). Dlatego 1, 2 i 6 to ten sam problem z trzech stron.
Nić przez wszystkie sześć
Każdy z tych wzorców kupuje własność systemu rozproszonego i płaci konkretną cenę: nieświeżością, złożonością, logiką kompensacji, kosztem operacyjnym. Mid nazywa wzorzec. Senior nazywa cenę, zanim go o nią zapytają — i wie, kiedy po wzorzec nie sięgać, bo najdroższy jest ten, który wprowadzono bez potrzeby.
Tak podchodzę do architektury: pytanie nigdy nie brzmi „który wzorzec", tylko „co za to kupujemy i ile to kosztuje".
