Rust en producción: por qué reescribimos nuestro procesador de señales
Reescribimos nuestro motor de procesamiento de señales de Go a Rust. Aquí está el porqué, lo que aprendimos y la mejora de 4x en throughput que logramos.
Hace seis meses tomamos la decisión de reescribir nuestro motor de procesamiento de señales —el componente que transforma las señales de navegador en bruto en vectores de características normalizados y hasheables— de Go a Rust. No fue una decisión que tomáramos a la ligera. Nuestra implementación en Go funcionaba. Estaba probada. Estaba desplegada. Pero se había convertido en el cuello de botella de nuestro pipeline, y necesitábamos una mejora escalonada en throughput. Esto es lo que pasó.
Por qué Go se nos quedó pequeño
Nuestro procesador de señales realiza un trabajo computacionalmente intensivo: parsear payloads JSON, aplicar funciones de normalización a 130+ señales, calcular hashes propietarios y construir vectores de hash de identificación. En Go, este trabajo estaba limitado por CPU, y el recolector de basura de Go se convirtió en un problema a escala. Cada ciclo de procesamiento de señales asignaba objetos intermedios —nodos JSON parseados, valores de cadena normalizados, búferes de hash— que generaban presión sobre el GC.
A 30K eventos/segundo, nuestro procesador de señales en Go mostraba pausas de GC de 2-5ms cada pocos segundos. Estas pausas eran aceptables. A 50K eventos/segundo, las pausas de GC crecían hasta 8-15ms y ocurrían con más frecuencia. A 80K eventos/segundo —nuestra carga proyectada para el Q3— las pausas de GC habrían hecho que la latencia p99 superara nuestro SLA. Necesitábamos o más servidores (caro) o una implementación más eficiente.
Por qué Rust
Evaluamos tres opciones: optimizar la implementación en Go (sync.Pool, asignación por arena, ajuste de GOGC), reescribir en C++ y reescribir en Rust. La optimización de Go rindió una mejora del 30% pero no resolvió de raíz el problema del GC. C++ se descartó por preocupaciones de seguridad de memoria en un sistema crítico para la seguridad. Rust ofrecía abstracciones de coste cero, ningún recolector de basura y garantías de seguridad de memoria impuestas en tiempo de compilación.
El ecosistema de Rust también tenía bibliotecas maduras para todo lo que necesitábamos: serde para parsear JSON, crates de hashing de alto rendimiento y tokio para I/O asíncrono. La curva de aprendizaje fue real —nuestro equipo tenía profunda experiencia en Go pero limitada en Rust— pero las características de rendimiento eran exactamente lo que necesitábamos.
El proceso de reescritura
Reescribimos el procesador de señales como un servicio independiente que se comunica con el resto de nuestro pipeline vía gRPC. Esto nos permitió desplegarlo junto a la implementación en Go y desplazar el tráfico de forma gradual. La reescritura llevó a tres ingenieros cuatro semanas: dos semanas para la implementación central y dos semanas para pruebas, benchmarking y manejo de casos límite.
El aspecto más desafiante no fue el lenguaje en sí, sino garantizar la paridad de comportamiento con la implementación en Go. Construimos un arnés de comparación que ejecutaba ambas implementaciones contra la misma entrada y verificaba que produjeran una salida idéntica. Descubrimos 14 diferencias sutiles durante este proceso, en su mayoría relacionadas con el manejo de punto flotante, la normalización Unicode y casos límite del parseo de JSON.
Resultados de rendimiento
La implementación en Rust procesa señales en 0.8ms de media frente a 3.2ms de Go: una mejora de 4x. El uso de memoria cayó de 2.1GB a 340MB para la misma carga de trabajo. No hay pausas de GC porque no hay recolector de basura. La utilización de CPU disminuyó un 60% con el mismo throughput, lo que significa que cada servidor gestiona 4x más tráfico.
A 80K eventos/segundo, la implementación en Rust mantiene un tiempo de procesamiento p99 de 1.4ms con cero pausas. Este margen significa que no necesitaremos revisar el rendimiento del procesamiento de señales en el futuro previsible. El menor uso de CPU y memoria también se traduce directamente en menores costes de infraestructura: retiramos 8 de 12 servidores de procesamiento de señales.
Lecciones aprendidas
Reescribir en Rust valió la pena para nuestro caso específico: una carga de trabajo limitada por CPU, intensiva en asignaciones y sensible a la latencia. No reescribiríamos nuestra capa de ingesta HTTP ni nuestro servicio de consultas ClickHouse en Rust, porque esos componentes están limitados por I/O y Go los gestiona de forma eficiente. La lección no es «reescribir todo en Rust», sino «usar Rust donde sus abstracciones de coste cero y su rendimiento determinista importan más».
La mayor sorpresa fue cuánto atrapó el compilador de Rust durante la reescritura. Varios errores latentes de nuestra implementación en Go —condiciones de carrera en búferes compartidos, desbordamiento de enteros en el cálculo de hashes y accesos fuera de límites ante entradas malformadas— se detectaron como errores en tiempo de compilación en Rust. El compilador es exigente, pero se paga solo en correctitud.
Honestamente, la primera semana fue dolorosa. Sarah llevaba una cuenta de «peleas con el borrow checker» en la pizarra: llegamos a 47 antes de que el equipo dejara de contar. Pero para la tercera semana, el código que compilaba simplemente funcionaba. Ningún panic misterioso en producción, ninguna condición de carrera de datos bajo carga. Ese compromiso vale la pena para cualquier cosa en la ruta caliente.