A/B тестирование в Rust: как не сломать продакшн и найти истину

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему 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                

Блоги

Рекомендаций по книгам