Problemy z race conditions przy rozliczaniu tokenów to coś, co wygląda absurdalnie prosto w teorii, a w praktyce potrafi generować straty finansowe i totalny chaos w systemie. Sprawdzasz saldo, wywołujesz API, odliczasz kredyty. Co może pójść nie tak?
Absolutnie wszystko.
Moment kiedy sprawdzenie salda przestaje wystarczać
Klasyczny flow wygląda następująco: klient wysyła request do API, serwer sprawdza czy ma wystarczającą ilość tokenów, jeśli tak – wykonuje operację i odlicza koszt. Problem pojawia się w momencie, gdy operacja nie jest natychmiastowa. W przypadku generacji AI mówimy o 15-25 sekundach. To wieczność w skali systemów rozproszonych.
Wyobraź sobie scenariusz: kiosk fotograficzny na weselu, operator ma dokładnie 1 token na koncie. O 23:00 do kiosku podchodzi dwóch gości jednocześnie, operatorzy obsługują dwa stanowiska z tego samego konta. Request #1 trafia na serwer, sprawdzamy saldo – jest 1 token, okej, wywołujemy model AI. Request #2 trafia na serwer pół sekundy później, sprawdzamy saldo – nadal widać 1 token, bo pierwszy request jeszcze nie zakończył się sukcesem i nie odliczył kredytu. Wywołujemy model AI drugi raz.
20 sekund później obie generacje się udają. Próbujemy odliczyć 2 tokeny z salda równego 1. Ujemne saldo. Albo – jeśli masz prostą walidację – drugi request zwróci błąd mimo że faktycznie wykorzystał zasób u dostawcy AI. Zapłaciłeś zewnętrznemu providerowi za dwie generacje, a spieniężyłeś jedną. To nie jest teoretyczny edge case, to podstawowa własność systemów współbieżnych.
Widziałem implementację, która „rozwiązywała” ten problem przez dodanie „time.sleep(100)” po sprawdzeniu salda, zanim wywołano API. Reasoning był taki: jeśli dwa requesty przyjdą w tym samym czasie, to sleep da drugiemu czas na zobaczenie zaktualizowanego salda. To oczywiście nie działa, bo sleep nie synchronizuje niczego, tylko spowalnia system. Przy trzech równoczesnych requestach masz dokładnie ten sam problem, tylko wolniejszy.
Window między decyzją a wykonaniem
Race condition window to czas między sprawdzeniem warunku a wykonaniem akcji opartej na tym warunku. W przypadku tokenów:
T0: SELECT balance FROM accounts WHERE id = 123 -> balance = 1
T1: (20 sekund wywołania AI)
T2: UPDATE accounts SET balance = balance - 1 WHERE id = 123
Między T0 a T2 mija 20 sekund. W tym czasie drugi, trzeci, piąty request mogą zobaczyć to samo saldo i podjąć te same decyzje. Problem nie leży w bazie danych – transakcje ACID działają świetnie. Problem leży w logice biznesowej, która zakłada że stan świata nie zmieni się między obserwacją a akcją.
Kluczowe jest zrozumienie, że to nie jest problem concurrency na poziomie bazy. Możesz mieć najwyższy isolation level, serializowalne transakcje, a nadal mieć race condition, bo decyzja („czy mogę wykonać operację”) jest podejmowana przed rozpoczeciem zużycia zasobu, a zasób zużywany jest przez zewnętrzny system poza Twoją transakcją.
Konsekwencje biznesowe są konkretne. Przy modelu pay-per-use tracisz pieniądze przy każdym takim przypadku. Przy modelu prepaid generujesz ujemne salda, co wymaga obsługi długów, blokowania kont, supportu próbującego zrozumieć co się stało. Operator dzwoni „system pokazał że mogę zrobić zdjęcie, a teraz mówi że nie mam tokenów, co z tymi gośćmi którzy czekają?”. Nie ma dobrej odpowiedzi.
"Najdroższe race conditions zwykle zaczynają się od kodu, który na code review wygląda zbyt prosto, żeby mógł być problemem. Produkcja potrzebuje potem dwóch requestów i pół sekundy, żeby wyprowadzić zespół z tego przekonania."
Three-phase lifecycle zasobu
Rozwiązanie polega na wprowadzeniu stanu pośredniego. Zamiast dwóch stanów (dostępny/zużyty), potrzebujesz trzech: dostępny, zarezerwowany, zużyty. Rezerwacja musi być atomowa względem sprawdzenia dostępności.
-- Atomic reservation
UPDATE accounts
SET balance_available = balance_available - 1,
balance_reserved = balance_reserved + 1
WHERE account_id = ?
AND balance_available >= 1
RETURNING balance_available;
„UPDATE … WHERE” z warunkiem na saldo to Twoja atomowa operacja. Jeśli drugi request wykonuje dokładnie tę samą klauzulę w tym samym czasie, baza danych zagwarantuje że tylko jeden dostanie wiersz spełniający warunek „balance_available >= 1”. Drugi dostanie 0 zaktualizowanych wierszy, czyli „RETURNING” zwróci pusty wynik. To Twój sygnał że rezerwacja się nie powiodła.
Po udanej rezerwacji wywołujesz zewnętrzne API. Teraz masz 20 sekund podczas których token jest „zamrożony”, ale nie stracony. Jeśli generacja się uda:
UPDATE accounts
SET balance_reserved = balance_reserved - 1,
balance_used = balance_used + 1
WHERE account_id = ?;
Jeśli się nie uda (timeout, błąd od providera, crash Twojego serwera):
UPDATE accounts
SET balance_reserved = balance_reserved - 1,
balance_available = balance_available + 1
WHERE account_id = ?;
Rollback rezerwacji. Token wraca do puli. Klient nie płaci za nieudaną próbę.
To brzmi prosto, ale diabeł tkwi w szczegółach implementacyjnych. Co jeśli Twój proces crashuje między wywołaniem AI a commitowaniem wyniku? Co jeśli API providera zwraca sukces, ale Ty nie dostajesz odpowiedzi przez timeout sieciowy? Co jeśli baza danych pada w trakcie UPDATE?
Handling partial failures
Największy problem to scenario: wywołałeś API, API wykonało operację (i pobrano koszt), ale Ty nie dostałeś odpowiedzi przez timeout połączenia. Z perspektywy providera: sukces, z Twojej perspektywy: failure, timeout exception. Jeśli rollbackujesz rezerwację, klient ma token który de facto został zużyty. Jeśli committujesz jako sukces, nie masz wyniku operacji.
Jedyne rozsądne rozwiązanie to idempotentne requesty z unique ID nadawanym przez klienta. Każdy request generacji dostaje UUID od kiosku, serwer zapisuje UUID -> job mapping. Retry tego samego UUID nie tworzy nowej generacji, tylko zwraca status istniejącej. Jeśli masz timeout, możesz bezpiecznie spytać ponownie „co się stało z requestem XYZ”. Jeśli się udał – dostajesz wynik, jeśli się nie udał – dostajesz błąd, jeśli nadal trwa – dostajesz pending.
To wymaga persystencji stanu generacji po stronie serwera. Job queue z unique constraint na client_request_id. Redis, PostgreSQL, cokolwiek co przetrwa restart procesu. Struktura mniej więcej taka:
generation_jobs:
- client_request_id (unique)
- account_id
- status (pending/running/completed/failed)
- result_url (jeśli completed)
- error_message (jeśli failed)
- reserved_token_id
- created_at
- updated_at
Przy restarcie serwera skanuj pending jobs starsze niż np. 2 minuty. Token był zarezerwowany, operacja się nie dokończyła, trzeba zdecydować co dalej. Jeśli masz callback URL od AI providera – sprawdź status tam. Jeśli nie – rollback rezerwacji i oznacz job jako failed z możliwością retry.
Najtrudniejszy przypadek: AI provider wykonał operację, Twój serwer zapisał wynik do bazy, ale crashnął przed commitowaniem tokena jako used. Przy restarcie widzisz completed job z tokenem nadal w stanie reserved. Potrzebujesz consistency check: jeśli job.status = completed AND reserved_token_id NOT NULL, to commit token jako used i wyczyść reserved_token_id. To fix-up logic do uruchomienia przy starcie albo w background job co N minut.
Zombie reservations i TTL cleanup
Rezerwacje które nigdy nie zostały zamknięte to wyciek zasobów. Kiosk zarezerwował token, wysłał request, dostał network error i użytkownik zamknął aplikację. Token wisi w stanie reserved na zawsze. Mnóż to przez tysiące requestów i masz problem.
Potrzebujesz TTL na rezerwacjach. Najprostszy sposób: kolumna „reserved_at TIMESTAMP” i background job co minutę:
UPDATE accounts a
SET balance_reserved = balance_reserved - r.count,
balance_available = balance_available + r.count
FROM (
SELECT account_id, COUNT(*) as count
FROM generation_jobs
WHERE status IN ("pending", "running")
AND reserved_at < NOW() - INTERVAL "5 minutes"
GROUP BY account_id
) r
WHERE a.account_id = r.account_id;
UPDATE generation_jobs
SET status = "failed",
error_message = "Timeout - reservation expired"
WHERE status IN ("pending", "running")
AND reserved_at < NOW() - INTERVAL "5 minutes";
5 minut to bardzo konserwatywna wartość dla operacji która powinna trwać 20 sekund. Możesz zejść do 2 minut. Ale nie idź poniżej 3x expected duration, żeby nie anulować operacji które po prostu trwają dłużej (AI provider przeciążony, sieć wolna).
Edge case: cleanup job rollbackuje rezerwację w momencie gdy generacja faktycznie się właśnie kończy. Request wraca z sukcesem, próbujesz commitować token jako used, ale balance_reserved jest już 0 bo cleanup go zwrócił. To wymaga defensive update:
UPDATE accounts
SET balance_reserved = GREATEST(0, balance_reserved - 1),
balance_used = balance_used + 1
WHERE account_id = ?;
Albo jeszcze lepiej: sprawdź przed commitem czy job nadal ma reserved_token_id. Jeśli został wyczyszczony przez TTL cleanup, nie próbuj commitować. Możesz też wrócić token do available zamiast used, bo technically klient dostał wynik ale nie zapłacił (choć to zależy od polityki biznesowej).
W jednym przypadku wdrożeniowym odkryliśmy że istniały rezerwacje starsze niż 3 dni. Okazało się że background cleanup job miał bug i nie działał przez tydzień po deploymencie. 15% tokenów klientów było zamrożonych w zombie reservations. Support zalany zgłoszeniami „kupiłem tokeny, system mówi że nie mam”. Manual cleanup SQL i hotfix. Nie testuj tylko happy path.
"Najgorsze partial failures to te, w których każdy komponent osobno zachował się poprawnie. Provider zrobił swoje, timeout zadziałał zgodnie z konfiguracją, cleanup posprzątał stare rezerwacje, a billing i tak właśnie stracił pojęcie, kto komu jest winien token."
Alternatywa: Redis INCR/DECR
Niektórzy próbują rozwiązać to przez Redis:
DECR account:123:balance
-> jeśli wynik >= 0: wywołaj API
-> jeśli wynik < 0: INCR z powrotem, zwróć błąd
Problem: co jeśli crashujesz między DECR a wywołaniem API? Token stracony. Co jeśli API zwraca błąd? Musisz INCR z powrotem, ale co jeśli INCR failuje? Co z persystencją do bazy? Redis to cache, nie source of truth.
Możesz użyć Redis do rate limiting („max N requestów na minutę”), ale billing powinien być w bazie danych z transakcjami. Redis jest świetny do atomic counters, ale nie do money. Za dużo sposobów żeby stracić consistent state przy crash/network issues.
Distributed locks to nie rozwiązanie
Kolejne podejście które widziałem: „zróbmy lock na account_id podczas sprawdzania salda”. RedLock, ZooKeeper, co tam wolisz. Problem: trzymasz lock przez 20 sekund podczas wywołania AI. To serializes wszystkie operacje na koncie. Jeśli klient ma 10 kiosków na evencie, jeden request blokuje 9 innych. Queue się tworzy, timeouty rosną, user experience leci na pysk.
Distributed locks są OK do koordinacji krótkich operacji (sekundy), nie do trzymania przez długie I/O operations. Race condition przy tokenach rozwiązujesz przez atomic state transitions w bazie, nie przez locking.
Event sourcing dla audit trail
Jeśli potrzebujesz pełnego audit trail (a przy pieniądzach zwykle potrzebujesz), rozważ event sourcing dla operacji na tokenach. Zamiast mutować saldo, appendujesz eventy:
token_events:
- event_id (autoincrement)
- account_id
- event_type (reserved/used/returned/purchased)
- amount
- job_id (jeśli dotyczy generacji)
- timestamp
Saldo to aggregacja eventów. Rezerwacja = append event „reserved”. Commit = append event „used”. Rollback = append event „returned”. Nigdy nie mutable state, tylko append-only log.
To daje Ci immutable history wszystkich operacji, łatwe debugging („pokaz mi wszystkie eventy dla tego konta w ostatniej godzinie”), łatwe reconciliation z zewnętrznymi systemami. Minus: bardziej skomplikowane queries (musisz sumować eventy), potrzeba eventual consistency w read models.
Dla większości przypadków UPDATE z three-phase lifecycle wystarcza. Event sourcing ma sens jeśli masz złożone wymagania audytu lub potrzebujesz replay ability dla debugging.
"Redis jako source of truth dla billingu wygląda świetnie, dopóki pierwszy crash nie wydarzy się dokładnie między DECR a INCR. Distributed lock też działa, dopóki nie odkryjesz, że właśnie zbudowałeś bardzo niezawodną kolejkę do własnego API."
Symptomy w produkcji i monitoring
Skąd wiesz że masz race condition w systemie? Nieoczywiste symptomy:
– Ujemne salda w bazie (jeśli nie masz constraint blokującego)
– Reklamacje klientów „system pozwolił mi wygenerować, a potem odjął 2 tokeny”
– Rozbieżność między liczbą wygenerowanych obrazów a zużytymi tokenami w analytics
– Spike’i błędów „insufficient balance” które nie korelują z faktycznym low balance
Metryki do monitorowania:
– Liczba aktywnych rezerwacji per account (jeśli > max_concurrent_requests to masz problem)
– Age najstarszej rezerwacji (jeśli > 5min to cleanup job nie działa albo masz operacje wiszące)
– Ratio reserved/used vs reserved/returned (jeśli returned >> used to dużo failów albo timeoutów)
– Częstość konfliktów przy rezerwacji (jeśli atomic UPDATE zwraca 0 rows)
Alert jeśli suma available + reserved + used nie zgadza się z total purchased. To znaczy że gdzieś masz leak albo double-counting.
W jednym systemie klient zgłosił że „tokeny znikają”. Spojrzeliśmy na eventy – faktycznie, 30% rezerwacji kończyło się returned, nie used. Ale klient twierdził że generacje się udawały. Okazało się że timeout HTTP był ustawiony na 15 sekund, a średni czas generacji wynosił 18 sekund. Kiosk dostawał timeout exception, rollbackował rezerwację, ale generacja faktycznie się kończyła po stronie serwera. Obrazy lądowały w galerii, ale tokeny wracały do puli. Klient generował de facto za darmo. Podwyższenie timeoutu do 30 sekund rozwiązało problem, ale to pokazuje jak łatwo popsuć billing logic przez złą konfigurację.
Consistency vs availability tradeoff
Cały ten pattern to wybór consistency over availability. Atomic reservation oznacza że w momencie konfliktów (dwa requesty, jeden token) jeden request zostanie odrzucony. To właściwe zachowanie dla systemu billing, ale oznacza że przy high contention możesz mieć dużo retries.
Jeśli stawiasz na availability, możesz pozwolić na temporary over-spending z późniejszym rozliczeniem. „Pozwól wygenerować nawet jeśli saldo ujemne, później naliczymy debt”. To sensowne dla niektórych business models (np. postpaid), ale wymaga całej infrastruktury debt management, collection, grace periods. Dla prepaid model atomic reservation jest prostszy i uczciwszy dla klienta.
Nie możesz mieć obu jednocześnie. Albo consistency – gwarantujesz że nigdy nie wydasz więcej niż masz, ale przy race conditions ktoś dostanie błąd. Albo availability – zawsze akceptujesz request, ale ryzykujesz negative balance i późniejsze problemy.
Praktyczna implementacja: co z retry logic
Klient dostał błąd „insufficient balance” przez race condition (ktoś inny zarezerwował ostatni token nanosekunde wcześniej). Co powinien zrobić? Retry ma sens, bo może ten drugi request się szybko skończy i token wróci do puli. Ale ile retries? Z jakim backoffem?
Exponential backoff nie ma sensu – to nie jest rate limiting ani overload. Token albo jest dostępny, albo nie. Lepiej: jeden natychmiastowy retry, potem zwróć błąd do użytkownika. Jeśli po retry nadal insufficient balance, to znaczy że faktycznie nie ma tokenów, nie ma sensu retryować w nieskończoność.
W aplikacji klienckiej (kiosk) potrzebujesz sensownego komunikatu: „Brak dostępnych tokenów. Inny operator może właśnie używać Twojego konta.” To wyjaśnia sytuację i nie wygląda jak bug systemu.
Jeden klient chciał żeby system automatycznie czekał aż token się zwolni i wtedy kontynuował. To brzmi rozsądnie, ale wymaga mechanizmu kolejkowania i notyfikacji. Przy 20-sekundowych operacjach kolejka szybko rośnie. Goście stoją przy kiosku 5 minut czekając na swoją kolej. Dyskusja z klientem sprowadzała się do tego że faktyczny problem był gdzie indziej – mieli za mało tokenów dla liczby kiosków. Rozwiązanie: kupili więcej tokenów, nie budowaliśmy skomplikowanego queue system. Czasem prostsze rozwiązanie biznesowe jest lepsze niż techniczne.
Lekcje
Race conditions w billing to nie problem algorytmiczny, tylko systemowy. Atomic operations w bazie rozwiązują 80% przypadków, ale pozostałe 20% to handling failures, timeouts, crashes i wszystkiego co może pójść nie tak w distributed system. Najbardziej zdradliwy aspekt to fakt że problem może nie wystąpić przez miesiące w staging (bo nikt nie testuje real concurrency), żeby wybuchnąć w produkcji podczas pierwszego większego eventu. System generuje wtedy albo straty finansowe albo zdenerwowanych klientów – często obie rzeczy jednocześnie. Nie projektujesz pod happy path gdzie wszystko działa idealnie, projektujesz pod chaos gdzie połowa requestów timeoutuje, baza się restartuje w środku transakcji, a klienci klikają retry 20 razy z frustracji.
Podsumowanie
Najgorsze race conditions nie wyglądają jak błędy, dopóki system nie dostanie prawdziwego ruchu. Na stagingu saldo zawsze się zgadza, requesty kończą się w przewidywalnej kolejności, a timeout jest po prostu wyjątkiem w logach. W produkcji dwa kioski wykorzystują ostatni token w tej samej sekundzie, provider kończy request po Twoim timeoutcie, a cleanup właśnie zwraca rezerwację, którą inny proces próbuje commitować.
Po kilku miesiącach utrzymywania takiego systemu przestajesz ufać samemu faktowi, że operacja „się udała”. Liczy się to, czy potrafisz odtworzyć jej stan po crashu, retry, timeoutcie i restarcie procesu oraz czy ten sam request wykonany drugi raz nie kosztuje Cię drugi raz pieniędzy. Atomic UPDATE rozwiązuje race condition. Cała reszta pracy zaczyna się wtedy, gdy coś padnie sekundę później.
Production traffic ma paskudny zwyczaj wykonywania operacji w kolejności, której nie było na diagramie. Dlatego billing jest poprawny nie wtedy, gdy działa happy path, tylko wtedy, gdy po tygodniu timeoutów, retry i częściowych awarii nadal potrafisz wyjaśnić, gdzie znajduje się każdy token i dlaczego.
Zobacz powiązane case studies i analizy
Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.