Skip to content

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

Полная документация по системе экспериментов MARV.

Обзор ​

Система экспериментов позволяет запускать несколько типов экспериментов:

  • AB-тесты (ab_test) — классические сплит-тесты с контрольной и вариантными группами
  • Фиче-флаги (feature_flag) — бинарные вкл/выкл переключатели фич
  • Раскатки (rollout) — постепенная процентная раскатка фичи

Модель данных ​

Таблицы ​

experiments
├── id              SERIAL PRIMARY KEY
├── type            VARCHAR  ("ab_test", "feature_flag", "rollout")
├── status          VARCHAR  NOT NULL DEFAULT 'created'  ("created" → "running" → "finished")
├── 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 — эксперименты в одном слое взаимоисключающи)
├── count_exited_in_size  BOOLEAN NOT NULL DEFAULT false  (вышедшие продолжают занимать место в max_size)
├── attributes      JSONB    (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 = активное назначение)

status — состояние эксперимента ​

Эксперимент проходит три состояния, и переходы между ними идут только вперёд:

created  ──start──▶  running  ──finish──▶  finished
СостояниеВиден клиентуНабирает участниковДержит слойПараметры
createdнетнетнетредактируются
runningдададане редактируются
finishedнетнетнетне редактируются
  • created — черновик. Эксперимента нет в каталоге, вступить в него нельзя, параметры и группы свободно редактируются. Такой эксперимент можно удалить, пока у него нет истории назначений.
  • running — эксперимент идёт: он в каталоге, принимает вступления и держит свой слой. Параметры в этом состоянии не меняются вообще — вместо правки идущего теста создают новый.
  • finished — эксперимент завершён навсегда: он ушёл из каталога, новых участников не берёт и слой больше не держит.

Обратных переходов нет. Слой проверяется только в момент вступления и после этого не пересматривается. Если бы завершённый эксперимент можно было запустить заново, игрок с активным назначением в нём успел бы, пока эксперимент завершён и слой не держит, вступить во второй эксперимент того же слоя — и после повторного запуска оказался бы сразу в двух запущенных экспериментах одного слоя. Односторонние переходы делают такое состояние недостижимым.

count_exited_in_size — фиксированная когорта ​

Флаг управляет тем, как считается заполненность эксперимента при проверке max_size:

  • false (по умолчанию) — учитываются только активные назначения (exited_at IS NULL). Выход игрока освобождает слот, и его занимает следующий вступающий.
  • true — вышедшие назначения продолжают учитываться. Набранная когорта фиксирована: освободившийся слот не переиспользуется, и после max_size вступлений эксперимент закрыт навсегда.

Тот же счётчик используется при выдаче каталога, чтобы вычислить флаг accepting для эксперимента.

attributes — непрозрачная нагрузка для клиента ​

Произвольный JSON, который сервер хранит и возвращает как есть и никогда не интерпретирует. Условия старта/выхода/продолжения эксперимента формулирует и вычисляет клиент: он получает attributes в каталоге, сам решает, в какие эксперименты просится, и шлёт получившиеся имена в enroll / exit. Сервер отвечает только за каталог, слои, вместимость и назначения.

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

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
idx_ea_experiment         ON experiment_assignments(experiment_id)  -- полный
idx_ea_user               ON experiment_assignments(user_id)        -- полный

idx_experiments_running   ON experiments(status) WHERE status = 'running'

Частичные индексы покрывают запросы по активным назначениям. Полные нужны там, где вышедшие тоже учитываются: подсчёт вместимости при count_exited_in_size, история назначений эксперимента и игрока, очистка назначений при удалении аккаунта.

idx_experiments_running покрывает выборку каталога и вступления — они читают только запущенные эксперименты — и не растёт от завершённых, которых со временем становится больше всего.

Стратегии аллокации ​

На хэше (hash) ​

Детерминированная аллокация через fnv32(user_id + ":" + salt) % 100.

Свойства:

  • Один и тот же пользователь всегда попадает в ту же группу данного эксперимента
  • Нет гонок — чистое вычисление, без записей в БД для аллокации
  • Поддерживает взвешенное распределение через поле группы weight (0-100)
  • Если platform_aware = true, в seed входит api_type: fnv32(user_id + ":" + salt + ":" + api_type) % 100

