Oluwa.io
Wszystkie teksty
·6 min czytaniainżynieriakryptoniezawodnośćtrading

Niezawodna realizacja zleceń krypto: jak ją zbudować

Niezawodność realizacji zleceń krypto opiera się na maszynach stanów, kluczach idempotencji i bezlitosnej rekoncyliacji. Wnioski z budowy MadaiOps.

Autor Vitor Lima

Większość produktów do handlu kryptowalutami sprzedaje szybkość i wykres. Trudną częścią nie jest rysowanie świec. Jest nią zdolność do udzielenia w dowolnej milisekundzie odpowiedzi na pozornie proste pytanie: jaki jest teraz prawdziwy stan mojego zlecenia? Gdy zaczynaliśmy budować MadaiOps, naszą wewnętrzną aplikację do obsługi zleceń i operacji tradingowych, to pytanie organizowało wszystko. Szybkość jest funkcją. Prawda jest produktem. Niezawodność realizacji zleceń krypto to stan, w którym aplikacja potrafi uczciwie odpowiedzieć na to pytanie nawet wtedy, gdy giełda podupada, a aplikacja, która pokazuje realizację, która się nie wydarzyła — lub ukrywa taką, która się wydarzyła — niszczy zaufanie w sposób, którego żadna wygrana na polu latencji nie odkupi.

Ten wpis jest o inżynierii, która sprawia, że aplikacja do obsługi zleceń jest godna zaufania: o maszynie stanów, idempotencji, rekoncyliacji przez WebSockety oraz o trybach awarii, które ujawniają się dopiero pod obciążeniem. Mamy w tych sprawach mocne zdanie, bo to właśnie nudne wybory utrzymują cię przy wypłacalności.

Modeluj zlecenie jako jawną maszynę stanów

Najlepszą decyzją, jaką podjęliśmy, było przestać traktować zlecenie jako wiersz, który się UPDATE, a zacząć traktować je jako skończoną maszynę stanów z nazwanymi, wyliczonymi przejściami. Zlecenie żyje w stanach takich jak PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED i EXPIRED. Każde przejście jest funkcją bieżącego stanu plus zdarzenia przychodzącego, a niedozwolone przejścia są odrzucane głośno, zamiast po cichu pochłaniane.

Zysk: niejednoznaczność staje się problemem czasu walidacji, a nie incydentem o drugiej w nocy. Zlecenie FILLED nigdy nie może wrócić do WORKING. Zdarzenie realizacji przychodzące dla zlecenia CANCELED nie jest „niemożliwe" — to prawdziwy wyścig, a maszyna stanów zmusza cię do zdecydowania, co on oznacza. Zwykle oznacza, że anulowanie przegrało wyścig, zlecenie faktycznie się zrealizowało, a twój lokalny CANCELED był błędny. Modeluj zlecenia jako mutowalne wiersze, a to zdarzenie po prostu coś nadpisze; nigdy tego nie zauważysz, dopóki rekoncyliacja — albo użytkownik — tego nie wychwyci.

Wniosek

Wylicz swoje stany, zanim napiszesz choćby jedno wywołanie API. Jeśli nie potrafisz narysować diagramu na tablicy, to jeszcze nie rozumiesz swojej domeny, a giełda nauczy cię tego twardą ręką. Przechowuj przejścia, a nie tylko bieżący stan. Dopisywany tylko do przodu (append-only) dziennik zdarzeń tego, co się wydarzyło, bije pojedynczą mutowalną kolumnę statusu, bo dziennik da się odtworzyć, a kolumna to zgadywanka.

Idempotencja nie jest opcjonalna

Sieci ponawiają. Twoja własna logika retry ponawia. Użytkownik dwukrotnie klika „Kup". Każde z tych zdarzeń może wysłać to samo zlecenie dwa razy, a na giełdzie duplikat zlecenia to realne pieniądze. Obroną jest generowany po stronie klienta klucz idempotencji — większość giełd nazywa go clientOrderId lub newClientOrderId — dołączany do każdego zlecenia w momencie, gdy użytkownik je zatwierdza, zanim żądanie w ogóle opuści urządzenie lub serwer.

