Rust în producție: de ce am rescris procesorul nostru de semnale
Am rescris motorul de procesare a semnalelor din Go în Rust. Iată de ce, ce am învățat și îmbunătățirea de 4x a debitului pe care am obținut-o.
Acum șase luni, am luat decizia de a rescrie motorul nostru de procesare a semnalelor — componenta care transformă semnalele brute de browser în vectori de caracteristici normalizați și hash-abili — din Go în Rust. Nu a fost o decizie pe care am luat-o cu ușurință. Implementarea noastră în Go funcționa. Era testată. Era în producție. Dar devenise gâtuirea de sistem în pipeline-ul nostru și aveam nevoie de o îmbunătățire în salt a debitului. Iată ce s-a întâmplat.
De ce am depășit posibilitățile Go
Procesorul nostru de semnale efectuează o muncă intensivă din punct de vedere computațional: parsarea payload-urilor JSON, aplicarea funcțiilor de normalizare la peste 130 de semnale, calcularea unor hash-uri proprietare și construirea de vectori de hash pentru identificare. În Go, această muncă era limitată de CPU, iar garbage collector-ul din Go devenea o problemă la scară. Fiecare ciclu de procesare a semnalelor aloca obiecte intermediare — noduri JSON parsate, valori de string normalizate, buffere de hash — care creau presiune asupra GC.
La 30K evenimente/secundă, procesorul nostru de semnale în Go prezenta pauze GC de 2-5ms la câteva secunde. Aceste pauze erau acceptabile. La 50K evenimente/secundă, pauzele GC au crescut la 8-15ms și apăreau mai frecvent. La 80K evenimente/secundă — sarcina noastră proiectată pentru T3 — pauzele GC ar fi făcut ca latența p99 să depășească SLA-ul nostru. Aveam nevoie fie de mai multe servere (scump), fie de o implementare mai eficientă.
De ce Rust
Am evaluat trei opțiuni: optimizarea implementării în Go (sync.Pool, alocare pe arene, reglarea GOGC), rescrierea în C++ și rescrierea în Rust. Optimizarea Go a adus o îmbunătățire de 30%, dar nu a rezolvat fundamental problema GC. C++ a fost respins din cauza problemelor de siguranță a memoriei într-un sistem critic pentru securitate. Rust oferea abstracții cu cost zero, niciun garbage collector și garanții de siguranță a memoriei impuse la compilare.
Ecosistemul Rust avea, de asemenea, biblioteci mature pentru tot ce ne trebuia: serde pentru parsarea JSON, crate-uri de hashing de înaltă performanță și tokio pentru I/O asincron. Curba de învățare era reală — echipa noastră avea o experiență profundă în Go, dar limitată în Rust — însă caracteristicile de performanță erau exact ceea ce ne trebuia.
Procesul de rescriere
Am rescris procesorul de semnale ca un serviciu de sine stătător care comunică cu restul pipeline-ului nostru prin gRPC. Acest lucru ne-a permis să îl implementăm alături de implementarea în Go și să mutăm treptat traficul. Rescrierea a durat trei ingineri patru săptămâni — două săptămâni pentru implementarea de bază și două săptămâni pentru testare, benchmarking și tratarea cazurilor limită.
Cel mai dificil aspect nu a fost limbajul în sine, ci asigurarea parității de comportament cu implementarea în Go. Am construit un harnașament de comparație care rula ambele implementări pe aceeași intrare și verifica dacă produceau o ieșire identică. Am descoperit 14 diferențe subtile în timpul acestui proces — majoritatea legate de gestionarea numerelor cu virgulă mobilă, normalizarea Unicode și cazurile limită de parsare JSON.
Rezultate de performanță
Implementarea în Rust procesează semnalele în 0,8ms în medie, față de 3,2ms pentru Go — o îmbunătățire de 4x. Utilizarea memoriei a scăzut de la 2,1GB la 340MB pentru aceeași sarcină. Nu există pauze GC, pentru că nu există garbage collector. Utilizarea CPU a scăzut cu 60% la același debit, ceea ce înseamnă că fiecare server gestionează de 4x mai mult trafic.
La 80K evenimente/secundă, implementarea în Rust menține un timp de procesare p99 de 1,4ms, fără nicio pauză. Această marjă înseamnă că nu va trebui să revizuim performanța procesării semnalelor în viitorul previzibil. Utilizarea redusă a CPU și a memoriei se traduce, de asemenea, direct în costuri de infrastructură mai mici — am retras 8 din 12 servere de procesare a semnalelor.
Lecții învățate
Rescrierea în Rust a meritat pentru cazul nostru specific — o sarcină limitată de CPU, cu multe alocări și sensibilă la latență. Nu am rescrie în Rust stratul nostru de ingestie HTTP sau serviciul nostru de interogare ClickHouse, pentru că acele componente sunt limitate de I/O, iar Go le gestionează eficient. Lecția nu este „rescrie totul în Rust”, ci „folosește Rust acolo unde abstracțiile sale cu cost zero și performanța deterministă contează cel mai mult”.
Cea mai mare surpriză a fost cât de mult a prins compilatorul Rust în timpul rescrierii. Câteva bug-uri latente din implementarea noastră în Go — condiții de cursă pe buffere partajate, depășire de întregi în calcularea hash-urilor și acces în afara limitelor la intrări malformate — au fost prinse ca erori de compilare în Rust. Compilatorul este exigent, dar se autofinanțează prin corectitudine.
Sincer, prima săptămână a fost dureroasă. Sarah ținea pe tablă un contor al „luptelor cu borrow checker-ul” — am ajuns la 47 înainte ca echipa să nu mai numere. Dar până în săptămâna a treia, codul care compila pur și simplu funcționa. Niciun panic misterios în producție, nicio cursă de date sub sarcină. Acest compromis merită pentru orice se află pe calea critică.