Как работают веса:

  • Решает сумма весов всех групп эксперимента, а не вес отдельной группы
  • Сумма > 0 → взвешенное накопительное распределение по группам, отсортированным по ID
  • Пример: control(30), variant_a(30), variant_b(40) → корзина 0-29 → control, 30-59 → variant_a, 60-99 → variant_b
  • Группа с weight = 0 в таком эксперименте не получает ни одной корзины; если сумма весов меньше 100, остаток корзин достаётся последней группе
  • Сумма = 0 (веса не заданы нигде) → равномерное распределение (100 / число_групп на группу)

Балансирующая (balanced) ​

Аллокация по счётчику — выбирает группу с наименьшим числом активных участников.

Свойства:

  • Гарантирует равные размеры групп
  • Если platform_aware = true, балансирует по api_type (каждая платформа получает равномерное распределение)
  • Использует SQL LEFT JOIN с COUNT(*), чтобы найти наименьшую группу
  • Лучше для небольших экспериментов, где важен точный баланс

Система слоёв ​

Эксперимент может принадлежать слою (например, "ui", "monetization", "gameplay").

Правила:

  • Пользователь может быть максимум в одном запущенном эксперименте на слой
  • Эксперименты с layer = NULL (без слоя) взаимоисключающи между собой
  • Пользователь может быть в нескольких экспериментах, если они в разных слоях

Пример:

Слой "ui":            [Тест цвета кнопки]  — пользователь в variant_a
Слой "monetization":  [Тест цены]          — тот же пользователь в control
Слой NULL:            [Legacy-тест]        — вступить нельзя (уже в бесслойном тесте)

Остановка эксперимента: завершение вместо удаления ​

Строки назначений — аналитические данные и не уничтожаются, а experiment_assignments ссылается на experiments, поэтому эксперимент с историей участия удалить нельзя: попытка отклоняется с experiment.has_assignments (409). Удаление остаётся возможным только для эксперимента в состоянии created, в который никто не входил: у запущенного или завершённого оно отклоняется с experiment.not_deletable (409).

Штатный способ остановить набор — завершить эксперимент (finish): он пропадает из каталога, новые вступления прекращаются, а история и уже выданные назначения сохраняются.

Слой держат только запущенные эксперименты ​

Проверка слоя смотрит на активные назначения игрока в экспериментах со статусом running. Завершение (finish) освобождает слой сразу у всех участников: их назначения остаются активными (exited_at не проставляется, история и видимость сохраняются), но больше не мешают вступить в другой эксперимент того же слоя. Второй способ освободить слой — выход самого игрока (exit), который проставляет exited_at.

Слой приходит вместе с назначением (поле layer в assignments): каталог отдаёт только запущенные эксперименты, и завершённого в нём нет, так что взять слой оттуда клиент уже не может. Имея layer в назначении, клиент заранее отсекает вступление в занятый слой, вместо того чтобы получать layer_conflict на каждую попытку.

Интеграция клиента ​

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-конверте {"time": ..., "data": ...} — ниже показано содержимое data.

Каталог — POST /v2/experiments ​

Request:  (тело не читается)
Response data: {
  "experiments": [
    {
      "id": 7,
      "name": "button_color_test",
      "layer": "ui",
      "attributes": {"min_level": 10},
      "groups": [{"name": "control", "data": {"color": "red"}}, ...],
      "accepting": true
    }
  ],
  "assignments": [
    {"id": 12, "name": "price_test", "layer": "monetization", "status": "running",
     "group": "variant_a", "data": {"price": 4.99}}
  ]
}
  • experiments — все запущенные (running) эксперименты с группами и непрозрачными attributes
  • accepting — эксперимент ещё принимает участников (max_size не достигнут; при max_size = 0 всегда true)
  • assignments — текущие активные назначения игрока; layer отсутствует у внеслойного эксперимента
  • status в назначении — состояние эксперимента этого назначения (running или finished)
  • Группы и счётчики вместимости грузятся сразу на весь каталог (по одному запросу на выборку), а не по запросу на каждый эксперимент

