Почему Rust идеален для A/B тестов?
Когда мы говорим про A/B тестирование, обычно вспоминают Python, R или JavaScript. Но Rust — тёмная лошадка, которая в реальных боевых условиях показывает себя быстрее, безопаснее и дешевле. Если ваш бэкенд написан на Rust, вы можете встраивать эксперименты прямо в ядро системы без оверхеда на микросервисы и Redis.
Представьте: вы меняете алгоритм рекомендаций, цвет кнопки "Купить" или логику парсинга. Ошибка в коде A/B теста на Python может положить сервер. В Rust — вы просто не скомпилируетесь, если пропустите ветку условия. Компилятор — ваш лучший QA.
В этой статье мы разберём A/B тестирование в Rust от архитектуры до продакшена. Вы узнаете, как делать сплит-тесты без боли, считать статистику и не терять пользователей.
Архитектура A/B теста в Rust: три кита
Любой эксперимент в Rust приложении держится на трёх компонентах:
- Рандомизатор — кто попадает в группу A, кто в B?
- Хранилище назначений — чтобы пользователь не прыгал между группами при каждом запросе.
- Логирование и статистика — считаем конверсию и p-value.
Напишем простой, но рабочий модуль. Никаких фреймворков — только Rust и его стандартная библиотека.
Шаг 1. Рандомизация на основе хэша
Детерминированная рандомизация — ключ к стабильности. Если вы используете rand::random(), то при каждом рестарте сервера пользователь может перескочить в другую группу. Используем взвешенный хэш от ID пользователя и названия эксперимента.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Group {
Control,
Treatment,
}
pub fn assign_group(user_id: &str, experiment: &str, treatment_weight: f64) -> Group {
let mut hasher = DefaultHasher::new();
user_id.hash(&mut hasher);
experiment.hash(&mut hasher);
let hash = hasher.finish();
let normalized = (hash as f64) / (u64::MAX as f64);
if normalized < treatment_weight {
Group::Treatment
} else {
Group::Control
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic() {
let g1 = assign_group("user_42", "button_color_v2", 0.5);
let g2 = assign_group("user_42", "button_color_v2", 0.5);
assert_eq!(g1, g2);
}
}
Этот код гарантирует, что один и тот же пользователь всегда будет в одной группе для конкретного эксперимента. Вес treatment_weight — доля трафика, которая идёт в тестовую группу (например, 0.1 = 10%).
Шаг 2. Хранилище: кэш или база?
Для высоконагруженных проектов хранить назначения в памяти — риск. Если сервер упадёт, пользователь потеряет группу. Лучше использовать Redis или SQLite. Но для простоты покажем in-memory кэш с TTL.
use std::collections::HashMap;
use std::time::{Duration, Instant};
struct AssignmentCache {
store: HashMap<String, (Group, Instant)>,
ttl: Duration,
}
impl AssignmentCache {
fn new(ttl_secs: u64) -> Self {
Self {
store: HashMap::new(),
ttl: Duration::from_secs(ttl_secs),
}
}
fn get(&mut self, key: &str) -> Option<Group> {
if let Some((group, time)) = self.store.get(key) {
if time.elapsed() < self.ttl {
return Some(*group);
}
}
None
}
fn set(&mut self, key: String, group: Group) {
self.store.insert(key, (group, Instant::now()));
}
}
В реальном проекте замените HashMap на dashmap или Redis — это даст потокобезопасность и персистентность.
Интеграция с веб-фреймворком (Actix-web)
Теперь соединим всё вместе. Допустим, мы тестируем новую кнопку "Купить сейчас" против старой "Добавить в корзину". Пишем middleware, который проставляет заголовок X-Experiment-Group.
use actix_web::{get, middleware, web, App, HttpRequest, HttpResponse, HttpServer};
#[get("/checkout")]
async fn checkout(req: HttpRequest) -> HttpResponse {
let user_id = req
.headers()
.get("X-User-Id")
.and_then(|v| v.to_str().ok())
.unwrap_or("anonymous");
let group = assign_group(user_id, "checkout_button_v3", 0.2);
// Логика рендеринга в зависимости от группы
match group {
Group::Control => HttpResponse::Ok().body("Старая кнопка"),
Group::Treatment => HttpResponse::Ok().body("Новая кнопка"),
}
}
Обратите внимание: никогда не доверяйте заголовкам от клиента. Мы используем X-User-Id только для примера. В бою берите ID из JWT токена или сессии.
Сбор метрик и статистика
Просто разделить трафик — мало. Нужно понять, какая группа победила. Для бинарных метрик (кликнул / не кликнул) используем Z-тест. Rust — не Python, но библиотека statrs спасает.
use statrs::distribution::{Normal, ContinuousCDF};
fn z_test(control_clicks: u64, control_total: u64, treatment_clicks: u64, treatment_total: u64) -> f64 {
let p1 = control_clicks as f64 / control_total as f64;
let p2 = treatment_clicks as f64 / treatment_total as f64;
let p_pool = (control_clicks + treatment_clicks) as f64 / (control_total + treatment_total) as f64;
let se = (p_pool * (1.0 - p_pool) * (1.0 / control_total as f64 + 1.0 / treatment_total as f64)).sqrt();
if se == 0.0 {
return 1.0;
}
let z = (p2 - p1) / se;
let normal = Normal::new(0.0, 1.0).unwrap();
1.0 - normal.cdf(z.abs()) // p-value (двусторонний)
}
Вызываем функцию раз в час из background-воркера. Если p-value < 0.05 — можно останавливать эксперимент.
Продвинутые техники: многофакторные тесты и бакетизация
Когда у вас 10 экспериментов одновременно, пользователь может попасть в 10 групп. Это создаёт интерференцию. Решение — бакетизация (hash слота). Каждый эксперимент использует свой salt, а пользователь привязывается к bucket'у.
pub fn assign_bucket(user_id: &str, num_buckets: u