Rust in Produktion: Warum wir unseren Signalprozessor neu geschrieben haben
Wir haben unsere Signalverarbeitungs-Engine von Go auf Rust umgestellt. Hier lesen Sie, warum, was wir gelernt haben und wie wir den vierfachen Durchsatz erreicht haben.
Vor sechs Monaten trafen wir die Entscheidung, unsere Signalverarbeitungs-Engine — die Komponente, die rohe Browser-Signale in normalisierte, hashbare Merkmalsvektoren umwandelt — von Go auf Rust umzuschreiben. Diese Entscheidung haben wir uns nicht leicht gemacht. Unsere Go-Implementierung funktionierte. Sie war getestet. Sie war ausgerollt. Aber sie war zum Engpass in unserer Pipeline geworden, und wir brauchten eine sprunghafte Verbesserung beim Durchsatz. Hier lesen Sie, was passiert ist.
Warum wir Go entwachsen sind
Unser Signalprozessor leistet rechenintensive Arbeit: das Parsen von JSON-Payloads, das Anwenden von Normalisierungsfunktionen auf 130+ Signale, das Berechnen proprietärer Hashes und den Aufbau von Identifikations-Hash-Vektoren. In Go war diese Arbeit CPU-gebunden, und der Garbage Collector von Go wurde bei zunehmender Last zum Problem. Jeder Signalverarbeitungszyklus allokierte Zwischenobjekte — geparste JSON-Knoten, normalisierte String-Werte, Hash-Puffer — die GC-Druck erzeugten.
Bei 30K Ereignissen/Sekunde zeigte unser Go-Signalprozessor GC-Pausen von 2-5 ms alle paar Sekunden. Diese Pausen waren akzeptabel. Bei 50K Ereignissen/Sekunde wuchsen die GC-Pausen auf 8-15 ms und traten häufiger auf. Bei 80K Ereignissen/Sekunde — unserer prognostizierten Last für Q3 — hätten die GC-Pausen dazu geführt, dass die p99-Latenz unser SLA überschreitet. Wir brauchten entweder mehr Server (teuer) oder eine effizientere Implementierung.
Warum Rust
Wir bewerteten drei Optionen: die Optimierung der Go-Implementierung (sync.Pool, Arena-Allokation, GOGC-Tuning), das Neuschreiben in C++ und das Neuschreiben in Rust. Die Go-Optimierung brachte eine Verbesserung von 30%, löste aber das GC-Problem nicht grundlegend. C++ wurde wegen Bedenken zur Speichersicherheit in einem sicherheitskritischen System verworfen. Rust bot Zero-Cost-Abstraktionen, keinen Garbage Collector und Speichersicherheitsgarantien, die zur Compile-Zeit erzwungen werden.
Das Rust-Ökosystem verfügte zudem über ausgereifte Bibliotheken für alles, was wir brauchten: serde für das JSON-Parsen, hochperformante Hashing-Crates und tokio für asynchrone I/O. Die Lernkurve war real — unser Team hatte tiefgreifende Go-Erfahrung, aber begrenzte Rust-Erfahrung — aber die Performance-Eigenschaften waren genau das, was wir brauchten.
Der Umschreibprozess
Wir schrieben den Signalprozessor als eigenständigen Dienst neu, der über gRPC mit dem Rest unserer Pipeline kommuniziert. Das erlaubte uns, ihn parallel zur Go-Implementierung auszurollen und den Traffic schrittweise umzuleiten. Das Neuschreiben nahm drei Ingenieure vier Wochen in Anspruch — zwei Wochen für die Kernimplementierung und zwei Wochen für Tests, Benchmarking und die Behandlung von Grenzfällen.
Der anspruchsvollste Aspekt war nicht die Sprache selbst, sondern die Sicherstellung der Verhaltensparität mit der Go-Implementierung. Wir bauten ein Vergleichs-Harness, das beide Implementierungen mit denselben Eingaben ausführte und verifizierte, dass sie identische Ausgaben produzierten. Wir entdeckten dabei 14 subtile Unterschiede — größtenteils im Zusammenhang mit Fließkomma-Behandlung, Unicode-Normalisierung und Grenzfällen beim JSON-Parsen.
Performance-Ergebnisse
Die Rust-Implementierung verarbeitet Signale in durchschnittlich 0.8 ms gegenüber 3.2 ms bei Go — eine vierfache Verbesserung. Der Speicherverbrauch sank von 2.1GB auf 340MB bei gleicher Arbeitslast. Es gibt keine GC-Pausen, weil es keinen Garbage Collector gibt. Die CPU-Auslastung sank bei gleichem Durchsatz um 60%, was bedeutet, dass jeder Server viermal mehr Traffic bewältigt.
Bei 80K Ereignissen/Sekunde hält die Rust-Implementierung eine p99-Verarbeitungszeit von 1.4 ms bei null Pausen. Dieser Spielraum bedeutet, dass wir uns auf absehbare Zeit nicht erneut mit der Performance der Signalverarbeitung befassen müssen. Der reduzierte CPU- und Speicherverbrauch führt zudem direkt zu niedrigeren Infrastrukturkosten — wir legten 8 von 12 Signalverarbeitungsservern still.
Was wir gelernt haben
Das Neuschreiben in Rust war für unseren spezifischen Fall lohnenswert — eine CPU-gebundene, allokationsintensive, latenzempfindliche Arbeitslast. Wir würden unsere HTTP-Ingestion-Schicht oder unseren ClickHouse-Abfragedienst nicht in Rust neu schreiben, weil diese Komponenten I/O-gebunden sind und Go sie effizient bewältigt. Die Lehre lautet nicht „alles in Rust neu schreiben", sondern „Rust dort einsetzen, wo seine Zero-Cost-Abstraktionen und deterministische Performance am wichtigsten sind".
Die größte Überraschung war, wie viel der Rust-Compiler während des Umschreibens abfing. Mehrere latente Bugs in unserer Go-Implementierung — Race Conditions auf gemeinsam genutzten Puffern, Integer-Overflows in der Hash-Berechnung und Out-of-Bounds-Zugriffe bei fehlerhaften Eingaben — wurden in Rust als Fehler zur Compile-Zeit abgefangen. Der Compiler ist anspruchsvoll, aber er macht sich durch Korrektheit bezahlt.
Ehrlich gesagt war die erste Woche schmerzhaft. Sarah führte an der Whiteboard-Tafel eine Strichliste über „Kämpfe mit dem Borrow Checker" — wir erreichten 47, bevor das Team aufhörte zu zählen. Aber ab Woche drei funktionierte der Code, der kompilierte, einfach. Keine mysteriösen Produktions-Panics, keine Data Races unter Last. Dieser Kompromiss lohnt sich für alles auf dem Hot Path.