Назначение завершённого эксперимента ​

Завершение не гасит назначения: exited_at не проставляется, и назначение продолжает приходить клиенту в assignments — с status: "finished". Клиент по этому признаку перестаёт применять данные группы и при этом знает, что игрок в тесте состоял, — ни одно назначение не исчезает из ответа молча.

Самого эксперимента в experiments уже нет (каталог отдаёт только running), а его слой свободен: игрок может вступить в другой эксперимент того же слоя, и после этого в assignments окажутся оба назначения — старое с finished и новое с running.

Вступление — POST /v2/experiments/enroll ​

Request:  {"experiments": ["button_color_test", "price_test"]}
Response data: {
  "assignments": [{"id": 7, "name": "button_color_test", "layer": "ui", "status": "running",
                   "group": "control", "data": {"color": "red"}}],
  "results":     [{"name": "button_color_test", "status": "enrolled", "group": "control", "data": {"color": "red"}},
                  {"name": "price_test", "status": "layer_conflict"}]
}
  • Имена обрабатываются по одному; на каждое возвращается свой status, весь запрос при этом успешен
  • group/data присутствуют только у только что назначенной группы (status: "enrolled")
  • assignments — все активные назначения игрока после обработки запроса
statusЗначение
enrolledИгрок назначен в группу
alreadyУже есть активное назначение в этом эксперименте
fullДостигнут max_size
layer_conflictИгрок уже в другом запущенном эксперименте того же слоя
disabledЭксперимент существует, но не запущен (created или finished)
not_foundЭксперимента с таким именем нет

Выход — POST /v2/experiments/exit ​

Request:  {"experiments": ["button_color_test"]}
Response data: [{"id": 12, "name": "price_test", "layer": "monetization", "status": "running",
                 "group": "variant_a", "data": {"price": 4.99}}]
  • Мягкий выход: назначению проставляется exited_at, запись остаётся в истории
  • Неизвестные имена игнорируются молча
  • В ответе — оставшиеся активные назначения

ACP API ​

POST /acp/experiments/list              — список всех экспериментов с группами и размерами
POST /acp/experiments/create            — создать эксперимент с группами (в состоянии created)
POST /acp/experiments/update            — обновить эксперимент и сверить его группы (только created)
POST /acp/experiments/delete            — удалить эксперимент (только created без истории назначений)
POST /acp/experiments/start             — created → running
POST /acp/experiments/finish            — running → finished
POST /acp/experiments/assignments       — список назначений по эксперименту
POST /acp/experiments/user-assignments  — все назначения пользователя (для профиля)
POST /acp/experiments/bulk-assign       — массовое назначение пользователей в эксперимент

start и finish — единственный способ сменить состояние; поле состояния не пишется через update. Недопустимый переход (повторный start, finish у ещё не запущенного, любая попытка вернуться назад) отклоняется доменной ошибкой experiment.invalid_transition (409).

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

Создание ​

  1. Админ создаёт эксперимент через ACP с группами, типом, стратегией аллокации
  2. Если salt не задан, сервис генерирует его при создании (8 случайных байт в hex). Собственный сид на эксперимент нужен, чтобы hash-аллокация не коррелировала между экспериментами: с пустой солью все hash-эксперименты раскладывают одних и тех же игроков одинаково. У колонки в БД есть и DEFAULT gen_random_uuid()::text — он срабатывает, только если колонка вообще не указана в INSERT
  3. Эксперимент всегда создаётся в состоянии created: он ещё не виден клиенту и не набирает участников, поэтому параметры и группы можно спокойно доводить до нужного вида

Соль неизменяема. salt намеренно отсутствует в наборе полей, которые пишет update: перезапись соли переразложила бы по группам всех уже назначенных игроков hash-эксперимента.

Редактирование ​

Параметры и группы правятся только у эксперимента в состоянии created. У запущенного эксперимента update отклоняется: правило команды — идущий тест не переделывают, а заводят вместо него новый. Иначе часть игроков уже разложена по старым параметрам, а результаты теста перестают относиться к одной и той же конфигурации.

Обновление групп ​

