Rust i produktion: därför skrev vi om vår signalprocessor
Vi skrev om vår signalbearbetningsmotor från Go till Rust. Här är varför, vad vi lärde oss och den fyrfaldiga genomströmningsförbättring vi uppnådde.
För sex månader sedan fattade vi beslutet att skriva om vår signalbearbetningsmotor — komponenten som omvandlar råa webbläsarsignaler till normaliserade, hashbara egenskapsvektorer — från Go till Rust. Det var inget beslut vi tog lättvindigt. Vår Go-implementering fungerade. Den var testad. Den var driftsatt. Men den hade blivit flaskhalsen i vår pipeline, och vi behövde en språngartad förbättring av genomströmningen. Här är vad som hände.
Varför vi växte ur Go
Vår signalprocessor utför beräkningsintensivt arbete: parsar JSON-payloads, tillämpar normaliseringsfunktioner på 130+ signaler, beräknar egenutvecklade hashar och bygger identifieringshashvektorer. I Go var detta arbete CPU-bundet, och Go:s garbage collector blev ett problem vid skala. Varje signalbearbetningscykel allokerade mellanliggande objekt — parsade JSON-noder, normaliserade strängvärden, hashbuffertar — som skapade GC-tryck.
Vid 30K händelser/sekund uppvisade vår Go-signalprocessor GC-pauser på 2–5 ms med några sekunders mellanrum. Dessa pauser var acceptabla. Vid 50K händelser/sekund växte GC-pauserna till 8–15 ms och inträffade oftare. Vid 80K händelser/sekund — vår prognostiserade last för Q3 — skulle GC-pauserna ha fått p99-latensen att överskrida vårt SLA. Vi behövde antingen fler servrar (dyrt) eller en effektivare implementering.
Varför Rust
Vi utvärderade tre alternativ: att optimera Go-implementeringen (sync.Pool, arena-allokering, GOGC-justering), att skriva om i C++ och att skriva om i Rust. Go-optimering gav en 30-procentig förbättring men löste inte GC-problemet i grunden. C++ förkastades på grund av oro för minnessäkerhet i ett säkerhetskritiskt system. Rust erbjöd zero-cost-abstraktioner, ingen garbage collector och garantier för minnessäkerhet som upprätthålls vid kompilering.
Rust-ekosystemet hade också mogna bibliotek för allt vi behövde: serde för JSON-parsning, högpresterande hashing-crates och tokio för asynkron I/O. Inlärningskurvan var påtaglig — vårt team hade djup Go-erfarenhet men begränsad Rust-erfarenhet — men prestandaegenskaperna var precis vad vi behövde.
Omskrivningsprocessen
Vi skrev om signalprocessorn som en fristående tjänst som kommunicerar med resten av vår pipeline via gRPC. Detta gjorde att vi kunde driftsätta den vid sidan av Go-implementeringen och gradvis flytta över trafik. Omskrivningen tog tre ingenjörer fyra veckor — två veckor för kärnimplementeringen och två veckor för testning, benchmarking och hantering av gränsfall.
Den mest utmanande aspekten var inte språket i sig utan att säkerställa beteendemässig paritet med Go-implementeringen. Vi byggde ett jämförelsehjälpmedel som körde båda implementeringarna mot samma indata och verifierade att de producerade identisk utdata. Vi upptäckte 14 subtila skillnader under denna process — mestadels relaterade till flyttalshantering, Unicode-normalisering och gränsfall i JSON-parsning.
Prestandaresultat
Rust-implementeringen bearbetar signaler på 0,8 ms i snitt mot 3,2 ms för Go — en fyrfaldig förbättring. Minnesåtgången sjönk från 2,1 GB till 340 MB för samma arbetsbelastning. Det finns inga GC-pauser eftersom det inte finns någon garbage collector. CPU-användningen minskade med 60 % vid samma genomströmning, vilket innebär att varje server hanterar 4x mer trafik.
Vid 80K händelser/sekund bibehåller Rust-implementeringen en p99-bearbetningstid på 1,4 ms utan några pauser. Denna marginal innebär att vi inte kommer att behöva se över signalbearbetningens prestanda inom överskådlig framtid. Den minskade CPU- och minnesåtgången översätts också direkt till lägre infrastrukturkostnader — vi avvecklade 8 av 12 signalbearbetningsservrar.
Lärdomar
Att skriva om i Rust var värt det för vårt specifika fall — en CPU-bunden, allokeringstung, latenskänslig arbetsbelastning. Vi skulle inte skriva om vårt HTTP-inmatningslager eller vår ClickHouse-frågetjänst i Rust, eftersom dessa komponenter är I/O-bundna och Go hanterar dem effektivt. Lärdomen är inte "skriv om allt i Rust" utan "använd Rust där dess zero-cost-abstraktioner och deterministiska prestanda betyder mest".
Den största överraskningen var hur mycket Rust-kompilatorn fångade under omskrivningen. Flera latenta buggar i vår Go-implementering — race conditions på delade buffertar, heltalsspill i hashberäkning och out-of-bounds-åtkomst vid felaktig indata — fångades som kompileringsfel i Rust. Kompilatorn är krävande, men den betalar för sig själv i korrekthet.
Ärligt talat var den första veckan smärtsam. Sarah förde en räknare över "strider med borrow checkern" på whiteboarden — vi nådde 47 innan teamet slutade räkna. Men vid vecka tre bara fungerade koden som kompilerade. Inga mystiska produktionspanikar, inga datakapplöpningar under last. Den avvägningen är värd det för allt på den heta vägen.