Puszczasz sto równoczesnych żądań na ten sam klucz API. Licznik użyć pokazuje osiemdziesiąt siedem. W logach nie ma żadnego błędu, żadne żądanie nie padło, nikt się nie poskarżył. Po prostu brakuje trzynastu inkrementów.
Ten błąd wyszedł mi trzy razy, w projektach, które nie dzielą ani jednej linii kodu, zawsze z tego samego powodu: licznik został napisany tak, jakby pisał do niego tylko jeden proces.
Wzorzec: odczyt, dodanie i zapis w trzech krokach
Kod, który to produkuje, nigdy nie wygląda groźnie. W API transkrypcji uwierzytelnienie klucza robiło dwie rzeczy: sprawdzało, czy nie jest odwołany, i aktualizowało useCount oraz lastUsedAt. Odczytaj wiersz, dodaj jeden w pamięci, zapisz wynik.
Przy jednym użytkowniku naraz działa. Przy workerach wsadowych waląc w ten sam klucz — nie: między odczytem a zapisem wchodzi inne żądanie, czyta tę samą wartość i zapisuje ten sam wynik. Dwa użycia, jeden inkrement. Zmierzone k6 przy 10, 50 i 100 wirtualnych użytkownikach, strata wynosiła od 8 do 14 procent.
Naprawa mieści się w jednej instrukcji:
UPDATE api_key
SET use_count = use_count + 1,
last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;
Ważne jest nie tylko use_count + 1, co jest oczywiste. Ważne jest to, że revoked_at IS NULL siedzi w tym samym UPDATE. Wcześniej sprawdzenie odwołania i inkrement były dwiema osobnymi podróżami, więc istniało okno, w którym ktoś odwoływał klucz tuż po jego walidacji, a użycie i tak się liczyło. Z filtrem w środku albo wiersz spełnia warunek i jest inkrementowany, albo nie wraca nic i leci 401. Nie ma pośredniego stanu do ogarniania.
Po zmianie dziewięć przebiegów testu współbieżności (trzy poziomy razy trzy powtórzenia), zero strat.
Przypadek odwrotny: policzyć to samo dwa razy
Drugi przypadek był na odwrót. W wyszukiwarce semantycznej każde wyszukanie zjada jednostkę limitu i zapisuje wiersz z tym, ile kosztowało: model embeddingów, tokeny, model rerankingu, tokeny wejścia i wyjścia.
Była deduplikacja: jeśli powtarzałeś to samo wyszukanie, rezerwacja zwracała duplicate, używała ponownie oryginalnego wiersza i nie pobierała limitu drugi raz.
Problem w tym, że zdeduplikowane wyszukiwanie i tak się wykonywało. Znowu wołało embeddingi, znowu wołało reranking, a dostawca znowu to fakturował. Ten koszt nie miał dokąd pójść: oryginalny wiersz niósł już swój, a nadpisanie go skasowałoby wydatek z pierwszej próby. Więc znikał. Panel administracyjny mówił, że wyszukiwania kosztują mniej, niż kosztowały.
Pokusą było wrzucenie kolejnego wiersza do tej samej tabeli z innym statusem. Zły pomysł, z dwóch konkretnych powodów:
- Każde zapytanie o limit liczy wiersze bez filtrowania. Dodatkowy wiersz pobrałby limit, którego nikt nie zużył.
- Wyszukiwanie duplikatu też nie filtruje po statusie. Bierze najnowszy wiersz z tym hashem. Nowy wiersz stałby się kotwicą kolejnej deduplikacji — wiersz, który nigdy nie był jednostką limitu.
Rozwiązaniem była osobna tabela, unbilled_search_spend, z tymi samymi kolumnami użycia i referencją do rezerwacji, przy której rozwiązano duplikat. Ta referencja jest audytowa, z ON DELETE set null, i nie jest referencją limitu. Koszt widać w raportach, ale nie wchodzi do kosztu za rozliczone wyszukiwanie, a to ta liczba służy do ustalania cen.
Trzeci: dwa salda czytane osobno
Trzeci przypadek to SaaS do generowania obrazów. Dodałem dzienne kredyty na wierzchu subskrypcyjnych i generowanie przestało działać dla tych, którzy mieli tylko dzienne. Endpoint sprawdzał profile.subCredits < estimatedCost i zwracał 402, całkowicie ignorując dailyCredits. Kredyty istniały, rysowały się na pasku bocznym, a serwer mówił, że nie starcza.
To nie jest błąd współbieżności, tylko rozbicia licznika na dwa i zapomnienia o jednym. Ale korzeń jest ten sam: saldo przestało być liczbą i stało się wynikiem operacji rozsianej po kodzie, robionej ręcznie w każdym miejscu.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Ta linia pojawia się raz przy sprawdzaniu i raz przy liczeniu pozostałego salda zwracanego klientowi. Dwa miejsca, w których trzeba pamiętać o dodawaniu. W dniu, w którym pojawi się trzeci rodzaj kredytu, będą trzy.
I ten, który nie jest błędem, tylko niespisaną polityką
Czwarty przypadek jest inny, ale należy do tej samej rodziny. Webhook invoice.paid nadpisywał saldo przy każdym odnowieniu:
balanceCents: pricing.balanceCents, // nadpisywało poprzednie saldo
Wszystko niezużyte z poprzedniego miesiąca parowało w dniu odnowienia. Na czterdziestu użytkownikach planu z saldem 1499 centów mediana straty wynosiła 310 centów, a 90. percentyl sięgał 890. W niektórych przypadkach prawie 60 procent planu w kosz.
Ciekawe jest to, że nikt tego nie zdecydował. To nie była polityka, tylko konsekwencja napisania przypisania zamiast dodawania. Teraz kumuluje się z sufitem trzech miesięcy:
const nextBalance = Math.min(
current.balanceCents + pricing.balanceCents,
pricing.balanceCents * 3
);
Sufit nie jest kosmetyczny: bez niego konto z okazjonalnym użyciem zbiera dwanaście miesięcy salda, a potem wydaje je w jednym szczycie, co jest dokładnie tym, czego nie chcesz, kiedy wyżej w łańcuchu płacisz dostawcy AI za użycie.
Zasada
Każdy licznik reprezentujący pieniądze pisze się z trzema rzeczami od pierwszego dnia. Raz: inkrement i jego warunki mieszkają w jednej instrukcji, SET x = x + 1 WHERE ..., nigdy w read-modify-write. Dwa: jeśli jest deduplikacja, realny koszt ma gdzie wylądować, nawet gdy limit nie jest pobierany. Trzy: polityka dotycząca tego, co zostaje przy odnowieniu, jest gdzieś spisana, choćby w komentarzu, bo jeśli nie jest, napisze ją za ciebie operator przypisania.
Żadna z tych trzech rzeczy nie kosztuje czasu, gdy robi się ją na początku. Wszystkie trzy kosztują incydent, gdy robi się je później.
