A/B тестирование в Go: Сплитуем трафик как профи

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

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

Начать курс

Введение: Почему вашему Go-сервису нужны эксперименты

Представьте: вы переписали страницу оплаты на новом стеке. Всё работает, код на Go летает, тесты зелёные. Но конверсия упала на 15%. Как бы вы ни старались, в боевых условиях предсказать поведение пользователей невозможно. Именно здесь на сцену выходит A/B тестирование в Go — единственный способ принимать решения на основе данных, а не интуиции.

В этой статье мы разберём, как внедрить сплит-тестирование Golang в ваше приложение. Вы узнаете, как реализовать бакетную систему, избежать проклятия множественного сравнения и не сломать прод. Поехали!

1. Базовая архитектура A/B теста на Go

Любой A/B тест Golang состоит из трёх китов: сплитователь трафика, система логирования и статистический движок. Начнём с первого.

1.1. Детерминированное распределение: алгоритм bucket'ов

Ключевое правило: один пользователь всегда должен попадать в одну и ту же группу. Используем консистентное хэширование на основе ID пользователя:

package abtest

import (
    "crypto/sha256"
    "encoding/binary"
    "fmt"
)

// Experiment определяет структуру эксперимента
type Experiment struct {
    Name   string
    Buckets []Bucket
}

type Bucket struct {
    Name  string
    Ratio float64 // 0.0 - 1.0
}

// AssignGroup возвращает название группы для userID
func (e *Experiment) AssignGroup(userID string) string {
    hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", e.Name, userID)))
    value := binary.BigEndian.Uint64(hash[:8]) % 1000
    threshold := uint64(0)
    
    for _, b := range e.Buckets {
        threshold += uint64(b.Ratio * 1000)
        if value < threshold {
            return b.Name
        }
    }
    return "control" // fallback
}

Почему sha256? Он даёт равномерное распределение и устойчив к коллизиям. Бакетное тестирование Go с таким подходом гарантирует, что пользователь не перескочит между группами при перезапуске сервера.

1.2. Middleware для HTTP-сервера

Интегрируем A/B тестирование Go в ваш роутер. Пример с net/http:

func ABMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := r.Header.Get("X-User-ID") // или из cookie
        if userID == "" {
            next.ServeHTTP(w, r)
            return
        }
        
        exp := &Experiment{
            Name: "checkout_v2",
            Buckets: []Bucket{
                {Name: "control", Ratio: 0.5},
                {Name: "treatment", Ratio: 0.5},
            },
        }
        
        group := exp.AssignGroup(userID)
        ctx := context.WithValue(r.Context(), "ab_group", group)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Теперь в хендлерах вы можете читать группу через r.Context().Value("ab_group"). Просто и эффективно.

2. Продвинутые техники: Feature Flags и многорукие бандиты

Простое A/B — это хорошо, но в реальных экспериментах в Golang часто нужна гибкость.

2.1. Feature Flags с динамическим включением

Иногда нужно включить фичу для 10% пользователей, а потом плавно увеличить до 100%. Реализуем через Redis:

type DynamicExperiment struct {
    Client *redis.Client
    Name   string
}

func (d *DynamicExperiment) IsEnabled(userID string) bool {
    // Берём текущий процент из Redis (можно менять без деплоя)
    percent, err := d.Client.Get(context.Background(), d.Name).Float64()
    if err != nil {
        return false // по умолчанию выключено
    }
    
    hash := sha256.Sum256([]byte(userID))
    value := binary.BigEndian.Uint64(hash[:8]) % 100
    return float64(value) < percent
}

Такой подход позволяет проводить A/B тестирование в Go без перекомпиляции бинарника. Меняете процент в Redis — и новая группа пользователей получает фичу.

2.2. Multi-armed bandit: когда статистика ждать не хочет

Классический A/B тест требует фиксированного размера выборки. Но если конверсия в группе А резко упала, вы теряете деньги. Алгоритм Томпсона (Thompson Sampling) решает эту проблему:

import (
    "math"
    "math/rand"
)

type BetaBandit struct {
    Alpha, Beta float64
}

func (b *BetaBandit) Sample() float64 {
    return rand.Beta(b.Alpha, b.Beta)
}

// Update обновляет распределение после наблюдения
func (b *BetaBandit) Update(success, failure float64) {
    b.Alpha += success
    b.Beta += failure
}

// ChooseArm выбирает руку (группу) с максимальной выборкой
func ChooseArm(arms []*BetaBandit) int {
    bestIdx := 0
    bestVal := -1.0
    for i, arm := range arms {
        sample := arm.Sample()
        if sample > bestVal {
            bestVal = sample
            bestIdx = i
        }
    }
    return bestIdx
}

Сплит-тестирование Golang с bandit-алгоритмами даёт до 30% больше конверсий по сравнению с классическим подходом. Но будьте осторожны: bandit требует постоянного мониторинга, иначе он может застрять в локальном оптимуме.

3. Статистическая значимость: не дайте случайности вас обмануть

Вы собрали данные, конверсия в группе B выше на 2%. Поздравлять с победой рано. Нужно проверить статистическую значимость.

3.1. Z-тест для пропорций

Самый простой способ для бинарных метрик (купил/не купил):

func ZTest(controlSuccess, controlTotal, treatmentSuccess, treatmentTotal float64) (zScore float64, pValue float64) {
    pControl := controlSuccess / controlTotal
    pTreatment := treatmentSuccess / treatmentTotal
    pPool := (controlSuccess + treatmentSuccess) / (controlTotal + treatmentTotal)
    
    se := math.Sqrt(pPool * (1 - pPool) * (1/controlTotal + 1/treatmentTotal))
    if se == 0 {
        return 0, 1
    }
    zScore = (pTreatment - pControl) / se
    pValue = 2 * (1 - normalCDF(math.Abs(zScore)))
    return
}

// normalCDF - аппроксимация функции распределения нормального закона
func normalCDF(x float64) float64 {
    return 0.5 * math.Erfc(-x/math.Sqrt2)
}

Если pValue < 0.05 — результат статистически значим. Но помните: при множественных сравнениях (5 тестов одновременно) используйте поправку Бонферрони: делите порог на количество тестов.

3.2. Байесовский подход: апостери

Блоги

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