Введение: Почему вашему 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 тестов одновременно) используйте поправку Бонферрони: делите порог на количество тестов.