Rust v produkci: proč jsme přepsali náš procesor signálů
Přepsali jsme náš engine pro zpracování signálů z Go do Rustu. Zde je proč, co jsme se naučili a jak jsme dosáhli čtyřnásobného zvýšení propustnosti.
Před šesti měsíci jsme se rozhodli přepsat náš engine pro zpracování signálů — komponentu, která transformuje surové signály z prohlížeče na normalizované, hashovatelné vektory příznaků — z Go do Rustu. Nebylo to rozhodnutí, které bychom brali na lehkou váhu. Naše implementace v Go fungovala. Byla otestovaná. Byla nasazená. Ale stala se úzkým hrdlem naší pipeline a potřebovali jsme skokové zlepšení propustnosti. Zde je, co se stalo.
Proč jsme z Go vyrostli
Náš procesor signálů vykonává výpočetně náročnou práci: parsuje JSON payloady, aplikuje normalizační funkce na více než 130 signálů, počítá proprietární hashe a sestavuje identifikační hashovací vektory. V Go byla tato práce vázaná na CPU a garbage collector Go se ve velkém měřítku stal problémem. Každý cyklus zpracování signálu alokoval mezilehlé objekty — naparsované JSON uzly, normalizované řetězcové hodnoty, hashovací buffery — které vytvářely tlak na GC.
Při 30 tis. událostech za sekundu vykazoval náš procesor signálů v Go pauzy GC v délce 2–5 ms každých pár sekund. Tyto pauzy byly přijatelné. Při 50 tis. událostech za sekundu narostly pauzy GC na 8–15 ms a objevovaly se častěji. Při 80 tis. událostech za sekundu — naší plánované zátěži pro Q3 — by pauzy GC způsobily, že by latence p99 překročila naše SLA. Potřebovali jsme buď více serverů (drahé), nebo efektivnější implementaci.
Proč Rust
Zvažovali jsme tři možnosti: optimalizaci implementace v Go (sync.Pool, arénová alokace, ladění GOGC), přepis do C++ a přepis do Rustu. Optimalizace Go přinesla 30% zlepšení, ale zásadně nevyřešila problém s GC. C++ jsme zamítli kvůli obavám o bezpečnost paměti v bezpečnostně kritickém systému. Rust nabízel abstrakce s nulovou režií, žádný garbage collector a záruky bezpečnosti paměti vynucené při kompilaci.
Ekosystém Rustu měl také vyzrálé knihovny pro všechno, co jsme potřebovali: serde pro parsování JSON, vysoce výkonné hashovací crate a tokio pro asynchronní I/O. Křivka učení byla reálná — náš tým měl hluboké zkušenosti s Go, ale omezené zkušenosti s Rustem — ale výkonnostní charakteristiky byly přesně to, co jsme potřebovali.
Proces přepisu
Procesor signálů jsme přepsali jako samostatnou službu, která komunikuje se zbytkem naší pipeline přes gRPC. To nám umožnilo nasadit ho vedle implementace v Go a postupně přesouvat provoz. Přepis zabral třem inženýrům čtyři týdny — dva týdny na jádro implementace a dva týdny na testování, benchmarking a ošetření hraničních případů.
Nejnáročnějším aspektem nebyl samotný jazyk, ale zajištění shody chování s implementací v Go. Vytvořili jsme srovnávací nástroj, který spouštěl obě implementace nad stejným vstupem a ověřoval, že produkují identický výstup. Během tohoto procesu jsme odhalili 14 jemných rozdílů — většinou souvisejících se zpracováním čísel s plovoucí desetinnou čárkou, normalizací Unicode a hraničními případy parsování JSON.
Výsledky výkonu
Implementace v Rustu zpracovává signály za průměrných 0,8 ms oproti 3,2 ms u Go — čtyřnásobné zlepšení. Využití paměti kleslo z 2,1 GB na 340 MB pro stejnou zátěž. Nejsou žádné pauzy GC, protože není žádný garbage collector. Využití CPU se snížilo o 60 % při stejné propustnosti, což znamená, že každý server obslouží čtyřnásobný provoz.
Při 80 tis. událostech za sekundu si implementace v Rustu udržuje dobu zpracování p99 na úrovni 1,4 ms bez jakýchkoli pauz. Tato rezerva znamená, že se výkonem zpracování signálů nebudeme muset v dohledné době znovu zabývat. Snížené využití CPU a paměti se také přímo promítá do nižších nákladů na infrastrukturu — vyřadili jsme 8 z 12 serverů pro zpracování signálů.
Poučení
Přepis do Rustu se v našem konkrétním případě vyplatil — u zátěže vázané na CPU, náročné na alokace a citlivé na latenci. Naši vrstvu HTTP příjmu ani naši službu dotazů do ClickHouse bychom do Rustu nepřepisovali, protože tyto komponenty jsou vázané na I/O a Go je zvládá efektivně. Poučením není „přepiš všechno do Rustu“, ale „používej Rust tam, kde na jeho abstrakcích s nulovou režií a deterministickém výkonu záleží nejvíc“.
Největším překvapením bylo, kolik toho kompilátor Rustu během přepisu zachytil. Několik skrytých chyb v naší implementaci v Go — race conditions na sdílených bufferech, přetečení celých čísel při výpočtu hashe a přístupy mimo hranice pole u chybného vstupu — bylo zachyceno jako chyby při kompilaci v Rustu. Kompilátor je náročný, ale vyplatí se svou korektností.
Upřímně, první týden byl bolestivý. Sarah si na tabuli vedla čárky „souboje s borrow checkerem“ — dosáhli jsme 47, než tým přestal počítat. Ale ve třetím týdnu kód, který se zkompiloval, prostě fungoval. Žádné záhadné pády v produkci, žádné datové závody pod zátěží. Ten kompromis stojí za to u čehokoli, co je výkonově kritické.