Reguła, którą egzekwujemy: klucz jest generowany raz, utrwalany lokalnie przed wywołaniem sieciowym i używany dosłownie tak samo przy każdym ponowieniu. Jeśli pierwsza próba wygasła bez odpowiedzi, retry niesie ten sam klucz, a giełda albo tworzy zlecenie raz, albo zwraca już istniejące. Tak czy inaczej zbiegasz się do jednego zlecenia. Wygeneruj klucz po awarii, a zbudowałeś maszynę do produkcji duplikatów zleceń.

Rozciąga się to na anulowania i modyfikacje. Żądanie anulowania również powinno być idempotentne: anulowanie już anulowanego zlecenia to operacja bez efektu (no-op), która zwraca sukces, a nie błąd wyzwalający kolejną pętlę retry. Wszędzie traktujemy „pożądany stan końcowy jest już prawdą" jako sukces. Brzmi trywialnie. Eliminuje całą kategorię burz retry.

Wniosek

Utrwalaj klucz idempotencji zanim zadziałasz, a nie po tym, jak się uda. Luka między „wysłałem to" a „wiem, że zadziałało" to dokładnie miejsce, w którym znikają pieniądze, a klucz jest jedyną rzeczą, która tę lukę domyka.

Rekoncyliuj bezlitośnie: WebSocket to podpowiedź, nie prawda

Giełdy wypychają aktualizacje zleceń przez WebSockety i są cudowne, gdy działają: realizacje w ułamku sekundy, żywe ilości częściowych realizacji, potwierdzenia anulowania. Ale WebSocket to strumień, który możesz przegapić. Zostaniesz rozłączony. Wiadomości docierają w złej kolejności, gubią się podczas ponownego połączenia albo po prostu nigdy nie przychodzą, bo giełda podupada. Jeśli stan twojego zlecenia to wyłącznie to, co ostatnio powiedział ci socket, twoja aplikacja jest pewnie błędna w chwili, gdy socket się zachłyśnie.

Dlatego uruchamiamy dwie pętle. Szybka pętla konsumuje WebSocket i stosuje zdarzenia optymistycznie. Wolna pętla odpytuje REST API — GET /order oraz endpointy otwartych zleceń — w określonych interwałach i po każdym ponownym połączeniu, a następnie rekoncyliuje ten snapshot ze stanem lokalnym. REST jest źródłem prawdy; socket to podpowiedź o niskiej latencji. Gdy się nie zgadzają, wygrywa REST, a my logujemy rozbieżność, bo rozbieżność to zgłoszenie błędu, które system napisał za ciebie.

Sekwencjonowanie ma znaczenie. Większość giełd stempluje aktualizacje monotonicznym numerem sekwencyjnym lub polem updateTime. Odrzucamy każde zdarzenie starsze niż ostatnio zastosowane dla danego zlecenia, co czyni dostarczanie w złej kolejności nieszkodliwym. Po ponownym połączeniu poprawny ruch to: zasubskrybuj ponownie, natychmiast pobierz pełny snapshot z REST, by uzupełnić lukę, a dopiero potem znów zaufaj żywemu strumieniowi. Pomiń snapshot, a przegapisz realizację, która wydarzyła się w ciągu tych dwóch sekund, gdy byłeś offline.

Wniosek

Nigdy nie pozwól, by kanał czasu rzeczywistego był jedynym zapisującym stan. Socket czyni cię szybkim; pętla rekoncyliacji czyni cię poprawnym. Potrzebujesz obu, a gdy wchodzą w konflikt, poprawność za każdym razem bije szybkość.

Częściowe realizacje i rozłączenia: te dwie rzeczy cię obnażają

