Система экспериментов
Полная документация по системе экспериментов MARV.
Обзор
Система экспериментов позволяет запускать несколько типов экспериментов:
- AB-тесты (
ab_test) — классические сплит-тесты с контрольной и вариантными группами - Фиче-флаги (
feature_flag) — бинарные вкл/выкл переключатели фич - Раскатки (
rollout) — постепенная процентная раскатка фичи
Модель данных
Таблицы
experiments
├── id SERIAL PRIMARY KEY
├── type VARCHAR ("ab_test", "feature_flag", "rollout")
├── enabled BOOLEAN
├── name VARCHAR (уникальный идентификатор, по нему обращается клиент)
├── max_size INT (0 = без ограничения на число участников)
├── platform_aware BOOLEAN (если true — балансировка по api_type)
├── allocation_strategy VARCHAR ("hash" или "balanced")
├── salt VARCHAR NOT NULL DEFAULT gen_random_uuid()::text (сид для hash-аллокации)
├── layer VARCHAR (nullable — эксперименты в одном слое взаимоисключающи)
├── created_at TIMESTAMPTZ
└── updated_at TIMESTAMPTZ
experiment_groups
├── id SERIAL PRIMARY KEY
├── experiment_id INT FK → experiments(id) ON DELETE CASCADE
├── name VARCHAR (например, "control", "variant_a")
├── weight INT (0-100, для взвешенного распределения при hash-аллокации)
├── data JSONB (произвольная нагрузка, отдаётся клиенту)
├── description VARCHAR (опциональные заметки админа)
├── created_at TIMESTAMPTZ
└── UNIQUE(experiment_id, name)
experiment_assignments
├── id BIGSERIAL PRIMARY KEY
├── user_id BIGINT
├── api_type INT
├── api_uid VARCHAR
├── experiment_id INT FK → experiments(id)
├── group_id INT FK → experiment_groups(id)
├── assigned_at TIMESTAMPTZ
└── exited_at TIMESTAMPTZ (NULL = активное назначение)Ключевые индексы
idx_ea_user_active ON experiment_assignments(user_id) WHERE exited_at IS NULL
idx_ea_experiment_active ON experiment_assignments(experiment_id) WHERE exited_at IS NULL
idx_ea_group ON experiment_assignments(group_id) WHERE exited_at IS NULL
idx_ea_unique_active ON experiment_assignments(user_id, experiment_id) WHERE exited_at IS NULL -- UNIQUEСтратегии аллокации
На хэше (hash)
Детерминированная аллокация через fnv32(user_id + ":" + salt) % 100.
Свойства:
- Один и тот же пользователь всегда попадает в ту же группу данного эксперимента
- Нет гонок — чистое вычисление, без записей в БД для аллокации
- Поддерживает взвешенное распределение через поле группы
weight(0-100) - Если
platform_aware = true, в seed входитapi_type:fnv32(user_id + ":" + salt + ":" + api_type) % 100
Как работают веса:
- Группы с weight > 0 используют взвешенное накопительное распределение
- Пример: control(30), variant_a(30), variant_b(40) → корзина 0-29 → control, 30-59 → variant_a, 60-99 → variant_b
- Группы с weight = 0 → равномерное распределение (100 / число_групп на группу)
Балансирующая (balanced)
Аллокация по счётчику — выбирает группу с наименьшим числом активных участников.
Свойства:
- Гарантирует равные размеры групп
- Если
platform_aware = true, балансирует поapi_type(каждая платформа получает равномерное распределение) - Использует SQL
LEFT JOINсCOUNT(*), чтобы найти наименьшую группу - Лучше для небольших экспериментов, где важен точный баланс
Система слоёв
Эксперимент может принадлежать слою (например, "ui", "monetization", "gameplay").
Правила:
- Пользователь может быть максимум в одном эксперименте на слой
- Эксперименты с
layer = NULL(без слоя) взаимоисключающи между собой - Пользователь может быть в нескольких экспериментах, если они в разных слоях
Пример:
Слой "ui": [Тест цвета кнопки] — пользователь в variant_a
Слой "monetization": [Тест цены] — тот же пользователь в control
Слой NULL: [Legacy-тест] — вступить нельзя (уже в бесслойном тесте)Интеграция клиента
V1 API (обратная совместимость)
POST /v1/ab/fetch
Request: {"filters": {"tests": ["experiment_name"], "stop": ["other_experiment"]}}
Response: {"ab_id": 7, "ab_group": "control", "ab_data": {"key": "value"}}Примечание: префикс V1-эндпоинта (
/v1/ab) и имена полей ответа (ab_id,ab_group,ab_data) сохранены для обратной совместимости. Внутри они используют таблицуexperiment_assignments.
- Возвращает первое (самое старое) активное назначение
ab_dataсодержит JSONB-нагрузкуdataгруппы- Возвращает все null, если активного назначения нет
V2 API
POST /v2/experiments/join
Request: {"join": ["experiment_name"], "exit": ["other_experiment"]}
Response: {
"experiments": [
{"id": 7, "name": "button_color_test", "group": "control", "data": {"color": "red"}},
{"id": 12, "name": "price_test", "group": "variant_a", "data": {"price": 4.99}}
]
}- Возвращает все активные назначения (поддержка нескольких экспериментов)
- Каждая запись включает id эксперимента, имя, имя группы и данные группы
ACP API
POST /acp/experiments/list — список всех экспериментов с группами и размерами
POST /acp/experiments/create — создать эксперимент с группами
POST /acp/experiments/update — обновить эксперимент (пересоздаёт группы)
POST /acp/experiments/delete — удалить эксперимент (выводит все назначения)
POST /acp/experiments/toggle — включить/выключить эксперимент
POST /acp/experiments/assignments — список назначений по эксперименту
POST /acp/experiments/user-assignments — все назначения пользователя (для профиля)
POST /acp/experiments/bulk-assign — массовое назначение пользователей в экспериментЖизненный цикл эксперимента
Создание
- Админ создаёт эксперимент через ACP с группами, типом, стратегией аллокации
- Значение
saltзаполняется на стороне БД: у колонкиsaltестьDEFAULT gen_random_uuid()::text, который срабатывает, только если колонка вообще не указана вINSERT. Прикладной код при создании эксперимента через ACPsaltне задаёт - Эксперимент стартует в состоянии
enabled: false
Включение
- Админ переключает
enabled: true - Клиенты теперь могут вступать через V1/V2 API
Флоу вступления
- Клиент шлёт
{"join": ["experiment_name"]} - Сервер находит включённый эксперимент по имени
- Проверка слоя: убедиться, что пользователь не в другом эксперименте того же слоя
- Проверка размера: убедиться, что эксперимент не достиг
max_size(если задан) - Аллокация: назначить группу по стратегии (hash или balanced)
- Создание назначения: вставка в
experiment_assignments - Вернуть все активные назначения
Флоу выхода
- Клиент шлёт
{"exit": ["experiment_name"]}или{"filters": {"stop": ["name"]}} - Сервер ставит
exited_at = now()на активное назначение - Назначение становится историческим (сохраняется для аудита)
Выключение
- Админ переключает
enabled: false - Существующие назначения остаются активными
- Новые вступления не принимаются
Удаление
- Все активные назначения выводятся (
exited_at = now()) - Группы каскадно удаляются
- Эксперимент удаляется
Типы экспериментов
AB-тест (ab_test)
- Стандартный A/B или A/B/C/... сплит-тест
- 2+ группы с опциональным взвешенным распределением
- Клиент получает нагрузку данных группы
Фиче-флаг (feature_flag)
- Бинарный переключатель: обычно 2 группы ("on"/"off")
- Для постепенных раскаток или kill-switch
- Данные группы могут содержать конфигурационные значения
Раскатка (rollout)
- Процентная раскатка фичи
- Обычно 2 группы: "enabled" (weight: 10) и "disabled" (weight: 90)
- Постепенно увеличивают вес "enabled", чтобы раскатать
Миграция с легаси-системы
Легаси-система хранила назначения экспериментов прямо в таблице users:
users.ab_id→ ID эксперимента (один эксперимент на пользователя)users.ab_group→ имя группы (строка)
Эти колонки удалены после миграции. Новая система использует таблицу experiment_assignments:
- Поддерживает несколько экспериментов на пользователя (через слои)
- Хранит историю назначений (отслеживание exited_at)
- Группы — сущности первого класса с ID
SQL миграции
Скрипт миграции (20260614120000_experiment_system_evolution.sql) выполнил:
- Создал
experiment_groupsиз JSONB-колонкиgroupsтаблицыexperiments - Создал
experiment_assignmentsиз легасиusers.ab_id/users.ab_group - Удалил старые колонки (
ab_id,ab_groupизusers;groupsизexperiments)
Все существующие данные сохранены. Down-миграция полностью восстанавливает исходную схему.
