Betrouwbare crypto-orderuitvoering bouwen die klopt onder druk
Betrouwbare crypto-orderuitvoering ontstaat uit state machines, idempotentiesleutels en meedogenloze reconciliatie. Lessen uit het bouwen van MadaiOps.
Door Vitor Lima
De meeste crypto-tradingproducten verkopen snelheid en een grafiek. Het moeilijke zit niet in het tekenen van de candles. Het zit in het kunnen beantwoorden, op elke willekeurige milliseconde, van een bedrieglijk simpele vraag: wat is de werkelijke status van mijn order op dit moment? Toen we begonnen met het bouwen van MadaiOps, onze interne app voor orders en trading-operaties, gaf die vraag structuur aan alles. Snelheid is een feature. Waarheid is het product. Betrouwbare crypto-orderuitvoering is wat je hebt wanneer de app die vraag eerlijk kan beantwoorden, zelfs terwijl de exchange achteruitgaat. En een app die een fill toont die niet plaatsvond — of er een verbergt die dat wel deed — vernietigt vertrouwen op een manier die geen enkele latencywinst ooit terugkoopt.
Deze post gaat over de engineering die een orders-app betrouwbaar maakt: de state machine, idempotentie, reconciliatie over WebSockets, en de faalmodi die alleen onder belasting opduiken. We hebben over dit alles een uitgesproken mening, want de saaie keuzes zijn precies de keuzes die je solvabel houden.
Modelleer de order als een expliciete state machine
De allerbeste beslissing die we namen, was om te stoppen met een order behandelen als een rij die je UPDATEt, en te beginnen met hem behandelen als een finite state machine met benoemde, opgesomde overgangen. Een order leeft in statussen als PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED en EXPIRED. Elke overgang is een functie van de huidige status plus een binnenkomend event, en ongeldige overgangen worden luid afgewezen in plaats van stilzwijgend geabsorbeerd.
De opbrengst: dubbelzinnigheid wordt een zorg tijdens validatie in plaats van een incident om twee uur 's nachts. Een FILLED-order kan nooit terug naar WORKING. Een fill-event dat binnenkomt voor een CANCELED-order is niet "onmogelijk" — het is een echte race, en de state machine dwingt je te beslissen wat het betekent. Meestal betekent het dat de cancel de race verloor, de order daadwerkelijk werd gevuld, en je lokale CANCELED fout was. Modelleer orders als muteerbare rijen en dat event overschrijft simpelweg iets; je merkt het pas als de reconciliatie — of een gebruiker — het opvangt.
De kernboodschap
Som je statussen op voordat je ook maar één API-call schrijft. Als je het diagram niet op een whiteboard kunt tekenen, begrijp je je domein nog niet, en dan leert de exchange het je op de harde manier. Sla de overgangen op, niet alleen de huidige status. Een append-only eventlog van wat er gebeurde verslaat een enkele muteerbare statuskolom, want de log is herspeelbaar en de kolom is een gok.
Idempotentie is niet optioneel
Netwerken herhalen. Je eigen retry-logica herhaalt. Een gebruiker dubbeltikt op "Kopen." Elk van deze kan dezelfde order twee keer versturen, en op een exchange is een dubbele order echt geld. De verdediging is een door de client gegenereerde idempotentiesleutel — de meeste exchanges noemen die clientOrderId of newClientOrderId — die aan elke order wordt gekoppeld op het moment dat de gebruiker bevestigt, voordat het verzoek ooit het apparaat of de server verlaat.
De regel die we afdwingen: de sleutel wordt één keer gegenereerd, lokaal gepersisteerd voordat de netwerkcall plaatsvindt, en woordelijk hergebruikt bij elke retry. Als de eerste poging een time-out kreeg zonder respons, draagt de retry dezelfde sleutel, en de exchange maakt de order óf één keer aan óf geeft de bestaande terug. Hoe dan ook convergeer je naar één enkele order. Genereer je de sleutel na een fout, dan heb je een machine gebouwd die dubbele orders produceert.
Dit strekt zich uit tot cancels en wijzigingen. Een cancel-verzoek moet ook idempotent zijn: het annuleren van een reeds geannuleerde order is een no-op die succes teruggeeft, geen fout die een nieuwe retry-loop op gang brengt. We behandelen "de gewenste eindstatus is al waar" overal als succes. Het klinkt triviaal. Het elimineert een hele categorie retry-stormen.
De kernboodschap
Persisteer de idempotentiesleutel voordat je handelt, niet nadat je slaagt. De kloof tussen "ik heb het verstuurd" en "ik weet dat het werkte" is precies waar geld verdwijnt, en de sleutel is het enige dat die kloof dicht.
Reconcilieer meedogenloos: de WebSocket is een hint, geen waarheid
Exchanges pushen order-updates over WebSockets, en die zijn geweldig zolang ze werken: fills binnen een seconde, live partiële-fill-hoeveelheden, cancel-bevestigingen. Maar een WebSocket is een stream die je kunt missen. Je zult de verbinding verliezen. Berichten komen in de verkeerde volgorde aan, worden gedropt tijdens een reconnect, of komen simpelweg nooit omdat de exchange achteruitgaat. Als je orderstatus alleen is wat de socket je het laatst vertelde, zit je app er zelfverzekerd naast op het moment dat de socket hapert.
Daarom draaien we twee loops. De snelle loop consumeert de WebSocket en past events optimistisch toe. De trage loop pollt de REST-API — GET /order en de open-orders-endpoints — met een interval en na elke reconnect, en reconcilieert die snapshot vervolgens met de lokale status. REST is de bron van waarheid; de socket is een low-latency hint. Wanneer ze het oneens zijn, wint REST en loggen we de divergentie, want een divergentie is een bugrapport dat het systeem voor je schreef.
Volgorde is belangrijk. De meeste exchanges stempelen updates met een monotoon oplopend sequencenummer of een updateTime. We droppen elk event dat ouder is dan het laatst toegepaste voor die order, wat aflevering in de verkeerde volgorde onschadelijk maakt. Bij een reconnect is de juiste zet: opnieuw abonneren, meteen een volledige REST-snapshot ophalen om het gat op te vullen, en dan pas de live stream weer vertrouwen. Sla de snapshot over en je mist de fill die plaatsvond tijdens de twee seconden dat je offline was.
De kernboodschap
Laat een realtime feed nooit de enige schrijver van je status zijn. De socket maakt je snel; de reconciliatie-loop maakt je correct. Je hebt beide nodig, en wanneer ze botsen, wint correct elke keer van snel.
Partiële fills en disconnects: de twee die je ontmaskeren
Partiële fills zijn waar naïeve apps stilletjes liegen. Een order voor 1,0 BTC die 0,3 vult, dan 0,4, en dan blijft hangen is niet "open" en is niet "klaar." Het is PARTIALLY_FILLED met 0,7 uitgevoerd en 0,3 resterend, en elk van die getallen moet worden bijgehouden op basis van de individuele fill-events, niet afgeleid uit één enkel statusveld. We accumuleren de uitgevoerde hoeveelheid en de volumegewogen gemiddelde prijs uit de losse fills, zodat de positie altijd reconstrueerbaar is uit primitieven. Een gebruiker die "gevuld" ziet, moet weten gevuld hoeveel, tegen welke gemiddelde prijs. Alles daaronder is een halve waarheid.
Disconnects verergeren dit. Val offline midden in een fill en de WebSocket heeft je nooit over die 0,4 verteld, dus je lokale status is verouderd. Precies daarom bestaat de discipline van reconnect-dan-snapshot: de REST-snapshot geeft de cumulatieve uitgevoerde hoeveelheid terug, en de reconciliatie-loop dicht het gat zonder dat de gebruiker ooit een verkeerd getal ziet. De faalmodus waartegen we ontwerpen is niet "de app crashte" — crashes zijn eerlijk. Het is "de app bleef draaien en toonde iets onwaars."
De kernboodschap
Houd de cumulatieve uitgevoerde hoeveelheid bij als het primaire feit en leid al het andere daaruit af. Sla "resterend" nooit op als een onafhankelijk veld dat je muteert. Leid het af (besteld − uitgevoerd), zodat het niet uit de pas kan lopen met de werkelijkheid.
Eerlijk onder druk verslaat slim
Er is een hardnekkige verleiding om slim te zijn: fills voorspellen vóór bevestiging, "tijdelijke" fouten verbergen, een reconnect gladstrijken zodat de UI nooit flikkert. We hebben geleerd het meeste hiervan te weerstaan. Een trading-operator wil geen gladde leugen; hij wil eerlijk weten dat de verbinding zojuist wegviel en dat de status wordt gereconcilieerd. Een zichtbare "opnieuw verbinden, orders verifiëren"-status is meer waard dan een naadloze UI die achteraf de waarheid van gisteren blijkt te tonen.
Concreet: we maken onzekerheid zichtbaar in plaats van hem toe te dekken. Wanneer de lokale status en de exchange recent niet zijn gereconcilieerd, zegt de app dat. Wanneer een order CANCEL_REQUESTED is maar nog niet bevestigd geannuleerd, toont hij precies dat, niet een voorbarige CANCELED. Optimistische UI is prima voor een to-do-app. Voor geld is optimisme een risico dat je betaalt in supporttickets en verloren vertrouwen.
De kernboodschap
Bij twijfel, toon de twijfel. Het meest geruststellende dat een financiële app onder druk kan doen, is weigeren te gokken.
Afsluitende gedachte
Niets hiervan is glamoureus. Er is geen demo waarin "onze idempotentiesleutels worden gepersisteerd vóór de netwerkcall" applaus oogst. Maar betrouwbaarheid is geen feature die je later toevoegt. Het is het geheel van beslissingen dat je vroeg neemt en weigert te compromitteren, en vervolgens elke keer verdedigt wanneer een sluiproute verleidelijk lijkt. Bouw het systeem dat de waarheid vertelt wanneer alles in brand staat, en de snelle, mooie onderdelen zorgen voor zichzelf.