Rust em produção: por que reescrevemos nosso processador de sinais
Reescrevemos nosso mecanismo de processamento de sinais de Go para Rust. Veja por quê, o que aprendemos e a melhoria de 4x na taxa de transferência que alcançamos.
Há seis meses, tomamos a decisão de reescrever nosso mecanismo de processamento de sinais — o componente que transforma sinais brutos do navegador em vetores de características normalizados e passíveis de hash — de Go para Rust. Essa não foi uma decisão tomada de forma leviana. Nossa implementação em Go funcionava. Estava testada. Estava em produção. Mas tinha se tornado o gargalo do nosso pipeline, e precisávamos de uma melhoria de degrau na taxa de transferência. Veja o que aconteceu.
Por que superamos o Go
Nosso processador de sinais realiza um trabalho computacionalmente intensivo: análise de payloads JSON, aplicação de funções de normalização a mais de 130 sinais, cálculo de hashes proprietários e construção de vetores de hash de identificação. Em Go, esse trabalho era limitado pela CPU, e o coletor de lixo do Go se tornou um problema em escala. Cada ciclo de processamento de sinais alocava objetos intermediários — nós JSON analisados, valores de string normalizados, buffers de hash — que geravam pressão sobre o GC.
A 30 mil eventos/segundo, nosso processador de sinais em Go exibia pausas de GC de 2-5ms a cada poucos segundos. Essas pausas eram aceitáveis. A 50 mil eventos/segundo, as pausas de GC cresceram para 8-15ms e ocorriam com mais frequência. A 80 mil eventos/segundo — nossa carga projetada para o Q3 — as pausas de GC teriam feito a latência de p99 ultrapassar nosso SLA. Precisávamos ou de mais servidores (caro) ou de uma implementação mais eficiente.
Por que Rust
Avaliamos três opções: otimizar a implementação em Go (sync.Pool, alocação por arena, ajuste de GOGC), reescrever em C++ e reescrever em Rust. A otimização do Go rendeu uma melhoria de 30%, mas não resolveu fundamentalmente o problema do GC. O C++ foi descartado por causa das preocupações com segurança de memória em um sistema crítico para a segurança. O Rust oferecia abstrações de custo zero, nenhum coletor de lixo e garantias de segurança de memória aplicadas em tempo de compilação.
O ecossistema do Rust também tinha bibliotecas maduras para tudo o que precisávamos: serde para análise de JSON, crates de hashing de alto desempenho e tokio para I/O assíncrono. A curva de aprendizado foi real — nossa equipe tinha profunda experiência com Go, mas experiência limitada com Rust — mas as características de desempenho eram exatamente o que precisávamos.
O processo de reescrita
Reescrevemos o processador de sinais como um serviço autônomo que se comunica com o resto do nosso pipeline via gRPC. Isso nos permitiu implantá-lo ao lado da implementação em Go e transferir o tráfego gradualmente. A reescrita levou três engenheiros por quatro semanas — duas semanas para a implementação principal e duas semanas para testes, benchmarking e tratamento de casos extremos.
O aspecto mais desafiador não foi a linguagem em si, mas garantir a paridade de comportamento com a implementação em Go. Construímos um arcabouço de comparação que rodava ambas as implementações com a mesma entrada e verificava se produziam saídas idênticas. Descobrimos 14 diferenças sutis durante esse processo — em sua maioria relacionadas ao tratamento de ponto flutuante, à normalização Unicode e a casos extremos de análise de JSON.
Resultados de desempenho
A implementação em Rust processa sinais em 0.8ms em média contra 3.2ms do Go — uma melhoria de 4x. O uso de memória caiu de 2.1GB para 340MB com a mesma carga de trabalho. Não há pausas de GC porque não há coletor de lixo. A utilização da CPU diminuiu 60% com a mesma taxa de transferência, o que significa que cada servidor lida com 4x mais tráfego.
A 80 mil eventos/segundo, a implementação em Rust mantém um tempo de processamento de p99 de 1.4ms sem pausa alguma. Essa folga significa que não precisaremos revisitar o desempenho do processamento de sinais num futuro previsível. A redução no uso de CPU e memória também se traduz diretamente em custos de infraestrutura mais baixos — desativamos 8 de 12 servidores de processamento de sinais.
Lições aprendidas
Reescrever em Rust valeu a pena para o nosso caso específico — uma carga de trabalho limitada pela CPU, com muita alocação e sensível à latência. Não reescreveríamos nossa camada de ingestão HTTP nem nosso serviço de consultas ao ClickHouse em Rust, porque esses componentes são limitados por I/O e o Go os trata com eficiência. A lição não é "reescreva tudo em Rust", mas "use Rust onde suas abstrações de custo zero e o desempenho determinístico mais importam".
A maior surpresa foi o quanto o compilador do Rust pegou durante a reescrita. Vários bugs latentes na nossa implementação em Go — condições de corrida em buffers compartilhados, estouro de inteiros no cálculo de hash e acesso fora dos limites em entradas malformadas — foram capturados como erros de tempo de compilação em Rust. O compilador é exigente, mas se paga em correção.
Sinceramente, a primeira semana foi dolorosa. A Sarah manteve uma contagem de "brigas com o borrow checker" no quadro branco — chegamos a 47 antes de a equipe parar de contar. Mas, na terceira semana, o código que compilava simplesmente funcionava. Sem panics misteriosos em produção, sem corridas de dados sob carga. Essa troca vale a pena para qualquer coisa no caminho crítico.