Częściowe realizacje to miejsce, w którym naiwne aplikacje po cichu kłamią. Zlecenie na 1,0 BTC, które realizuje się w 0,3, potem 0,4, a potem wisi, nie jest „otwarte" i nie jest „zakończone". Jest PARTIALLY_FILLED z 0,7 zrealizowanym i 0,3 pozostałym, a każdą z tych liczb trzeba śledzić z poszczególnych zdarzeń realizacji, a nie wywnioskować z jednego pola statusu. Akumulujemy zrealizowaną ilość i średnią cenę ważoną wolumenem z dyskretnych realizacji, tak by pozycja zawsze dawała się zrekonstruować z prymitywów. Użytkownik, który widzi „zrealizowane", musi wiedzieć zrealizowane ile, po jakiej średniej cenie. Cokolwiek mniej to półprawda.

Rozłączenia to pogłębiają. Padnij offline w połowie realizacji, a WebSocket nigdy nie powie ci o tych 0,4, więc twój stan lokalny jest nieaktualny. To właśnie dlatego istnieje dyscyplina „najpierw ponowne połączenie, potem snapshot": snapshot z REST zwraca skumulowaną zrealizowaną ilość, a pętla rekoncyliacji domyka lukę bez tego, by użytkownik kiedykolwiek zobaczył błędną liczbę. Trybem awarii, przeciwko któremu projektujemy, nie jest „aplikacja się wywaliła" — awarie są uczciwe. Jest nim „aplikacja dalej działała i pokazywała coś fałszywego".

Wniosek

Śledź skumulowaną zrealizowaną ilość jako fakt podstawowy i wyprowadzaj z niej wszystko inne. Nigdy nie przechowuj „pozostałego" jako niezależnego pola, które mutujesz. Wyprowadzaj je (zlecone − zrealizowane), tak by nie mogło się rozjechać z rzeczywistością.

Prawdomówny pod presją bije sprytny

Istnieje uporczywa pokusa, by być sprytnym: przewidywać realizacje przed potwierdzeniem, ukrywać „przejściowe" błędy, wygładzać ponowne połączenie tak, by UI nigdy nie mrugnęło. Nauczyliśmy się opierać większości z tego. Operator tradingowy nie chce gładkiego kłamstwa; chce wiedzieć, uczciwie, że połączenie właśnie padło i stan się rekoncyliuje. Widoczny stan „ponowne łączenie, weryfikuję zlecenia" jest wart więcej niż bezszwowe UI, które okazuje się pokazywać wczorajszą prawdę.

Konkretnie, ujawniamy niepewność zamiast ją zaklejać. Gdy stan lokalny i giełda nie zrekoncyliowały się od jakiegoś czasu, aplikacja to mówi. Gdy zlecenie jest CANCEL_REQUESTED, ale jeszcze nie potwierdzone jako anulowane, pokazuje dokładnie to, a nie przedwczesny CANCELED. Optymistyczny UI jest w porządku w aplikacji z listą zadań. Przy pieniądzach optymizm to obciążenie, za które płacisz zgłoszeniami do supportu i utraconym zaufaniem.

Wniosek

W razie wątpliwości pokaż wątpliwość. Najbardziej uspokajającą rzeczą, jaką aplikacja finansowa może zrobić pod presją, jest odmowa zgadywania.

Myśl na koniec

Nic z tego nie jest efektowne. Nie ma demo, na którym „nasze klucze idempotencji są utrwalane przed wywołaniem sieciowym" zbiera oklaski. Ale niezawodność nie jest funkcją, którą dodaje się później. To zestaw decyzji, które podejmujesz wcześnie i których odmawiasz kompromitować, a potem bronisz za każdym razem, gdy skrót zaczyna kusić. Zbuduj system, który mówi prawdę, gdy wszystko płonie, a szybkie i ładne części zatroszczą się o siebie same.