Por que Rust é ideal para testes A/B?
\nQuando falamos sobre teste A/B, geralmente lembramos de Python, R ou JavaScript. Mas Rust é a carta na manga que, em condições reais de batalha, se mostra mais rápida, segura e barata. Se o seu backend é escrito em Rust, você pode incorporar experimentos diretamente no núcleo do sistema sem a sobrecarga de microsserviços e Redis.
\nImagine: você altera o algoritmo de recomendações, a cor do botão "Comprar" ou a lógica de parsing. Um erro no código do teste A/B em Python pode derrubar o servidor. Em Rust — você simplesmente não compila se pular um ramo de condição. O compilador é o seu melhor QA.
\nNeste artigo, vamos abordar o teste A/B em Rust da arquitetura à produção. Você aprenderá como fazer testes de divisão sem dor, calcular estatísticas e não perder usuários.
\n\nArquitetura do teste A/B em Rust: três pilares
\nQualquer experimento em uma aplicação Rust se sustenta em três componentes:
\n- \n
- Randomizador — quem vai para o grupo A, quem vai para o B? \n
- Armazenamento de atribuições — para que o usuário não salte entre grupos a cada requisição. \n
- Registro e estatísticas — calculamos a conversão e o p-value. \n
Vamos escrever um módulo simples, mas funcional. Nada de frameworks — apenas Rust e sua biblioteca padrão.
\n\nPasso 1. Randomização baseada em hash
\nRandomização determinística é a chave para a estabilidade. Se você usar rand::random(), a cada reinicialização do servidor o usuário pode pular para outro grupo. Usamos um hash ponderado do ID do usuário e do nome do experimento.
use std::collections::hash_map::DefaultHasher;\nuse std::hash::{Hash, Hasher};\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum Group {\n Control,\n Treatment,\n}\n\npub fn assign_group(user_id: &str, experiment: &str, treatment_weight: f64) -> Group {\n let mut hasher = DefaultHasher::new();\n user_id.hash(&mut hasher);\n experiment.hash(&mut hasher);\n let hash = hasher.finish();\n\n let normalized = (hash as f64) / (u64::MAX as f64);\n if normalized < treatment_weight {\n Group::Treatment\n } else {\n Group::Control\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_deterministic() {\n let g1 = assign_group("user_42", "button_color_v2", 0.5);\n let g2 = assign_group("user_42", "button_color_v2", 0.5);\n assert_eq!(g1, g2);\n }\n}\n\n\nEste código garante que o mesmo usuário estará sempre no mesmo grupo para um experimento específico. O peso treatment_weight é a fração do tráfego que vai para o grupo de teste (por exemplo, 0.1 = 10%).
Passo 2. Armazenamento: cache ou banco de dados?
\nPara projetos de alta carga, armazenar atribuições em memória é um risco. Se o servidor cair, o usuário perde o grupo. O melhor é usar Redis ou SQLite. Mas, para simplificar, mostraremos um cache in-memory com TTL.
\n\nuse std::collections::HashMap;\nuse std::time::{Duration, Instant};\n\nstruct AssignmentCache {\n store: HashMap<String, (Group, Instant)>,\n ttl: Duration,\n}\n\nimpl AssignmentCache {\n fn new(ttl_secs: u64) -> Self {\n Self {\n store: HashMap::new(),\n ttl: Duration::from_secs(ttl_secs),\n }\n }\n\n fn get(&mut self, key: &str) -> Option<Group> {\n if let Some((group, time)) = self.store.get(key) {\n if time.elapsed() < self.ttl {\n return Some(*group);\n }\n }\n None\n }\n\n fn set(&mut self, key: String, group: Group) {\n self.store.insert(key, (group, Instant::now()));\n }\n}\n\n\nEm um projeto real, substitua HashMap por dashmap ou Redis — isso fornecerá segurança de thread e persistência.