update не пересоздаёт группы, а сверяет присланный список с текущим: группа с известным id обновляется на месте, группа без id создаётся, а удаляются только те, которых в запросе больше нет. Пересоздание меняло бы id групп на каждом сохранении, а experiment_assignments.group_id ссылается на experiment_groups(id) без каскада.

По той же причине группу, на которую ссылается хоть одно назначение (активное или историческое), удалить нельзя: update с таким списком групп отклоняется с явным сообщением.

Запуск ​

  1. Админ вызывает start: created → running
  2. Эксперимент появляется в каталоге, клиенты могут вступать через V1/V2 API
  3. Параметры с этого момента не редактируются
  4. Запустить эксперимент повторно нельзя: start у running или finished — недопустимый переход

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

  1. Клиент шлёт {"experiments": ["experiment_name"]} на /v2/experiments/enroll (в V1 — {"filters": {"tests": ["name"]}})
  2. Сервер находит запущенный эксперимент по имени (нет среди запущенных → disabled или not_found)
  3. Проверка назначения: если активное назначение уже есть → already
  4. Проверка слоя: убедиться, что пользователь не в другом запущенном эксперименте того же слоя → иначе layer_conflict
  5. Блокировка эксперимента: строка experiments берётся SELECT ... FOR UPDATE, и шаги 6-8 выполняются в одной транзакции
  6. Проверка размера: убедиться, что эксперимент не достиг max_size (если задан) → иначе full; вышедшие участники учитываются в счёте при count_exited_in_size
  7. Аллокация: назначить группу по стратегии (hash или balanced)
  8. Создание назначения: вставка в experiment_assignments → enrolled
  9. Вернуть пер-элементные статусы и все активные назначения

Блокировка нужна потому, что и проверка вместимости, и balanced-аллокация читают счётчик и пишут по прочитанному. Без сериализации два одновременных вступления проходят одну и ту же проверку max_size (переполняя эксперимент) или выбирают одну и ту же «наименьшую» группу (перекашивая баланс).

Флоу выхода ​

  1. Клиент шлёт {"experiments": ["experiment_name"]} на /v2/experiments/exit (в V1 — {"filters": {"stop": ["name"]}})
  2. Сервер ставит exited_at = now() на активное назначение
  3. Назначение становится историческим (сохраняется для аудита)
  4. Слот в max_size освобождается — если у эксперимента не выставлен count_exited_in_size

Завершение ​

  1. Админ вызывает finish: running → finished
  2. Эксперимент уходит из каталога, новые вступления не принимаются
  3. Существующие назначения остаются активными — exited_at не проставляется, история цела
  4. Слой освобождается: назначения завершённого эксперимента его больше не держат
  5. Клиент видит эти назначения со status: "finished" и снимает эффект группы
  6. Обратно эксперимент не запускается: finished — конечное состояние

Удаление ​

  1. Удаляется только эксперимент в состоянии created; запущенный или завершённый отклоняется с experiment.not_deletable (409) — он уже часть того, что происходило с игроками
  2. Считаются все назначения эксперимента — вместе с вышедшими
  3. Если хоть одно есть, удаление отклоняется: experiment.has_assignments (409). Штатный способ остановить эксперимент с историей — завершить его (finish)
  4. Эксперимент без единого назначения удаляется, группы уходят каскадом

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

AB-тест (ab_test) ​

  • Стандартный A/B или A/B/C/... сплит-тест
  • 2+ группы с опциональным взвешенным распределением
  • Клиент получает нагрузку данных группы

Фиче-флаг (feature_flag) ​

  • Бинарный переключатель: обычно 2 группы ("on"/"off")
  • Для постепенных раскаток или kill-switch
  • Данные группы могут содержать конфигурационные значения

Раскатка (rollout) ​

  • Процентная раскатка фичи
  • Обычно 2 группы, например on (weight: 10) и off (weight: 90) — это имена групп, к состоянию эксперимента они отношения не имеют
  • Вес группы задаётся при создании: у запущенного эксперимента параметры не меняются, поэтому следующий шаг раскатки — это новый эксперимент с другими весами

Миграция с легаси-системы ​

Легаси-система хранила назначения экспериментов прямо в таблице 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-миграция полностью восстанавливает исходную схему.