Rust in produzione: perché abbiamo riscritto il nostro elaboratore di segnali
Abbiamo riscritto il nostro motore di elaborazione dei segnali da Go a Rust. Ecco perché, cosa abbiamo imparato e il miglioramento di throughput di 4x che abbiamo ottenuto.
Sei mesi fa abbiamo preso la decisione di riscrivere il nostro motore di elaborazione dei segnali — il componente che trasforma i segnali grezzi del browser in vettori di feature normalizzati e trasformabili in hash — da Go a Rust. Non è stata una decisione presa alla leggera. La nostra implementazione in Go funzionava. Era testata. Era in produzione. Ma era diventata il collo di bottiglia della nostra pipeline e ci serviva un miglioramento del throughput a gradino. Ecco cosa è successo.
Perché Go non ci bastava più
Il nostro elaboratore di segnali svolge un lavoro computazionalmente intensivo: parsing di payload JSON, applicazione di funzioni di normalizzazione a oltre 130+ segnali, calcolo di hash proprietari e costruzione di vettori di hash di identificazione. In Go questo lavoro era CPU-bound e il garbage collector di Go è diventato un problema su scala. Ogni ciclo di elaborazione dei segnali allocava oggetti intermedi — nodi JSON parsificati, valori stringa normalizzati, buffer di hash — che generavano pressione sul GC.
A 30K eventi/secondo il nostro elaboratore in Go mostrava pause GC di 2-5ms ogni pochi secondi. Queste pause erano accettabili. A 50K eventi/secondo le pause GC crescevano a 8-15ms e si verificavano più di frequente. A 80K eventi/secondo — il carico proiettato per il terzo trimestre — le pause GC avrebbero fatto superare alla latenza p99 il nostro SLA. Ci servivano o più server (costoso) o un'implementazione più efficiente.
Perché Rust
Abbiamo valutato tre opzioni: ottimizzare l'implementazione Go (sync.Pool, arena allocation, tuning di GOGC), riscrivere in C++ e riscrivere in Rust. L'ottimizzazione di Go dava un miglioramento del 30% ma non risolveva alla radice il problema del GC. Il C++ è stato scartato per via dei dubbi sulla memory safety in un sistema critico per la sicurezza. Rust offriva astrazioni a costo zero, nessun garbage collector e garanzie di memory safety imposte in fase di compilazione.
L'ecosistema Rust disponeva inoltre di librerie mature per tutto ciò che ci serviva: serde per il parsing JSON, crate di hashing ad alte prestazioni e tokio per l'I/O asincrono. La curva di apprendimento era reale — il nostro team aveva una profonda esperienza in Go ma un'esperienza limitata in Rust — ma le caratteristiche prestazionali erano esattamente ciò di cui avevamo bisogno.
Il processo di riscrittura
Abbiamo riscritto l'elaboratore di segnali come servizio autonomo che comunica con il resto della nostra pipeline tramite gRPC. Questo ci ha permesso di distribuirlo affiancandolo all'implementazione in Go e di spostare il traffico gradualmente. La riscrittura ha impegnato tre ingegneri per quattro settimane — due settimane per l'implementazione centrale e due settimane per test, benchmarking e gestione dei casi limite.
L'aspetto più impegnativo non è stato il linguaggio in sé, ma garantire la parità comportamentale con l'implementazione in Go. Abbiamo costruito un banco di confronto che eseguiva entrambe le implementazioni sullo stesso input e verificava che producessero un output identico. Durante questo processo abbiamo scoperto 14 differenze sottili — per lo più legate alla gestione dei numeri in virgola mobile, alla normalizzazione Unicode e a casi limite del parsing JSON.
Risultati prestazionali
L'implementazione in Rust elabora i segnali in 0,8ms in media contro i 3,2ms di Go — un miglioramento di 4x. L'uso della memoria è sceso da 2,1GB a 340MB per lo stesso carico di lavoro. Non ci sono pause GC perché non c'è alcun garbage collector. L'utilizzo della CPU è diminuito del 60% a parità di throughput, il che significa che ciascun server gestisce 4x più traffico.
A 80K eventi/secondo l'implementazione in Rust mantiene un tempo di elaborazione p99 di 1,4ms senza alcuna pausa. Questo margine significa che non dovremo rimettere mano alle prestazioni dell'elaborazione dei segnali per il prossimo futuro. Il ridotto utilizzo di CPU e memoria si traduce anche direttamente in costi infrastrutturali inferiori — abbiamo dismesso 8 dei 12 server di elaborazione dei segnali.
Lezioni apprese
Riscrivere in Rust ne è valsa la pena per il nostro caso specifico — un carico di lavoro CPU-bound, ad alta intensità di allocazioni e sensibile alla latenza. Non riscriveremmo in Rust il nostro livello di ingestione HTTP né il nostro servizio di query su ClickHouse, perché quei componenti sono I/O-bound e Go li gestisce in modo efficiente. La lezione non è «riscrivere tutto in Rust» ma «usare Rust dove le sue astrazioni a costo zero e le prestazioni deterministiche contano di più».
La sorpresa più grande è stata quanto il compilatore Rust abbia intercettato durante la riscrittura. Diversi bug latenti nella nostra implementazione in Go — race condition su buffer condivisi, overflow di interi nel calcolo degli hash e accessi fuori dai limiti su input malformati — sono stati intercettati come errori in fase di compilazione in Rust. Il compilatore è esigente, ma si ripaga da solo in termini di correttezza.
Sinceramente, la prima settimana è stata dolorosa. Sarah teneva un conteggio delle «lotte con il borrow checker» sulla lavagna — siamo arrivati a 47 prima che il team smettesse di contare. Ma già dalla terza settimana il codice che compilava semplicemente funzionava. Nessun panic misterioso in produzione, nessuna data race sotto carico. Questo compromesso vale la pena per qualsiasi cosa si trovi sul percorso critico.