Rust w produkcji: dlaczego przepisaliśmy nasz procesor sygnałów
Przepisaliśmy nasz silnik przetwarzania sygnałów z Go na Rust. Oto dlaczego, czego się nauczyliśmy i jak osiągnęliśmy czterokrotny wzrost przepustowości.
Sześć miesięcy temu podjęliśmy decyzję o przepisaniu naszego silnika przetwarzania sygnałów — komponentu, który przekształca surowe sygnały przeglądarki w znormalizowane, hashowalne wektory cech — z Go na Rust. Nie była to decyzja podjęta pochopnie. Nasza implementacja w Go działała. Była przetestowana. Była wdrożona. Stała się jednak wąskim gardłem naszego potoku, a my potrzebowaliśmy skokowej poprawy przepustowości. Oto, co się wydarzyło.
Dlaczego wyrośliśmy z Go
Nasz procesor sygnałów wykonuje pracę wymagającą dużej mocy obliczeniowej: parsuje ładunki JSON, stosuje funkcje normalizacji do ponad 130 sygnałów, oblicza autorskie hashe i buduje wektory hashy identyfikacyjnych. W Go praca ta była ograniczona przez CPU, a garbage collector Go stawał się problemem przy skali. Każdy cykl przetwarzania sygnału alokował obiekty pośrednie — sparsowane węzły JSON, znormalizowane wartości tekstowe, bufory hashy — które generowały presję na GC.
Przy 30 tys. zdarzeń na sekundę nasz procesor sygnałów w Go wykazywał pauzy GC rzędu 2–5 ms co kilka sekund. Te pauzy były akceptowalne. Przy 50 tys. zdarzeń na sekundę pauzy GC wzrosły do 8–15 ms i występowały częściej. Przy 80 tys. zdarzeń na sekundę — naszym prognozowanym obciążeniu na III kwartał — pauzy GC spowodowałyby przekroczenie naszego SLA przez opóźnienie p99. Potrzebowaliśmy albo większej liczby serwerów (kosztowne), albo wydajniejszej implementacji.
Dlaczego Rust
Rozważaliśmy trzy opcje: optymalizację implementacji w Go (sync.Pool, alokacja arenowa, strojenie GOGC), przepisanie w C++ oraz przepisanie w Rust. Optymalizacja Go dała poprawę o 30%, ale nie rozwiązała fundamentalnie problemu GC. C++ odrzucono ze względu na obawy o bezpieczeństwo pamięci w systemie krytycznym dla bezpieczeństwa. Rust oferował abstrakcje bez kosztów, brak garbage collectora oraz gwarancje bezpieczeństwa pamięci egzekwowane w czasie kompilacji.
Ekosystem Rust dysponował też dojrzałymi bibliotekami do wszystkiego, czego potrzebowaliśmy: serde do parsowania JSON, wysokowydajne biblioteki do hashowania oraz tokio do asynchronicznego I/O. Krzywa uczenia była realna — nasz zespół miał głębokie doświadczenie w Go, ale ograniczone w Rust — jednak charakterystyka wydajności była dokładnie tym, czego potrzebowaliśmy.
Proces przepisywania
Przepisaliśmy procesor sygnałów jako samodzielną usługę, która komunikuje się z resztą naszego potoku przez gRPC. Pozwoliło nam to wdrożyć go obok implementacji w Go i stopniowo przenosić ruch. Przepisanie zajęło trzem inżynierom cztery tygodnie — dwa tygodnie na podstawową implementację oraz dwa tygodnie na testowanie, benchmarki i obsługę przypadków brzegowych.
Najtrudniejszym aspektem nie był sam język, lecz zapewnienie zgodności zachowania z implementacją w Go. Zbudowaliśmy uprząż porównawczą, która uruchamiała obie implementacje na tych samych danych wejściowych i weryfikowała, że produkują identyczne wyniki. W trakcie tego procesu odkryliśmy 14 subtelnych różnic — związanych głównie z obsługą liczb zmiennoprzecinkowych, normalizacją Unicode oraz przypadkami brzegowymi parsowania JSON.
Wyniki wydajnościowe
Implementacja w Rust przetwarza sygnały ze średnią 0,8 ms wobec 3,2 ms w Go — czterokrotna poprawa. Zużycie pamięci spadło z 2,1 GB do 340 MB przy tym samym obciążeniu. Nie ma pauz GC, ponieważ nie ma garbage collectora. Wykorzystanie CPU zmniejszyło się o 60% przy tej samej przepustowości, co oznacza, że każdy serwer obsługuje czterokrotnie większy ruch.
Przy 80 tys. zdarzeń na sekundę implementacja w Rust utrzymuje czas przetwarzania p99 na poziomie 1,4 ms bez żadnych pauz. Ten zapas oznacza, że w dającej się przewidzieć przyszłości nie będziemy musieli wracać do kwestii wydajności przetwarzania sygnałów. Zmniejszone zużycie CPU i pamięci przekłada się także bezpośrednio na niższe koszty infrastruktury — wyłączyliśmy 8 z 12 serwerów przetwarzania sygnałów.
Wyciągnięte wnioski
Przepisanie w Rust opłaciło się w naszym konkretnym przypadku — obciążeniu ograniczonym przez CPU, intensywnym alokacyjnie i wrażliwym na opóźnienia. Nie przepisalibyśmy w Rust naszej warstwy przyjmowania HTTP ani usługi zapytań do ClickHouse, ponieważ te komponenty są ograniczone przez I/O, a Go obsługuje je wydajnie. Wnioskiem nie jest „przepisz wszystko w Rust”, lecz „używaj Rust tam, gdzie jego abstrakcje bez kosztów i deterministyczna wydajność mają największe znaczenie”.
Największym zaskoczeniem było to, jak wiele wychwycił kompilator Rust w trakcie przepisywania. Kilka utajonych błędów w naszej implementacji w Go — wyścigi na współdzielonych buforach, przepełnienie liczb całkowitych w obliczaniu hashy oraz dostęp poza zakres przy zniekształconych danych wejściowych — zostało wychwyconych jako błędy czasu kompilacji w Rust. Kompilator jest wymagający, ale zwraca się z nawiązką dzięki poprawności.
Szczerze mówiąc, pierwszy tydzień był bolesny. Sarah prowadziła na tablicy licznik „walk z borrow checkerem” — doszliśmy do 47, zanim zespół przestał liczyć. Ale do trzeciego tygodnia kod, który się kompilował, po prostu działał. Żadnych tajemniczych panik na produkcji, żadnych wyścigów danych pod obciążeniem. Ten kompromis jest wart każdego elementu na gorącej ścieżce.