02Architektura5 min

Ten sam błąd współbieżności pojawił mi się w trzech różnych projektach

Licznik, który dodaje pod obciążeniem, gubi inkrementy, a taki, który odejmuje, potrafi policzyć to samo dwa razy. Kiedy licznikiem są pieniądze, to nie jest szczegół do naprawienia później.

Autor: Salva Castells

Zdjęcie okładkowe: kisnikee, CC0.
W tym wpisie

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.