Skip to content

Система экспериментов

Полная документация по системе экспериментов 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 = активное назначение)

Ключевые индексы

sql
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       — массовое назначение пользователей в эксперимент

Жизненный цикл эксперимента

Создание

  1. Админ создаёт эксперимент через ACP с группами, типом, стратегией аллокации
  2. Значение salt заполняется на стороне БД: у колонки salt есть DEFAULT gen_random_uuid()::text, который срабатывает, только если колонка вообще не указана в INSERT. Прикладной код при создании эксперимента через ACP salt не задаёт
  3. Эксперимент стартует в состоянии enabled: false

Включение

  1. Админ переключает enabled: true
  2. Клиенты теперь могут вступать через V1/V2 API

Флоу вступления

  1. Клиент шлёт {"join": ["experiment_name"]}
  2. Сервер находит включённый эксперимент по имени
  3. Проверка слоя: убедиться, что пользователь не в другом эксперименте того же слоя
  4. Проверка размера: убедиться, что эксперимент не достиг max_size (если задан)
  5. Аллокация: назначить группу по стратегии (hash или balanced)
  6. Создание назначения: вставка в experiment_assignments
  7. Вернуть все активные назначения

Флоу выхода

  1. Клиент шлёт {"exit": ["experiment_name"]} или {"filters": {"stop": ["name"]}}
  2. Сервер ставит exited_at = now() на активное назначение
  3. Назначение становится историческим (сохраняется для аудита)

Выключение

  1. Админ переключает enabled: false
  2. Существующие назначения остаются активными
  3. Новые вступления не принимаются

Удаление

  1. Все активные назначения выводятся (exited_at = now())
  2. Группы каскадно удаляются
  3. Эксперимент удаляется

Типы экспериментов

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) выполнил:

  1. Создал experiment_groups из JSONB-колонки groups таблицы experiments
  2. Создал experiment_assignments из легаси users.ab_id / users.ab_group
  3. Удалил старые колонки (ab_id, ab_group из users; groups из experiments)

Все существующие данные сохранены. Down-миграция полностью восстанавливает исходную схему.