Система экспериментов
Полная документация по системе экспериментов 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. Сервер отвечает только за каталог, слои, вместимость и назначения.
Ключевые индексы
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) эксперименты с группами и непрозрачнымиattributesaccepting— эксперимент ещё принимает участников (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).
Жизненный цикл эксперимента
Создание
- Админ создаёт эксперимент через ACP с группами, типом, стратегией аллокации
- Если
saltне задан, сервис генерирует его при создании (8 случайных байт в hex). Собственный сид на эксперимент нужен, чтобы hash-аллокация не коррелировала между экспериментами: с пустой солью все hash-эксперименты раскладывают одних и тех же игроков одинаково. У колонки в БД есть иDEFAULT gen_random_uuid()::text— он срабатывает, только если колонка вообще не указана вINSERT - Эксперимент всегда создаётся в состоянии
created: он ещё не виден клиенту и не набирает участников, поэтому параметры и группы можно спокойно доводить до нужного вида
Соль неизменяема. salt намеренно отсутствует в наборе полей, которые пишет update: перезапись соли переразложила бы по группам всех уже назначенных игроков hash-эксперимента.
Редактирование
Параметры и группы правятся только у эксперимента в состоянии created. У запущенного эксперимента update отклоняется: правило команды — идущий тест не переделывают, а заводят вместо него новый. Иначе часть игроков уже разложена по старым параметрам, а результаты теста перестают относиться к одной и той же конфигурации.
Обновление групп
update не пересоздаёт группы, а сверяет присланный список с текущим: группа с известным id обновляется на месте, группа без id создаётся, а удаляются только те, которых в запросе больше нет. Пересоздание меняло бы id групп на каждом сохранении, а experiment_assignments.group_id ссылается на experiment_groups(id) без каскада.
По той же причине группу, на которую ссылается хоть одно назначение (активное или историческое), удалить нельзя: update с таким списком групп отклоняется с явным сообщением.
Запуск
- Админ вызывает
start:created→running - Эксперимент появляется в каталоге, клиенты могут вступать через V1/V2 API
- Параметры с этого момента не редактируются
- Запустить эксперимент повторно нельзя:
startуrunningилиfinished— недопустимый переход
Флоу вступления
- Клиент шлёт
{"experiments": ["experiment_name"]}на/v2/experiments/enroll(в V1 —{"filters": {"tests": ["name"]}}) - Сервер находит запущенный эксперимент по имени (нет среди запущенных →
disabledилиnot_found) - Проверка назначения: если активное назначение уже есть →
already - Проверка слоя: убедиться, что пользователь не в другом запущенном эксперименте того же слоя → иначе
layer_conflict - Блокировка эксперимента: строка
experimentsберётсяSELECT ... FOR UPDATE, и шаги 6-8 выполняются в одной транзакции - Проверка размера: убедиться, что эксперимент не достиг
max_size(если задан) → иначеfull; вышедшие участники учитываются в счёте приcount_exited_in_size - Аллокация: назначить группу по стратегии (hash или balanced)
- Создание назначения: вставка в
experiment_assignments→enrolled - Вернуть пер-элементные статусы и все активные назначения
Блокировка нужна потому, что и проверка вместимости, и balanced-аллокация читают счётчик и пишут по прочитанному. Без сериализации два одновременных вступления проходят одну и ту же проверку max_size (переполняя эксперимент) или выбирают одну и ту же «наименьшую» группу (перекашивая баланс).
Флоу выхода
- Клиент шлёт
{"experiments": ["experiment_name"]}на/v2/experiments/exit(в V1 —{"filters": {"stop": ["name"]}}) - Сервер ставит
exited_at = now()на активное назначение - Назначение становится историческим (сохраняется для аудита)
- Слот в
max_sizeосвобождается — если у эксперимента не выставленcount_exited_in_size
Завершение
- Админ вызывает
finish:running→finished - Эксперимент уходит из каталога, новые вступления не принимаются
- Существующие назначения остаются активными —
exited_atне проставляется, история цела - Слой освобождается: назначения завершённого эксперимента его больше не держат
- Клиент видит эти назначения со
status: "finished"и снимает эффект группы - Обратно эксперимент не запускается:
finished— конечное состояние
Удаление
- Удаляется только эксперимент в состоянии
created; запущенный или завершённый отклоняется сexperiment.not_deletable(409) — он уже часть того, что происходило с игроками - Считаются все назначения эксперимента — вместе с вышедшими
- Если хоть одно есть, удаление отклоняется:
experiment.has_assignments(409). Штатный способ остановить эксперимент с историей — завершить его (finish) - Эксперимент без единого назначения удаляется, группы уходят каскадом
Типы экспериментов
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) выполнил:
- Создал
experiment_groupsиз JSONB-колонкиgroupsтаблицыexperiments - Создал
experiment_assignmentsиз легасиusers.ab_id/users.ab_group - Удалил старые колонки (
ab_id,ab_groupизusers;groupsизexperiments)
Все существующие данные сохранены. Down-миграция полностью восстанавливает исходную схему.
