Публичное интервью по System Design. Александр Поломодов.
Сегодня мы разберём публичное интервью по System Design, в котором технический директор Александр проводит собеседование с кандидатом Никитой — архитектором из команды инвестиций. В течение часа они совместно проектируют систему A/B-тестирования для веба и мобильных приложений, проходя путь от сбора требований и уточнения сценариев использования до декомпозиции на сервисов, выбора технологического стека (Kafka, ClickHouse, Cassandra) и обсуждения нефункциональных требований — включая прилипание пользователя к эксперименту и задержку расчёта статистики. Интервью демонстрирует живой процесс проектирования, где кандидат задаёт уточняющие вопросы, выявляет граничные случаи, а интервьюер направляет и усложняет задачу, постепенно раскрывая глубину системы.
Вопрос 1. Как работают A/B-тесты и зависит ли их логика от системы?
Таймкод: 00:06:44
Ответ собеседника: Правильный. Уточняется, что классический A/B-тест подразумевает наличие текущей ситуации и новой гипотезы. Система отвечает только за заведение теста и показ нужной функциональности, а сама логика тестов реализуется на стороне приложения.
Правильный ответ:
Архитектура A/B-тестирования
Классический A/B-тест — это метод сравнения двух версий продукта (контрольная группа A и экспериментальная группа B) для определения, какая из них лучше справляется с заданной метрикой.
Ключевые компоненты системы A/B-тестирования:
1. Сплит-система (Traffic Splitting) Отвечает за распределение пользователей по группам. Обычно используется детерминированный алгоритм на основе хеширования user_id для обеспечения консистентности:
func getBucket(userID string, experimentID string, totalBuckets int) int {
h := fnv.New32a()
h.Write([]byte(userID + ":" + experimentID))
return int(h.Sum32()) % totalBuckets
}
func isUserInExperiment(userID, experimentID string, trafficPercent int) bool {
bucket := getBucket(userID, experimentID, 100)
return bucket < trafficPercent
}
2. Feature Flags (Флаги функциональности) Система управления фичами, которая определяет, какую версию показывать пользователю:
type Experiment struct {
ID string
Name string
TrafficPct int
Variants []Variant
StartTime time.Time
EndTime *time.Time
}
type Variant struct {
ID string
Name string
Config map[string]interface{}
}
3. Точка интеграции в коде приложения
func GetVariantForUser(userID string, experimentID string) (*Variant, error) {
experiment, err := experimentStore.GetActiveExperiment(experimentID)
if err != nil {
return nil, err
}
if !isUserInExperiment(userID, experimentID, experiment.TrafficPct) {
return &experiment.Variants[0], nil // Control group
}
bucket := getBucket(userID, experimentID, len(experiment.Variants))
return &experiment.Variants[bucket], nil
}
Разделение ответственности:
Система A/B-тестирования отвечает за:
- Хранение конфигурации экспериментов
- Определение принадлежности пользователя к группе
- Сбор и агрегация метрик
- Статистический анализ результатов
Приложение отвечает за:
- Получение нужной конфигурации от системы
- Отображение соответствующей функциональности
- Логирование событий для аналитики
- Корректную обработку обоих вариантов
Статистическая значимость
Важно учитывать:
- Минимальный размер выборки перед началом теста
- P-value < 0.05 для статистической значимости
- Коррекция при множественных сравнениях (Bonferroni correction)
- Длительность теста для учета сезонности
Вопрос 1. Как работают A/B-тесты и зависит ли их логика от системы?
Таймкод: 00:06:44
Ответ собеседника: Правильный. Уточняется, что классический A/B-тест подразумевает наличие текущей ситуации и новой гипотезы. Система отвечает только за заведение теста и показ нужной функциональности, а сама логика тестов реализуется на стороне приложения.
Правильный ответ:
A/B-тестирование (сплит-тестирование) — это метод сравнения двух или более вариантов продукта, функциональности или поведения системы с целью определения статистически значимого влияния на целевые метрики.
1. Классическая архитектура A/B-тестирования
В классическом понимании A/B-тест состоит из следующих компонентов:
- Контрольная группа (A) — текущая версия функциональности (baseline).
- Экспериментальная группа (B) — новая версия с изменением, которое проверяется.
- Сплит-механизм — система распределения пользователей по группам.
- Метрики — измеримые показатели для сравнения (конверсия, retention, revenue и т.д.).
2. Разделение ответственности: система vs приложение
Ключевой принцип заключается в разделении ответственности:
Система A/B-тестирования отвечает за:
- Создание и управление экспериментами (создание, запуск, остановка).
- Распределение пользователей по группам (сплит).
- Сбор и агрегацию данных.
- Расчёт статистической значимости результатов.
- Хранение конфигурации тестов.
Приложение (бизнес-логика) отвечает за:
- Реализацию конкретных гипотез.
- Определение, какой функционал показывать пользователю.
- Применение изменений в зависимости от группы пользователя.
- Логику работы с фичами и их вариациями.
3. Пример реализации на Go
package abtest
import (
"context"
"crypto/md5"
"fmt"
"strconv"
)
// Experiment представляет A/B-тест
type Experiment struct {
ID string
Name string
Variants []Variant
TrafficPct float64 // процент трафика для теста
IsActive bool
}
// Variant — вариант теста
type Variant struct {
ID string
Name string
Weight float64 // вес для распределения трафика
Config map[string]interface{} // конфигурация варианта
}
// ABTestService — сервис для работы с A/B-тестами
type ABTestService struct {
experiments map[string]*Experiment
// хранилище для персистентности
storage Storage
}
// Storage интерфейс для хранения данных
type Storage interface {
GetExperiment(ctx context.Context, id string) (*Experiment, error)
SaveAssignment(ctx context.Context, userID, experimentID, variantID string) error
GetAssignment(ctx context.Context, userID, experimentID string) (string, error)
}
// AssignVariant назначает вариант пользователю
func (s *ABTestService) AssignVariant(ctx context.Context, userID string, experiment *Experiment) (*Variant, error) {
// Проверяем, активен ли эксперимент
if !experiment.IsActive {
return nil, fmt.Errorf("experiment %s is not active", experiment.ID)
}
// Проверяем, попадает ли пользователь в тест по проценту трафика
if !s.isUserInTraffic(userID, experiment.TrafficPct) {
return nil, fmt.Errorf("user %s is not in traffic", userID)
}
// Проверяем, не назначен ли уже вариант
if variantID, err := s.storage.GetAssignment(ctx, userID, experiment.ID); err == nil {
for _, v := range experiment.Variants {
if v.ID == variantID {
return &v, nil
}
}
}
// Назначаем вариант на основе хеша пользователя
variant := s.selectVariant(userID, experiment.Variants)
// Сохраняем назначение
if err := s.storage.SaveAssignment(ctx, userID, experiment.ID, variant.ID); err != nil {
return nil, err
}
return variant, nil
}
// isUserInTraffic проверяет, попадает ли пользователь в тест
func (s *ABTestService) isUserInTraffic(userID string, trafficPct float64) bool {
hash := md5.Sum([]byte(userID))
hashInt := int64(hash[0])<<24 | int64(hash[1])<<16 | int64(hash[2])<<8 | int64(hash[3])
userBucket := float64(hashInt%100) / 100.0
return userBucket < trafficPct
}
// selectVariant выбирает вариант на основе весов
func (s *ABTestService) selectVariant(userID string, variants []Variant) *Variant {
hash := md5.Sum([]byte(userID + "_variant"))
hashInt := int64(hash[0])<<24 | int64(hash[1])<<16 | int64(hash[2])<<8 | int64(hash[3])
userValue := float64(hashInt%10000) / 10000.0
totalWeight := 0.0
for _, v := range variants {
totalWeight += v.Weight
}
cumulative := 0.0
for _, v := range variants {
cumulative += v.Weight / totalWeight
if userValue < cumulative {
return &v
}
}
return &variants[len(variants)-1]
}
4. Использование в бизнес-логике
// FeatureService — сервис, использующий A/B-тесты
type FeatureService struct {
abTestService *ABTestService
defaultConfig map[string]interface{}
}
// GetFeatureConfig возвращает конфигурацию фичи с учётом A/B-теста
func (s *FeatureService) GetFeatureConfig(ctx context.Context, userID, featureName string) (map[string]interface{}, error) {
// Получаем активный эксперимент для фичи
experiment, err := s.abTestService.GetActiveExperiment(ctx, featureName)
if err != nil || experiment == nil {
// Если нет активного эксперимента, возвращаем дефолтную конфигурацию
return s.defaultConfig, nil
}
// Назначаем вариант пользователю
variant, err := s.abTestService.AssignVariant(ctx, userID, experiment)
if err != nil {
return s.defaultConfig, nil
}
// Возвращаем конфигурацию варианта
return variant.Config, nil
}
5. Важные аспекты реализации
Детерминированность сплита: Один и тот же пользователь всегда должен попадать в одну и ту же группу. Это достигается через хеширование user_id.
Статистическая значимость: Необходимо обеспечить достаточный размер выборки для получения достоверных результатов. Обычно требуется от 1000 до 10000+ пользователей на вариант.
Изоляция экспериментов: Разные эксперименты не должны влиять друг на друга. Пользователь может участвовать в нескольких тестах одновременно, но они должны быть ортогональны.
Мониторинг и алертинг: Необходимо отслеживать аномалии в метриках и иметь возможность быстро отключить эксперимент.
6. Зависимость логики от системы
Логика A/B-тестирования зависит от системы в следующих аспектах:
- Механизм сплита должен быть реализован на уровне системы или инфраструктуры.
- Хранение конфигурации экспериментов требует персистентного хранилища.
- Сбор метрик и их агрегация — системная задача.
- API для управления экспериментами — часть системы.
Однако бизнес-логика гипотезы реализуется на стороне приложения. Система лишь предоставляет инструменты для проведения тестов, а разработчик решает, что именно тестировать и как реализовать варианты.
Вопрос 2. Какие сегменты аудитории поддерживает система и откуда они берутся?
Таймкод: 00:08:00
Ответ собеседника: Правильный. Система должна по идентификатору пользователя восстанавливать список сегментов, в которые он входит. Подготовка и загрузка самих сегментов происходит вне системы (например, из DWH), а система лишь хранит и использует их для проведения экспериментов.
Правильный ответ:
Типы сегментов аудитории
1. Демографические сегменты
- Возраст, пол, география
- Источник: CRM, данные регистрации, внешние провайдеры
2. Поведенческие сегменты
- Частота использования, глубина вовлеченности
- RFM-сегментация (Recency, Frequency, Monetary)
- Источник: аналитические системы, event-хранилища
3. Технические сегменты
- Тип устройства, ОС, браузер
- Версия приложения
- Источник: данные сессий, user-agent парсинг
4. Кастомные бизнес-сегменты
- VIP-клиенты, новые пользователи, подписчики
- Источник: бизнес-логика приложения, внешние системы
Архитектура работы с сегментами:
type Segment struct {
ID string
Name string
Type SegmentType // demographic, behavioral, technical, custom
Source string // источник данных
Rules []Rule // правила вхождения в сегмент
UpdatedAt time.Time
}
type UserSegmentService interface {
// Получение всех сегментов пользователя
GetUserSegments(ctx context.Context, userID string) ([]Segment, error)
// Проверка вхождения в конкретный сегмент
IsUserInSegment(ctx context.Context, userID string, segmentID string) (bool, error)
// Получение пользователей сегмента (для аналитики)
GetSegmentUsers(ctx context.Context, segmentID string) ([]string, error)
}
Источники данных и интеграция:
1. Data Warehouse (DWH) Основной источник для сложных сегментов:
-- Пример запроса для формирования сегмента
SELECT DISTINCT user_id
FROM user_events
WHERE event_date >= CURRENT_DATE - INTERVAL '30 days'
AND event_type IN ('purchase', 'cart_add')
GROUP BY user_id
HAVING COUNT(*) >= 3;
2. Real-time события Для оперативных сегментов:
// Обновление сегмента на основе событий
func (s *SegmentService) ProcessEvent(ctx context.Context, event UserEvent) error {
segments := s.segmentMatcher.Match(event)
for _, segmentID := range segments {
if err := s.cache.AddUserToSegment(ctx, event.UserID, segmentID); err != nil {
return err
}
}
return nil
}
3. Внешние системы
- CRM системы
- Платежные провайдеры
- Маркетинговые платформы
Кэширование и производительность:
type SegmentCache struct {
redis *redis.Client
ttl time.Duration
}
func (c *SegmentCache) GetUserSegments(ctx context.Context, userID string) ([]string, error) {
key := fmt.Sprintf("user_segments:%s", userID)
// Пробуем получить из кэша
segments, err := c.redis.SMembers(ctx, key).Result()
if err == nil && len(segments) > 0 {
return segments, nil
}
// Если нет в кэше - загружаем из БД
segments, err = c.loadFromDB(ctx, userID)
if err != nil {
return nil, err
}
// Сохраняем в кэш
if len(segments) > 0 {
c.redis.SAdd(ctx, key, segments)
c.redis.Expire(ctx, key, c.ttl)
}
return segments, nil
}
Приоритеты при разработке:
1. Консистентность данных
- Регулярная синхронизация с источниками
- Механизмы валидации и очистки
2. Производительность
- Кэширование горячих данных
- Batch-операции для массовых обновлений
3. Гибкость
- Поддержка динамических сегментов
- Возможность комбинирования сегментов
4. Мониторинг
- Отслеживание размера сегментов
- Контроль актуальности данных
Вопрос 3. В каком виде система получает сегменты — как набор логики или как готовые когорты?
Таймкод: 00:09:28
Ответ собеседния: Правильный. Система получает готовый набор номеров сегментов, в которые попадает идентификатор пользователя. Предполагается, что сегментов может быть до 10 тысяч, и данные могут быть разреженными.
Правильный ответ:
Модель хранения: Готовые когорты (Pre-computed Segments)
Система работает с уже вычисленными принадлежностями пользователей к сегментам, а не с логикой их определения. Это ключевое архитектурное решение.
Структура данных:
type UserSegmentMapping struct {
UserID string
SegmentIDs []int64 // Массив ID сегментов
UpdatedAt time.Time
}
type SegmentDefinition struct {
ID int64
Name string
Description string
Source string // откуда получен сегмент
Rules string // описание логики для документации
IsActive bool
}
Оптимизация для разреженных данных:
При 10 000 сегментов и разреженных данных важно эффективно хранить только непустые принадлежности:
// Хранение в Redis - используем Set для каждого пользователя
type SegmentStorage struct {
redis *redis.Client
}
func (s *SegmentStorage) SetUserSegments(ctx context.Context, userID string, segmentIDs []int64) error {
key := fmt.Sprintf("user:%s:segments", userID)
// Конвертируем в строки для Redis
members := make([]interface{}, len(segmentIDs))
for i, id := range segmentIDs {
members[i] = strconv.FormatInt(id, 10)
}
pipe := s.redis.Pipeline()
pipe.Del(ctx, key)
if len(members) > 0 {
pipe.SAdd(ctx, key, members...)
}
pipe.Expire(ctx, key, 24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
func (s *SegmentStorage) GetUserSegments(ctx context.Context, userID string) ([]int64, error) {
key := fmt.Sprintf("user:%s:segments", userID)
members, err := s.redis.SMembers(ctx, key).Result()
if err != nil {
return nil, err
}
segments := make([]int64, 0, len(members))
for _, m := range members {
id, err := strconv.ParseInt(m, 10, 64)
if err != nil {
continue
}
segments = append(segments, id)
}
return segments, nil
}
Альтернатива: Bitmap для плотных данных:
Если сегменты плотные (много пользователей в каждом), можно использовать bitmap:
type BitmapSegmentStorage struct {
redis *redis.Client
}
func (s *BitmapSegmentStorage) IsUserInSegment(ctx context.Context, segmentID int64, userID string) (bool, error) {
key := fmt.Sprintf("segment:%d:users", segmentID)
// Хешируем userID в битовый индекс
bitOffset := hashUserID(userID)
return s.redis.GetBit(ctx, key, bitOffset).Result()
}
func hashUserID(userID string) int64 {
h := fnv.New64a()
h.Write([]byte(userID))
return int64(h.Sum64() % (1024 * 1024 * 100)) // Ограничиваем размер
}
Преимущества подхода с готовыми когортами:
1. Производительность
- O(1) для проверки принадлежности
- Не нужно вычислять логику на лету
- Предсказуемое время ответа
2. Масштабируемость
- Легко кэшировать
- Можно шардировать по пользователям
- Нет зависимости от сложности правил сегментации
3. Надежность
- Стабильные результаты для одного пользователя
- Проще отладка и аудит
- Можем версионировать состояние сегментов
Механизм обновления данных:
type SegmentSyncService struct {
storage SegmentStorage
source SegmentSource // DWH, API, etc.
}
func (s *SegmentSyncService) SyncSegments(ctx context.Context) error {
// Получаем обновления из источника
updates, err := s.source.GetUpdates(ctx, s.lastSyncTime)
if err != nil {
return err
}
// Обновляем батчами
batch := make(map[string][]int64)
for _, update := range updates {
batch[update.UserID] = update.SegmentIDs
if len(batch) >= 1000 {
s.flushBatch(ctx, batch)
batch = make(map[string][]int64)
}
}
if len(batch) > 0 {
s.flushBatch(ctx, batch)
}
return nil
}
Обработка разреженности:
При 10 000 сегментов и разреженных данных:
- Средний пользователь может входить в 5-20 сегментов
- Храним только непустые множества
- Используем сжатие для хранения
- Применяем lazy loading для редко используемых сегментов
Вопрос 2. Какие сегменты аудитории поддерживает система и откуда они берутся?
Таймкод: 00:08:00
Ответ собеседника: Правильный. Система должна по идентификатору пользователя восстанавливать список сегментов, в которые он входит. Подготовка и загрузка самих сегментов происходит вне системы (например, из DWH), а система лишь хранит и использует их для проведения экспериментов.
Правильный ответ:
1. Архитектура работы с сегментами
Система A/B-тестирования работает с сегментами по принципу разделения ответственности:
- Подготовка сегментов — задача внешних систем (DWH, CDP, аналитические платформы).
- Хранение маппинга user_id → сегменты — задача системы A/B-тестирования.
- Фильтрация пользователей по сегментам при назначении вариантов — задача системы.
2. Типы сегментов аудитории
Демографические сегменты:
- Возраст, пол, география
- Устройство, ОС, браузер
- Язык интерфейса
Поведенческие сегменты:
- Новые vs возвращающиеся пользователи
- Частота использования (ежедневные, еженедельные, редкие)
- Глубина воронки (зарегистрированные, совершившие первый платёж, активные)
- История покупок (сумма, количество, категории)
Технические сегменты:
- Тип подписки (free, premium, enterprise)
- Версия приложения
- Источник трафика (organic, paid, referral)
Кастомные бизнес-сегменты:
- Пользователи, которые видели определённый контент
- Участники предыдущих экспериментов
- Пользователи с определёнными событиями в истории
3. Источники данных для сегментов
DWH (Data Warehouse):
-- Пример запроса для формирования сегмента
WITH user_segments AS (
SELECT
u.user_id,
CASE
WHEN u.registration_date >= CURRENT_DATE - INTERVAL '30 days'
THEN 'new_users'
ELSE 'old_users'
END as tenure_segment,
CASE
WHEN COUNT(o.order_id) = 0 THEN 'never_purchased'
WHEN COUNT(o.order_id) BETWEEN 1 AND 3 THEN 'low_frequency'
WHEN COUNT(o.order_id) > 3 THEN 'high_frequency'
END as purchase_segment,
SUM(o.total_amount) as lifetime_value
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
AND o.created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY u.user_id, u.registration_date
)
SELECT * FROM user_segments;
Customer Data Platform (CDP):
- Единый профиль пользователя
- Реал-тайм обновление атрибутов
- Интеграция с множеством источников
Продуктовая аналитика:
- События в приложении
- Фunnel-анализ
- Когортный анализ
4. Реализация хранения сегментов в системе
package segment
import (
"context"
"time"
)
// Segment представляет сегмент аудитории
type Segment struct {
ID string
Name string
Description string
// Правила для динамических сегментов
Rules []SegmentRule
// Для статических сегментов — список user_id
UserIDs []string
IsDynamic bool
UpdatedAt time.Time
TTL time.Duration // время жизни кэша
}
// SegmentRule — правило для динамического сегмента
type SegmentRule struct {
Field string // поле для проверки
Operator string // eq, neq, gt, lt, in, not_in
Value interface{} // значение для сравнения
}
// SegmentService — сервис работы с сегментами
type SegmentService struct {
storage SegmentStorage
userSegment UserSegmentStorage
cache Cache
}
// SegmentStorage — хранилище метаданных сегментов
type SegmentStorage interface {
GetSegment(ctx context.Context, id string) (*Segment, error)
ListSegments(ctx context.Context) ([]*Segment, error)
}
// UserSegmentStorage — хранилище маппинга user_id -> сегменты
type UserSegmentStorage interface {
GetUserSegments(ctx context.Context, userID string) ([]string, error)
SetUserSegments(ctx context.Context, userID string, segments []string) error
IsUserInSegment(ctx context.Context, userID, segmentID string) (bool, error)
}
// Cache — интерфейс кэширования
type Cache interface {
Get(ctx context.Context, key string) ([]string, error)
Set(ctx context.Context, key string, value []string, ttl time.Duration) error
}
// GetUserSegments возвращает сегменты пользователя
func (s *SegmentService) GetUserSegments(ctx context.Context, userID string) ([]string, error) {
// Проверяем кэш
cacheKey := fmt.Sprintf("user_segments:%s", userID)
if segments, err := s.cache.Get(ctx, cacheKey); err == nil {
return segments, nil
}
// Получаем из хранилища
segments, err := s.userSegment.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Кэшируем
s.cache.Set(ctx, cacheKey, segments, 5*time.Minute)
return segments, nil
}
// CheckUserInSegment проверяет принадлежность пользователя к сегменту
func (s *SegmentService) CheckUserInSegment(ctx context.Context, userID, segmentID string) (bool, error) {
segments, err := s.GetUserSegments(ctx, userID)
if err != nil {
return false, err
}
for _, segID := range segments {
if segID == segmentID {
return true, nil
}
}
return false, nil
}
5. Интеграция с A/B-тестированием
// Experiment с поддержкой сегментов
type Experiment struct {
ID string
Name string
Variants []Variant
// Целевые сегменты
TargetSegments []string
// Минимальный размер выборки на вариант
MinSampleSize int
// Уровень значимости
SignificanceLevel float64
}
// AssignVariant с учётом сегментов
func (s *ABTestService) AssignVariant(ctx context.Context, userID string, experiment *Experiment) (*Variant, error) {
// Проверяем принадлежность к целевым сегментам
if len(experiment.TargetSegments) > 0 {
userSegments, err := s.segmentService.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
if !s.hasIntersection(userSegments, experiment.TargetSegments) {
return nil, fmt.Errorf("user %s is not in target segments", userID)
}
}
// Обычная логика назначения варианта
return s.selectVariant(ctx, userID, experiment)
}
func (s *ABTestService) hasIntersection(userSegments, targetSegments []string) bool {
targetSet := make(map[string]bool)
for _, seg := range targetSegments {
targetSet[seg] = true
}
for _, seg := range userSegments {
if targetSet[seg] {
return true
}
}
return false
}
6. Процесс загрузки сегментов
ETL-процесс:
- Извлечение данных из источников (DWH, аналитика)
- Трансформация и применение правил сегментации
- Загрузка маппинга user_id → сегменты в систему A/B-тестирования
Реал-тайм обновление:
- Событийная архитектура (Kafka, RabbitMQ)
- Потоковая обработка событий
- Обновление сегментов при изменении атрибутов пользователя
7. Важные аспекты
Согласованность данных: Необходимо обеспечить eventual consistency между источником данных и системой A/B-тестирования.
Производительность: Проверка сегментов должна быть быстрой, особенно для реал-тайм экспериментов.
Приватность: Сегменты могут содержать чувствительные данные, необходимо соблюдать требования GDPR/CCPA.
Версионирование: Сегменты могут меняться со временем, важно фиксировать состояние на момент эксперимента.
Вопрос 4. Сколько пользователей в системе и как они распределены по платформам?
Таймкод: 00:10:37
Ответ собеседника: Правильный. Уточняется, что всего около 30 млн активных пользователей в месяц, из них 10 млн в мобильных приложениях и 3 млн в вебе.
Правильный ответ:
Масштаб системы
Исходя из предоставленных данных:
- 30 млн MAU (Monthly Active Users) — общая база
- 10 млн — мобильные приложения (iOS + Android)
- 3 млн — веб-платформа
- ~17 млн — предположительно другие каналы или неактивные в конкретный период
Архитектурные последствия масштаба:
1. Требования к хранилищу сегментов
// Расчет объема данных
const (
TotalUsers = 30_000_000
AvgSegmentsPerUser = 10 // среднее количество сегментов на пользователя
SegmentIDSize = 8 // int64
UserIDSize = 16 // UUID или similar
)
// Объем для хранения маппинга: ~5-6 GB в памяти
// С учетом накладных расходов Redis: ~8-10 GB
2. Распределение трафика по платформам:
type PlatformDistribution struct {
Mobile int64 // 10M - 33%
Web int64 // 3M - 10%
Other int64 // 17M - 57%
}
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
PlatformWeb Platform = "web"
)
Оптимизация под мобильные платформы:
Мобильные приложения требуют особого внимания:
type MobileOptimizedService struct {
cache *redis.Client
localCache *lru.Cache // локальный кэш на инстансе
}
func (s *MobileOptimizedService) GetUserSegmentsForMobile(ctx context.Context, userID string) (*MobileSegmentResponse, error) {
// Для мобильных - минимум данных, быстрый ответ
segments, err := s.getFromLocalCache(userID)
if err == nil {
return &MobileSegmentResponse{Segments: segments}, nil
}
// Fallback на Redis
segments, err = s.getFromRedis(ctx, userID)
if err != nil {
return nil, err
}
// Кэшируем локально
s.localCache.Set(userID, segments, 5*time.Minute)
return &MobileSegmentResponse{Segments: segments}, nil
}
Особенности веб-платформы:
type WebSegmentService struct {
storage SegmentStorage
}
func (s *WebSegmentService) GetSegmentsForABTest(ctx context.Context, userID string, experimentID string) ([]int64, error) {
// Для веба можно позволить более сложную логику
segments, err := s.storage.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Фильтруем только релевантные для эксперимента
relevantSegments := s.filterRelevantSegments(segments, experimentID)
return relevantSegments, nil
}
Шардирование по платформам:
type PlatformAwareRouter struct {
mobileShard *redis.Client
webShard *redis.Client
}
func (r *PlatformAwareRouter) GetShard(platform Platform) *redis.Client {
switch platform {
case PlatformIOS, PlatformAndroid:
return r.mobileShard
case PlatformWeb:
return r.webShard
default:
return r.mobileShard // default fallback
}
}
Метрики и мониторинг:
type PlatformMetrics struct {
mobileLatency prometheus.Histogram
webLatency prometheus.Histogram
mobileErrors prometheus.Counter
webErrors prometheus.Counter
}
func (m *PlatformMetrics) RecordLatency(platform Platform, duration time.Duration) {
switch platform {
case PlatformIOS, PlatformAndroid:
m.mobileLatency.Observe(duration.Seconds())
case PlatformWeb:
m.webLatency.Observe(duration.Seconds())
}
}
Требования к производительности:
| Платформа | P99 Latency | RPS (пиковый) | Доступность |
|---|---|---|---|
| Mobile | < 50ms | 100K+ | 99.95% |
| Web | < 100ms | 30K+ | 99.9% |
Стратегия кэширования:
type MultiLayerCache struct {
l1 *sync.Map // In-process, ~1ms
l2 *redis.Client // Redis, ~5ms
l3 SegmentStorage // Database, ~50ms
}
func (c *MultiLayerCache) Get(ctx context.Context, key string) ([]int64, error) {
// L1: Локальный кэш
if val, ok := c.l1.Load(key); ok {
return val.([]int64), nil
}
// L2: Redis
val, err := c.l2.Get(ctx, key).Result()
if err == nil {
segments := parseSegments(val)
c.l1.Store(key, segments)
return segments, nil
}
// L3: Database
val, err = c.l3.GetFromDB(ctx, key)
if err != nil {
return nil, err
}
// Заполняем кэши
c.l2.Set(ctx, key, val, time.Hour)
c.l1.Store(key, parseSegments(val))
return parseSegments(val), nil
}
Планирование ресурсов:
Для 30M пользователей:
- Redis кластер: 3-5 шардов по 32GB RAM каждый
- Приложение: 10-15 инстансов с автоскейлингом
- База данных: Primary + 2 Replica для аналитики
- CDN: для веб-платформы с кэшированием на edge
Вопрос 5. Сколько активных экспериментов может быть одновременно?
Таймкод: 00:11:20
Ответ собеседника: Правильный. Уточняется, что система должна поддерживать до тысяч активных экспериментов единовременно. Это целевое значение для проектирования.
Правильный ответ:
Масштаб экспериментов
Система спроектирована для поддержки ~1000 одновременно активных экспериментов. Это требует особого внимания к эффективности хранения и быстрому поиску.
Структура данных эксперимента:
type Experiment struct {
ID string `json:"id"`
Name string `json:"name"`
Status ExperimentStatus `json:"status"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
TrafficPercent int `json:"traffic_percent"`
Variants []Variant `json:"variants"`
Segments []int64 `json:"segments"` // Целевые сегменты
Platforms []Platform `json:"platforms"`
Config ExperimentConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Variant struct {
ID string `json:"id"`
Name string `json:"name"`
TrafficPct int `json:"traffic_percent"`
Config map[string]interface{} `json:"config"`
}
type ExperimentStatus string
const (
ExperimentStatusDraft ExperimentStatus = "draft"
ExperimentStatusRunning ExperimentStatus = "running"
ExperimentStatusPaused ExperimentStatus = "paused"
ExperimentStatusCompleted ExperimentStatus = "completed"
)
Индексация для быстрого поиска:
type ExperimentIndex struct {
// Индекс по статусу
byStatus map[ExperimentStatus]map[string]*Experiment
// Индекс по сегментам: segmentID -> []experimentID
bySegment map[int64]map[string]bool
// Индекс по платформам
byPlatform map[Platform]map[string]*Experiment
mu sync.RWMutex
}
func (idx *ExperimentIndex) GetActiveForUser(userSegments []int64, platform Platform) []*Experiment {
idx.mu.RLock()
defer idx.mu.RUnlock()
result := make([]*Experiment, 0)
seen := make(map[string]bool)
// Собираем эксперименты по сегментам пользователя
for _, segID := range userSegments {
if experiments, ok := idx.bySegment[segID]; ok {
for expID := range experiments {
if !seen[expID] {
seen[expID] = true
if exp, ok := idx.byStatus[ExperimentStatusRunning][expID]; ok {
if exp.IsPlatformActive(platform) && exp.IsCurrentlyActive() {
result = append(result, exp)
}
}
}
}
}
}
return result
}
Оптимизация памяти:
type CompactExperiment struct {
ID [16]byte // UUID в бинарном виде
Status uint8 // 0=draft, 1=running, 2=paused, 3=completed
StartTime int64 // Unix timestamp
EndTime int64 // 0 если не задан
Segments []int64 // Ссылка на сегменты
}
func (e *Experiment) ToCompact() *CompactExperiment {
var id [16]byte
copy(id[:], e.ID)
return &CompactExperiment{
ID: id,
Status: e.Status.ToUint8(),
StartTime: e.StartTime.Unix(),
EndTime: e.EndTime.UnixOrZero(),
Segments: e.Segments,
}
}
Кэширование активных экспериментов:
type ExperimentCache struct {
redis *redis.Client
localCache *lru.Cache
index *ExperimentIndex
mu sync.RWMutex
}
func (c *ExperimentCache) RefreshActiveExperiments(ctx context.Context) error {
// Получаем все активные эксперименты
experiments, err := c.getActiveFromDB(ctx)
if err != nil {
return err
}
// Обновляем локальный индекс
newIndex := NewExperimentIndex()
for _, exp := range experiments {
newIndex.Add(exp)
}
c.mu.Lock()
c.index = newIndex
c.mu.Unlock()
// Обновляем Redis кэш
pipe := c.redis.Pipeline()
for _, exp := range experiments {
key := fmt.Sprintf("experiment:%s", exp.ID)
data, _ := json.Marshal(exp)
pipe.Set(ctx, key, data, 5*time.Minute)
}
_, err = pipe.Exec(ctx)
return err
}
Проверка участия пользователя в эксперименте:
type ExperimentMatcher struct {
index *ExperimentIndex
storage SegmentStorage
}
func (m *ExperimentMatcher) GetExperimentsForUser(ctx context.Context, userID string, platform Platform) ([]*Experiment, error) {
// Получаем сегменты пользователя
segments, err := m.storage.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Находим подходящие эксперименты
candidates := m.index.GetActiveForUser(segments, platform)
// Проверяем квоту и хеш
result := make([]*Experiment, 0)
for _, exp := range candidates {
if m.isUserInExperiment(userID, exp) {
result = append(result, exp)
}
}
return result, nil
}
func (m *ExperimentMatcher) isUserInExperiment(userID string, exp *Experiment) bool {
// Детерминированный хеш для консистентности
bucket := getBucket(userID, exp.ID, 100)
return bucket < exp.TrafficPercent
}
Мониторинг количества экспериментов:
type ExperimentMetrics struct {
activeCount prometheus.Gauge
totalCount prometheus.Gauge
byStatus *prometheus.GaugeVec
byPlatform *prometheus.GaugeVec
}
func (m *ExperimentMetrics) Update(index *ExperimentIndex) {
index.mu.RLock()
defer index.mu.RUnlock()
activeCount := len(index.byStatus[ExperimentStatusRunning])
totalCount := 0
for _, exps := range index.byStatus {
totalCount += len(exps)
}
m.activeCount.Set(float64(activeCount))
m.totalCount.Set(float64(totalCount))
}
Оценка ресурсов для 1000 экспериментов:
// Память для хранения экспериментов
const (
AvgExperimentSize = 2 * 1024 // 2KB на эксперимент
TotalExperiments = 1000
IndexOverhead = 2.0 // Коэффициент на индексы
)
// Общий объем памяти: ~4MB для экспериментов + индексы
// Redis: ~10MB с учетом накладных расходов
// Локальный кэш: ~50MB (с вариантами и конфигами)
Оптимизации для масштаба:
1. Ленивая загрузка конфигов:
type LazyExperiment struct {
ID string
Status ExperimentStatus
Segments []int64
config atomic.Value // Загружается по требованию
}
func (e *LazyExperiment) GetConfig() *ExperimentConfig {
if cfg := e.config.Load(); cfg != nil {
return cfg.(*ExperimentConfig)
}
// Загружаем из БД/кэша
cfg := loadConfig(e.ID)
e.config.Store(cfg)
return cfg
}
2. Батчевая обработка:
func (m *ExperimentMatcher) GetExperimentsForUsers(ctx context.Context, userIDs []string) (map[string][]*Experiment, error) {
// Группируем по сегментам
segmentGroups := make(map[string][]string)
for _, userID := range userIDs {
segments := m.getUserSegmentsFast(userID)
key := hashSegments(segments)
segmentGroups[key] = append(segmentGroups[key], userID)
}
// Обрабатываем группами
results := make(map[string][]*Experiment)
for _, group := range segmentGroups {
experiments := m.getExperimentsForSegmentGroup(group[0])
for _, userID := range group {
results[userID] = experiments
}
}
return results, nil
}
3. Sharding по экспериментам:
type ShardedExperimentStore struct {
shards []*ExperimentShard
}
func (s *ShardedExperimentStore) GetShard(experimentID string) *ExperimentShard {
shardIndex := hash(experimentID) % len(s.shards)
return s.shards[shardIndex]
}
Вопрос 3. В каком виде система получает сегменты — как набор логики или как готовые когорты?
Таймкод: 00:09:28
Ответ собеседника: Правильный. Система получает готовый набор номеров сегментов, в которые попадает идентификатор пользователя. Предполагается, что сегментов может быть до 10 тысяч, и данные могут быть разреженными.
Правильный ответ:
1. Формат передачи сегментов: готовые когорты (предрасчитанные списки)
Система A/B-тестирования получает сегменты в виде предрасчитанных маппингов user_id → []segment_id, а не в виде логических правил. Это ключевое архитектурное решение.
Почему именно так:
- Производительность: Проверка принадлежности к сегменту выполняется за O(1) или O(log n) вместо вычисления правил на лету.
- Масштабируемость: Система может обрабатывать миллионы пользователей и тысячи сегментов.
- Предсказуемость: Поведение пользователя фиксируется на момент назначения варианта.
- Разделение ответственности: Логика сегментации остаётся в DWH/аналитике, система A/B только использует результат.
2. Масштаб данных
Характеристики типичной системы:
- Пользователи: от 1 млн до 100+ млн
- Сегменты: до 10 000 (как указано в вопросе)
- Разреженность: типичный пользователь принадлежит 5-20 сегментам из 10 000
- Объём данных: при 100M пользователей × 10 сегментов × 8 байт ≈ 8 ГБ в памяти
3. Оптимизация хранения разреженных данных
package segment
import (
"context"
"sync"
)
// UserSegmentIndex — оптимизированное хранение маппинга user_id -> сегменты
type UserSegmentIndex struct {
// Основное хранилище: map[user_id]map[segment_id]bool
// Используем map вместо slice для эффективной проверки принадлежности
index map[int64]map[int]struct{}
mu sync.RWMutex
}
func NewUserSegmentIndex() *UserSegmentIndex {
return &UserSegmentIndex{
index: make(map[int64]map[int]struct{}),
}
}
// AddUserToSegment добавляет пользователя в сегмент
func (idx *UserSegmentIndex) AddUserToSegment(userID int64, segmentID int) {
idx.mu.Lock()
defer idx.mu.Unlock()
if _, exists := idx.index[userID]; !exists {
idx.index[userID] = make(map[int]struct{})
}
idx.index[userID][segmentID] = struct{}{}
}
// RemoveUserFromSegment удаляет пользователя из сегмента
func (idx *UserSegmentIndex) RemoveUserFromSegment(userID int64, segmentID int) {
idx.mu.Lock()
defer idx.mu.Unlock()
if segments, exists := idx.index[userID]; exists {
delete(segments, segmentID)
if len(segments) == 0 {
delete(idx.index, userID)
}
}
}
// GetUserSegments возвращает список сегментов пользователя
func (idx *UserSegmentIndex) GetUserSegments(userID int64) []int {
idx.mu.RLock()
defer idx.mu.RUnlock()
segments, exists := idx.index[userID]
if !exists {
return nil
}
result := make([]int, 0, len(segments))
for segID := range segments {
result = append(result, segID)
}
return result
}
// IsUserInSegment проверяет принадлежность пользователя к сегменту
func (idx *UserSegmentIndex) IsUserInSegment(userID int64, segmentID int) bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
if segments, exists := idx.index[userID]; exists {
_, inSegment := segments[segmentID]
return inSegment
}
return false
}
// GetSegmentUsers возвращает всех пользователей сегмента
func (idx *UserSegmentIndex) GetSegmentUsers(segmentID int) []int64 {
idx.mu.RLock()
defer idx.mu.RUnlock()
var users []int64
for userID, segments := range idx.index {
if _, inSegment := segments[segmentID]; inSegment {
users = append(users, userID)
}
}
return users
}
4. Хранение в персистентном хранилище
-- Таблица маппинга user_id -> segment_id
CREATE TABLE user_segments (
user_id BIGINT NOT NULL,
segment_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, segment_id)
);
-- Индекс для быстрого поиска пользователей в сегменте
CREATE INDEX idx_segment_users ON user_segments(segment_id, user_id);
-- Для эффективной загрузки всех сегментов пользователя
CREATE INDEX idx_user_segments ON user_segments(user_id);
-- Партиционирование по segment_id для больших объёмов
CREATE TABLE user_segments_partitioned (
user_id BIGINT NOT NULL,
segment_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY HASH (segment_id);
-- Создание партиций
CREATE TABLE user_segments_p0 PARTITION OF user_segments_partitioned
FOR VALUES WITH (MODULUS 16, REMAINDER 0);
-- ... и так далее для всех 16 партиций
5. Альтернатива: Roaring Bitmaps для компактного хранения
Для очень больших объёмов данных используются битовые индексы:
import "github.com/RoaringBitmap/roaring"
// SegmentStore на основе Roaring Bitmaps
type SegmentStore struct {
segments map[int]*roaring.Bitmap // segment_id -> bitmap user_ids
mu sync.RWMutex
}
func NewSegmentStore() *SegmentStore {
return &SegmentStore{
segments: make(map[int]*roaring.Bitmap),
}
}
// AddUserToSegment добавляет user_id в bitmap сегмента
func (s *SegmentStore) AddUserToSegment(segmentID int, userID uint32) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.segments[segmentID]; !exists {
s.segments[segmentID] = roaring.New()
}
s.segments[segmentID].Add(userID)
}
// IsUserInSegment проверяет наличие user_id в bitmap
func (s *SegmentStore) IsUserInSegment(segmentID int, userID uint32) bool {
s.mu.RLock()
defer s.mu.RUnlock()
if bitmap, exists := s.segments[segmentID]; exists {
return bitmap.Contains(userID)
}
return false
}
// Intersection находит пересечение сегментов (пользователи в обоих сегментах)
func (s *SegmentStore) Intersection(segmentID1, segmentID2 int) *roaring.Bitmap {
s.mu.RLock()
defer s.mu.RUnlock()
bitmap1, exists1 := s.segments[segmentID1]
bitmap2, exists2 := s.segments[segmentID2]
if !exists1 || !exists2 {
return roaring.New()
}
return roaring.And(bitmap1, bitmap2)
}
// GetCardinality возвращает количество пользователей в сегменте
func (s *SegmentStore) GetCardinality(segmentID int) uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
if bitmap, exists := s.segments[segmentID]; exists {
return bitmap.GetCardinality()
}
return 0
}
6. Загрузка данных из DWH
package loader
import (
"context"
"database/sql"
"sync"
)
// SegmentLoader загружает сегменты из DWH
type SegmentLoader struct {
dwhDB *sql.DB
segmentStore *SegmentStore
}
// LoadUserSegments загружает маппинг user_id -> segments
func (l *SegmentLoader) LoadUserSegments(ctx context.Context) error {
query := `
SELECT user_id, segment_id
FROM user_segments
WHERE updated_at >= $1
ORDER BY user_id
`
lastLoadTime := l.getLastLoadTime()
rows, err := l.dwhDB.QueryContext(ctx, query, lastLoadTime)
if err != nil {
return err
}
defer rows.Close()
// Параллельная загрузка батчами
batchSize := 10000
batch := make([]UserSegment, 0, batchSize)
for rows.Next() {
var us UserSegment
if err := rows.Scan(&us.UserID, &us.SegmentID); err != nil {
return err
}
batch = append(batch, us)
if len(batch) >= batchSize {
l.processBatch(batch)
batch = batch[:0]
}
}
if len(batch) > 0 {
l.processBatch(batch)
}
return rows.Err()
}
// processBatch обрабатывает батч записей
func (l *SegmentLoader) processBatch(batch []UserSegment) {
for _, us := range batch {
l.segmentStore.AddUserToSegment(us.SegmentID, uint32(us.UserID))
}
}
// SyncSegments синхронизирует сегменты инкрементально
func (l *SegmentLoader) SyncSegments(ctx context.Context) error {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := l.LoadUserSegments(ctx); err != nil {
log.Printf("Failed to load segments: %v", err)
}
}
}
}
7. Сравнение подходов
| Подход | Плюсы | Минусы | Когда использовать |
|---|---|---|---|
| Готовые когорты (предрасчитанные) | Быстрый lookup O(1), предсказуемость, простота | Требует синхронизации, задержка данных | Продакшен A/B-тесты, высокие нагрузки |
| Логические правила (динамические) | Актуальность данных, гибкость | Медленно, сложно масштабировать | Прототипы, простые сегменты, реал-тайм |
8. Итоговая архитектура
[DWH/Аналитика] → [ETL] → [user_segments таблица] → [Loader] → [In-Memory Index] → [AB Test Service]
↓ ↓
Логика сегментации Проверка принадлежности
Расчёт атрибутов Назначение вариантов
Система A/B-тестирования получает готовые когорты в виде маппинга user_id → []segment_id, что обеспечивает высокую производительность и масштабируемость при работе с тысячами сегментов и миллионами пользователей.
Вопрос 6. Сколько аналитиков работают с системой и как часто они создают эксперименты?
Таймкод: 00:12:19
Ответ собеседника: Правильный. Уточняется, что аналитиков около 100 человек. Они не очень активно создают эксперименты по сравнению с клиентами.
Правильный ответ:
Масштаб команды
~100 аналитиков работают с системой экспериментов. Это достаточно большая команда, которая требует качественного UX и системы управления доступом.
Профиль использования:
type UserRole string
const (
RoleAdmin UserRole = "admin" // Полный доступ
RoleAnalyst UserRole = "analyst" // Создание и управление своими экспериментами
RoleViewer UserRole = "viewer" // Только просмотр результатов
RoleApprover UserRole = "approver" // Утверждение экспериментов
)
type UserActivity struct {
UserID string
Role UserRole
LastLogin time.Time
ExperimentsCreated int
ExperimentsViewed int
ActiveExperiments int
}
Оценка нагрузки от аналитиков:
// Статистика использования
const (
TotalAnalysts = 100
ActiveDailyUsers = 30 // ~30% активных ежедневно
ActiveWeeklyUsers = 60 // ~60% активных еженедельно
// Создание экспериментов
ExperimentsPerDay = 5-10 // Новых экспериментов в день
ExperimentsPerWeek = 20-40 // Новых экспериментов в неделю
// Просмотр результатов
QueriesPerAnalystPerDay = 10-50
TotalDailyQueries = 300-1500
)
Rate Limiting для аналитиков:
type AnalystRateLimiter struct {
redis *redis.Client
}
func (rl *AnalystRateLimiter) AllowExperimentCreation(ctx context.Context, userID string) error {
key := fmt.Sprintf("rate_limit:create:%s:%s", userID, time.Now().Format("2006-01-02"))
// Лимит: 5 экспериментов в день на аналитика
current, err := rl.redis.Incr(ctx, key).Result()
if err != nil {
return err
}
if current == 1 {
rl.redis.Expire(ctx, key, 24*time.Hour)
}
if current > 5 {
return fmt.Errorf("daily experiment creation limit exceeded")
}
return nil
}
Система согласования экспериментов:
type ExperimentApproval struct {
ExperimentID string
RequesterID string
ApproverID string
Status ApprovalStatus
CreatedAt time.Time
ReviewedAt *time.Time
Comments string
}
type ApprovalStatus string
const (
ApprovalStatusPending ApprovalStatus = "pending"
ApprovalStatusApproved ApprovalStatus = "approved"
ApprovalStatusRejected ApprovalStatus = "rejected"
)
func (s *ExperimentService) CreateExperiment(ctx context.Context, req CreateExperimentRequest, analystID string) (*Experiment, error) {
// Проверяем права
if !s.hasPermission(analystID, PermissionCreateExperiment) {
return nil, ErrForbidden
}
// Проверяем лимит
if err := s.rateLimiter.AllowExperimentCreation(ctx, analystID); err != nil {
return nil, err
}
// Создаем эксперимент
experiment := &Experiment{
ID: generateID(),
Name: req.Name,
Status: ExperimentStatusDraft,
CreatedBy: analystID,
CreatedAt: time.Now(),
}
// Требуется ли согласование
if s.requiresApproval(experiment) {
experiment.Status = ExperimentStatusPendingApproval
s.createApprovalRequest(experiment, analystID)
}
return experiment, nil
}
Аудит действий аналитиков:
type AuditLog struct {
ID string
UserID string
Action AuditAction
ResourceID string
ResourceType string
Timestamp time.Time
Details map[string]interface{}
}
type AuditAction string
const (
AuditActionCreate AuditAction = "create"
AuditActionUpdate AuditAction = "update"
AuditActionDelete AuditAction = "delete"
AuditActionStart AuditAction = "start"
AuditActionStop AuditAction = "stop"
AuditActionView AuditAction = "view"
)
type AuditService struct {
storage AuditStorage
}
func (s *AuditService) Log(ctx context.Context, log AuditLog) error {
log.ID = generateID()
log.Timestamp = time.Now()
return s.storage.Save(ctx, log)
}
Мониторинг активности аналитиков:
type AnalystMetrics struct {
dailyActiveUsers prometheus.Gauge
experimentsCreated prometheus.Counter
queriesPerUser prometheus.Histogram
sessionDuration prometheus.Histogram
}
func (m *AnalystMetrics) RecordExperimentCreation(analystID string) {
m.experimentsCreated.Inc()
}
func (m *AnalystMetrics) RecordQuery(analystID string, duration time.Duration) {
m.queriesPerUser.Observe(duration.Seconds())
}
Партиционирование данных по аналитикам:
-- Хранение экспериментов с партиционированием
CREATE TABLE experiments (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_by VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
-- Партиции по месяцам
CREATE TABLE experiments_2024_01 PARTITION OF experiments
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
Кэширование для аналитиков:
type AnalystCache struct {
redis *redis.Client
}
func (c *AnalystCache) GetRecentExperiments(ctx context.Context, analystID string) ([]*Experiment, error) {
key := fmt.Sprintf("analyst:%s:recent_experiments", analystID)
// Пробуем получить из кэша
data, err := c.redis.Get(ctx, key).Result()
if err == nil {
var experiments []*Experiment
if err := json.Unmarshal([]byte(data), &experiments); err == nil {
return experiments, nil
}
}
// Загружаем из БД
experiments, err := c.loadRecentFromDB(ctx, analystID)
if err != nil {
return nil, err
}
// Кэшируем на 5 минут
data, _ = json.Marshal(experiments)
c.redis.Set(ctx, key, data, 5*time.Minute)
return experiments, nil
}
Выводы для архитектуры:
1. Нагрузка от аналитиков относительно низкая:
- 100 пользователей - это не критично для read/write
- Основная нагрузка - просмотр результатов, не создание
2. Важность UX:
- Аналитики не разработчики - нужен удобный интерфейс
- Важна скорость загрузки результатов
3. Контроль доступа:
- Разграничение прав между аналитиками
- Согласование экспериментов для критичных изменений
4. Масштабирование:
- 100 пользователей не требует сложного шардирования
- Важнее оптимизировать запросы для аналитики
Вопрос 4. Сколько пользователей в системе и как они распределены по платформам?
Таймкод: 00:10:37
Ответ собеседника: Правильный. Уточняется, что всего около 30 млн активных пользователей в месяц, из них 10 млн в мобильных приложениях и 3 млн в вебе.
Правильный ответ:
1. Масштаб системы
Исходя из предоставленных данных:
- Общий MAU (Monthly Active Users): ~30 млн
- Мобильные приложения: ~10 млн MAU
- Веб-платформа: ~3 млн MAU
- Остальные (~17 млн): могут включать API-клиентов, десктопные приложения, партнёрские интеграции или пользователей, использующих несколько платформ
2. Распределение нагрузки
При таком масштабе типичная нагрузка:
Запросов в секунду (RPS):
- Пиковый RPS: 50,000 - 100,000
- Средний RPS: 10,000 - 30,000
- Запросов на назначение варианта: ~30-50% от общего трафика
Хранение данных:
- Маппинг user_id → segments: ~8-16 ГБ в памяти
- Маппинг user_id → variant: ~2-4 ГБ в памяти
- События для аналитики: ~100-500 ГБ в день
3. Особенности работы с мультиплатформенной аудиторией
package platform
// Platform представляет платформу пользователя
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
PlatformWeb Platform = "web"
PlatformDesktop Platform = "desktop"
PlatformAPI Platform = "api"
)
// UserContext контекст пользователя для A/B-теста
type UserContext struct {
UserID int64
Platform Platform
AppVersion string
DeviceID string
SessionID string
Country string
Language string
}
// Experiment с поддержкой платформенной фильтрации
type Experiment struct {
ID string
Name string
Variants []Variant
TargetPlatforms []Platform // целевые платформы
MinAppVersion map[Platform]string // минимальная версия приложения по платформе
TargetSegments []string
TrafficPct float64
IsActive bool
}
// ABTestService с поддержкой платформ
type ABTestService struct {
experimentStore ExperimentStorage
segmentService SegmentService
platformFilter PlatformFilter
}
// PlatformFilter фильтрация по платформе
type PlatformFilter interface {
IsPlatformSupported(experiment *Experiment, platform Platform) bool
IsVersionSupported(experiment *Experiment, platform Platform, version string) bool
}
// AssignVariant с учётом платформы
func (s *ABTestService) AssignVariant(ctx context.Context, userCtx UserContext, experimentID string) (*Variant, error) {
experiment, err := s.experimentStore.GetExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
// Проверяем активность эксперимента
if !experiment.IsActive {
return nil, fmt.Errorf("experiment %s is not active", experimentID)
}
// Проверяем поддержку платформы
if !s.platformFilter.IsPlatformSupported(experiment, userCtx.Platform) {
return nil, fmt.Errorf("platform %s is not supported", userCtx.Platform)
}
// Проверяем версию приложения
if !s.platformFilter.IsVersionSupported(experiment, userCtx.Platform, userCtx.AppVersion) {
return nil, fmt.Errorf("app version %s is not supported", userCtx.AppVersion)
}
// Проверяем сегменты
if len(experiment.TargetSegments) > 0 {
userSegments, err := s.segmentService.GetUserSegments(ctx, userCtx.UserID)
if err != nil {
return nil, err
}
if !hasIntersection(userSegments, experiment.TargetSegments) {
return nil, fmt.Errorf("user is not in target segments")
}
}
// Назначаем вариант
return s.selectVariant(ctx, userCtx.UserID, experiment)
}
4. Стратегии сплита для мультиплатформенной аудитории
Стратегия 1: Еный сплит для всех платформ
- Один experiment_key для всех платформ
- Пользователь видит одинаковый вариант на всех устройствах
- Подходит для экспериментов с серверной логикой
Стратегия 2: Раздельный сплит по платформам
- Разные experiment_key для каждой платформы
- Позволяет тестировать платформоспецифичные изменения
- Увеличивает количество экспериментов
Стратегия 3: Иерархический сплит
// Генерация ключа сплита с учётом платформы
func generateSplitKey(experimentID string, platform Platform) string {
return fmt.Sprintf("%s_%s", experimentID, platform)
}
// Или единый ключ для кросс-платформенных экспериментов
func generateUnifiedSplitKey(experimentID string) string {
return experimentID
}
5. Хранение данных с учётом платформ
-- Таблица экспериментов с поддержкой платформ
CREATE TABLE experiments (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
status VARCHAR(50) DEFAULT 'draft',
target_platforms VARCHAR(100)[] DEFAULT ARRAY['ios', 'android', 'web'],
min_app_version JSONB, -- {"ios": "1.2.0", "android": "2.0.0", "web": null}
traffic_percentage FLOAT DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
ended_at TIMESTAMP
);
-- Таблица назначений вариантов
CREATE TABLE user_variants (
user_id BIGINT NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, experiment_id, platform)
);
-- Индекс для аналитики по платформам
CREATE INDEX idx_user_variants_platform ON user_variants(experiment_id, platform);
-- Аналитика по событиям
CREATE TABLE experiment_events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY RANGE (created_at);
6. Мониторинг по платформам
// MetricsCollector сбор метрик по платформам
type MetricsCollector struct {
// Счётчики по платформом
assignmentsByPlatform map[Platform]*prometheus.CounterVec
eventsByPlatform map[Platform]*prometheus.CounterVec
}
// RecordAssignment фиксирует назначение варианта
func (m *MetricsCollector) RecordAssignment(platform Platform, experimentID, variantID string) {
m.assignmentsByPlatform[platform].WithLabelValues(experimentID, variantID).Inc()
}
// RecordEvent фиксирует событие
func (m *MetricsCollector) RecordEvent(platform Platform, experimentID, variantID, eventType string) {
m.eventsByPlatform[platform].WithLabelValues(experimentID, variantID, eventType).Inc()
}
// GetConversionRateByPlatform возвращает конверсию по платформам
func (m *MetricsCollector) GetConversionRateByPlatform(experimentID, variantID string) map[Platform]float64 {
result := make(map[Platform]float64)
for _, platform := range []Platform{PlatformIOS, PlatformAndroid, PlatformWeb} {
conversions := m.getEventCount(platform, experimentID, variantID, "conversion")
assignments := m.getAssignmentCount(platform, experimentID, variantID)
if assignments > 0 {
result[platform] = float64(conversions) / float64(assignments)
}
}
return result
}
7. Особенности для мобильных платформ
Проблема: отложенная синхронизация
- Мобильные приложения могут быть оффлайн
- Необходимо кэширование вариантов на устройстве
// MobileVariantCache кэш вариантов для мобильных приложений
type MobileVariantCache struct {
cache map[string]CachedVariant
mu sync.RWMutex
}
type CachedVariant struct {
VariantID string
ExpiresAt time.Time
Config map[string]interface{}
}
// GetVariantsBatch получает варианты для нескольких экспериментов
func (s *ABTestService) GetVariantsBatch(ctx context.Context, userCtx UserContext, experimentIDs []string) (map[string]*Variant, error) {
result := make(map[string]*Variant)
for _, expID := range experimentIDs {
variant, err := s.AssignVariant(ctx, userCtx, expID)
if err != nil {
// Логируем, но не прерываем весь запрос
log.Printf("Failed to assign variant for experiment %s: %v", expID, err)
continue
}
result[expID] = variant
}
return result, nil
}
8. Масштабирование под 30 млн MAU
Архитектура:
- 3-5 инстансов AB Test Service (горизонтальное масштабирование)
- Redis Cluster для кэширования маппингов
- PostgreSQL/Cassandra для персистентного хранения
- Kafka для асинхронной записи событий
Распределение трафика:
- Мобильные (10M MAU): ~40% нагрузки
- Веб (3M MAU): ~15% нагрузки
- Остальные (17M MAU): ~45% нагрузки
При таком масштабе критически важны: эффективное кэширование, батчинг запросов, асинхронная запись аналитики и мониторинг latency на уровне p99 < 10ms для назначения вариантов.
Вопрос 7. Как долго живёт эксперимент и кто решает о его завершении?
Таймкод: 00:12:44
Ответ собеседника: Правильный. Уточняется, что обычно эксперимент длится 1-2 недели, но может завершиться быстрее или длиться до месяца. Аналитик смотрит на статистику и решает, продолжать или завершить эксперимент.
Правильный ответ:
Жизненный цикл эксперимента
Типичная длительность:
- Стандарт: 1-2 недели
- Минимум: несколько дней (быстрое завершение при негативном результате)
- Максимум: до 1 месяца (для медленно накапливаемых метрик)
Модель жизненного цикла:
type ExperimentLifecycle struct {
ID string
Status ExperimentStatus
CreatedAt time.Time
StartedAt *time.Time
EndedAt *time.Time
PlannedEnd *time.Time
CreatedBy string
EndedBy *string
EndReason *EndReason
}
type EndReason string
const (
EndReasonCompleted EndReason = "completed" // Успешное завершение
EndReasonEarlyStop EndReason = "early_stop" // Ранняя остановка
EndReasonNegativeResult EndReason = "negative_result" // Негативный результат
EndReasonTechnical EndReason = "technical" // Техническая проблема
EndReasonTimeout EndReason = "timeout" // Превышен таймаут
)
Автоматические проверки завершения:
type ExperimentMonitor struct {
metrics MetricsCollector
storage ExperimentStorage
notifier Notifier
}
func (m *ExperimentMonitor) CheckExperimentHealth(ctx context.Context, experimentID string) error {
exp, err := m.storage.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// Проверка 1: Длительность
if m.isExpired(exp) {
m.stopExperiment(ctx, exp, EndReasonTimeout)
return nil
}
// Проверка 2: Статистическая значимость
if m.hasStatisticalSignificance(exp) {
m.notifyAnalyst(exp, "Experiment has reached statistical significance")
}
// Проверка 3: Негативное влияние на метрики
if m.hasNegativeImpact(exp) {
m.notifyAnalyst(exp, "WARNING: Experiment shows negative impact")
}
// Проверка 4: Достаточный размер выборки
if m.hasEnoughSample(exp) {
m.notifyAnalyst(exp, "Experiment has enough sample size for analysis")
}
return nil
}
func (m *ExperimentMonitor) isExpired(exp *Experiment) bool {
if exp.PlannedEnd != nil {
return time.Now().After(*exp.PlannedEnd)
}
// По умолчанию максимум 30 дней
return time.Since(*exp.StartedAt) > 30*24*time.Hour
}
Решение о завершении:
type ExperimentDecisionService struct {
metrics MetricsCollector
}
type ExperimentDecision struct {
ExperimentID string
Recommendation DecisionRecommendation
Confidence float64
Reasoning string
Metrics map[string]MetricValue
SuggestedAction string
}
type DecisionRecommendation string
const (
RecommendationContinue DecisionRecommendation = "continue"
RecommendationStop DecisionRecommendation = "stop"
RecommendationExtend DecisionRecommendation = "extend"
)
func (s *ExperimentDecisionService) AnalyzeExperiment(ctx context.Context, experimentID string) (*ExperimentDecision, error) {
metrics, err := s.metrics.GetExperimentMetrics(ctx, experimentID)
if err != nil {
return nil, err
}
// Проверяем статистическую значимость
isSignificant := s.checkSignificance(metrics)
// Проверяем размер эффекта
effectSize := s.calculateEffectSize(metrics)
// Проверяем guardrail метрики
guardrailsOK := s.checkGuardrails(metrics)
if !guardrailsOK {
return &ExperimentDecision{
ExperimentID: experimentID,
Recommendation: RecommendationStop,
Confidence: 0.95,
Reasoning: "Guardrail metrics violated",
Metrics: metrics,
SuggestedAction: "Stop experiment immediately",
}, nil
}
if isSignificant && effectSize > 0 {
return &ExperimentDecision{
ExperimentID: experimentID,
Recommendation: RecommendationStop,
Confidence: 0.9,
Reasoning: "Positive result with statistical significance",
Metrics: metrics,
SuggestedAction: "Consider rolling out the winning variant",
}, nil
}
if isSignificant && effectSize < 0 {
return &ExperimentDecision{
ExperimentID: experimentID,
Recommendation: RecommendationStop,
Confidence: 0.9,
Reasoning: "Negative result with statistical significance",
Metrics: metrics,
SuggestedAction: "Stop and revert to control",
}, nil
}
return &ExperimentDecision{
ExperimentID: experimentID,
Recommendation: RecommendationContinue,
Confidence: 0.7,
Reasoning: "Need more data for conclusive results",
Metrics: metrics,
SuggestedAction: "Continue experiment",
}, nil
}
Ручное завершение аналитиком:
type ExperimentService struct {
storage ExperimentStorage
monitor *ExperimentMonitor
audit AuditService
}
func (s *ExperimentService) StopExperiment(ctx context.Context, experimentID string, analystID string, reason EndReason) error {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// Проверяем права
if !s.canStopExperiment(analystID, exp) {
return ErrForbidden
}
now := time.Now()
exp.Status = ExperimentStatusCompleted
exp.EndedAt = &now
exp.EndedBy = &analystID
exp.EndReason = &reason
if err := s.storage.UpdateExperiment(ctx, exp); err != nil {
return err
}
// Логируем действие
s.audit.Log(ctx, AuditLog{
UserID: analystID,
Action: AuditActionStop,
ResourceID: experimentID,
ResourceType: "experiment",
Details: map[string]interface{}{
"reason": reason,
"end_time": now,
},
})
// Уведомляем заинтересованных
s.notifyExperimentEnded(exp)
return nil
}
Автоматические алерты:
type AlertService struct {
notifier Notifier
}
func (s *AlertService) SendExperimentAlerts(ctx context.Context, experimentID string) error {
exp, err := s.getExperiment(ctx, experimentID)
if err != nil {
return err
}
alerts := s.generateAlerts(exp)
for _, alert := range alerts {
if alert.Severity == SeverityCritical {
// Критичные алерты - немедленно
s.notifier.SendImmediate(ctx, alert)
} else {
// Остальные - в дайджест
s.notifier.SendDigest(ctx, alert)
}
}
return nil
}
func (s *AlertService) generateAlerts(exp *Experiment) []Alert {
var alerts []Alert
// Алерт: эксперимент близок к завершению
if exp.PlannedEnd != nil {
timeLeft := time.Until(*exp.PlannedEnd)
if timeLeft < 24*time.Hour {
alerts = append(alerts, Alert{
Severity: SeverityInfo,
Message: fmt.Sprintf("Experiment %s ends in %v", exp.ID, timeLeft),
})
}
}
// Алерт: длительность превышает норму
if time.Since(*exp.StartedAt) > 14*24*time.Hour {
alerts = append(alerts, Alert{
Severity: SeverityWarning,
Message: fmt.Sprintf("Experiment %s running for more than 2 weeks", exp.ID),
})
}
return alerts
}
Планирование и расписание:
type ExperimentScheduler struct {
storage ExperimentStorage
}
func (s *ExperimentScheduler) ScheduleExperiment(ctx context.Context, experimentID string, schedule ExperimentSchedule) error {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
exp.PlannedStart = schedule.StartTime
exp.PlannedEnd = schedule.EndTime
// Валидация
if schedule.EndTime != nil {
duration := schedule.EndTime.Sub(*schedule.StartTime)
if duration < 24*time.Hour {
return fmt.Errorf("experiment duration must be at least 24 hours")
}
if duration > 30*24*time.Hour {
return fmt.Errorf("experiment duration cannot exceed 30 days")
}
}
return s.storage.UpdateExperiment(ctx, exp)
}
Отчет о завершении:
type ExperimentReport struct {
ExperimentID string
Duration time.Duration
TotalUsers int64
Variants []VariantResult
StatisticalResults StatisticalSummary
Conclusion string
Recommendations []string
}
func (s *ExperimentService) GenerateReport(ctx context.Context, experimentID string) (*ExperimentReport, error) {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
metrics, err := s.metrics.GetExperimentMetrics(ctx, experimentID)
if err != nil {
return nil, err
}
report := &ExperimentReport{
ExperimentID: experimentID,
Duration: exp.EndedAt.Sub(*exp.StartedAt),
TotalUsers: metrics.TotalUsers,
Variants: s.calculateVariantResults(metrics),
StatisticalResults: s.calculateStatistics(metrics),
Conclusion: s.generateConclusion(metrics),
Recommendations: s.generateRecommendations(metrics),
}
return report, nil
}
Матрица решений для завершения:
| Ситуация | Решение | Кто принимает |
|---|---|---|
| Достигнута статистическая значимость | Завершить | Автоматически + уведомление |
| Негативное влияние на guardrail метрики | Немедленная остановка | Автоматически |
| Истек плановый срок | Завершить | Автоматически |
| Недостаточно данных после 2 недель | Продолжить/остановить | Аналитик |
| Техническая проблема | Остановить | Аналитик/админ |
Вопрос 8. Нужно ли моделировать авторизацию пользователей?
Таймкод: 00:14:10
Ответ собеседова: Правильный. Уточняется, что авторизацию моделировать не нужно. Можно свести всё к некоторому идентификатору, который приходит с клиента (ID пользователя, cookie, device ID).
Правильный ответ:
Идентификация без авторизации
Система экспериментов работает с идентификаторами, а не с авторизацией. Это ключевое упрощение архитектуры.
Модель идентификации:
type UserIdentifier struct {
// Основной идентификатор
UserID string `json:"user_id"`
// Альтернативные идентификаторы
DeviceID string `json:"device_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
CookieID string `json:"cookie_id,omitempty"`
// Контекст
Platform Platform `json:"platform"`
AppVersion string `json:"app_version,omitempty"`
}
type IdentityResolver struct {
// Приоритет идентификаторов
priority []string
}
func (r *IdentityResolver) Resolve(ctx context.Context, identifiers UserIdentifier) (string, error) {
// Приоритет: UserID > DeviceID > CookieID > SessionID
if identifiers.UserID != "" {
return identifiers.UserID, nil
}
if identifiers.DeviceID != "" {
return "device:" + identifiers.DeviceID, nil
}
if identifiers.CookieID != "" {
return "cookie:" + identifiers.CookieID, nil
}
if identifiers.SessionID != "" {
return "session:" + identifiers.SessionID, nil
}
return "", ErrNoIdentifier
}
API без авторизации:
type ExperimentHandler struct {
experimentService ExperimentService
identityResolver IdentityResolver
}
func (h *ExperimentHandler) GetExperiments(w http.ResponseWriter, r *http.Request) {
// Извлекаем идентификаторы из запроса
identifiers := UserIdentifier{
UserID: r.Header.Get("X-User-ID"),
DeviceID: r.Header.Get("X-Device-ID"),
CookieID: getCookieValue(r, "experiment_id"),
Platform: Platform(r.Header.Get("X-Platform")),
}
// Разрешаем идентификатор
resolvedID, err := h.identityResolver.Resolve(r.Context(), identifiers)
if err != nil {
http.Error(w, "No valid identifier", http.StatusBadRequest)
return
}
// Получаем эксперименты
experiments, err := h.experimentService.GetExperimentsForUser(r.Context(), resolvedID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(experiments)
}
Хранение маппинга идентификаторов:
type IdentityMappingService struct {
storage IdentityStorage
}
func (s *IdentityMappingService) LinkIdentifiers(ctx context.Context, primaryID string, aliases []string) error {
// Связываем несколько идентификаторов с одним пользователем
pipe := s.storage.Pipeline()
for _, alias := range aliases {
pipe.Set(ctx,
fmt.Sprintf("identity:%s", alias),
primaryID,
30*24*time.Hour,
)
}
_, err := pipe.Exec(ctx)
return err
}
func (s *IdentityMappingService) GetPrimaryID(ctx context.Context, identifier string) (string, error) {
primaryID, err := s.storage.Get(ctx, fmt.Sprintf("identity:%s", identifier)).Result()
if err == nil {
return primaryID, nil
}
// Если нет маппинга, используем как есть
return identifier, nil
}
Анонимные пользователи:
type AnonymousIdentifierGenerator struct{}
func (g *AnonymousIdentifierGenerator) GenerateFromRequest(r *http.Request) UserIdentifier {
// Пытаемся получить из различных источников
if cookieID := g.getOrCreateCookie(r); cookieID != "" {
return UserIdentifier{
CookieID: cookieID,
Platform: g.detectPlatform(r),
}
}
// Fallback на fingerprint
return UserIdentifier{
DeviceID: g.generateFingerprint(r),
Platform: g.detectPlatform(r),
}
}
func (g *AnonymousIdentifierGenerator) generateFingerprint(r *http.Request) string {
// Простой fingerprint на основе заголовков
data := fmt.Sprintf("%s|%s|%s",
r.UserAgent(),
r.Header.Get("Accept-Language"),
r.Header.Get("Accept-Encoding"),
)
h := fnv.New64a()
h.Write([]byte(data))
return fmt.Sprintf("fp:%x", h.Sum64())
}
Middleware для извлечения идентификаторов:
type IdentifierMiddleware struct {
resolver IdentityResolver
}
func (m *IdentifierMiddleware) Handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identifiers := m.extractIdentifiers(r)
resolvedID, err := m.resolver.Resolve(r.Context(), identifiers)
if err != nil {
// Для экспериментов можно продолжить с анонимным ID
resolvedID = m.generateAnonymousID(r)
}
// Добавляем в контекст
ctx := context.WithValue(r.Context(), "resolved_id", resolvedID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *IdentifierMiddleware) extractIdentifiers(r *http.Request) UserIdentifier {
return UserIdentifier{
UserID: r.Header.Get("X-User-ID"),
DeviceID: r.Header.Get("X-Device-ID"),
CookieID: getCookieValue(r, "experiment_id"),
SessionID: getCookieValue(r, "session_id"),
Platform: Platform(r.Header.Get("X-Platform")),
AppVersion: r.Header.Get("X-App-Version"),
}
}
API для мобильных приложений:
type MobileExperimentService struct {
experimentService ExperimentService
identityService IdentityMappingService
}
func (s *MobileExperimentService) GetExperimentsBatch(ctx context.Context, req BatchRequest) (*BatchResponse, error) {
// Мобильные приложения могут отправлять несколько идентификаторов
primaryID, err := s.identityService.ResolvePrimaryID(ctx, req.ToResolve())
if err != nil {
return nil, err
}
experiments, err := s.experimentService.GetExperimentsForUser(ctx, primaryID)
if err != nil {
return nil, err
}
// Связываем идентификаторы для будущих запросов
if len(req.Aliases) > 0 {
s.identityService.LinkIdentifiers(ctx, primaryID, req.Aliases)
}
return &BatchResponse{
Experiments: experiments,
UserID: primaryID,
}, nil
}
Консистентность при смене идентификаторов:
type ConsistentBucketService struct {
storage SegmentStorage
}
func (s *ConsistentBucketService) GetConsistentBucket(ctx context.Context, identifier string, experimentID string) (int, error) {
// Хешируем для консистентности
bucket := getBucket(identifier, experimentID, 100)
// Проверяем, не менялся ли bucket для этого пользователя
storedBucket, err := s.storage.GetUserBucket(ctx, identifier, experimentID)
if err == nil && storedBucket != nil {
return *storedBucket, nil
}
// Сохраняем bucket
s.storage.SetUserBucket(ctx, identifier, experimentID, bucket)
return bucket, nil
}
Безопасность без авторизации:
type SecurityConfig struct {
// Rate limiting по идентификаторам
RateLimitEnabled bool
MaxRequestsPerMinute int
// Валидация идентификаторов
ValidateIdentifiers bool
// Логирование
LogAllRequests bool
}
func (s *SecurityConfig) ValidateIdentifier(identifier string) error {
if len(identifier) > 256 {
return ErrIdentifierTooLong
}
if !isValidFormat(identifier) {
return ErrInvalidIdentifierFormat
}
return nil
}
Преимущества подхода без авторизации:
1. Упрощение архитектуры:
- Нет необходимости в auth-сервисе
- Нет зависимости от сессий
- Проще масштабирование
2. Универсальность:
- Работает для анонимных пользователей
- Не зависит от способа аутентификации
- Легче интеграция с разными клиентами
3. Производительность:
- Нет дополнительных запросов к auth-сервису
- Меньше latency
- Проще кэширование
4. Гибкость:
- Легко добавлять новые типы идентификаторов
- Можно связывать идентификаторы постфактум
- Поддержка кросс-девайс трекинга
Ограничения:
- Нет гарантии уникальности для анонимных пользователей
- Возможны коллизии при использовании fingerprint
- Нет защиты от подмены идентификаторов (но для экспериментов это не критично)
Вопрос 5. Сколько активных экспериментов может быть одновременно?
Таймкод: 00:11:20
Ответ собеседника: Правильный. Уточняется, что система должна поддерживать до тысяч активных экспериментов единовременно. Это целевое значение для проектирования.
Правильный ответ:
1. Масштаб системы экспериментов
Целевое значение: 1000+ активных экспериментов одновременно.
Для контекста, это соответствует масштабу крупных технологических компаний:
- Google, Meta, Amazon: 10,000+ экспериментов
- Крупные e-commerce (Wildberries, Ozon, Яндекс): 1,000-5,000 экспериментов
- Средние компании: 100-500 экспериментов
2. Распределение экспериментов по типам
Типичное распределение:
- A/B тесты: 70-80%
- A/B/n тесты (множественные варианты): 15-20%
- Многовариантные тесты (MVT): 5-10%
По длительности:
- Краткосрочные (1-7 дней): 30%
- Среднесрочные (1-4 недели): 50%
- Долгосрочные (1-3 месяца): 15%
- Перманентные: 5%
3. Архитектура для поддержки тысяч экспериментов
package experiment
import (
"context"
"sync"
"time"
)
// ExperimentRegistry реестр активных экспериментов
type ExperimentRegistry struct {
experiments map[string]*Experiment
mu sync.RWMutex
index *ExperimentIndex
}
// ExperimentIndex индекс для быстрого поиска
type ExperimentIndex struct {
// Индекс по статусу
byStatus map[ExperimentStatus]map[string]*Experiment
// Индекс по платформе
byPlatform map[Platform]map[string]*Experiment
// Индекс по сегментам
bySegment map[string]map[string]*Experiment
mu sync.RWMutex
}
type ExperimentStatus string
const (
StatusDraft ExperimentStatus = "draft"
StatusRunning ExperimentStatus = "running"
StatusPaused ExperimentStatus = "paused"
StatusCompleted ExperimentStatus = "completed"
StatusStopped ExperimentStatus = "stopped"
)
// Experiment полная модель эксперимента
type Experiment struct {
ID string
Name string
Description string
Status ExperimentStatus
Variants []Variant
TargetSegments []string
TargetPlatforms []Platform
TrafficPct float64
MinSampleSize int
SignificanceLevel float64
CreatedAt time.Time
StartedAt *time.Time
EndedAt *time.Time
CreatedBy string
UpdatedAt time.Time
}
// AddExperiment добавляет эксперимент в реестр
func (r *ExperimentRegistry) AddExperiment(ctx context.Context, exp *Experiment) error {
r.mu.Lock()
defer r.mu.Unlock()
// Валидация
if err := r.validateExperiment(exp); err != nil {
return err
}
r.experiments[exp.ID] = exp
// Обновляем индексы
r.index.AddExperiment(exp)
return nil
}
// GetActiveExperiments возвращает все активные эксперименты
func (r *ExperimentRegistry) GetActiveExperiments(ctx context.Context) []*Experiment {
r.index.mu.RLock()
defer r.index.mu.RUnlock()
exps := r.index.byStatus[StatusRunning]
result := make([]*Experiment, 0, len(exps))
for _, exp := range exps {
result = append(result, exp)
}
return result
}
// GetExperimentsForUser возвращает эксперименты, применимые к пользователю
func (r *ExperimentRegistry) GetExperimentsForUser(ctx context.Context, userCtx UserContext) ([]*Experiment, error) {
r.index.mu.RLock()
defer r.index.mu.RUnlock()
var result []*Experiment
// Получаем сегменты пользователя
userSegments := getUserSegments(ctx, userCtx.UserID)
// Ищем эксперименты по платформе
platformExps := r.index.byPlatform[userCtx.Platform]
for _, exp := range platformExps {
if exp.Status != StatusRunning {
continue
}
// Проверяем процент трафика
if !isUserInTraffic(userCtx.UserID, exp.TrafficPct) {
continue
}
// Проверяем сегменты
if len(exp.TargetSegments) > 0 && !hasIntersection(userSegments, exp.TargetSegments) {
continue
}
result = append(result, exp)
}
return result, nil
}
4. Оптимизация памяти для тысяч экспериментов
// MemoryOptimizedRegistry оптимизированный реестр
type MemoryOptimizedRegistry struct {
// Храним только активные эксперименты в памяти
activeExps sync.Map // map[string]*Experiment
// Кэш для назначений
assignmentCache *ristretto.Cache
// Бэкграунд обновление
refreshInterval time.Duration
}
func NewMemoryOptimizedRegistry() (*MemoryOptimizedRegistry, error) {
cache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 10M
MaxCost: 1 << 30, // 1GB
BufferItems: 64,
})
if err != nil {
return nil, err
}
return &MemoryOptimizedRegistry{
activeExps: sync.Map{},
assignmentCache: cache,
refreshInterval: 30 * time.Second,
}, nil
}
// RefreshActiveExps обновляет список активных экспериментов
func (r *MemoryOptimizedRegistry) RefreshActiveExps(ctx context.Context) error {
// Загружаем только активные эксперименты из БД
activeExps, err := r.loadActiveExperiments(ctx)
if err != nil {
return err
}
// Очищаем sync.Map
r.activeExps.Range(func(key, value interface{}) bool {
r.activeExps.Delete(key)
return true
})
// Загружаем новые
for _, exp := range activeExps {
r.activeExps.Store(exp.ID, exp)
}
return nil
}
// GetExperiment получает эксперимент по ID
func (r *MemoryOptimizedRegistry) GetExperiment(expID string) (*Experiment, bool) {
if val, ok := r.activeExps.Load(expID); ok {
return val.(*Experiment), true
}
return nil, false
}
5. Хранение в базе данных
-- Таблица экспериментов
CREATE TABLE experiments (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'draft',
variants JSONB NOT NULL,
target_segments VARCHAR(255)[],
target_platforms VARCHAR(100)[],
traffic_percentage FLOAT DEFAULT 1.0,
min_sample_size INTEGER DEFAULT 1000,
significance_level FLOAT DEFAULT 0.05,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
ended_at TIMESTAMP,
created_by VARCHAR(255),
updated_by VARCHAR(255)
);
-- Индексы для быстрого поиска
CREATE INDEX idx_experiments_status ON experiments(status);
CREATE INDEX idx_experiments_status_platforms ON experiments USING GIN(target_platforms);
CREATE INDEX idx_experiments_status_segments ON experiments USING GIN(target_segments);
-- Таблица назначений вариантов (шардированная)
CREATE TABLE user_variants (
user_id BIGINT NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, experiment_id)
);
-- Партиционирование по experiment_id
CREATE TABLE user_variants_partitioned (
user_id BIGINT NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY HASH (experiment_id);
-- Создание 32 партиций для распределения нагрузки
DO $$
BEGIN
FOR i IN 0..31 LOOP
EXECUTE format('CREATE TABLE user_variants_p%s PARTITION OF user_variants_partitioned FOR VALUES WITH (MODULUS 32, REMAINDER %s)', i, i);
END LOOP;
END $$;
6. Производительность при тысячах экспериментов
// BatchVariantAssigner пакетное назначение вариантов
type BatchVariantAssigner struct {
registry *ExperimentRegistry
segmentSvc SegmentService
assignSvc AssignmentService
}
// AssignVariantsBatch назначает варианты для множества экспериментов
func (b *BatchVariantAssigner) AssignVariantsBatch(ctx context.Context, userCtx UserContext, experimentIDs []string) (map[string]*Variant, error) {
result := make(map[string]*Variant)
// Получаем сегменты пользователя один раз
userSegments, err := b.segmentSvc.GetUserSegments(ctx, userCtx.UserID)
if err != nil {
return nil, err
}
// Параллельно обрабатываем эксперименты
var wg sync.WaitGroup
var mu sync.Mutex
errChan := make(chan error, len(experimentIDs))
for _, expID := range experimentIDs {
wg.Add(1)
go func(id string) {
defer wg.Done()
exp, err := b.registry.GetExperiment(id)
if err != nil {
errChan <- err
return
}
// Проверяем применимость эксперимента
if !b.isExperimentApplicable(exp, userCtx, userSegments) {
return
}
variant, err := b.assignSvc.AssignVariant(ctx, userCtx.UserID, exp)
if err != nil {
errChan <- err
return
}
mu.Lock()
result[id] = variant
mu.Unlock()
}(expID)
}
wg.Wait()
close(errChan)
// Обрабатываем ошибки
for err := range errChan {
// Логируем, но не прерываем
log.Printf("Error assigning variant: %v", err)
}
return result, nil
}
// isExperimentApplicable проверяет применимость эксперимента
func (b *BatchVariantAssigner) isExperimentApplicable(exp *Experiment, userCtx UserContext, userSegments []string) bool {
if exp.Status != StatusRunning {
return false
}
// Проверяем платформу
if len(exp.TargetPlatforms) > 0 {
platformMatch := false
for _, p := range exp.TargetPlatforms {
if p == userCtx.Platform {
platformMatch = true
break
}
}
if !platformMatch {
return false
}
}
// Проверяем сегменты
if len(exp.TargetSegments) > 0 {
if !hasIntersection(userSegments, exp.TargetSegments) {
return false
}
}
return true
}
7. Мониторинг и лимиты
// ExperimentLimits лимиты на количество экспериментов
type ExperimentLimits struct {
MaxActiveExperiments int
MaxExperimentsPerUser int
MaxVariantsPerExperiment int
}
// CheckLimits проверяет лимиты перед созданием эксперимента
func (s *ExperimentService) CheckLimits(ctx context.Context, userID string) error {
// Проверяем общее количество активных экспериментов
activeCount, err := s.registry.CountActiveExperiments(ctx)
if err != nil {
return err
}
if activeCount >= s.limits.MaxActiveExperiments {
return fmt.Errorf("maximum active experiments reached: %d", s.limits.MaxActiveExperiments)
}
// Проверяем количество экспериментов пользователя
userCount, err := s.registry.CountExperimentsByUser(ctx, userID)
if err != nil {
return err
}
if userCount >= s.limits.MaxExperimentsPerUser {
return fmt.Errorf("user has reached maximum experiments: %d", s.limits.MaxExperimentsPerUser)
}
return nil
}
8. Масштабирование
Для 1000+ активных экспериментов:
1. Память:
- Активные эксперименты в RAM: ~50-100 MB
- Кэш назначений: ~1-2 GB
- Сегменты: ~8-16 GB
2. База данных:
- Таблица experiments: ~10,000 записей
- Таблица user_variants: ~30M * среднее_количество_экспериментов
- Партиционирование обязательно
3. Производительность:
- Загрузка активных экспериментов: < 100ms
- Назначение варианта: < 5ms (из кэша)
- Batch назначение для 100 экспериментов: < 50ms
4. Инфраструктура:
- 3-5 инстансов сервиса
- Redis Cluster (6+ нод)
- PostgreSQL с партиционированием
- Kafka для событий
Поддержка тысяч активных экспериментов требует тщальной оптимизации памяти, эффективного индексирования и кэширования, а также партиционирования данных в базе.
Вопрос 9. Какая допустимая задержка в расчёте статистики для аналитика?
Таймкод: 00:17:15
Ответ собеседника: Правильный. Уточняется, что для первой версии допустим лаг 10-15 минут, может быть до часа. В идеале нужно стремиться к мгновенному расчёту.
Правильный ответ:
Требования к latency статистики
Целевые значения:
- Идеал: real-time (секунды)
- Допустимо: 10-15 минут
- Максимум: 1 час
Архитектура с разными уровнями latency:
type StatsService struct {
realTimeStore *redis.Client // ~1 сек
preAggStore *clickhouse.Conn // ~1 мин
batchStore *postgres.Conn // ~15 мин
}
type StatsRequest struct {
ExperimentID string
TimeRange TimeRange
Granularity Granularity
}
type Granularity string
const (
GranularityRealtime Granularity = "realtime" // Последние 5 мин
GranularityMinute Granularity = "minute" // Последний час
GranularityHour Granularity = "hour" // Последние 24 часа
GranularityDay Granularity = "day" // Последние 30 дней
)
func (s *StatsService) GetStats(ctx context.Context, req StatsRequest) (*StatsResponse, error) {
// Выбираем хранилище в зависимости от запрашиваемого периода
store := s.selectStore(req.TimeRange, req.Granularity)
return store.GetStats(ctx, req)
}
func (s *StatsService) selectStore(timeRange TimeRange, granularity Granularity) StatsStore {
duration := timeRange.End.Sub(timeRange.Start)
switch {
case duration <= 5*time.Minute:
return s.realTimeStore
case duration <= time.Hour && granularity == GranularityMinute:
return s.preAggStore
default:
return s.batchStore
}
}
Real-time слой (Redis):
type RealTimeStats struct {
redis *redis.Client
}
func (r *RealTimeStats) RecordEvent(ctx context.Context, event ExperimentEvent) error {
pipe := r.redis.Pipeline()
// Инкрементим счетчики
minuteKey := event.Timestamp.Truncate(time.Minute).Unix()
// Общее количество событий
pipe.Incr(ctx, fmt.Sprintf("exp:%s:events:%d", event.ExperimentID, minuteKey))
// По вариантам
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:events:%d",
event.ExperimentID, event.VariantID, minuteKey))
// Уникальные пользователи (HyperLogLog)
pipe.PFAdd(ctx, fmt.Sprintf("exp:%s:users:%d", event.ExperimentID, minuteKey),
event.UserID)
// Конверсии
if event.IsConversion {
pipe.Incr(ctx, fmt.Sprintf("exp:%s:conversions:%d", event.ExperimentID, minuteKey))
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:conversions:%d",
event.ExperimentID, event.VariantID, minuteKey))
}
// TTL для автоочистки
pipe.Expire(ctx, fmt.Sprintf("exp:%s:events:%d", event.ExperimentID, minuteKey), 2*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
func (r *RealTimeStats) GetRealtimeStats(ctx context.Context, experimentID string) (*RealtimeStats, error) {
now := time.Now()
keys := make([]string, 5)
for i := 0; i < 5; i++ {
minute := now.Add(-time.Duration(i) * time.Minute).Truncate(time.Minute)
keys[i] = fmt.Sprintf("exp:%s:events:%d", experimentID, minute.Unix())
}
results, err := r.redis.MGet(ctx, keys...).Result()
if err != nil {
return nil, err
}
totalEvents := 0
for _, result := range results {
if result != nil {
count, _ := strconv.Atoi(result.(string))
totalEvents += count
}
}
return &RealtimeStats{
ExperimentID: experimentID,
EventsPerMinute: totalEvents / 5,
LastUpdated: now,
}, nil
}
Pre-aggregated слой (ClickHouse):
type ClickHouseStats struct {
conn *clickhouse.Conn
}
func (c *ClickHouseStats) CreateTables(ctx context.Context) error {
queries := []string{
`CREATE TABLE IF NOT EXISTS experiment_events (
experiment_id String,
variant_id String,
user_id String,
event_type String,
timestamp DateTime,
properties Map(String, String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, timestamp)`,
`CREATE TABLE IF NOT EXISTS experiment_minute_stats (
experiment_id String,
variant_id String,
minute DateTime,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (experiment_id, variant_id, minute)`,
}
for _, query := range queries {
if err := c.conn.Exec(ctx, query); err != nil {
return err
}
}
return nil
}
func (c *ClickHouseStats) GetMinuteStats(ctx context.Context, experimentID string, from, to time.Time) ([]MinuteStats, error) {
query := `
SELECT
variant_id,
minute,
sum(events) as events,
sum(unique_users) as unique_users,
sum(conversions) as conversions,
sum(revenue) as revenue
FROM experiment_minute_stats
WHERE experiment_id = ?
AND minute BETWEEN ? AND ?
GROUP BY variant_id, minute
ORDER BY minute
`
rows, err := c.conn.Query(ctx, query, experimentID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []MinuteStats
for rows.Next() {
var s MinuteStats
if err := rows.Scan(&s.VariantID, &s.Minute, &s.Events,
&s.UniqueUsers, &s.Conversions, &s.Revenue); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, nil
}
Batch слой (PostgreSQL):
type BatchStatsService struct {
db *sql.DB
}
func (s *BatchStatsService) CalculateHourlyStats(ctx context.Context, hour time.Time) error {
query := `
INSERT INTO experiment_hourly_stats (
experiment_id, variant_id, hour,
events, unique_users, conversions, revenue,
calculated_at
)
SELECT
experiment_id,
variant_id,
date_trunc('hour', timestamp) as hour,
count(*) as events,
count(DISTINCT user_id) as unique_users,
count(*) FILTER (WHERE event_type = 'conversion') as conversions,
sum(CAST(properties['revenue'] AS FLOAT)) FILTER (WHERE event_type = 'conversion') as revenue,
NOW() as calculated_at
FROM experiment_events
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY experiment_id, variant_id, date_trunc('hour', timestamp)
ON CONFLICT (experiment_id, variant_id, hour)
DO UPDATE SET
events = EXCLUDED.events,
unique_users = EXCLUDED.unique_users,
conversions = EXCLUDED.conversions,
revenue = EXCLUDED.revenue,
calculated_at = EXCLUDED.calculated_at
`
nextHour := hour.Add(time.Hour)
_, err := s.db.ExecContext(ctx, query, hour, nextHour)
return err
}
Материализованные представления:
-- Автоматическое обновление статистики
CREATE MATERIALIZED VIEW experiment_daily_stats_mv
TO experiment_daily_stats
AS
SELECT
experiment_id,
variant_id,
toDate(timestamp) as date,
count() as events,
uniqExact(user_id) as unique_users,
countIf(event_type = 'conversion') as conversions,
sumIf(CAST(properties['revenue'] AS Float64), event_type = 'conversion') as revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, toDate(timestamp);
API с индикатором свежести данных:
type StatsResponse struct {
ExperimentID string `json:"experiment_id"`
Stats []VariantStats `json:"stats"`
DataFreshness DataFreshness `json:"data_freshness"`
LastCalculated time.Time `json:"last_calculated"`
IsRealtime bool `json:"is_realtime"`
}
type DataFreshness struct {
Level string `json:"level"` // "realtime", "minute", "hour", "stale"
Lag time.Duration `json:"lag"`
LastEvent time.Time `json:"last_event"`
}
func (s *StatsService) GetStatsWithFreshness(ctx context.Context, experimentID string) (*StatsResponse, error) {
stats, err := s.GetStats(ctx, StatsRequest{ExperimentID: experimentID})
if err != nil {
return nil, err
}
freshness := s.getDataFreshness(ctx, experimentID)
return &StatsResponse{
ExperimentID: experimentID,
Stats: stats,
DataFreshness: freshness,
LastCalculated: time.Now(),
IsRealtime: freshness.Level == "realtime",
}, nil
}
Пайплайн обработки событий:
type EventPipeline struct {
kafka *kafka.Reader
realtime *RealTimeStats
preAgg *ClickHouseStats
batch *BatchStatsService
}
func (p *EventPipeline) ProcessEvents(ctx context.Context) error {
for {
msg, err := p.kafka.ReadMessage(ctx)
if err != nil {
return err
}
var event ExperimentEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
// Параллельно отправляем в разные слои
go p.realtime.RecordEvent(ctx, event)
go p.preAgg.InsertEvent(ctx, event)
// Batch обработка по расписанию
p.batch.AddToQueue(event)
}
}
Мониторинг latency:
type LatencyMetrics struct {
eventToRealtime prometheus.Histogram
eventToPreAgg prometheus.Histogram
eventToBatch prometheus.Histogram
}
func (m *LatencyMetrics) RecordEventLatency(layer string, eventTime time.Time) {
latency := time.Since(eventTime)
switch layer {
case "realtime":
m.eventToRealtime.Observe(latency.Seconds())
case "preagg":
m.eventToPreAgg.Observe(latency.Seconds())
case "batch":
m.eventToBatch.Observe(latency.Seconds())
}
}
Рекомендации по latency:
| Слой | Latency | Использование | Точность |
|---|---|---|---|
| Real-time | < 1 сек | Мониторинг, алерты | ~95% |
| Pre-agg | < 1 мин | Дашборды, аналитика | ~99% |
| Batch | < 15 мин | Отчеты, финальная статистика | 100% |
Trade-offs:
1. Real-time:
- Плюсы: мгновенная обратная связь
- Минусы: неточность, высокая нагрузка на Redis
2. Pre-aggregated:
- Плюсы: баланс скорости и точности
- Минусы: задержка 1-5 минут
3. Batch:
- Плюсы: высокая точность, сложные запросы
- Минусы: задержка 15-60 минут
Вопрос 10. Можно ли запускать эксперимент с отложенным действием?
Таймкод: 00:18:53
Ответ собеседника: Правильный. Уточняется, что для простоты можно считать, что эксперимент запускается сразу после создания. В реальных системах бывают очереди на запуск, но это выходит за рамки текущего проектирования.
Правильный ответ:
Модель запуска экспериментов
Для первой версии системы принимаем упрощение: эксперимент начинает работать сразу после активации.
Упрощенная модель состояний:
type ExperimentStatus string
const (
ExperimentStatusDraft ExperimentStatus = "draft"
ExperimentStatusRunning ExperimentStatus = "running"
ExperimentStatusPaused ExperimentStatus = "paused"
ExperimentStatusCompleted ExperimentStatus = "completed"
)
type SimpleExperiment struct {
ID string `json:"id"`
Name string `json:"name"`
Status ExperimentStatus `json:"status"`
Config ExperimentConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CreatedBy string `json:"created_by"`
}
func (s *ExperimentService) StartExperiment(ctx context.Context, experimentID string, analystID string) error {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// Проверяем статус
if exp.Status != ExperimentStatusDraft {
return fmt.Errorf("experiment must be in draft status")
}
// Запускаем немедленно
now := time.Now()
exp.Status = ExperimentStatusRunning
exp.StartedAt = &now
// Обновляем в БД
if err := s.storage.UpdateExperiment(ctx, exp); err != nil {
return err
}
// Инвалидируем кэш
s.cache.InvalidateExperiment(ctx, experimentID)
// Логируем
s.audit.Log(ctx, AuditLog{
UserID: analystID,
Action: AuditActionStart,
ResourceID: experimentID,
ResourceType: "experiment",
})
return nil
}
Расширенная модель с отложенным запуском:
type ScheduledExperiment struct {
ID string `json:"id"`
Name string `json:"name"`
Status ExperimentStatus `json:"status"`
Config ExperimentConfig `json:"config"`
// Планирование
ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
ActualStart *time.Time `json:"actual_start,omitempty"`
ActualEnd *time.Time `json:"actual_end,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
}
type ExperimentScheduler struct {
storage ExperimentStorage
scheduler *cron.Cron
}
func (s *ExperimentScheduler) ScheduleExperiment(ctx context.Context, experimentID string, schedule ScheduleConfig) error {
exp, err := s.storage.GetExperiment(ctx, experimentID)
err != nil {
return err
}
// Валидируем расписание
if schedule.StartTime != nil && schedule.StartTime.Before(time.Now()) {
return fmt.Errorf("start time must be in the future")
}
if schedule.EndTime != nil && schedule.EndTime.Before(*schedule.StartTime) {
return fmt.Errorf("end time must be after start time")
}
exp.ScheduledStart = schedule.StartTime
exp.ScheduledEnd = schedule.EndTime
// Если запланировано на будущее - ставим в очередь
if schedule.StartTime != nil && schedule.StartTime.After(time.Now()) {
exp.Status = ExperimentStatusScheduled
s.scheduleCronJob(exp)
} else {
// Немедленный запуск
exp.Status = ExperimentStatusRunning
now := time.Now()
exp.ActualStart = &now
}
return s.storage.UpdateExperiment(ctx, exp)
}
func (s *ExperimentScheduler) scheduleCronJob(exp *ScheduledExperiment) {
// Планируем запуск
s.scheduler.Schedule(
cron.At(*exp.ScheduledStart),
cron.FuncJob(func() {
s.executeExperimentStart(exp.ID)
}),
)
// Планируем остановку
if exp.ScheduledEnd != nil {
s.scheduler.Schedule(
cron.At(*exp.ScheduledEnd),
cron.FuncJob(func() {
s.executeExperimentStop(exp.ID, EndReasonScheduled)
}),
)
}
}
Очередь на запуск (для сложных сценариев):
type ExperimentQueue struct {
redis *redis.Client
}
type QueuedExperiment struct {
ExperimentID string `json:"experiment_id"`
Action string `json:"action"` // "start", "stop", "pause"
ScheduledAt time.Time `json:"scheduled_at"`
Priority int `json:"priority"`
}
func (q *ExperimentQueue) Enqueue(ctx context.Context, item QueuedExperiment) error {
data, err := json.Marshal(item)
if err != nil {
return err
}
// Используем Sorted Set с timestamp как score
score := float64(item.ScheduledAt.Unix())
return q.redis.ZAdd(ctx, "experiment:queue", redis.Z{
Score: score,
Member: data,
}).Err()
}
func (q *ExperimentQueue) ProcessQueue(ctx context.Context) error {
now := float64(time.Now().Unix())
// Получаем эксперименты, которые нужно запустить
items, err := q.redis.ZRangeByScore(ctx, "experiment:queue", redis.ZRangeBy{
Min: "0",
Max: strconv.FormatFloat(now, 'f', 0, 64),
}).Result()
if err != nil {
return err
}
for _, itemData := range items {
var item QueuedExperiment
if err := json.Unmarshal([]byte(itemData), &item); err != nil {
continue
}
// Выполняем действие
switch item.Action {
case "start":
q.executeStart(ctx, item.ExperimentID)
case "stop":
q.executeStop(ctx, item.ExperimentID)
case "pause":
q.executePause(ctx, item.ExperimentID)
}
// Удаляем из очереди
q.redis.ZRem(ctx, "experiment:queue", itemData)
}
return nil
}
Проверка статуса перед показом:
type ExperimentMatcher struct {
storage ExperimentStorage
cache *ExperimentCache
}
func (m *ExperimentMatcher) IsExperimentActive(experimentID string) bool {
exp, err := m.cache.GetExperiment(experimentID)
if err != nil {
return false
}
now := time.Now()
// Проверяем статус
if exp.Status != ExperimentStatusRunning {
return false
}
// Проверяем время начала
if exp.StartedAt != nil && now.Before(*exp.StartedAt) {
return false
}
// Проверяем время окончания
if exp.ScheduledEnd != nil && now.After(*exp.ScheduledEnd) {
return false
}
return true
}
API для аналитиков:
type ExperimentHandler struct {
service ExperimentService
scheduler ExperimentScheduler
}
func (h *ExperimentHandler) CreateExperiment(w http.ResponseWriter, r *http.Request) {
var req CreateExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
analystID := r.Context().Value("user_id").(string)
experiment, err := h.service.CreateExperiment(r.Context(), req, analystID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Если указано время старта - планируем
if req.ScheduledStart != nil {
if err := h.scheduler.ScheduleExperiment(r.Context(), experiment.ID, ScheduleConfig{
StartTime: req.ScheduledStart,
EndTime: req.ScheduledEnd,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Немедленный запуск
if err := h.service.StartExperiment(r.Context(), experiment.ID, analystID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
json.NewEncoder(w).Encode(experiment)
}
Мониторинг запланированных экспериментов:
type ScheduledExperimentMonitor struct {
storage ExperimentStorage
metrics *Metrics
}
func (m *ScheduledExperimentMonitor) CheckScheduledExperiments(ctx context.Context) error {
now := time.Now()
// Находим эксперименты, которые должны были запуститься
overdue, err := m.storage.GetOverdueExperiments(ctx, now)
if err != nil {
return err
}
for _, exp := range overdue {
m.metrics.ScheduledExperimentOverdue.Inc()
log.Warn().Str("experiment_id", exp.ID).
Time("scheduled_start", *exp.ScheduledStart).
Msg("Experiment start is overdue")
}
return nil
}
Рекомендации для первой версии:
1. Упрощенная модель:
- Эксперимент запускается сразу после нажатия кнопки "Start"
- Нет очередей и планировщиков
- Минимализм в архитектуре
2. Минимальные проверки:
func (s *ExperimentService) CanStartExperiment(exp *Experiment) error {
if exp.Status != ExperimentStatusDraft {
return ErrNotDraftStatus
}
if len(exp.Variants) < 2 {
return ErrNotEnoughVariants
}
if exp.TrafficPercent <= 0 || exp.TrafficPercent > 100 {
return ErrInvalidTrafficPercent
}
return nil
}
3. Логирование:
- Все действия аналитика логируются
- Аудит-трейл для отслеживания изменений
4. Мониторинг:
- Алерты при проблемах с запуском
- Метрики по количеству активных экспериментов
Вывод:
Для первой версии отложенный запуск не требуется. Это упрощает архитектуру и сроки разработки. В будущем можно добавить планировщик как отдельный сервис без изменения основной логики.
Вопрос 6. Сколько аналитиков работают с системой и как часто они создают эксперименты?
Таймкод: 00:12:19
Ответ собеседника: Правильный. Уточняется, что аналитиков около 100 человек. Они не очень активно создают эксперименты по сравнению с клиентами.
Правильный ответ:
1. Масштаб команды аналитиков
100 аналитиков — это значительная команда, которая требует соответствующей инфраструктуры:
Распределение по активности:
- Супер-активные (20%): создают 2+ эксперимента в неделю
- Активные (30%): создают 1 эксперимент в неделю
- Умеренные (30%): создают 1 эксперимент в месяц
- Редкие (20%): создают 1-2 эксперимента в квартал
Среднее количество создаваемых экспериментов:
- В неделю: 30-50 новых экспериментов
- В месяц: 120-200 новых экспериментов
- В год: 1500-2500 новых экспериментов
2. Разделение пользователей системы
Типы пользователей системы A/B-тестирования:
1. Аналитики (~100 человек):
- Создают эксперименты
- Настраивают параметры
- Анализируют результаты
- Принимают решения о раскатке
2. Разработчики (~200-500 человек):
- Интегрируют SDK в приложения
- Реализуют варианты экспериментов
- Фиксируют события и метрики
- Техническая поддержка экспериментов
3. Менеджеры продуктов (~50-100 человек):
- Просматривают результаты
- Утверждают гипотезы
- Принимают бизнес-решения
4. Системы/API:
- Автоматические эксперименты
- Интеграции с другими системами
3. Паттерны использования
package analytics
// UserActivity активность пользователей системы
type UserActivity struct {
UserID string
UserType UserType
LastLoginAt time.Time
ExperimentsCreated int
ExperimentsViewed int
ReportsGenerated int
}
type UserType string
const (
UserTypeAnalyst UserType = "analyst"
UserTypeDeveloper UserType = "developer"
UserTypeManager UserType = "manager"
UserTypeAPI UserType = "api"
)
// UsageMetrics метрики использования системы
type UsageMetrics struct {
// Создание экспериментов
ExperimentsCreatedTotal int
ExperimentsCreatedDaily int
ExperimentsCreatedWeekly int
// Просмотр результатов
ResultsViewedTotal int
ResultsViewedDaily int
// Активные пользователи
DAU int // Daily Active Users
WAU int // Weekly Active Users
MAU int // Monthly Active Users
}
// TrackExperimentCreation отслеживает создание экспериментов
func (s *AnalyticsService) TrackExperimentCreation(ctx context.Context, userID string, experiment *Experiment) error {
activity := &UserActivity{
UserID: userID,
UserType: s.getUserType(userID),
ExperimentsCreated: 1,
LastLoginAt: time.Now(),
}
return s.activityStore.IncrementExperimentCount(ctx, userID, activity)
}
4. Ролевой доступ (RBAC)
package rbac
// Permission права доступа
type Permission string
const (
PermissionExperimentCreate Permission = "experiment:create"
PermissionExperimentRead Permission = "experiment:read"
PermissionExperimentUpdate Permission = "experiment:update"
PermissionExperimentDelete Permission = "experiment:delete"
PermissionExperimentStart Permission = "experiment:start"
PermissionExperimentStop Permission = "experiment:stop"
PermissionResultsView Permission = "results:view"
PermissionResultsExport Permission = "results:export"
PermissionAdmin Permission = "admin"
)
// Role роль пользователя
type Role struct {
Name string
Permissions []Permission
}
// Предустановленные роли
var (
RoleAnalyst = Role{
Name: "analyst",
Permissions: []Permission{
PermissionExperimentCreate,
PermissionExperimentRead,
PermissionExperimentUpdate,
PermissionExperimentStart,
PermissionExperimentStop,
PermissionResultsView,
PermissionResultsExport,
},
}
RoleDeveloper = Role{
Name: "developer",
Permissions: []Permission{
PermissionExperimentRead,
PermissionResultsView,
},
}
RoleManager = Role{
Name: "manager",
Permissions: []Permission{
PermissionExperimentRead,
PermissionResultsView,
PermissionResultsExport,
},
}
RoleAdmin = Role{
Name: "admin",
Permissions: []Permission{
PermissionExperimentCreate,
PermissionExperimentRead,
PermissionExperimentUpdate,
PermissionExperimentDelete,
PermissionExperimentStart,
PermissionExperimentStop,
PermissionResultsView,
PermissionResultsExport,
PermissionAdmin,
},
}
)
// RBACService сервис управления доступом
type RBACService struct {
userRoles map[string][]Role
}
// HasPermission проверяет наличие права
func (s *RBACService) HasPermission(userID string, permission Permission) bool {
roles, exists := s.userRoles[userID]
if !exists {
return false
}
for _, role := range roles {
for _, p := range role.Permissions {
if p == permission {
return true
}
}
}
return false
}
5. Воронка создания экспериментов
package workflow
// ExperimentWorkflow воронка создания эксперимента
type ExperimentWorkflow struct {
stages []WorkflowStage
}
type WorkflowStage struct {
Name string
Description string
Assignee string // роль или конкретный пользователь
Status StageStatus
}
type StageStatus string
const (
StageStatusPending StageStatus = "pending"
StageStatusInProgress StageStatus = "in_progress"
StageStatusApproved StageStatus = "approved"
StageStatusRejected StageStatus = "rejected"
)
// Типичная воронка для аналитика
func NewExperimentWorkflow() *ExperimentWorkflow {
return &ExperimentWorkflow{
stages: []WorkflowStage{
{
Name: "hypothesis",
Description: "Формулировка гипотезы",
Assignee: "analyst",
Status: StageStatusPending,
},
{
Name: "design",
Description: "Дизайн эксперимента",
Assignee: "analyst",
Status: StageStatusPending,
},
{
Name: "review",
Description: "Ревью дизайна",
Assignee: "senior_analyst",
Status: StageStatusPending,
},
{
Name: "implementation",
Description: "Реализация вариантов",
Assignee: "developer",
Status: StageStatusPending,
},
{
Name: "qa",
Description: "Тестирование",
Assignee: "qa",
Status: StageStatusPending,
},
{
Name: "launch",
Description: "Запуск эксперимента",
Assignee: "analyst",
Status: StageStatusPending,
},
},
}
}
6. Аналитика использования системы
-- Метрики использования системы аналитиками
CREATE TABLE system_usage_metrics (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
user_type VARCHAR(50) NOT NULL,
action VARCHAR(100) NOT NULL,
experiment_id VARCHAR(255),
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Агрегированная статистика
CREATE MATERIALIZED VIEW daily_usage_stats AS
SELECT
DATE(created_at) as date,
user_type,
action,
COUNT(*) as action_count,
COUNT(DISTINCT user_id) as unique_users
FROM system_usage_metrics
GROUP BY DATE(created_at), user_type, action;
-- Топ аналитиков по количеству экспериментов
CREATE MATERIALIZED VIEW top_analysts AS
SELECT
user_id,
COUNT(*) as experiments_created,
MIN(created_at) as first_experiment,
MAX(created_at) as last_experiment
FROM system_usage_metrics
WHERE action = 'experiment_created'
AND user_type = 'analyst'
GROUP BY user_id
ORDER BY experiments_created DESC;
7. Дашборд для аналитиков
package dashboard
// AnalystDashboard дашборд аналитика
type AnalystDashboard struct {
ActiveExperiments int
CompletedExperiments int
DraftExperiments int
RunningExperiments int
RecentResults []ExperimentResult
PendingApprovals int
}
// DashboardService сервис дашбордов
type DashboardService struct {
experimentStore ExperimentStorage
resultsStore ResultsStorage
}
// GetAnalystDashboard возвращает дашборд для аналитика
func (s *DashboardService) GetAnalystDashboard(ctx context.Context, analystID string) (*AnalystDashboard, error) {
dashboard := &AnalystDashboard{}
// Активные эксперименты аналитика
active, err := s.experimentStore.CountByAnalyst(ctx, analystID, StatusRunning)
if err != nil {
return nil, err
}
dashboard.ActiveExperiments = active
// Завершённые эксперименты
completed, err := s.experimentStore.CountByAnalyst(ctx, analystID, StatusCompleted)
if err != nil {
return nil, err
}
dashboard.CompletedExperiments = completed
// Черновики
drafts, err := s.experimentStore.CountByAnalyst(ctx, analystID, StatusDraft)
if err != nil {
return nil, err
}
dashboard.DraftExperiments = drafts
// Последние результаты
recentResults, err := s.resultsStore.GetRecentByAnalyst(ctx, analystID, 10)
if err != nil {
return nil, err
}
dashboard.RecentResults = recentResults
return dashboard, nil
}
8. Типичные сценарии использования
Сценарий 1: Аналитик создаёт A/B-тест
1. Формулирует гипотезу
2. Определяет метрики
3. Рассчитывает необходимый размер выборки
4. Создаёт эксперимент в системе
5. Настраивает варианты и таргетинг
6. Отправляет на ревью
7. После одобрения запускает
Сценарий 2: Мониторинг результатов
1. Аналитик открывает дашборд
2. Просматривает текущие эксперименты
3. Проверяет статистическую значимость
4. Анализирует метрики по сегментам
5. Принимает решение о раскатке или остановке
Сценарий 3: Массовые операции
1. Планирование экспериментов на квартал
2. Создание серии связанных экспериментов
3. Клонирование успешных экспериментов
4. Остановка неэффективных экспериментов
9. Масштабирование для 100 аналитиков
Нагрузка на систему:
- DAU аналитиков: 40-60 человек
- Создание экспериментов: 30-50 в неделю
- Просмотр результатов: 200-500 запросов в день
- Экспорт данных: 50-100 запросов в день
Требования к интерфейсу:
- Интуитивный UX для не-технических пользователей
- Шаблоны экспериментов
- Автоматический расчёт sample size
- Визуализация результатов
- Уведомления о значимых результатах
При 100 аналитиках важно обеспечить удобный пользовательский интерфейс, автоматизацию рутинных операций и систему уведомлений для повышения эффективности работы команды.
Вопрос 11. Что происходит после завершения эксперимента?
Таймкод: 00:20:27
Ответ собеседова: Правильный. Уточняется, что после завершения эксперимента прекращается сбор статистики. Если гипотеза подтвердилась, аналитик идёт и ставит задачу на раскатку изменения на всех пользователей.
Правильный ответ:
Процесс завершения эксперимента
Основные действия при завершении:
type ExperimentCompletionService struct {
storage ExperimentStorage
statsService StatsService
auditService AuditService
notifier Notifier
}
func (s *ExperimentCompletionService) CompleteExperiment(ctx context.Context, experimentID string, analystID string, reason EndReason) error {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// 1. Меняем статус
now := time.Now()
exp.Status = ExperimentStatusCompleted
exp.EndedAt = &now
exp.EndedBy = &analystID
exp.EndReason = &reason
if err := s.storage.UpdateExperiment(ctx, exp); err != nil {
return err
}
// 2. Финальный расчет статистики
finalStats, err := s.statsService.CalculateFinalStats(ctx, experimentID)
if err != nil {
log.Error().Err(err).Str("experiment_id", experimentID).Msg("Failed to calculate final stats")
}
// 3. Сохраняем результаты
results := &ExperimentResults{
ExperimentID: experimentID,
FinalStats: finalStats,
CompletedAt: now,
CompletedBy: analystID,
EndReason: reason,
}
if err := s.storage.SaveResults(ctx, results); err != nil {
return err
}
// 4. Останавливаем сбор данных
s.statsService.StopCollection(ctx, experimentID)
// 5. Уведомляем заинтересованных
s.notifier.NotifyExperimentCompleted(exp, results)
// 6. Логируем
s.auditService.Log(ctx, AuditLog{
UserID: analystID,
Action: AuditActionComplete,
ResourceID: experimentID,
ResourceType: "experiment",
Details: map[string]interface{}{
"reason": reason,
"final_stats": finalStats,
},
})
return nil
}
Остановка сбора данных:
type StatsCollector struct {
activeExperiments sync.Map // experimentID -> bool
kafka *kafka.Reader
}
func (c *StatsCollector) StopCollection(ctx context.Context, experimentID string) error {
// Помечаем эксперимент как неактивный
c.activeExperiments.Delete(experimentID)
// Останавливаем обработку событий для этого эксперимента
// События в очереди будут дообработаны, но новые отбрасываются
// Инвалидируем кэш
c.cache.Invalidate(ctx, fmt.Sprintf("experiment:%s:stats", experimentID))
return nil
}
func (c *StatsCollector) ShouldCollect(experimentID string) bool {
_, isActive := c.activeExperiments.Load(experimentID)
return isActive
}
Сохранение результатов:
type ExperimentResults struct {
ExperimentID string `json:"experiment_id"`
Name string `json:"name"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
EndReason EndReason `json:"end_reason"`
FinalStats *FinalStats `json:"final_stats"`
VariantResults []VariantResult `json:"variant_results"`
Conclusion string `json:"conclusion"`
WinnerVariant *string `json:"winner_variant,omitempty"`
CompletedAt time.Time `json:"completed_at"`
CompletedBy string `json:"completed_by"`
}
type FinalStats struct {
TotalUsers int64 `json:"total_users"`
TotalEvents int64 `json:"total_events"`
StatisticalPower float64 `json:"statistical_power"`
ConfidenceLevel float64 `json:"confidence_level"`
IsSignificant bool `json:"is_significant"`
Metrics map[string]MetricSummary `json:"metrics"`
}
func (s *ExperimentStorage) SaveResults(ctx context.Context, results *ExperimentResults) error {
query := `
INSERT INTO experiment_results (
experiment_id, name, start_time, end_time, duration,
end_reason, final_stats, variant_results, conclusion,
winner_variant, completed_at, completed_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
finalStatsJSON, _ := json.Marshal(results.FinalStats)
variantResultsJSON, _ := json.Marshal(results.VariantResults)
_, err := s.db.ExecContext(ctx, query,
results.ExperimentID, results.Name, results.StartTime, results.EndTime,
results.Duration, results.EndReason, finalStatsJSON, variantResultsJSON,
results.Conclusion, results.WinnerVariant, results.CompletedAt, results.CompletedBy,
)
return err
}
Определение победителя:
type WinnerDeterminationService struct{}
func (s *WinnerDeterminationService) DetermineWinner(stats *FinalStats) (*string, string) {
if !stats.IsSignificant {
return nil, "No statistically significant winner"
}
// Сравниваем варианты по основной метрике
controlMetric := stats.Metrics["primary"]
var winnerID *string
var maxImprovement float64
for variantID, metric := range stats.Metrics {
if variantID == "control" {
continue
}
improvement := (metric.Value - controlMetric.Value) / controlMetric.Value
if improvement > maxImprovement && metric.IsSignificant {
maxImprovement = improvement
vid := variantID
winnerID = &vid
}
}
if winnerID != nil {
conclusion := fmt.Sprintf("Variant %s wins with %.2f%% improvement", *winnerID, maxImprovement*100)
return winnerID, conclusion
}
return nil, "No clear winner despite significance"
}
Уведомления после завершения:
type ExperimentNotifier struct {
emailService EmailService
slackService SlackService
}
func (n *ExperimentNotifier) NotifyExperimentCompleted(exp *Experiment, results *ExperimentResults) error {
// Формируем сообщение
message := CompletionMessage{
ExperimentName: exp.Name,
ExperimentID: exp.ID,
Duration: results.Duration,
TotalUsers: results.FinalStats.TotalUsers,
Winner: results.WinnerVariant,
Conclusion: results.Conclusion,
DashboardURL: fmt.Sprintf("https://experiments.company.com/%s", exp.ID),
}
// Отправляем создателю эксперимента
n.emailService.SendTemplate(
exp.CreatedBy,
"experiment_completed",
message,
)
// Отправляем в общий канал
n.slackService.SendNotification(
"#experiments",
formatSlackMessage(message),
)
return nil
}
Действия аналитика после завершения:
type PostExperimentAction string
const (
PostExperimentActionRollout PostExperimentAction = "rollout" // Раскатка на всех
PostExperimentActionDiscard PostExperimentAction = "discard" // Отклонить
PostExperimentActionIterate PostExperimentAction = "iterate" // Новый эксперимент
PostExperimentActionExtend PostExperimentAction = "extend" // Продлить
)
type PostExperimentDecision struct {
ExperimentID string `json:"experiment_id"`
Action PostExperimentAction `json:"action"`
Reason string `json:"reason"`
DecidedBy string `json:"decided_by"`
DecidedAt time.Time `json:"decided_at"`
}
func (s *ExperimentService) RecordPostExperimentDecision(ctx context.Context, decision PostExperimentDecision) error {
// Сохраняем решение
if err := s.storage.SaveDecision(ctx, decision); err != nil {
return err
}
// Если решили раскатать - создаем задачу
if decision.Action == PostExperimentActionRollout {
s.createRolloutTask(ctx, decision)
}
return nil
}
Архивация данных:
type ExperimentArchiver struct {
storage ExperimentStorage
coldStorage ColdStorage // S3, GCS, etc.
}
func (a *ExperimentArchiver) ArchiveExperiment(ctx context.Context, experimentID string) error {
// 1. Экспортируем сырые данные
rawData, err := a.storage.ExportRawData(ctx, experimentID)
if err != nil {
return err
}
// 2. Сохраняем в cold storage
archivePath := fmt.Sprintf("experiments/%s/%s",
time.Now().Format("2006/01"), experimentID)
if err := a.coldStorage.Upload(ctx, archivePath, rawData); err != nil {
return err
}
// 3. Удаляем из hot storage
a.storage.DeleteRawData(ctx, experimentID)
// 4. Сохраняем только агрегированную статистику
a.storage.MarkAsArchived(ctx, experimentID)
return nil
}
Мониторинг завершенных экспериментов:
type CompletedExperimentMetrics struct {
completedTotal prometheus.Counter
byReason *prometheus.CounterVec
duration prometheus.Histogram
timeToDecision prometheus.Histogram
}
func (m *CompletedExperimentMetrics) RecordCompletion(exp *Experiment, reason EndReason) {
m.completedTotal.Inc()
m.byReason.WithLabelValues(string(reason)).Inc()
if exp.StartedAt != nil && exp.EndedAt != nil {
duration := exp.EndedAt.Sub(*exp.StartedAt)
m.duration.Observe(duration.Hours())
}
}
Хранение истории:
-- Таблица с результатами экспериментов
CREATE TABLE experiment_results (
id SERIAL PRIMARY KEY,
experiment_id UUID NOT NULL,
name VARCHAR(255),
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
duration INTERVAL,
end_reason VARCHAR(50),
final_stats JSONB,
variant_results JSONB,
conclusion TEXT,
winner_variant VARCHAR(100),
completed_at TIMESTAMP NOT NULL,
completed_by VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Индексы для быстрого поиска
CREATE INDEX idx_results_experiment_id ON experiment_results(experiment_id);
CREATE INDEX idx_results_end_time ON experiment_results(end_time);
CREATE INDEX idx_results_winner ON experiment_results(winner_variant);
Типичный workflow после завершения:
1. Аналитик получает уведомление о завершении эксперимента 2. Изучает финальную статистику на дашборде 3. Принимает решение:
- Раскатить winning variant
- Отклонить (вернуть как было)
- Запустить новый эксперимент на основе инсайтов
- Продлить текущий эксперимент 4. Результаты сохраняются для будущих референсов 5. Данные архивируются для экономии места
Важно: После завершения эксперимента нельзя его перезапустить. Если нужны дополнительные данные - создается новый эксперимент.
Вопрос 12. Как приложение определяет, в какие эксперименты попадает пользователь?
Таймкод: 00:22:56
Ответ собеседова: Правильный. Уточняется, что приложение получает список экспериментов, применимых к конкретному пользователю. Система отдаёт только те эксперименты, в которые попадает данный пользователь, а не весь общий список.
Правильный ответ:
Механизм определения участия пользователя
Общий процесс:
type ExperimentResolver struct {
segmentService SegmentService
experimentIndex *ExperimentIndex
}
type UserExperimentsRequest struct {
UserID string
Platform Platform
Context map[string]interface{}
}
type UserExperimentsResponse struct {
Experiments []UserExperiment `json:"experiments"`
}
type UserExperiment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
Config map[string]interface{} `json:"config"`
}
func (r *ExperimentResolver) ResolveForUser(ctx context.Context, req UserExperimentsRequest) (*UserExperimentsResponse, error) {
// 1. Получаем сегменты пользователя
segments, err := r.segmentService.GetUserSegments(ctx, req.UserID)
if err != nil {
return nil, err
}
// 2. Находим подходящие эксперименты
candidates := r.experimentIndex.GetActiveExperiments(segments, req.Platform)
// 3. Фильтруем по дополнительным критериям
filtered := r.filterExperiments(candidates, req)
// 4. Определяем вариант для каждого эксперимента
result := make([]UserExperiment, 0, len(filtered))
for _, exp := range filtered {
variant := r.determineVariant(req.UserID, exp)
if variant != nil {
result = append(result, UserExperiment{
ExperimentID: exp.ID,
VariantID: variant.ID,
Config: variant.Config,
})
}
}
return &UserExperimentsResponse{Experiments: result}, nil
}
Фильтрация экспериментов:
type ExperimentFilter struct {
segmentService SegmentService
}
func (f *ExperimentFilter) FilterExperiments(
candidates []*Experiment,
req UserExperimentsRequest,
) []*Experiment {
filtered := make([]*Experiment, 0, len(candidates))
for _, exp := range candidates {
if f.matchesCriteria(exp, req) {
filtered = append(filtered, exp)
}
}
return filtered
}
func (f *ExperimentFilter) matchesCriteria(exp *Experiment, req UserExperimentsRequest) bool {
// Проверка платформы
if !exp.HasPlatform(req.Platform) {
return false
}
// Проверка времени
if !exp.IsCurrentlyActive() {
return false
}
// Проверка квоты трафика
if !f.isUserInTraffic(req.UserID, exp) {
return false
}
// Проверка дополнительных условий
if exp.HasConditions() {
return f.evaluateConditions(exp.Conditions, req.Context)
}
return true
}
func (f *ExperimentFilter) isUserInTraffic(userID string, exp *Experiment) bool {
bucket := getBucket(userID, exp.ID, 100)
return bucket < exp.TrafficPercent
}
Определение варианта:
type VariantDeterminator struct{}
func (d *VariantDeterminator) DetermineVariant(userID string, exp *Experiment) *Variant {
// Детерминированный хеш для консистентности
bucket := getBucket(userID, exp.ID, 100)
// Распределяем по вариантам пропорционально
cumulative := 0
for _, variant := range exp.Variants {
cumulative += variant.TrafficPct
if bucket < cumulative {
return &variant
}
}
// Fallback на контрольный вариант
return &exp.Variants[0]
}
func getBucket(userID, experimentID string, totalBuckets int) int {
h := fnv.New32a()
h.Write([]byte(userID + ":" + experimentID))
return int(h.Sum32()) % totalBuckets
}
API endpoint:
type ExperimentHandler struct {
resolver *ExperimentResolver
cache *ExperimentCache
}
func (h *ExperimentHandler) GetUserExperiments(w http.ResponseWriter, r *http.Request) {
// Извлекаем идентификаторы
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Missing user ID", http.StatusBadRequest)
return
}
platform := Platform(r.Header.Get("X-Platform"))
// Пробуем получить из кэша
cacheKey := fmt.Sprintf("user_experiments:%s:%s", userID, platform)
if cached, err := h.cache.Get(r.Context(), cacheKey); err == nil {
json.NewEncoder(w).Encode(cached)
return
}
// Резолвим эксперименты
req := UserExperimentsRequest{
UserID: userID,
Platform: platform,
Context: extractContext(r),
}
response, err := h.resolver.ResolveForUser(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Кэшируем на 5 минут
h.cache.Set(r.Context(), cacheKey, response, 5*time.Minute)
json.NewEncoder(w).Encode(response)
}
Кэширование результатов:
type ExperimentCache struct {
redis *redis.Client
ttl time.Duration
}
func (c *ExperimentCache) GetUserExperiments(ctx context.Context, userID string, platform Platform) (*UserExperimentsResponse, error) {
key := fmt.Sprintf("user_experiments:%s:%s", userID, platform)
data, err := c.redis.Get(ctx, key).Result()
if err == nil {
var response UserExperimentsResponse
if err := json.Unmarshal([]byte(data), &response); err == nil {
return &response, nil
}
}
return nil, ErrCacheMiss
}
func (c *ExperimentCache) InvalidateUser(ctx context.Context, userID string) error {
// Инвалидируем все кэши для пользователя
pattern := fmt.Sprintf("user_experiments:%s:*", userID)
iter := c.redis.Scan(ctx, 0, pattern, 0).Iterator()
for iter.Next(ctx) {
c.redis.Del(ctx, iter.Val())
}
return iter.Err()
}
Индекс для быстрого поиска:
type ExperimentIndex struct {
// Индекс: сегмент -> список экспериментов
bySegment map[int64]map[string]*Experiment
// Индекс: платформа -> список экспериментов
byPlatform map[Platform]map[string]*Experiment
// Все активные эксперименты
active map[string]*Experiment
mu sync.RWMutex
}
func (idx *ExperimentIndex) GetActiveExperiments(segments []int64, platform Platform) []*Experiment {
idx.mu.RLock()
defer idx.mu.RUnlock()
candidates := make(map[string]*Experiment)
// Собираем эксперименты по сегментам
for _, segID := range segments {
if experiments, ok := idx.bySegment[segID]; ok {
for id, exp := range experiments {
candidates[id] = exp
}
}
}
// Добавляем эксперименты по платформе
if experiments, ok := idx.byPlatform[platform]; ok {
for id, exp := range experiments {
candidates[id] = exp
}
}
// Фильтруем только активные
result := make([]*Experiment, 0, len(candidates))
for _, exp := range candidates {
if exp.IsCurrentlyActive() {
result = append(result, exp)
}
}
return result
}
func (idx *ExperimentIndex) AddExperiment(exp *Experiment) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.active[exp.ID] = exp
// Индексируем по сегментам
for _, segID := range exp.Segments {
if _, ok := idx.bySegment[segID]; !ok {
idx.bySegment[segID] = make(map[string]*Experiment)
}
idx.bySegment[segID][exp.ID] = exp
}
// Индексируем по платформам
for _, platform := range exp.Platforms {
if _, ok := idx.byPlatform[platform]; !ok {
idx.byPlatform[platform] = make(map[string]*Experiment)
}
idx.byPlatform[platform][exp.ID] = exp
}
}
Batch-запрос для нескольких пользователей:
type BatchExperimentRequest struct {
UserIDs []string `json:"user_ids"`
Platform Platform `json:"platform"`
}
type BatchExperimentResponse struct {
Experiments map[string][]UserExperiment `json:"experiments"`
}
func (h *ExperimentHandler) GetBatchExperiments(w http.ResponseWriter, r *http.Request) {
var req BatchExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := &BatchExperimentResponse{
Experiments: make(map[string][]UserExperiment),
}
// Обрабатываем батчем
for _, userID := range req.UserIDs {
userReq := UserExperimentsRequest{
UserID: userID,
Platform: req.Platform,
}
userResp, err := h.resolver.ResolveForUser(r.Context(), userReq)
if err != nil {
continue
}
response.Experiments[userID] = userResp.Experiments
}
json.NewEncoder(w).Encode(response)
}
SDK для клиентов:
type ExperimentClient struct {
baseURL string
httpClient *http.Client
cache *LocalCache
}
func (c *ExperimentClient) GetUserExperiments(ctx context.Context, userID string, platform Platform) ([]UserExperiment, error) {
// Пробуем из локального кэша
if cached := c.cache.Get(userID, platform); cached != nil {
return cached, nil
}
// Делаем запрос к серверу
req, _ := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("%s/api/v1/experiments", c.baseURL), nil)
req.Header.Set("X-User-ID", userID)
req.Header.Set("X-Platform", string(platform))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var response UserExperimentsResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, err
}
// Кэшируем локально
c.cache.Set(userID, platform, response.Experiments, 5*time.Minute)
return response.Experiments, nil
}
func (c *ExperimentClient) GetVariant(userID string, experimentID string, platform Platform) (string, error) {
experiments, err := c.GetUserExperiments(userID, platform)
if err != nil {
return "", err
}
for _, exp := range experiments {
if exp.ExperimentID == experimentID {
return exp.VariantID, nil
}
}
return "", ErrExperimentNotFound
}
Инвалидация кэша при изменениях:
type CacheInvalidator struct {
redis *redis.Client
}
func (ci *CacheInvalidator) InvalidateForExperiment(ctx context.Context, experimentID string) error {
// Находим затронутых пользователей
// Это может быть дорого, поэтому используем lazy invalidation
// Помечаем эксперимент как измененный
ci.redis.Set(ctx,
fmt.Sprintf("experiment:%s:modified", experimentID),
time.Now().Unix(),
24*time.Hour,
)
return nil
}
func (ci *CacheInvalidator) IsExperimentModified(experimentID string, since time.Time) bool {
modified, err := ci.redis.Get(ctx, fmt.Sprintf("experiment:%s:modified", experimentID)).Int64()
if err != nil {
return false
}
return time.Unix(modified, 0).After(since)
}
Метрики производительности:
type ExperimentMetrics struct {
resolveDuration prometheus.Histogram
cacheHitRate prometheus.Counter
cacheMissRate prometheus.Counter
activeExperiments prometheus.Gauge
}
func (m *ExperimentMetrics) RecordResolve(duration time.Duration, fromCache bool) {
m.resolveDuration.Observe(duration.Seconds())
if fromCache {
m.cacheHitRate.Inc()
} else {
m.cacheMissRate.Inc()
}
}
Оптимизации:
1. Предварительный расчет:
type PrecomputedUserExperiments struct {
storage *redis.Client
}
func (p *PrecomputedUserExperiments) PrecomputeForUser(ctx context.Context, userID string) error {
// Запускается по расписанию или при изменении сегментов
experiments, err := p.resolver.ResolveForUser(ctx, UserExperimentsRequest{
UserID: userID,
})
if err != nil {
return err
}
key := fmt.Sprintf("precomputed:%s", userID)
data, _ := json.Marshal(experiments)
return p.storage.Set(ctx, key, data, 10*time.Minute).Err()
}
2. Bloom Filter для быстрой проверки:
type ExperimentBloomFilter struct {
filter *bloom.BloomFilter
}
func (bf *ExperimentBloomFilter) MightContain(userID string, experimentID string) bool {
key := fmt.Sprintf("%s:%s", userID, experimentID)
return bf.filter.TestString(key)
}
Типичные значения производительности:
| Метрика | Целевое значение |
|---|---|
| P50 latency | < 5ms |
| P99 latency | < 20ms |
| Cache hit rate | > 95% |
| Throughput | > 10K RPS |
Вопрос 7. Как долго живёт эксперимент и кто решает о его завершении?
Таймкод: 00:12:44
Ответ собеседника: Правильный. Уточняется, что обычно эксперимент длится 1-2 недели, но может завершиться быстрее или длиться до месяца. Аналитик смотрит на статистику и решает, продолжать или завершить эксперимент.
Правильный ответ:
1. Типичная длительность экспериментов
Распределение по длительности:
Краткосрочные (1-3 дays): 10-15%
- Срочные проверки гипотез
- Тестирование критичных изменений
- Проверка технических проблем
Среднесрочные (1-2 недели): 50-60%
- Стандартный A/B-тест
- Оптимизация конверсии
- Тестирование UI/UX изменений
Долгосрочные (2-4 недели): 20-25%
- Сложные гипотезы
- Низкие базовые конверсии
- Необходимость большей выборки
Очень долгие (1-3 месяца): 5-10%
- Изменения в продуктовых метриках
- Сезонные эффекты
- Retention метрики
2. Факторы, влияющие на длительность
package experiment
// DurationCalculator расчёт необходимой длительности
type DurationCalculator struct {
baseRate float64 // базовая конверсия
mde float64 // minimum detectable effect
alpha float64 // уровень значимости (обычно 0.05)
power float64 // мощность (обычно 0.8)
dailyTraffic int // дневной трафик на вариант
}
// CalculateDuration рассчитывает необходимую длительность эксперимента
func (c *DurationCalculator) CalculateDuration() (int, error) {
// Используем формулу для расчёта размера выборки
// n = (Z_alpha/2 + Z_beta)^2 * (p1*(1-p1) + p2*(1-p2)) / (p1-p2)^2
p1 := c.baseRate
p2 := c.baseRate * (1 + c.mde) // ожидаемая конверсия в тесте
zAlpha := 1.96 // для alpha = 0.05
zBeta := 0.84 // для power = 0.8
numerator := math.Pow(zAlpha+zBeta, 2) * (p1*(1-p1) + p2*(1-p2))
denominator := math.Pow(p1-p2, 2)
sampleSize := int(math.Ceil(numerator / denominator))
// Учитываем количество вариантов
totalSampleSize := sampleSize * 2 // для A/B теста
// Рассчитываем количество дней
days := int(math.Ceil(float64(totalSampleSize) / float64(c.dailyTraffic)))
return days, nil
}
// EstimateDuration оценивает длительность на основе параметров эксперимента
func EstimateDuration(baseRate, mde float64, dailyTraffic int) (int, error) {
calc := &DurationCalculator{
baseRate: baseRate,
mde: mde,
alpha: 0.05,
power: 0.8,
dailyTraffic: dailyTraffic,
}
return calc.CalculateDuration()
}
3. Принятие решений о завершении
// ExperimentDecision решение по эксперименту
type ExperimentDecision struct {
ExperimentID string
Decision DecisionType
DecidedBy string
DecidedAt time.Time
Reason string
Statistics *ExperimentStatistics
}
type DecisionType string
const (
DecisionContinue DecisionType = "continue"
DecisionStop DecisionType = "stop"
DecisionPromote DecisionType = "promote" // раскатить победителя
DecisionRollback DecisionType = "rollback" // откатить к контролю
)
// DecisionMaker принимает решения о завершении
type DecisionMaker struct {
statsService StatisticsService
rulesEngine RulesEngine
}
// EvaluateExperiment оценивает готовность эксперимента к завершению
func (dm *DecisionMaker) EvaluateExperiment(ctx context.Context, exp *Experiment) (*ExperimentDecision, error) {
stats, err := dm.statsService.GetStatistics(ctx, exp.ID)
if err != nil {
return nil, err
}
// Проверяем минимальную длительность
minDuration := 7 * 24 * time.Hour // минимум 1 неделя
if time.Since(exp.StartedAt) < minDuration {
return &ExperimentDecision{
ExperimentID: exp.ID,
Decision: DecisionContinue,
Reason: "Minimum duration not reached",
Statistics: stats,
}, nil
}
// Проверяем размер выборки
if !stats.HasEnoughSampleSize(exp.MinSampleSize) {
return &ExperimentDecision{
ExperimentID: exp.ID,
Decision: DecisionContinue,
Reason: "Not enough sample size",
Statistics: stats,
}, nil
}
// Проверяем статистическую значимость
if stats.IsSignificant(exp.SignificanceLevel) {
winner := stats.GetWinner()
if winner != nil {
return &ExperimentDecision{
ExperimentID: exp.ID,
Decision: DecisionPromote,
Reason: fmt.Sprintf("Winner found: %s", winner.VariantID),
Statistics: stats,
}, nil
}
}
// Проверяем на вред (guardrail метрики)
if stats.HasNegativeImpact() {
return &ExperimentDecision{
ExperimentID: exp.ID,
Decision: DecisionStop,
Reason: "Negative impact on guardrail metrics",
Statistics: stats,
}, nil
}
return &ExperimentDecision{
ExperimentID: exp.ID,
Decision: DecisionContinue,
Reason: "No significant result yet",
Statistics: stats,
}, nil
}
4. Автоматические правила завершения
package rules
// AutoStopRule правило автоматической остановки
type AutoStopRule struct {
Name string
Condition func(*ExperimentStatistics) bool
Action DecisionType
Description string
}
// DefaultAutoStopRules стандартные правила остановки
var DefaultAutoStopRule = []AutoStopRule{
{
Name: "negative_impact",
Condition: func(stats *ExperimentStatistics) bool {
// Если конверсия упала более чем на 10% с p-value < 0.01
return stats.GetRelativeChange() < -0.1 && stats.GetPValue() < 0.01
},
Action: DecisionStop,
Description: "Significant negative impact detected",
},
{
Name: "strong_winner",
Condition: func(stats *ExperimentStatistics) bool {
// Если победитель с p-value < 0.001 и улучшение > 5%
winner := stats.GetWinner()
return winner != nil && stats.GetPValue() < 0.001 && winner.RelativeChange > 0.05
},
Action: DecisionPromote,
Description: "Strong statistical significance",
},
{
Name: "max_duration",
Condition: func(stats *ExperimentStatistics) bool {
// Если эксперимент длится более 30 дней
return stats.Duration > 30*24*time.Hour
},
Action: DecisionStop,
Description: "Maximum duration reached",
},
{
Name: "no_traffic",
Condition: func(stats *ExperimentStatistics) bool {
// Если нет трафика более 3 дней
return stats.LastEventAt.Before(time.Now().Add(-3 * 24 * time.Hour))
},
Action: DecisionStop,
Description: "No traffic for 3 days",
},
}
// RulesEngine движок правил
type RulesEngine struct {
rules []AutoStopRule
}
// Evaluate оценивает эксперимент по всем правилам
func (e *RulesEngine) Evaluate(stats *ExperimentStatistics) *ExperimentDecision {
for _, rule := range e.rules {
if rule.Condition(stats) {
return &ExperimentDecision{
Decision: rule.Action,
Reason: rule.Description,
Statistics: stats,
}
}
}
return nil
}
5. Workflow завершения эксперимента
package workflow
// ExperimentLifecycle жизненный цикл эксперимента
type ExperimentLifecycle struct {
experimentStore ExperimentStorage
decisionMaker *DecisionMaker
notifier Notifier
}
// CompleteExperiment завершает эксперимент
func (lc *ExperimentLifecycle) CompleteExperiment(ctx context.Context, expID string, decision DecisionType, decidedBy string) error {
exp, err := lc.experimentStore.GetExperiment(ctx, expID)
if err != nil {
return err
}
// Обновляем статус
exp.Status = StatusCompleted
exp.EndedAt = time.Now()
// Сохраняем решение
experimentDecision := &ExperimentDecision{
ExperimentID: expID,
Decision: decision,
DecidedBy: decidedBy,
DecidedAt: time.Now(),
}
// Если решили раскатить победителя
if decision == DecisionPromote {
stats, err := lc.statsService.GetStatistics(ctx, expID)
if err != nil {
return err
}
winner := stats.GetWinner()
if winner != nil {
// Раскатываем победителя на 100% трафика
if err := lc.rolloutWinner(ctx, exp, winner.VariantID); err != nil {
return err
}
}
}
// Сохраняем в БД
if err := lc.experimentStore.UpdateExperiment(ctx, exp); err != nil {
return err
}
// Уведомляем заинтересованных
if err := lc.notifier.NotifyExperimentCompleted(ctx, exp, experimentDecision); err != nil {
log.Printf("Failed to notify: %v", err)
}
return nil
}
// AutoEvaluateLoop периодическая проверка экспериментов
func (lc *ExperimentLifecycle) AutoEvaluateLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
lc.evaluateAllRunningExperiments(ctx)
}
}
}
func (lc *ExperimentLifecycle) evaluateAllRunningExperiments(ctx context.Context) {
experiments, err := lc.experimentStore.GetRunningExperiments(ctx)
if err != nil {
log.Printf("Failed to get running experiments: %v", err)
return
}
for _, exp := range experiments {
decision, err := lc.decisionMaker.EvaluateExperiment(ctx, exp)
if err != nil {
log.Printf("Failed to evaluate experiment %s: %v", exp.ID, err)
continue
}
if decision.Decision != DecisionContinue {
// Отправляем уведомление аналитику
lc.notifier.NotifyDecisionRequired(ctx, exp, decision)
}
}
}
6. Роли в принятии решений
// DecisionAuthority кто может принимать решения
type DecisionAuthority struct {
Role string
CanStop bool
CanPromote bool
CanExtend bool
}
var (
// Аналитик может остановить или продлить эксперимент
AnalystAuthority = DecisionAuthority{
Role: "analyst",
CanStop: true,
CanPromote: false,
CanExtend: true,
}
// Старший аналитик может раскатить победителя
SeniorAnalystAuthority = DecisionAuthority{
Role: "senior_analyst",
CanStop: true,
CanPromote: true,
CanExtend: true,
}
// Админ может всё
AdminAuthority = DecisionAuthority{
Role: "admin",
CanStop: true,
CanPromote: true,
CanExtend: true,
}
)
// CheckAuthority проверяет права на решение
func CheckAuthority(userRole string, decision DecisionType) bool {
var authority DecisionAuthority
switch userRole {
case "analyst":
authority = AnalystAuthority
case "senior_analyst":
authority = SeniorAnalystAuthority
case "admin":
authority = AdminAuthority
default:
return false
}
switch decision {
case DecisionStop, DecisionRollback:
return authority.CanStop
case DecisionPromote:
return authority.CanPromote
case DecisionContinue:
return authority.CanExtend
default:
return false
}
}
7. Уведомления и эскалации
package notifier
// NotificationService сервис уведомлений
type NotificationService struct {
emailClient EmailClient
slackClient SlackClient
webhookClient WebhookClient
}
// NotifyDecisionRequired уведомляет о необходимости решения
func (s *NotificationService) NotifyDecisionRequired(ctx context.Context, exp *Experiment, decision *ExperimentDecision) error {
// Отправляем аналитику
if err := s.sendToAnalyst(ctx, exp, decision); err != nil {
log.Printf("Failed to notify analyst: %v", err)
}
// Если эксперимент длится слишком долго - эскалация
if time.Since(exp.StartedAt) > 14*24*time.Hour {
if err := s.escalateToManager(ctx, exp, decision); err != nil {
log.Printf("Failed to escalate: %v", err)
}
}
return nil
}
// NotifyExperimentCompleted уведомляет о завершении
func (s *NotificationService) NotifyExperimentCompleted(ctx context.Context, exp *Experiment, decision *ExperimentDecision) error {
// Уведомляем создателя эксперимента
if err := s.sendToCreator(ctx, exp, decision); err != nil {
log.Printf("Failed to notify creator: %v", err)
}
// Уведомляем команду
if err := s.sendToTeam(ctx, exp, decision); err != nil {
log.Printf("Failed to notify team: %v", err)
}
return nil
}
8. Дашборд мониторинга
-- Представление для мониторинга экспериментов
CREATE VIEW experiment_monitoring AS
SELECT
e.id,
e.name,
e.status,
e.started_at,
e.ended_at,
EXTRACT(EPOCH FROM (COALESCE(e.ended_at, NOW()) - e.started_at))/86400 as duration_days,
e.created_by,
COUNT(DISTINCT ev.user_id) as total_users,
COUNT(DISTINCT CASE WHEN ev.event_type = 'conversion' THEN ev.user_id END) as conversions,
COUNT(DISTINCT CASE WHEN ev.event_type = 'conversion' THEN ev.user_id END)::float /
NULLIF(COUNT(DISTINCT ev.user_id), 0) as conversion_rate
FROM experiments e
LEFT JOIN experiment_events ev ON e.id = ev.experiment_id
GROUP BY e.id, e.name, e.status, e.started_at, e.ended_at, e.created_by;
-- Эксперименты, требующие внимания
CREATE VIEW experiments_requiring_attention AS
SELECT * FROM experiment_monitoring
WHERE status = 'running'
AND (
duration_days > 14 -- длится более 2 недель
OR total_users = 0 -- нет пользователей
);
9. Типичные сценарии завершения
Сценарий 1: Успешное завершение
- Длительность: 10 дней
- Результат: статистически значимое улучшение конверсии на 5%
- Решение: раскатить победителя
- Кто решает: старший аналитик
Сценарий 2: Остановка по негативному воздействию
- Длительность: 3 дня
- Результат: падение retention на 15%
- Решение: остановить и откатить
- Кто решает: автоматическое правило + уведомление аналитику
Сценарий 3: Продление эксперимента
- Длительность: 14 дней
- Результат: недостаточно данных для вывода
- Решение: продлить на 7 дней
- Кто решает: аналитик
Сценарий 4: Завершение по таймауту
- Длительность: 30 дней
- Результат: нет значимого результата
- Решение: остановить, зафиксировать отсутствие эффекта
- Кто решает: автоматическое правило
10. Рекомендации по длительности
Минимальная длительность: 7 дней
- Учитывает недельные циклы
- Даёт достаточно данных для анализа
Оптимальная длительность: 10-14 дней
- Баланс между скоростью и надёжностью
- Учитывает сезонность
Максимальная длительность: 30 дней
- После 30 дней решение должно быть принято
- Длительные эксперименты могут искажать метрики
Автоматическая остановка:
- При негативном воздействии на guardrail метрики
- При достижении максимальной длительности
- При отсутствии трафика более 3 дней
Аналитик является основным лицом, принимающим решения о завершении эксперимента, но система должна поддерживать автоматические правила безопасности и эскалации для защиты от негативного воздействия на продукт.
Вопрос 13. Когда применяются сегменты - при входе в приложение или в реальном времени?
Таймкод: 00:24:27
Ответ собеседова: Правильный. Уточняется, что для простоты системы список экспериментов загружается на старте приложения, и дальнейшие изменения списка в ходе работы не происходят.
Правильный ответ:
Модель применения сегментов
Для первой версии принимаем упрощение: сегменты применяются один раз при входе в приложение.
Архитектура с однократной загрузкой:
type ExperimentManager struct {
experiments []UserExperiment
loadedAt time.Time
userID string
platform Platform
}
func (em *ExperimentManager) LoadExperiments(ctx context.Context) error {
// Загружаем эксперименты один раз при старте
experiments, err := em.fetchExperiments(ctx)
if err != nil {
return err
}
em.experiments = experiments
em.loadedAt = time.Now()
return nil
}
func (em *ExperimentManager) GetExperiment(experimentID string) *UserExperiment {
// Работаем только с локальным списком
for _, exp := range em.experiments {
if exp.ExperimentID == experimentID {
return &exp
}
}
return nil
}
func (em *ExperimentManager) GetAllExperiments() []UserExperiment {
return em.experiments
}
Жизненный цикл на клиенте:
type ClientExperimentService struct {
manager *ExperimentManager
apiClient *ExperimentClient
}
func (s *ClientExperimentService) Initialize(ctx context.Context, userID string, platform Platform) error {
s.manager = &ExperimentManager{
userID: userID,
platform: platform,
}
// Загружаем эксперименты при старте
return s.manager.LoadExperiments(ctx)
}
func (s *ClientExperimentService) GetVariant(ctx context.Context, experimentID string) (string, error) {
// Проверяем TTL кэша
if s.manager.IsExpired() {
// При следующем старте загрузим заново
log.Warn().Msg("Experiment cache expired, will refresh on next launch")
}
exp := s.manager.GetExperiment(experimentID)
if exp == nil {
return "", ErrExperimentNotFound
}
return exp.VariantID, nil
}
func (em *ExperimentManager) IsExpired() bool {
return time.Since(em.loadedAt) > 24*time.Hour
}
Серверная реализация:
type ServerExperimentService struct {
resolver *ExperimentResolver
}
func (s *ServerExperimentService) GetExperimentsForUser(ctx context.Context, req UserExperimentsRequest) (*UserExperimentsResponse, error) {
// Резолвим все эксперименты для пользователя
return s.resolver.ResolveForUser(ctx, req)
}
func (s *ServerExperimentService) GetVariant(ctx context.Context, userID, experimentID string, platform Platform) (*VariantResponse, error) {
// Получаем эксперименты пользователя
experiments, err := s.GetExperimentsForUser(ctx, UserExperimentsRequest{
UserID: userID,
Platform: platform,
})
if err != nil {
return nil, err
}
// Ищем нужный эксперимент
for _, exp := range experiments.Experiments {
if exp.ExperimentID == experimentID {
return &VariantResponse{
VariantID: exp.VariantID,
Config: exp.Config,
}, nil
}
}
return nil, ErrExperimentNotFound
}
Инициализация приложения (пример для мобильного):
type AppExperimentModule struct {
experimentService *ClientExperimentService
isInitialized bool
}
func (m *AppExperimentModule) OnAppLaunch(ctx context.Context, userID string, platform Platform) error {
// Показываем сплэш скрин
// Загружаем эксперименты
if err := m.experimentService.Initialize(ctx, userID, platform); err != nil {
// Не блокируем запуск приложения
log.Error().Err(err).Msg("Failed to load experiments")
}
m.isInitialized = true
// Скрываем сплэш скрин
return nil
}
func (m *AppExperimentModule) GetVariant(experimentID string) string {
if !m.isInitialized {
return "control" // fallback
}
variant, err := m.experimentService.GetVariant(context.Background(), experimentID)
if err != nil {
return "control"
}
return variant
}
Расширенная модель с обновлением:
type RefreshingExperimentManager struct {
manager *ExperimentManager
apiClient *ExperimentClient
refreshChan chan struct{}
stopChan chan struct{}
}
func NewRefreshingExperimentManager(apiClient *ExperimentClient, userID string, platform Platform) *RefreshingExperimentManager {
return &RefreshingExperimentManager{
manager: &ExperimentManager{
userID: userID,
platform: platform,
},
apiClient: apiClient,
refreshChan: make(chan struct{}, 1),
stopChan: make(chan struct{}),
}
}
func (rm *RefreshingExperimentManager) Start(ctx context.Context) error {
// Первоначальная загрузка
if err := rm.manager.LoadExperiments(ctx); err != nil {
return err
}
// Запускаем фоновое обновление
go rm.refreshLoop(ctx)
return nil
}
func (rm *RefreshingExperimentManager) refreshLoop(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rm.refresh(ctx)
case <-rm.refreshChan:
rm.refresh(ctx)
case <-rm.stopChan:
return
}
}
}
func (rm *RefreshingExperimentManager) refresh(ctx context.Context) {
if err := rm.manager.LoadExperiments(ctx); err != nil {
log.Error().Err(err).Msg("Failed to refresh experiments")
}
}
func (rm *RefreshingExperimentManager) ForceRefresh() {
select {
case rm.refreshChan <- struct{}{}:
default:
}
}
func (rm *RefreshingExperimentManager) Stop() {
close(rm.stopChan)
}
WebSocket для real-time обновлений:
type WebSocketExperimentHandler struct {
upgrader websocket.Upgrader
clients map[string]*ClientConn
}
type ClientConn struct {
conn *websocket.Conn
userID string
platform Platform
send chan []byte
}
func (h *WebSocketExperimentHandler) Handle(w http.ResponseWriter, r *http.Request) {
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
userID := r.Header.Get("X-User-ID")
platform := Platform(r.Header.Get("X-Platform"))
client := &ClientConn{
conn: conn,
userID: userID,
platform: platform,
send: make(chan []byte, 256),
}
h.clients[userID] = client
go client.writePump()
go client.readPump()
}
func (h *WebSocketExperimentHandler) NotifyExperimentChange(userID string, experimentID string) {
client, ok := h.clients[userID]
if !ok {
return
}
message := ExperimentChangeMessage{
Type: "experiment_change",
ExperimentID: experimentID,
Timestamp: time.Now(),
}
data, _ := json.Marshal(message)
select {
case client.send <- data:
default:
// Канал заполнен, пропускаем
}
}
Push-уведомления для мобильных:
type PushExperimentNotifier struct {
apnsClient *apns2.Client
fcmClient *fcm.Client
}
func (n *PushExperimentNotifier) NotifySegmentChange(ctx context.Context, userID string, segmentID int64) error {
// Отправляем silent push для обновления экспериментов
payload := map[string]interface{}{
"type": "experiment_refresh",
"segment_id": segmentID,
"timestamp": time.Now().Unix(),
}
// Определяем платформу и нужный клиент
if n.isIOS(userID) {
return n.sendAPNS(ctx, userID, payload)
}
return n.sendFCM(ctx, userID, payload)
}
Оптимизация: Stale-While-Revalidate:
type StaleWhileRevalidateManager struct {
manager *ExperimentManager
apiClient *ExperimentClient
}
func (s *StaleWhileRevalidateManager) GetExperiments(ctx context.Context) ([]UserExperiment, error) {
// Если кэш свежий - возвращаем сразу
if !s.manager.IsStale() {
return s.manager.GetAllExperperiments(), nil
}
// Если кэш устарел, но не протух - возвращаем старый и обновляем в фоне
if !s.manager.IsExpired() {
go s.refreshAsync(ctx)
return s.manager.GetAllExperiments(), nil
}
// Кэш протух - ждем обновления
return s.refreshSync(ctx)
}
func (s *StaleWhileRevalidateManager) IsStale() bool {
return time.Since(s.manager.loadedAt) > 5*time.Minute
}
func (s *StaleWhileRevalidateManager) IsExpired() bool {
return time.Since(s.manager.loadedAt) > 30*time.Minute
}
Сравнение подходов:
| Подход | Latency | Сложность | Консистентность | Нагрузка на сервер |
|---|---|---|---|---|
| Загрузка при старте | Высокая | Низкая | Низкая | Низкая |
| Периодический polling | Средняя | Средняя | Средняя | Средняя |
| WebSocket | Низкая | Высокая | Высокая | Высокая |
| Push + загрузка | Низкая | Высокая | Высокая | Средняя |
Рекомендация для первой версии:
type SimpleExperimentConfig struct {
// Загружаем один раз при старте
LoadOnStartup bool
TTL time.Duration
// Без real-time обновлений
EnableRealtime bool
}
func NewSimpleExperimentService(config SimpleExperimentConfig) *SimpleExperimentService {
return &SimpleExperimentService{
config: config,
cache: NewExperimentCache(config.TTL),
}
}
Вывод:
Для первой версии однократная загрузка при старте — оптимальное решение:
- Минимальная сложность реализации
- Предсказуемое поведение
- Низкая нагрузка на сервер
- Нет проблем с консистентностью
Real-time обновления можно добавить позже как оптимизацию, когда будет понятна реальная потребность.
Вопрос 14. Как связана аналитика экспериментов с общей системой сбора аналитики?
Таймкод: 00:25:36
Ответ собеседова: Правильный. Уточняется, что аналитика экспериментов является модификацией существующей системы сбора аналитики. К стандартным событиям добавляется информация о том, при каком эксперименте произошло действие.
Правильный ответ:
Интеграция экспериментов с аналитикой
Общая архитектура:
type AnalyticsEvent struct {
// Стандартные поля аналитики
EventID string `json:"event_id"`
EventType string `json:"event_type"`
UserID string `json:"user_id"`
Timestamp time.Time `json:"timestamp"`
Platform string `json:"platform"`
SessionID string `json:"session_id"`
// Контекст события
Properties map[string]interface{} `json:"properties"`
// Экспериментальный контекст (добавляется)
Experiments []ExperimentContext `json:"experiments,omitempty"`
}
type ExperimentContext struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
}
Enrichment событий экспериментальным контекстом:
type ExperimentEnricher struct {
experimentService ExperimentService
}
func (e *ExperimentEnricher) EnrichEvent(ctx context.Context, event *AnalyticsEvent) error {
// Получаем активные эксперименты для пользователя
experiments, err := e.experimentService.GetUserExperiments(ctx, event.UserID, Platform(event.Platform))
if err != nil {
// Не блокируем отправку события из-за ошибки экспериментов
log.Warn().Err(err).Msg("Failed to enrich event with experiments")
return nil
}
// Добавляем контекст экспериментов
event.Experiments = make([]ExperimentContext, 0, len(experiments))
for _, exp := range experiments {
event.Experiments = append(event.Experiments, ExperimentContext{
ExperimentID: exp.ExperimentID,
VariantID: exp.VariantID,
})
}
return nil
}
Сервис отправки событий:
type EventSender struct {
enricher *ExperimentEnricher
kafka *kafka.Writer
metrics *Metrics
}
func (s *EventSender) SendEvent(ctx context.Context, event *AnalyticsEvent) error {
start := time.Now()
// Обогащаем экспериментальным контекстом
if err := s.enricher.EnrichEvent(ctx, event); err != nil {
s.metrics.EnrichmentErrors.Inc()
}
// Сериализуем
data, err := json.Marshal(event)
if err != nil {
return err
}
// Отправляем в Kafka
err = s.kafka.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Topic: "analytics.events",
})
s.metrics.EventLatency.Observe(time.Since(start).Seconds())
return err
}
SDK для клиентов:
type ExperimentAnalytics struct {
experimentClient *ExperimentClient
eventSender *EventSender
experimentsCache []UserExperiment
}
func (ea *ExperimentAnalytics) Initialize(ctx context.Context, userID string, platform Platform) error {
// Загружаем эксперименты при старте
experiments, err := ea.experimentClient.GetUserExperiments(ctx, userID, platform)
if err != nil {
return err
}
ea.experimentsCache = experiments
return nil
}
func (ea *ExperimentAnalytics) TrackEvent(ctx context.Context, eventType string, properties map[string]interface{}) error {
event := &AnalyticsEvent{
EventID: generateID(),
EventType: eventType,
UserID: ea.userID,
Timestamp: time.Now(),
Properties: properties,
}
// Добавляем эксперименты из кэша
event.Experiments = make([]ExperimentContext, 0, len(ea.experimentsCache))
for _, exp := range ea.experimentsCache {
event.Experiments = append(event.Experiments, ExperimentContext{
ExperimentID: exp.ExperimentID,
VariantID: exp.VariantID,
})
}
return ea.eventSender.SendEvent(ctx, event)
}
Обработка событий для статистики экспериментов:
type ExperimentStatsCollector struct {
kafka *kafka.Reader
statsStore StatsStorage
}
func (c *ExperimentStatsCollector) ProcessEvents(ctx context.Context) error {
for {
msg, err := c.kafka.ReadMessage(ctx)
if err != nil {
return err
}
var event AnalyticsEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
// Обрабатываем каждый эксперимент в событии
for _, exp := range event.Experiments {
c.processExperimentEvent(ctx, &event, &exp)
}
}
}
func (c *ExperimentStatsCollector) processExperimentEvent(ctx context.Context, event *AnalyticsEvent, exp *ExperimentContext) {
// Создаем запись для статистики эксперимента
stat := &ExperimentEvent{
ExperimentID: exp.ExperimentID,
VariantID: exp.VariantID,
UserID: event.UserID,
EventType: event.EventType,
Timestamp: event.Timestamp,
Properties: event.Properties,
}
// Сохраняем
c.statsStore.SaveEvent(ctx, stat)
// Обновляем счетчики в реальном времени
c.statsStore.IncrementCounters(ctx, stat)
}
Схема хранения событий экспериментов:
-- Таблица событий с экспериментальным контекстом
CREATE TABLE experiment_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id String NOT NULL,
variant_id String NOT NULL,
user_id String NOT NULL,
event_type String NOT NULL,
timestamp DateTime NOT NULL,
properties Map(String, String),
-- Индексы
INDEX idx_experiment (experiment_id, variant_id) TYPE minmax GRANULARITY 4,
INDEX idx_user (user_id) TYPE bloom_filter GRANULARITY 4,
INDEX idx_timestamp (timestamp) TYPE minmax GRANULARITY 4
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, timestamp);
-- Агрегированная статистика
CREATE TABLE experiment_daily_stats (
experiment_id String,
variant_id String,
date Date,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64,
-- Метрики
metric_values Map(String, Float64)
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant_id, date);
Materialized View для агрегации:
-- Автоматическая агрегация по минутам
CREATE MATERIALIZED VIEW experiment_minute_stats_mv
TO experiment_minute_stats
AS
SELECT
experiment_id,
variant_id,
toStartOfMinute(timestamp) as minute,
count() as events,
uniqExact(user_id) as unique_users,
countIf(event_type = 'conversion') as conversions,
sumIf(CAST(properties['revenue'] AS Float64), event_type = 'conversion') as revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, toStartOfMinute(timestamp);
Запрос для аналитика:
-- Получение статистики эксперимента
SELECT
variant_id,
sum(events) as total_events,
sum(unique_users) as total_users,
sum(conversions) as total_conversions,
sum(revenue) as total_revenue,
sum(conversions) / sum(unique_users) as conversion_rate,
sum(revenue) / sum(unique_users) as arpu
FROM experiment_daily_stats
WHERE experiment_id = 'exp_123'
AND date BETWEEN '2024-01-01' AND '2024-01-14'
GROUP BY variant_id
ORDER BY variant_id;
Обработка конфликтов экспериментов:
type ExperimentConflictDetector struct {
experimentService ExperimentService
}
func (d *ExperimentConflictDetector) DetectConflicts(ctx context.Context, userID string, newExperimentID string) ([]string, error) {
// Получаем текущие эксперименты пользователя
current, err := d.experimentService.GetUserExperiments(ctx, userID)
if err != nil {
return nil, err
}
newExp, err := d.experimentService.GetExperiment(ctx, newExperimentID)
if err != nil {
return nil, err
}
conflicts := make([]string, 0)
for _, curr := range current {
currExp, err := d.experimentService.GetExperiment(ctx, curr.ExperimentID)
if err != nil {
continue
}
// Проверяем пересечение по метрикам
if d.hasMetricOverlap(newExp, currExp) {
conflicts = append(conflicts, curr.ExperimentID)
}
}
return conflicts, nil
}
Fallback при недоступности сервиса экспериментов:
type ResilientExperimentEnricher struct {
experimentService ExperimentService
circuitBreaker *gobreaker.CircuitBreaker
}
func (e *ResilientExperimentEnricher) EnrichEvent(ctx context.Context, event *AnalyticsEvent) error {
// Используем circuit breaker для защиты от каскадных отказов
result, err := e.circuitBreaker.Execute(func() (interface{}, error) {
return e.experimentService.GetUserExperiments(ctx, event.UserID, Platform(event.Platform))
})
if err != nil {
// Логируем, но не блокируем отправку события
log.Warn().Err(err).Msg("Failed to get experiments, sending without enrichment")
return nil
}
experiments := result.([]UserExperiment)
event.Experiments = make([]ExperimentContext, 0, len(experiments))
for _, exp := range experiments {
event.Experiments = append(event.Experiments, ExperimentContext{
ExperimentID: exp.ExperimentID,
VariantID: exp.VariantID,
})
}
return nil
}
Метрики интеграции:
type IntegrationMetrics struct {
eventsEnriched prometheus.Counter
enrichmentErrors prometheus.Counter
enrichmentLatency prometheus.Histogram
experimentsPerEvent prometheus.Histogram
}
func (m *IntegrationMetrics) RecordEnrichment(duration time.Duration, experimentCount int, err error) {
m.enrichmentLatency.Observe(duration.Seconds())
m.experimentsPerEvent.Observe(float64(experimentCount))
if err != nil {
m.enrichmentErrors.Inc()
} else {
m.eventsEnriched.Inc()
}
}
Пайплайн данных:
Клиентское приложение
│
▼
┌─────────────────────┐
│ SDK экспериментов │ ◄── Загрузка экспериментов при старте
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Enrichment слой │ ◄── Добавление экспериментального контекста
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Kafka Topic │
│ analytics.events │
└─────────────────────┘
│
├──────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Общая │ │ Экспериментальная│
│ аналитика │ │ статистика │
└──────────────┘ └──────────────────┘
Вывод:
Интеграция экспериментов с аналитикой реализуется через enrichment событий — добавление экспериментального контекста к стандартным событиям. Это позволяет:
- Использовать существующую инфраструктуру сбора данных
- Минимизировать изменения в клиентском коде
- Обеспечить консистентность данных
- Упростить анализ результатов экспериментов
Вопрос 8. Нужно ли моделировать авторизацию пользователей?
Таймкод: 00:14:10
Ответ собеседника: Правильный. Уточняется, что авторизацию моделировать не нужно. Можно свести всё к некоторому идентификатору, который приходит с клиента (ID пользователя, cookie, device ID).
Правильный ответ:
1. Принцип работы с идентификацией в A/B-тестировании
Система A/B-тестирования не занимается авторизацией — она работает с уже идентифицированными пользователями. Это принципиальное архитектурное решение.
Разделение ответственности:
[Система авторизации] → [ID пользователя] → [A/B Test Service]
↓ ↓
Аутентификация Сплит по вариантам
Управление сессиями Назначение фичей
Безопасность Сбор метрик
2. Типы идентификаторов
package identity
// UserIdentifier идентификатор пользователя
type UserIdentifier struct {
// Основной ID (обязательный)
ID string
// Тип идентификатора
Type IdentifierType
// Дополнительные идентификаторы для кросс-девайс трекинга
SecondaryIDs map[IdentifierType]string
}
type IdentifierType string
const (
IdentifierTypeUserID IdentifierType = "user_id" // авторизованный пользователь
IdentifierTypeCookie IdentifierType = "cookie" // cookie/anonymous ID
IdentifierTypeDeviceID IdentifierType = "device_id" // идентификатор устройства
IdentifierTypeSessionID IdentifierType = "session_id" // идентификатор сессии
)
// IdentifierResolver резолвер идентификаторов
type IdentifierResolver struct {
// Маппинг анонимных ID к user_id
anonymousToUser map[string]string
}
// Resolve преобразует любой ID в канонический формат
func (r *IdentifierResolver) Resolve(ctx context.Context, id UserIdentifier) (string, error) {
switch id.Type {
case IdentifierTypeUserID:
// Уже авторизованный пользователь
return id.ID, nil
case IdentifierTypeCookie, IdentifierTypeDeviceID:
// Пробуем найти маппинг к user_id
if userID, exists := r.anonymousToUser[id.ID]; exists {
return userID, nil
}
// Если нет маппинга — используем как есть
return fmt.Sprintf("%s:%s", id.Type, id.ID), nil
default:
return "", fmt.Errorf("unknown identifier type: %s", id.Type)
}
}
3. Обработка анонимных и авторизованных пользователей
package abtest
// AnonymousUserHandler обработка анонимных пользователей
type AnonymousUserHandler struct {
storage AnonymousMappingStorage
}
// AnonymousMappingStorage хранилище маппингов
type AnonymousMappingStorage interface {
// GetUserID возвращает user_id по анонимному ID
GetUserID(ctx context.Context, anonymousID string) (string, error)
// SaveMapping сохраняет маппинг anonymous_id -> user_id
SaveMapping(ctx context.Context, anonymousID, userID string) error
}
// HandleUserIdentified вызывается когда анонимный пользователь авторизовался
func (h *AnonymousUserHandler) HandleUserIdentified(ctx context.Context, anonymousID, userID string) error {
// Сохраняем маппинг
if err := h.storage.SaveMapping(ctx, anonymousID, userID); err != nil {
return err
}
// Мигрируем назначения вариантов
if err := h.migrateAssignments(ctx, anonymousID, userID); err != nil {
return err
}
return nil
}
// migrateAssignments мигрирует назначения вариантов при авторизации
func (h *AnonymousUserHandler) migrateAssignments(ctx context.Context, anonymousID, userID string) error {
// Получаем все назначения для анонимного ID
assignments, err := h.storage.GetAssignments(ctx, anonymousID)
if err != nil {
return err
}
// Копируем назначения на user_id
for _, assignment := range assignments {
if err := h.storage.SaveAssignment(ctx, userID, assignment.ExperimentID, assignment.VariantID); err != nil {
log.Printf("Failed to migrate assignment for experiment %s: %v", assignment.ExperimentID, err)
}
}
return nil
}
4. Стратегии сплита для разных типов идентификации
// SplitStrategy стратегия сплита
type SplitStrategy int
const (
// UserBasedSplit сплит по user_id (один пользователь = один вариант на всех устройствах)
UserBasedSplit SplitStrategy = iota
// DeviceBasedSplit сплит по device_id (разные устройства могут видеть разные варианты)
DeviceBasedSplit
// SessionBasedSplit сплит по session (каждый визит может показывать разный вариант)
SessionBasedSplit
)
// Experiment с указанием стратегии сплита
type Experiment struct {
ID string
Name string
SplitStrategy SplitStrategy
Variants []Variant
// ...
}
// GenerateSplitKey генерирует ключ для сплита
func GenerateSplitKey(strategy SplitStrategy, userCtx UserContext) string {
switch strategy {
case UserBasedSplit:
return fmt.Sprintf("%s_%s", userCtx.UserID, "user")
case DeviceBasedSplit:
return fmt.Sprintf("%s_%s", userCtx.DeviceID, "device")
case SessionBasedSplit:
return fmt.Sprintf("%s_%s", userCtx.SessionID, "session")
default:
return fmt.Sprintf("%s_%s", userCtx.UserID, "user")
}
}
5. Пример использования в API
package api
// AssignVariantRequest запрос на назначение варианта
type AssignVariantRequest struct {
// Идентификатор пользователя (любой тип)
UserID string `json:"user_id"`
// Тип идентификатора
IDType string `json:"id_type"` // "user", "cookie", "device"
// Контекст запроса
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
// Список экспериментов
ExperimentIDs []string `json:"experiment_ids"`
}
// AssignVariantResponse ответ с назначенными вариантами
type AssignVariantResponse struct {
Variants map[string]*VariantAssignment `json:"variants"`
}
type VariantAssignment struct {
VariantID string `json:"variant_id"`
Config map[string]interface{} `json:"config"`
}
// Handler HTTP handler
type Handler struct {
abTestService *abtest.ABTestService
idResolver *identity.IdentifierResolver
}
// AssignVariants handler для назначения вариантов
func (h *Handler) AssignVariants(w http.ResponseWriter, r *http.Request) {
var req AssignVariantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
// Резолвим идентификатор
userIdentifier := identity.UserIdentifier{
ID: req.UserID,
Type: identity.IdentifierType(req.IDType),
}
canonicalID, err := h.idResolver.Resolve(ctx, userIdentifier)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
userCtx := abtest.UserContext{
UserID: canonicalID,
Platform: abtest.Platform(req.Platform),
AppVersion: req.AppVersion,
}
// Получаем варианты
variants := make(map[string]*VariantAssignment)
for _, expID := range req.ExperimentIDs {
variant, err := h.abTestService.AssignVariant(ctx, userCtx, expID)
if err != nil {
log.Printf("Failed to assign variant for experiment %s: %v", expID, err)
continue
}
variants[expID] = &VariantAssignment{
VariantID: variant.ID,
Config: variant.Config,
}
}
resp := AssignVariantResponse{Variants: variants}
json.NewEncoder(w).Encode(resp)
}
6. Хранение маппингов
-- Маппинг анонимных ID к user_id
CREATE TABLE user_identity_mapping (
anonymous_id VARCHAR(255) PRIMARY KEY,
user_id BIGINT,
id_type VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_identity_mapping_user_id ON user_identity_mapping(user_id);
-- Назначения вариантов с учётом типа ID
CREATE TABLE user_variants (
id VARCHAR(255) NOT NULL, -- может быть user_id или anonymous_id
id_type VARCHAR(50) NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
platform VARCHAR(50),
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, experiment_id)
);
CREATE INDEX idx_variants_experiment ON user_variants(experiment_id);
CREATE INDEX idx_variants_user ON user_variants(id, id_type);
7. Кросс-девайс трекинга
// CrossDeviceTracker кросс-девайс трекинг
type CrossDeviceTracker struct {
storage CrossDeviceStorage
}
// CrossDeviceStorage хранилище связей между устройствами
type CrossDeviceStorage interface {
// LinkDevices связывает несколько устройств с одним пользователем
LinkDevices(ctx context.Context, userID string, deviceIDs []string) error
// GetUserDevices возвращает все устройства пользователя
GetUserDevices(ctx context.Context, userID string) ([]string, error)
}
// GetConsistentVariant возвращает консистентный вариант для пользователя на всех устройствах
func (t *CrossDeviceTracker) GetConsistentVariant(ctx context.Context, userID, experimentID string) (string, error) {
// Получаем все устройства пользователя
devices, err := t.storage.GetUserDevices(ctx, userID)
if err != nil {
return "", err
}
// Проверяем, есть ли уже назначение на каком-то из устройств
for _, deviceID := range devices {
variant, err := t.storage.GetVariant(ctx, deviceID, experimentID)
if err == nil && variant != "" {
return variant, nil
}
}
return "", nil // нет назначения
}
8. Безопасность и приватность
// PrivacyFilter фильтр для соблюдения приватности
type PrivacyFilter struct {
// Список экспериментов, которые не должны отслеживать анонимных пользователей
anonymousBlacklist map[string]bool
}
// CanTrack проверяет, можно ли отслеживать пользователя
func (f *PrivacyFilter) CanTrack(userID string, experimentID string) bool {
// Анонимным пользователям нельзя отслеживать некоторые эксперименты
if isAnonymous(userID) && f.anonymousBlacklist[experimentID] {
return false
}
return true
}
// SanitizeUserData очищает данные пользователя перед сохранением
func SanitizeUserData(data map[string]interface{}) map[string]interface{} {
// Удаляем чувствительные поля
sensitiveFields := []string{"email", "phone", "name", "address"}
result := make(map[string]interface{})
for k, v := range data {
if !contains(sensitiveFields, k) {
result[k] = v
}
}
return result
}
9. Итоговая архитектура идентификации
┌─────────────────────────────────────────────────────────────┐
│ Клиентское приложение │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Authorized │ │ Anonymous │ │ Device │ │
│ │ user_id │ │ cookie │ │ ID │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼─────────────────┘
│ │ │
└────────────────┼────────────────┘
│
▼
┌────────────────────────┐
│ IdentifierResolver │
│ (резолвим ID) │
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ A/B Test Service │
│ (работаем с ID) │
└────────────────────────┘
10. Ключевые принципы
-
Система A/B-тестирования не авторизует — она доверяет идентификатору, полученному от клиента.
-
Поддержка разных типов ID — user_id, cookie, device_id, session_id.
-
Маппинг анонимных ID к user_id — для кросс-девайс консистентности.
-
Стратегия сплита — определяет, как именно назначать варианты (по пользователю, устройству или сессии).
-
Приватность — не храним чувствительные данные, соблюдаем GDPR/CCPA.
-
Консистентность — один пользователь видит один вариант на всех устройствах (при UserBasedSplit).
Такой подход позволяет системе A/B-тестирования быть независимой от системы авторизации и работать с любым типом идентификаторов.
Вопрос 15. Есть ли отличия между мобильными и веб-приложениями в работе с экспериментами?
Таймкод: 00:27:09
Ответ собеседова: Правильный. Уточняется, что в реальности отличия есть (в мобильных загрузка всех экспериментов сразу, в вебе зависимость от архитектуры), но для текущего проектирования можно объединить сценарии и говорить просто о клиенте.
Правильный ответ:
Унифицированная модель клиента
Для текущего проектирования принимаем упрощение: работа с экспериментами одинакова для всех платформ.
Единый интерфейс клиента:
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
PlatformWeb Platform = "web"
)
type ClientExperimentService interface {
// Инициализация при запуске
Initialize(ctx context.Context, userID string, platform Platform) error
// Получение варианта эксперимента
GetVariant(ctx context.Context, experimentID string) (string, error)
// Получение всех экспериментов пользователя
GetAllExperiments(ctx context.Context) ([]UserExperiment, error)
// Отправка события с экспериментальным контекстом
TrackEvent(ctx context.Context, eventType string, properties map[string]interface{}) error
}
Абстракция над платформой:
type UnifiedExperimentClient struct {
httpClient *http.Client
baseURL string
cache ExperimentCache
platform Platform
userID string
}
func (c *UnifiedExperimentClient) Initialize(ctx context.Context) error {
req := &UserExperimentsRequest{
UserID: c.userID,
Platform: c.platform,
}
experiments, err := c.fetchExperiments(ctx, req)
if err != nil {
return err
}
c.cache.Set(c.userID, experiments, c.getTTL())
return nil
}
func (c *UnifiedExperimentClient) GetVariant(ctx context.Context, experimentID string) (string, error) {
// Платформонезависимая логика
experiments := c.cache.Get(c.userID)
for _, exp := range experiments {
if exp.ExperimentID == experimentID {
return exp.VariantID, nil
}
}
return "control", ErrExperimentNotFound
}
func (c *UnifiedExperimentClient) getTTL() time.Duration {
// Для всех платформ одинаковый TTL
return 5 * time.Minute
}
API endpoint (единый для всех платформ):
type ExperimentHandler struct {
resolver *ExperimentResolver
}
func (h *ExperimentHandler) GetUserExperiments(w http.ResponseWriter, r *http.Request) {
// Извлекаем параметры
userID := r.Header.Get("X-User-ID")
platform := Platform(r.Header.Get("X-Platform"))
// Валидация
if userID == "" {
http.Error(w, "Missing user ID", http.StatusBadRequest)
return
}
if !isValidPlatform(platform) {
http.Error(w, "Invalid platform", http.StatusBadRequest)
return
}
// Резолвим эксперименты (логика одинакова для всех платформ)
req := UserExperimentsRequest{
UserID: userID,
Platform: platform,
Context: extractContext(r),
}
response, err := h.resolver.ResolveForUser(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Добавляем заголовки кэширования
w.Header().Set("Cache-Control", "private, max-age=300")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func isValidPlatform(platform Platform) bool {
switch platform {
case PlatformIOS, PlatformAndroid, PlatformWeb:
return true
default:
return false
}
}
Конфигурация эксперимента с учетом платформы:
type ExperimentConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Platforms []Platform `json:"platforms"` // На каких платформах активен
Variants []Variant `json:"variants"`
}
func (c *ExperimentConfig) IsPlatformEnabled(platform Platform) bool {
if len(c.Platforms) == 0 {
return true // Если не указано - все платформы
}
for _, p := range c.Platforms {
if p == platform {
return true
}
}
return false
}
Примеры SDK для разных платформ:
// iOS SDK (Swift-псевдокод)
type iOSExperimentClient struct {
baseURL: String
cache: ExperimentCache
}
func (client *iOSExperimentClient) initialize(userID: String) async throws {
let experiments = try await fetchExperiments(userID: userID, platform: "ios")
cache.set(experiments)
}
// Android SDK (Kotlin-псевдокод)
class AndroidExperimentClient(
private val baseUrl: String,
private val cache: ExperimentCache
) {
suspend fun initialize(userID: String) {
val experiments = fetchExperiments(userID, "android")
cache.set(experiments)
}
}
// Web SDK (TypeScript-псевдокод)
class WebExperimentClient {
constructor(private baseUrl: string, private cache: ExperimentCache) {}
async initialize(userID: string) {
const experiments = await this.fetchExperiments(userID, "web");
this.cache.set(experiments);
}
}
Различия в реализации клиентов (для справки):
type PlatformSpecificBehavior struct {
platform Platform
}
func (b *PlatformSpecificBehavior) GetRefreshStrategy() RefreshStrategy {
switch b.platform {
case PlatformIOS, PlatformAndroid:
// Мобильные: загрузка при старте, фоновое обновление
return RefreshStrategy{
LoadOnStartup: true,
BackgroundRefresh: true,
RefreshInterval: 5 * time.Minute,
}
case PlatformWeb:
// Веб: загрузка при каждом запросе или long-polling
return RefreshStrategy{
LoadOnStartup: false,
BackgroundRefresh: false,
RefreshInterval: 0, // Загружаем каждый раз
}
default:
return DefaultRefreshStrategy()
}
}
func (b *PlatformSpecificBehavior) GetCacheStrategy() CacheStrategy {
switch b.platform {
case PlatformIOS, PlatformAndroid:
return CacheStrategy{
InMemory: true,
Persistent: true, // Сохраняем в UserDefaults/SharedPreferences
TTL: 24 * time.Hour,
}
case PlatformWeb:
return CacheStrategy{
InMemory: true,
Persistent: false, // Используем sessionStorage
TTL: 5 * time.Minute,
}
default:
return DefaultCacheStrategy()
}
}
Унифицированная структура ответа:
type UserExperimentsResponse struct {
Experiments []UserExperiment `json:"experiments"`
Meta ResponseMeta `json:"meta"`
}
type ResponseMeta struct {
RequestID string `json:"request_id"`
Timestamp time.Time `json:"timestamp"`
CacheTTL int `json:"cache_ttl_seconds"`
Platform Platform `json:"platform"`
}
// Клиент решает сам, как использовать данные
func (c *UnifiedExperimentClient) HandleResponse(response *UserExperimentsResponse) {
// Сохраняем в кэш на время из ответа
c.cache.Set(c.userID, response.Experiments,
time.Duration(response.Meta.CacheTTL)*time.Second)
// Логируем
log.Info().
Str("request_id", response.Meta.RequestID).
Int("experiment_count", len(response.Experiments)).
Msg("Experiments loaded")
}
Feature flags для платформенных различий:
type FeatureFlags struct {
// Включаем платформенную специфику только когда нужно
EnablePlatformSpecificBehavior bool
EnableMobileBackgroundRefresh bool
EnableWebRealtimeUpdates bool
}
func (f *FeatureFlags) UseUnifiedClient() bool {
// Пока флаг не включен - используем унифицированную модель
return !f.EnablePlatformSpecificBehavior
}
Миграция к платформенной специфике:
type ExperimentServiceFactory struct {
featureFlags *FeatureFlags
}
func (f *ExperimentServiceFactory) CreateClient(
platform Platform,
httpClient *http.Client,
) ClientExperimentService {
if f.featureFlags.UseUnifiedClient() {
return &UnifiedExperimentClient{
httpClient: httpClient,
platform: platform,
}
}
// Платформенная специфика включается позже
switch platform {
case PlatformIOS, PlatformAndroid:
return &MobileExperimentClient{
httpClient: httpClient,
}
case PlatformWeb:
return &WebExperimentClient{
httpClient: httpClient,
}
default:
return &UnifiedExperimentClient{
httpClient: httpClient,
platform: platform,
}
}
}
Вывод:
Для текущего этапа проектирования:
- Единый API для всех платформ
- Единая логика определения экспериментов и вариантов
- Единая модель данных для ответов
- Платформа передается как параметр запроса
- Различия в клиентах (кэширование, обновление) реализуются на стороне SDK
Это позволяет:
- Упростить серверную архитектуру
- Сократить время разработки
- Обеспечить консистентность экспериментов
- Легко добавлять новые платформы
Платформенная специфика может быть добавлена позже через feature flags без изменения основной архитектуры.
Вопрос 16. Кто навешивает сегменты на пользователей?
Таймкод: 00:28:56
Ответ собеседова: Правильный. Уточняется, что обычно это отдельный аналитик, который готовит сегментацию по клиентам. Для простоты можно считать, что это тот же аналитик, но в реальности продуктовому аналитику лучше, чтобы сегменты были заранее подготовлены.
Правильный ответ:
Процесс назначения сегментов
Разделение ответственности:
type SegmentRole string
const (
RoleSegmentAnalyst SegmentRole = "segment_analyst" // Готовит сегменты
RoleProductAnalyst SegmentRole = "product_analyst" // Использует в экспериментах
RoleDataEngineer SegmentRole = "data_engineer" // Настраивает пайплайны
)
type SegmentAssignment struct {
SegmentID int64 `json:"segment_id"`
UserID string `json:"user_id"`
AssignedBy string `json:"assigned_by"` // Кто назначил
AssignedAt time.Time `json:"assigned_at"`
Source string `json:"source"` // Откуда получен
Confidence float64 `json:"confidence"` // Уверенность в принадлежности
}
Источники сегментов:
type SegmentSource struct {
Name string `json:"name"`
Type string `json:"type"` // "manual", "automatic", "import"
Description string `json:"description"`
}
// Примеры источников
var SegmentSources = map[string]SegmentSource{
"manual": {
Name: "Manual Assignment",
Type: "manual",
Description: "Аналитик вручную загрузил список пользователей",
},
"dwh_query": {
Name: "DWH Query",
Type: "automatic",
Description: "Сегмент рассчитан по данным из хранилища",
},
"ml_model": {
Name: "ML Model",
Type: "automatic",
Description: "Сегмент предсказан ML моделью",
},
"crm_import": {
Name: "CRM Import",
Type: "import",
Description: "Импортировано из CRM системы",
},
}
Загрузка сегментов аналитиком:
type SegmentUploadService struct {
storage SegmentStorage
validator *SegmentValidator
audit AuditService
}
type SegmentUploadRequest struct {
SegmentID int64 `json:"segment_id"`
UserIDs []string `json:"user_ids"`
Source string `json:"source"`
UploadedBy string `json:"uploaded_by"`
}
func (s *SegmentUploadService) UploadSegmentUsers(ctx context.Context, req SegmentUploadRequest) error {
// Валидация
if err := s.validator.Validate(req); err != nil {
return err
}
// Проверяем права
if !s.hasUploadPermission(req.UploadedBy, req.SegmentID) {
return ErrForbidden
}
// Загружаем батчами
batchSize := 1000
for i := 0; i < len(req.UserIDs); i += batchSize {
end := i + batchSize
if end > len(req.UserIDs) {
end = len(req.UserIDs)
}
batch := req.UserIDs[i:end]
assignments := make([]SegmentAssignment, 0, len(batch))
for _, userID := range batch {
assignments = append(assignments, SegmentAssignment{
SegmentID: req.SegmentID,
UserID: userID,
AssignedBy: req.UploadedBy,
AssignedAt: time.Now(),
Source: req.Source,
Confidence: 1.0,
})
}
if err := s.storage.SaveAssignments(ctx, assignments); err != nil {
return err
}
}
// Аудит
s.audit.Log(ctx, AuditLog{
UserID: req.UploadedBy,
Action: "upload_segment",
ResourceID: fmt.Sprintf("%d", req.SegmentID),
ResourceType: "segment",
Details: map[string]interface{}{
"user_count": len(req.UserIDs),
"source": req.Source,
},
})
return nil
}
Автоматический расчет сегментов из DWH:
type DWHSegmentCalculator struct {
dwh *sql.DB
segmentStore SegmentStorage
scheduler *cron.Cron
}
type SegmentQuery struct {
SegmentID int64 `json:"segment_id"`
SQLQuery string `json:"sql_query"`
Schedule string `json:"schedule"` // cron expression
LastRun time.Time `json:"last_run"`
NextRun time.Time `json:"next_run"`
}
func (c *DWHSegmentCalculator) CalculateSegment(ctx context.Context, query SegmentQuery) error {
// Выполняем запрос
rows, err := c.dwh.QueryContext(ctx, query.SQLQuery)
if err != nil {
return err
}
defer rows.Close()
userIDs := make([]string, 0)
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
continue
}
userIDs = append(userIDs, userID)
}
// Обновляем сегмент
return c.segmentStore.UpdateSegmentUsers(ctx, query.SegmentID, userIDs)
}
// Пример запроса для сегмента "Активные пользователи"
const ActiveUsersQuery = `
SELECT DISTINCT user_id
FROM user_events
WHERE event_date >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY user_id
HAVING COUNT(*) >= 5
`
Пайплайн расчета сегментов:
type SegmentPipeline struct {
calculator *DWHSegmentCalculator
queue *SegmentQueue
}
func (p *SegmentPipeline) ScheduleRecalculation(ctx context.Context) error {
// Получаем все сегменты, требующие пересчета
segments, err := p.calculator.GetSegmentsForRecalculation(ctx)
if err != nil {
return err
}
for _, segment := range segments {
// Добавляем в очередь
p.queue.Enqueue(ctx, SegmentJob{
SegmentID: segment.ID,
Priority: segment.Priority,
ScheduledAt: time.Now(),
})
}
return nil
}
func (p *SegmentPipeline) ProcessJob(ctx context.Context, job SegmentJob) error {
// Получаем запрос для сегмента
query, err := p.calculator.GetSegmentQuery(ctx, job.SegmentID)
if err != nil {
return err
}
// Вычисляем
return p.calculator.CalculateSegment(ctx, query)
}
Интерфейс для загрузки сегментов:
type SegmentUploadHandler struct {
uploadService *SegmentUploadService
}
func (h *SegmentUploadHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
// Парсим файл
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file", http.StatusBadRequest)
return
}
defer file.Close()
// Читаем user IDs
userIDs, err := parseUserIDsFromCSV(file)
if err != nil {
http.Error(w, "Failed to parse file", http.StatusBadRequest)
return
}
segmentID, _ := strconv.ParseInt(r.FormValue("segment_id"), 10, 64)
analystID := r.Context().Value("user_id").(string)
req := SegmentUploadRequest{
SegmentID: segmentID,
UserIDs: userIDs,
Source: "manual",
UploadedBy: analystID,
}
if err := h.uploadService.UploadSegmentUsers(r.Context(), req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"user_count": len(userIDs),
})
}
Валидация сегментов:
type SegmentValidator struct {
maxUsersPerSegment int
}
func (v *SegmentValidator) Validate(req SegmentUploadRequest) error {
// Проверяем размер
if len(req.UserIDs) > v.maxUsersPerSegment {
return fmt.Errorf("too many users: %d (max: %d)",
len(req.UserIDs), v.maxUsersPerSegment)
}
// Проверяем дубликаты
seen := make(map[string]bool)
for _, userID := range req.UserIDs {
if seen[userID] {
return fmt.Errorf("duplicate user: %s", userID)
}
seen[userID] = true
}
// Валидируем формат user IDs
for _, userID := range req.UserIDs {
if !isValidUserID(userID) {
return fmt.Errorf("invalid user ID format: %s", userID)
}
}
return nil
}
Мониторинг качества сегментов:
type SegmentQualityMetrics struct {
segmentSize prometheus.GaugeVec
freshness prometheus.GaugeVec
overlapRatio prometheus.GaugeVec
}
func (m *SegmentQualityMetrics) UpdateSegmentMetrics(segmentID int64, size int, lastUpdate time.Time) {
m.segmentSize.WithLabelValues(fmt.Sprintf("%d", segmentID)).Set(float64(size))
m.freshness.WithLabelValues(fmt.Sprintf("%d", segmentID)).Set(time.Since(lastUpdate).Hours())
}
Типичный workflow:
1. Аналитик данных готовит сегмент:
-- Запрос для создания сегмента "Платящие пользователи"
SELECT user_id
FROM payments
WHERE payment_date >= CURRENT_DATE - INTERVAL '30 days'
AND amount > 0
GROUP BY user_id
HAVING SUM(amount) >= 1000;
2. Загружает в систему:
- Через UI (загрузка CSV)
- Через API (программно)
- Через автоматический пайплайн из DWH
3. Продуктовый аналитик использует сегмент:
// Создает эксперимент с таргетингом на сегмент
experiment := &Experiment{
Name: "New pricing for paying users",
Segments: []int64{123}, // ID сегмента "Платящие"
Variants: []Variant{
{ID: "control", TrafficPct: 50},
{ID: "treatment", TrafficPct: 50},
},
}
Рекомендации:
- Сегменты должны быть
Вопрос 9. Какая допустимая задержка в расчёте статистики для аналитика?
Таймкод: 00:17:15
Ответ собеседника: Правильный. Уточняется, что для первой версии допустим лаг 10-15 минут, может быть до часа. В идеале нужно стремиться к мгновенному расчёту.
Правильный ответ:
1. Требования к latency статистики
Уровни задержки и их применимость:
Реал-тайм (0-1 секунда):
- Мониторинг здоровья эксперимента
- Алерты на аномалии
- Техническая отладка
Близкий к реал-тайму (1-5 минут):
- Оперативный мониторинг
- Быстрые решения об остановке
- Дашборды для аналитиков
Приемлемый лаг (10-15 минут):
- Стандартный анализ результатов
- Принятие решений о завершении
- Первая версия системы
Допустимый лаг (30-60 минут):
- Пакетная аналитика
- Отчёты и экспорт данных
- Историческая аналитика
2. Архитектура сбора и обработки событий
package events
import (
"context"
"time"
)
// Event событие эксперимента
type Event struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
EventType string `json:"event_type"` // "exposure", "conversion", "revenue"
EventValue float64 `json:"event_value"`
Platform string `json:"platform"`
Timestamp time.Time `json:"timestamp"`
Metadata map[string]interface{} `json:"metadata"`
}
// EventPipeline pipeline обработки событий
type EventPipeline struct {
// Горячий путь (реал-тайм)
hotPath HotPathProcessor
// Холодный путь (пакетная обработка)
coldPath ColdPathProcessor
// Буфер
buffer *EventBuffer
}
// HotPathProcessor обработка в реал-тайме
type HotPathProcessor interface {
Process(ctx context.Context, event *Event) error
}
// ColdPathProcessor пакетная обработка
type ColdPathProcessor interface {
ProcessBatch(ctx context.Context, events []*Event) error
}
// EventBuffer буфер событий
type EventBuffer struct {
events []*Event
mu sync.Mutex
maxSize int
flushInterval time.Duration
}
// Add добавляет событие в буфер
func (b *EventBuffer) Add(event *Event) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, event)
if len(b.events) >= b.maxSize {
b.flush()
}
}
// flush отправляет события на обработку
func (b *EventBuffer) flush() {
if len(b.events) == 0 {
return
}
// Отправляем в горячий путь
for _, event := range b.events {
go b.hotPath.Process(context.Background(), event)
}
// Отправляем в холодный путь (пакетно)
go b.coldPath.ProcessBatch(context.Background(), b.events)
// Очищаем буфер
b.events = b.events[:0]
}
3. Горячий путь: Kafka + Stream Processing
package hotpath
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
)
// KafkaEventConsumer потребитель событий из Kafka
type KafkaEventConsumer struct {
reader *kafka.Reader
stats *RealtimeStats
}
// RealtimeStats статистика в реал-тайме
type RealtimeStats struct {
// Счётчики по экспериментам
counters map[string]*ExperimentCounters
mu sync.RWMutex
}
type ExperimentCounters struct {
ExperimentID string
VariantCounters map[string]*VariantCounters
LastUpdated time.Time
}
type VariantCounters struct {
VariantID string
Exposures int64
Conversions int64
Revenue float64
LastUpdated time.Time
}
// Consume потребляет события из Kafka
func (c *KafkaEventConsumer) Consume(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
msg, err := c.reader.ReadMessage(ctx)
if err != nil {
return err
}
var event Event
if err := json.Unmarshal(msg.Value, &event); err != nil {
log.Printf("Failed to unmarshal event: %v", err)
continue
}
// Обновляем счётчики
c.stats.Update(&event)
}
}
// Update обновляет счётчики
func (s *RealtimeStats) Update(event *Event) {
s.mu.Lock()
defer s.mu.Unlock()
expCounters, exists := s.counters[event.ExperimentID]
if !exists {
expCounters = &ExperimentCounters{
ExperimentID: event.ExperimentID,
VariantCounters: make(map[string]*VariantCounters),
}
s.counters[event.ExperimentID] = expCounters
}
variantCounters, exists := expCounters.VariantCounters[event.VariantID]
if !exists {
variantCounters = &VariantCounters{
VariantID: event.VariantID,
}
expCounters.VariantCounters[event.VariantID] = variantCounters
}
switch event.EventType {
case "exposure":
variantCounters.Exposures++
case "conversion":
variantCounters.Conversions++
case "revenue":
variantCounters.Revenue += event.EventValue
}
variantCounters.LastUpdated = time.Now()
expCounters.LastUpdated = time.Now()
}
// GetStats возвращает статистику эксперимента
func (s *RealtimeStats) GetStats(experimentID string) (*ExperimentStats, error) {
s.mu.RLock()
defer s.mu.RUnlock()
expCounters, exists := s.counters[experimentID]
if !exists {
return nil, fmt.Errorf("experiment not found: %s", experimentID)
}
stats := &ExperimentStats{
ExperimentID: experimentID,
Variants: make(map[string]*VariantStats),
}
for variantID, counters := range expCounters.VariantCounters {
stats.Variants[variantID] = &VariantStats{
VariantID: variantID,
Exposures: counters.Exposures,
Conversions: counters.Conversions,
Revenue: counters.Revenue,
ConversionRate: float64(counters.Conversions) / float64(counters.Exposures),
}
}
return stats, nil
}
4. Холодный путь: Пакетная обработка
package coldpath
import (
"context"
"time"
)
// BatchProcessor пакетный процессор
type BatchProcessor struct {
db *sql.DB
clickhouse *sql.DB
interval time.Duration
}
// ProcessBatch обрабатывает пакет событий
func (p *BatchProcessor) ProcessBatch(ctx context.Context, events []*Event) error {
// Агрегируем события
aggregations := p.aggregate(events)
// Сохраняем в ClickHouse для аналитики
if err := p.saveToClickHouse(ctx, aggregations); err != nil {
return err
}
// Обновляем материализованные представления
if err := p.refreshMaterializedViews(ctx); err != nil {
return err
}
return nil
}
// aggregate агрегирует события
func (p *BatchProcessor) aggregate(events []*Event) []*Aggregation {
aggMap := make(map[string]*Aggregation)
for _, event := range events {
key := fmt.Sprintf("%s:%s:%s", event.ExperimentID, event.VariantID, event.EventType)
agg, exists := aggMap[key]
if !exists {
agg = &Aggregation{
ExperimentID: event.ExperimentID,
VariantID: event.VariantID,
EventType: event.EventType,
Period: time.Now().Truncate(time.Minute),
}
aggMap[key] = agg
}
agg.Count++
agg.Value += event.EventValue
}
result := make([]*Aggregation, 0, len(aggMap))
for _, agg := range aggMap {
result = append(result, agg)
}
return result
}
// RunLoop запускает пакетную обработку
func (p *BatchProcessor) RunLoop(ctx context.Context) {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := p.processAndRefresh(ctx); err != nil {
log.Printf("Batch processing error: %v", err)
}
}
}
}
5. Хранение статистики в ClickHouse
-- Таблица событий (сырые данные)
CREATE TABLE experiment_events (
event_id String,
user_id String,
experiment_id String,
variant_id String,
event_type Enum('exposure' = 1, 'conversion' = 2, 'revenue' = 3),
event_value Float64,
platform LowCardinality(String),
country LowCardinality(String),
timestamp DateTime,
created_at DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, event_type, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Агрегированная статистика (минутная гранулярность)
CREATE TABLE experiment_stats_minute (
experiment_id String,
variant_id String,
platform LowCardinality(String),
minute DateTime,
exposures UInt64,
conversions UInt64,
revenue Float64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (experiment_id, variant_id, platform, minute);
-- Материализованное представление для автоматической агрегации
CREATE MATERIALIZED VIEW experiment_stats_minute_mv
TO experiment_stats_minute
AS
SELECT
experiment_id,
variant_id,
platform,
toStartOfMinute(timestamp) as minute,
countIf(event_type = 'exposure') as exposures,
countIf(event_type = 'conversion') as conversions,
sumIf(event_value, event_type = 'revenue') as revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, platform, minute;
-- Представление для аналитиков (последние 15 минут)
CREATE VIEW experiment_stats_realtime AS
SELECT
experiment_id,
variant_id,
platform,
sum(exposures) as total_exposures,
sum(conversions) as total_conversions,
sum(revenue) as total_revenue,
sum(conversions) / sum(exposures) as conversion_rate,
max(minute) as last_updated
FROM experiment_stats_minute
WHERE minute >= now() - INTERVAL 15 MINUTE
GROUP BY experiment_id, variant_id, platform;
6. API для получения статистики
package api
// StatsHandler handler для статистики
type StatsHandler struct {
realtimeStats *hotpath.RealtimeStats
clickhouse *sql.DB
cache *ristretto.Cache
}
// GetExperimentStats возвращает статистику эксперимента
func (h *StatsHandler) GetExperimentStats(w http.ResponseWriter, r *http.Request) {
experimentID := r.URL.Query().Get("experiment_id")
ctx := r.Context()
// Пробуем получить из кэша
cacheKey := fmt.Sprintf("stats:%s", experimentID)
if cached, found := h.cache.Get(cacheKey); found {
json.NewEncoder(w).Encode(cached)
return
}
// Получаем статистику
stats, err := h.getStats(ctx, experimentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Кэшируем на 1 минуту
h.cache.SetWithTTL(cacheKey, stats, 1, time.Minute)
json.NewEncoder(w).Encode(stats)
}
// getStats получает статистику из разных источников
func (h *StatsHandler) getStats(ctx context.Context, experimentID string) (*ExperimentStatsResponse, error) {
// Получаем реал-тайм статистику
realtimeStats, err := h.realtimeStats.GetStats(experimentID)
if err != nil {
log.Printf("Failed to get realtime stats: %v", err)
}
// Получаем историческую статистику из ClickHouse
historicalStats, err := h.getHistoricalStats(ctx, experimentID)
if err != nil {
log.Printf("Failed to get historical stats: %v", err)
}
// Объединяем
return h.mergeStats(realtimeStats, historicalStats), nil
}
// getHistoricalStats получает историческую статистику из ClickHouse
func (h *StatsHandler) getHistoricalStats(ctx context.Context, experimentID string) (*HistoricalStats, error) {
query := `
SELECT
variant_id,
platform,
sum(exposures) as total_exposures,
sum(conversions) as total_conversions,
sum(revenue) as total_revenue
FROM experiment_stats_minute
WHERE experiment_id = ?
AND minute >= now() - INTERVAL 7 DAY
GROUP BY variant_id, platform
`
rows, err := h.clickhouse.QueryContext(ctx, query, experimentID)
if err != nil {
return nil, err
}
defer rows.Close()
// Обрабатываем результаты...
return nil, nil
}
7. Мониторинг задержки
package monitoring
// LatencyMonitor мониторинг задержки
type LatencyMonitor struct {
metrics *prometheus.HistogramVec
}
// RecordEventLatency фиксирует задержку обработки события
func (m *LatencyMonitor) RecordEventLatency(eventTime time.Time, processedTime time.Time) {
latency := processedTime.Sub(eventTime).Seconds()
m.metrics.WithLabelValues("event_processing").Observe(latency)
}
// RecordStatsLatency фиксирует задержку обновления статистики
func (m *LatencyMonitor) RecordStatsLatency(statsTime time.Time) {
latency := time.Since(statsTime).Seconds()
m.metrics.WithLabelValues("stats_update").Observe(latency)
}
// HealthCheck проверка здоровья системы
func (m *LatencyMonitor) HealthCheck() *HealthStatus {
return &HealthStatus{
EventProcessingLatency: m.getLatencyPercentile("event_processing", 0.99),
StatsUpdateLatency: m.getLatencyPercentile("stats_update", 0.99),
LastEventProcessed: m.getLastEventTime(),
LastStatsUpdate: m.getLastStatsUpdateTime(),
}
}
8. Итоговая архитектура
┌─────────────────────────────────────────────────────────────────┐
│ Клиент │
│ (отправляет события) │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Kafka │ │ Kafka │ │ Kafka │
│ Topic 1 │ │ Topic 2 │ │ Topic 3 │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
└───────────────┼───────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Hot Path │ │ Hot Path │ │ Cold Path │
│ (реал-тайм) │ │ (реал-тайм) │ │ (пакетная) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ In-Memory │ │ In-Memory │ │ ClickHouse │
│ Stats │ │ Stats │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────┼───────────────┘
│
▼
┌─────────────────┐
│ API для │
│ аналитиков │
└─────────────────┘
9. Сравнение подходов
| Подход | Задержка | Сложность | Стоимость | Когда использовать |
|---|---|---|---|---|
| Реал-тайм (Kafka + Flink) | 1-5 сек | Высокая | Высокая | Критичные эксперименты, алерты |
| Близкий к реал-тайму (Kafka + Consumer) | 1-5 мин | Средняя | Средняя | Оперативный мониторинг |
| Пакетная обработка (10-15 мин) | 10-15 мин | Низкая | Низкая | Стандартный анализ |
| Hourly batch | 30-60 мин | Низкая | Низкая | Отчёты, экспорт |
10. Рекомендации
Для первой версии (MVP):
- Пакетная обработка каждые 10-15 минут
- Простое хранение в PostgreSQL
- Базовые метрики: exposures, conversions, conversion_rate
Для продакшена:
- Горячий путь: Kafka + потоковая обработка
- Холодный путь: ClickHouse для аналитики
- Кэширование статистики с TTL 1-5 минут
- Мониторинг latency (p99 < 15 минут)
Для масштабирования:
- Шардирование Kafka по experiment_id
- Отдельные топики для разных типов событий
- Materialized Views в ClickHouse
- Предрасчитанные агредации
Задержка 10-15 минут для первой версии — это разумный компромисс между сложностью реализации и потребностями аналитиков. По мере роста системы можно добавлять реал-тайм компоненты.
Вопрос 17. Какие атрибуты эксперимента указывает аналитик при создании?
Таймкод: 00:32:21
Ответ собеседова: Правильный. Уточняется, что аналитик указывает наименование эксперимента, мета-информацию (дата, описание) и конфигурацию эксперимента в формате JSON, которая может содержать различные настройки в зависимости от типа эксперимента.
Правильный ответ:
Атрибуты эксперимента
Основная модель данных:
type Experiment struct {
// Идентификатор
ID string `json:"id"`
// Мета-информация
Name string `json:"name"`
Description string `json:"description"`
Hypothesis string `json:"hypothesis"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Параметры запуска
Status ExperimentStatus `json:"status"`
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
// Таргетинг
Segments []int64 `json:"segments"` // Целевые сегменты
Platforms []Platform `json:"platforms"` // Платформы
// Распределение трафика
TrafficPercent int `json:"traffic_percent"` // Общий процент трафика
// Варианты
Variants []Variant `json:"variants"`
// Конфигурация
Config ExperimentConfig `json:"config"`
// Метрики
PrimaryMetric string `json:"primary_metric"`
SecondaryMetrics []string `json:"secondary_metrics"`
GuardrailMetrics []string `json:"guardrail_metrics"`
}
type Variant struct {
ID string `json:"id"`
Name string `json:"name"`
TrafficPct int `json:"traffic_percent"` // Процент внутри эксперимента
Config map[string]interface{} `json:"config"` // Конфигурация варианта
Description string `json:"description"`
}
type ExperimentConfig struct {
// Тип эксперимента
Type ExperimentType `json:"type"`
// Настройки для конкретного типа
FeatureFlags map[string]bool `json:"feature_flags,omitempty"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
// Дополнительные настройы
Custom map[string]interface{} `json:"custom,omitempty"`
}
type ExperimentType string
const (
ExperimentTypeFeatureFlag ExperimentType = "feature_flag"
ExperimentTypeUIChange ExperimentType = "ui_change"
ExperimentTypeAlgorithm ExperimentType = "algorithm"
ExperimentTypePricing ExperimentType = "pricing"
ExperimentTypeCustom ExperimentType = "custom"
)
Запрос на создание эксперимента:
type CreateExperimentRequest struct {
// Обязательные поля
Name string `json:"name" validate:"required,min=3,max=255"`
Description string `json:"description" validate:"required"`
Hypothesis string `json:"hypothesis" validate:"required"`
Segments []int64 `json:"segments" validate:"required,min=1"`
Variants []Variant `json:"variants" validate:"required,min=2"`
PrimaryMetric string `json:"primary_metric" validate:"required"`
// Опциональные поля
Platforms []Platform `json:"platforms"`
TrafficPercent int `json:"traffic_percent" validate:"min=1,max=100"`
SecondaryMetrics []string `json:"secondary_metrics"`
GuardrailMetrics []string `json:"guardrail_metrics"`
Config ExperimentConfig `json:"config"`
// Планирование
ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
}
Валидация при создании:
type ExperimentValidator struct {
segmentService SegmentService
metricService MetricService
}
func (v *ExperimentValidator) Validate(req CreateExperimentRequest) error {
// 1. Валидация имени
if len(req.Name) < 3 {
return fmt.Errorf("name must be at least 3 characters")
}
// 2. Проверка сегментов
for _, segID := range req.Segments {
if !v.segmentService.Exists(segID) {
return fmt.Errorf("segment %d does not exist", segID)
}
}
// 3. Проверка вариантов
if len(req.Variants) < 2 {
return fmt.Errorf("at least 2 variants required")
}
totalTraffic := 0
for _, variant := range req.Variants {
totalTraffic += variant.TrafficPct
}
if totalTraffic != 100 {
return fmt.Errorf("variant traffic must sum to 100%%, got %d%%", totalTraffic)
}
// 4. Проверка метрики
if !v.metricService.IsValidMetric(req.PrimaryMetric) {
return fmt.Errorf("invalid primary metric: %s", req.PrimaryMetric)
}
// 5. Проверка дат
if req.ScheduledStart != nil && req.ScheduledEnd != nil {
if req.ScheduledEnd.Before(*req.ScheduledStart) {
return fmt.Errorf("end time must be after start time")
}
}
return nil
}
Сервис создания эксперимента:
type ExperimentService struct {
storage ExperimentStorage
validator *ExperimentValidator
audit AuditService
notifier Notifier
}
func (s *ExperimentService) CreateExperiment(ctx context.Context, req CreateExperimentRequest, analystID string) (*Experiment, error) {
// Валидация
if err := s.validator.Validate(req); err != nil {
return nil, err
}
// Создаем эксперимент
now := time.Now()
experiment := &Experiment{
ID: generateID(),
Name: req.Name,
Description: req.Description,
Hypothesis: req.Hypothesis,
Status: ExperimentStatusDraft,
CreatedBy: analystID,
CreatedAt: now,
UpdatedAt: now,
Segments: req.Segments,
Platforms: req.Platforms,
TrafficPercent: req.TrafficPercent,
Variants: req.Variants,
Config: req.Config,
PrimaryMetric: req.PrimaryMetric,
SecondaryMetrics: req.SecondaryMetrics,
GuardrailMetrics: req.GuardrailMetrics,
StartTime: req.ScheduledStart,
EndTime: req.ScheduledEnd,
}
// Устанавливаем дефолтные значения
if experiment.TrafficPercent == 0 {
experiment.TrafficPercent = 100
}
if len(experiment.Platforms) == 0 {
experiment.Platforms = []Platform{PlatformIOS, PlatformAndroid, PlatformWeb}
}
// Сохраняем
if err := s.storage.SaveExperiment(ctx, experiment); err != nil {
return nil, err
}
// Аудит
s.audit.Log(ctx, AuditLog{
UserID: analystID,
Action: AuditActionCreate,
ResourceID: experiment.ID,
ResourceType: "experiment",
Details: map[string]interface{}{
"name": experiment.Name,
"segments": experiment.Segments,
},
})
// Уведомляем
s.notifier.NotifyExperimentCreated(experiment)
return experiment, nil
}
API handler:
type ExperimentHandler struct {
service *ExperimentService
}
func (h *ExperimentHandler) CreateExperiment(w http.ResponseWriter, r *http.Request) {
var req CreateExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
analystID := r.Context().Value("user_id").(string)
experiment, err := h.service.CreateExperiment(r.Context(), req, analystID)
if err != nil {
switch {
case errors.Is(err, ErrInvalidRequest):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(experiment)
}
Примеры конфигураций для разных типов экспериментов:
// Feature Flag эксперимент
featureFlagConfig := ExperimentConfig{
Type: ExperimentTypeFeatureFlag,
FeatureFlags: map[string]bool{
"new_checkout_flow": true,
},
}
// UI Change эксперимент
uiChangeConfig := ExperimentConfig{
Type: ExperimentTypeUIChange,
Parameters: map[string]interface{}{
"button_color": "#FF5733",
"button_text": "Buy Now",
"placement": "top_right",
},
}
// Algorithm эксперимент
algorithmConfig := ExperimentConfig{
Type: ExperimentTypeAlgorithm,
Parameters: map[string]interface{}{
"algorithm_version": "v2",
"model_params": map[string]interface{}{
"learning_rate": 0.01,
"iterations": 1000,
},
},
}
// Pricing эксперимент
pricingConfig := ExperimentConfig{
Type: ExperimentTypePricing,
Parameters: map[string]interface{}{
"price": 99.99,
"currency": "USD",
"discount_pct": 10,
},
}
SQL схема хранения:
CREATE TABLE experiments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
hypothesis TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
created_by VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
start_time TIMESTAMP,
end_time TIMESTAMP,
traffic_percent INTEGER DEFAULT 100,
primary_metric VARCHAR(100) NOT NULL,
config JSONB,
-- Индексы
CONSTRAINT valid_traffic CHECK (traffic_percent BETWEEN 1 AND 100)
);
CREATE INDEX idx_experiments_status ON experiments(status);
CREATE INDEX idx_experiments_created_by ON experiments(created_by);
CREATE INDEX idx_experiments_start_time ON experiments(start_time);
-- Связь с сегментами
CREATE TABLE experiment_segments (
experiment_id UUID REFERENCES experiments(id) ON DELETE CASCADE,
segment_id BIGINT NOT NULL,
PRIMARY KEY (experiment_id, segment_id)
);
-- Варианты
CREATE TABLE experiment_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id UUID REFERENCES experiments(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
traffic_percent INTEGER NOT NULL,
config JSONB,
description TEXT
);
-- Метрики
CREATE TABLE experiment_metrics (
experiment_id UUID REFERENCES experiments(id) ON DELETE CASCADE,
metric_name VARCHAR(100) NOT NULL,
metric_type VARCHAR(50) NOT NULL, -- 'primary', 'secondary', 'guardrail'
PRIMARY KEY (experiment_id, metric_name, metric_type)
);
Минимальный пример создания эксперимента:
{
"name": "New checkout button color",
"description": "Testing if red button increases conversion",
"hypothesis": "Red button will increase conversion by 5%",
"segments": [123, 456],
"variants": [
{
"id": "control",
"name": "Blue button",
"traffic_percent": 50,
"config": {
"button_color": "#0066CC"
}
},
{
"id": "treatment",
"name": "Red button",
"traffic_percent": 50,
"config": {
"button_color": "#CC0000"
}
}
],
"primary_metric": "conversion_rate",
"secondary_metrics": ["revenue", "bounce_rate"],
"guardrail_metrics": ["page_load_time"],
"config": {
"type": "ui_change"
}
}
Обязательные атрибуты:
name— название экспериментаdescription— описаниеhypothesis— гипотезаsegments— целевые сегментыvariants— варианты (минимум 2)primary_metric— основная метрика
Опциональные атрибуты:
platforms— платформы (по умолчанию все)traffic_percent— процент трафика (по умолчанию 100%)secondary_metrics— вторичные метрикиguardrail_metrics— защитные метрикиconfig— дополнительная конфигурацияscheduled_start/scheduled_end— расписание
Вопрос 18. Как отличается отгрузка аналитики в мобильных и веб-приложениях?
Таймкод: 00:37:54
Ответ собеседова: Правильный. Уточняется, что мобильное приложение может накапливать данные в оффлайне и отправлять пачками (бандлами), а веб-приложение отправляет события по одному в реальном времени.
Правильный ответ:
Различия в отгрузке аналитики
Общая модель:
type EventBatch struct {
Events []AnalyticsEvent `json:"events"`
BatchID string `json:"batch_id"`
Timestamp time.Time `json:"timestamp"`
Platform Platform `json:"platform"`
UserID string `json:"user_id"`
}
Мобильные приложения (batch-отправка):
type MobileEventCollector struct {
db *sqlite.DB
batchSize int
maxQueueSize int
flushInterval time.Duration
}
func (c *MobileEventCollector) Initialize() error {
// Создаем локальную БД для хранения событий
return c.db.Exec(`
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
retry_count INTEGER DEFAULT 0
)
`).Error
}
func (c *MobileEventCollector) TrackEvent(event *AnalyticsEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
// Сохраняем в локальную БД
return c.db.Exec(
"INSERT INTO pending_events (event_data) VALUES (?)",
string(data),
).Error
}
func (c *MobileEventCollector) Flush() error {
// Получаем батч событий
var events []string
err := c.db.Raw(`
SELECT event_data FROM pending_events
ORDER BY created_at
LIMIT ?
`, c.batchSize).Scan(&events).Error
if err != nil || len(events) == 0 {
return err
}
// Отправляем батч
batch := &EventBatch{
Events: make([]AnalyticsEvent, 0, len(events)),
BatchID: generateID(),
Platform: PlatformMobile,
}
for _, data := range events {
var event AnalyticsEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
batch.Events = append(batch.Events, event)
}
// Отправляем
if err := c.sendBatch(batch); err != nil {
return err
}
// Удаляем отправленные
return c.db.Exec(`
DELETE FROM pending_events
WHERE id IN (
SELECT id FROM pending_events
ORDER BY created_at
LIMIT ?
)
`, len(events)).Error
}
func (c *MobileEventCollector) sendBatch(batch *EventBatch) error {
data, _ := json.Marshal(batch)
// Отправляем с retry логико
return retry.WithBackoff(func() error {
resp, err := c.httpClient.Post(
c.batchEndpoint,
"application/json",
bytes.NewReader(data),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return nil
}, retry.MaxRetries(3))
}
Веб-приложения (real-time отправка):
type WebEventSender struct {
endpoint string
httpClient *http.Client
}
func (s *WebEventSender) TrackEvent(event *AnalyticsEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
// Отправляем сразу
resp, err := s.httpClient.Post(
s.endpoint,
"application/json",
bytes.NewReader(data),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return nil
}
// Или через Beacon API (для надежности при закрытии страницы)
func (s *WebEventSender) TrackEventBeacon(event *AnalyticsEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
// Используем Beacon API для гарантированной доставки
// при закрытии страницы
success := s.jsNavigator.SendBeacon(s.endpoint, data)
if !success {
return fmt.Errorf("beacon failed")
}
return nil
}
Унифицированный серверный API:
type AnalyticsHandler struct {
kafka *kafka.Writer
validator *EventValidator
}
func (h *AnalyticsHandler) HandleSingleEvent(w http.ResponseWriter, r *http.Request) {
var event AnalyticsEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.validator.Validate(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Отправляем в Kafka
if err := h.sendToKafka(r.Context(), &event); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *AnalyticsHandler) HandleBatchEvents(w http.ResponseWriter, r *http.Request) {
var batch EventBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Валидируем каждое событие
validEvents := make([]AnalyticsEvent, 0, len(batch.Events))
for _, event := range batch.Events {
if err := h.validator.Validate(&event); err != nil {
continue
}
validEvents = append(validEvents, event)
}
// Отправляем батч
for _, event := range validEvents {
if err := h.sendToKafka(r.Context(), &event); err != nil {
log.Error().Err(err).Msg("Failed to send event")
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"received": len(batch.Events),
"accepted": len(validEvents),
})
}
func (h *AnalyticsHandler) sendToKafka(ctx context.Context, event *AnalyticsEvent) error {
data, _ := json.Marshal(event)
return h.kafka.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Topic: "analytics.events",
Headers: []kafka.Header{
{Key: "platform", Value: []byte(event.Platform)},
},
})
}
Обработка батчей на сервере:
type BatchProcessor struct {
kafkaReader *kafka.Reader
statsStore StatsStorage
}
func (p *BatchProcessor) ProcessBatches(ctx context.Context) error {
for {
msg, err := p.kafkaReader.ReadMessage(ctx)
if err != nil {
return err
}
var event AnalyticsEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
// Обогащаем экспериментальным контекстом
p.enrichWithExperiments(ctx, &event)
// Сохраняем для статистики
p.statsStore.SaveEvent(ctx, &event)
}
}
Сравнение подходов:
type PlatformStrategy struct {
platform Platform
}
func (s *PlatformStrategy) GetBatchingConfig() BatchingConfig {
switch s.platform {
case PlatformIOS, PlatformAndroid:
return BatchingConfig{
Enabled: true,
BatchSize: 50,
FlushInterval: 30 * time.Second,
MaxQueueSize: 1000,
UseLocalDB: true,
}
case PlatformWeb:
return BatchingConfig{
Enabled: false,
BatchSize: 1,
FlushInterval: 0,
MaxQueueSize: 0,
UseLocalDB: false,
}
default:
return DefaultBatchingConfig()
}
}
Retry логика для мобильных:
type RetryManager struct {
db *sqlite.DB
maxRetries int
}
func (r *RetryManager) ScheduleRetry(eventID int64) error {
return r.db.Exec(`
UPDATE pending_events
SET retry_count = retry_count + 1,
next_retry_at = datetime('now', '+' || (retry_count + 1) || ' minutes')
WHERE id = ? AND retry_count < ?
`, eventID, r.maxRetries).Error
}
func (r *RetryManager) GetRetryableEvents() ([]string, error) {
var events []string
err := r.db.Raw(`
SELECT event_data FROM pending_events
WHERE next_retry_at <= datetime('now')
AND retry_count < ?
ORDER BY created_at
LIMIT 10
`, r.maxRetries).Scan(&events).Error
return events, err
}
Мониторинг доставки:
type DeliveryMetrics struct {
eventsSent *prometheus.CounterVec
eventsFailed *prometheus.CounterVec
batchLatency *prometheus.HistogramVec
queueSize *prometheus.GaugeVec
}
func (m *DeliveryMetrics) RecordSend(platform Platform, success bool, count int) {
if success {
m.eventsSent.WithLabelValues(string(platform)).Add(float64(count))
} else {
m.eventsFailed.WithLabelValues(string(platform)).Add(float64(count))
}
}
func (m *DeliveryMetrics) RecordQueueSize(platform Platform, size int) {
m.queueSize.WithLabelValues(string(platform)).Set(float64(size))
}
Ключевые различия:
| Аспект | Мобильные | Веб |
|---|---|---|
| Отправка | Батчами (50-100 событий) | По одному |
| Хранение | Локальная SQLite | Нет |
| Оффлайн | Поддерживается | Нет |
| Retry | Автоматический | Ручной |
| Beacon API | Нет | Да |
| Интервал отправки | 30-60 секунд | Мгновенно |
Рекомендации:
Для мобильных:
- Использовать локальную БД для надежности
- Отправлять при подключении к сети
- Учитывать заряд батареи (не отправлять при низком уровне)
- Сжимать данные перед отправкой
Для веба:
- Использовать Beacon API для событий при закрытии страницы
- Отправлять сразу для минимизации потерь
- Использовать keep-alive соединения
Вопрос 10. Можно ли запускать эксперимент с отложенным действием?
Таймкод: 00:18:53
Ответ собеседника: Правильный. Уточняется, что для простоты можно считать, что эксперимент запускается сразу после создания. В реальных системах бывают очереди на запуск, но это выходит за рамки текущего проектирования.
Правильный ответ:
1. Упрощённая модель: немедленный запуск
Для первой версии системы принимаем упрощение:
- Эксперимент начинается сразу после нажатия кнопки "Запуск"
- Нет очередей на запуск
- Нет отложенных запусков
- Нет планирования по расписанию
Упрощённый жизненный цикл:
[Draft] → [Running] → [Completed/Stopped]
↑
Мгновенный переход
после нажатия "Start"
2. Реализация немедленного запуска
package experiment
// ExperimentService сервис управления экспериментами
type ExperimentService struct {
store ExperimentStorage
notifier Notifier
validator *ExperimentValidator
}
// StartExperiment запускает эксперимент
func (s *ExperimentService) StartExperiment(ctx context.Context, experimentID string, startedBy string) error {
exp, err := s.store.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// Проверяем, что эксперимент в статусе Draft
if exp.Status != StatusDraft {
return fmt.Errorf("experiment must be in draft status, current status: %s", exp.Status)
}
// Валидируем эксперимент перед запуском
if err := s.validator.ValidateForStart(exp); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Запускаем немедленно
now := time.Now()
exp.Status = StatusRunning
exp.StartedAt = &now
exp.UpdatedBy = startedBy
exp.UpdatedAt = now
// Сохраняем
if err := s.store.UpdateExperiment(ctx, exp); err != nil {
return err
}
// Уведомляем
if err := s.notifier.NotifyExperimentStarted(ctx, exp); err != nil {
log.Printf("Failed to notify about experiment start: %v", err)
}
return nil
}
// ValidateForStart валидация перед запуском
func (v *ExperimentValidator) ValidateForStart(exp *Experiment) error {
// Проверяем наличие вариантов
if len(exp.Variants) < 2 {
return fmt.Errorf("experiment must have at least 2 variants")
}
// Проверяем сумму весов вариантов
totalWeight := 0.0
for _, v := range exp.Variants {
totalWeight += v.Weight
}
if math.Abs(totalWeight-1.0) > 0.001 {
return fmt.Errorf("variant weights must sum to 1.0, got %f", totalWeight)
}
// Проверяем процент трафика
if exp.TrafficPct <= 0 || exp.TrafficPct > 1.0 {
return fmt.Errorf("traffic percentage must be between 0 and 1")
}
// Проверяем минимальный размер выборки
if exp.MinSampleSize <= 0 {
return fmt.Errorf("minimum sample size must be positive")
}
return nil
}
3. Расширенная модель: отложенный запуск (для справки)
Хотя в текущем проектировании отложенный запуск не требуется, полезно понимать, как это работает в реальных системах:
// ScheduledExperiment эксперимент с отложенным запуском
type ScheduledExperiment struct {
Experiment
ScheduledAt *time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at"`
}
// ScheduleExperiment планирует запуск эксперимента
func (s *ExperimentService) ScheduleExperiment(ctx context.Context, experimentID string, scheduledAt time.Time, scheduledBy string) error {
exp, err := s.store.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
if exp.Status != StatusDraft {
return fmt.Errorf("experiment must be in draft status")
}
if scheduledAt.Before(time.Now()) {
return fmt.Errorf("scheduled time must be in the future")
}
// Переводим в статус "запланирован"
exp.Status = StatusScheduled
exp.ScheduledAt = &scheduledAt
exp.UpdatedBy = scheduledBy
exp.UpdatedAt = time.Now()
return s.store.UpdateExperiment(ctx, exp)
}
// Scheduler планировщик запуска экспериментов
type Scheduler struct {
store ExperimentStorage
executor *Executor
interval time.Duration
}
// Run запускает планировщик
func (s *Scheduler) Run(ctx context.Context) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.checkScheduledExperiments(ctx)
}
}
}
// checkScheduledExperiments проверяет запланированные эксперименты
func (s *Scheduler) checkScheduledExperiments(ctx context.Context) {
// Получаем эксперименты, которые нужно запустить
experiments, err := s.store.GetScheduledToStart(ctx, time.Now())
if err != nil {
log.Printf("Failed to get scheduled experiments: %v", err)
return
}
for _, exp := range experiments {
if err := s.executor.StartExperiment(ctx, exp.ID, "scheduler"); err != nil {
log.Printf("Failed to start scheduled experiment %s: %v", exp.ID, err)
}
}
}
4. Статусы эксперимента
// ExperimentStatus статус эксперимента
type ExperimentStatus string
const (
// Для упрощённой модели
StatusDraft ExperimentStatus = "draft"
StatusRunning ExperimentStatus = "running"
StatusPaused ExperimentStatus = "paused"
StatusCompleted ExperimentStatus = "completed"
StatusStopped ExperimentStatus = "stopped"
// Для расширенной модели (не используется в MVP)
StatusScheduled ExperimentStatus = "scheduled"
StatusApproving ExperimentStatus = "approving"
)
// CanTransition проверяет возможность перехода между статусами
func CanTransition(from, to ExperimentStatus) bool {
transitions := map[ExperimentStatus][]ExperimentStatus{
StatusDraft: {StatusRunning, StatusScheduled},
StatusScheduled: {StatusRunning, StatusDraft},
StatusRunning: {StatusPaused, StatusCompleted, StatusStopped},
StatusPaused: {StatusRunning, StatusStopped},
StatusCompleted: {}, // финальный статус
StatusStopped: {}, // финальный статус
}
allowed, exists := transitions[from]
if !exists {
return false
}
for _, status := range allowed {
if status == to {
return true
}
}
return false
}
5. API для запуска эксперимента
package api
// StartExperimentRequest запрос на запуск эксперимента
type StartExperimentRequest struct {
ExperimentID string `json:"experiment_id"`
UserID string `json:"user_id"`
}
// StartExperimentResponse ответ на запуск эксперимента
type StartExperimentResponse struct {
ExperimentID string `json:"experiment_id"`
Status ExperimentStatus `json:"status"`
StartedAt time.Time `json:"started_at"`
StartedBy string `json:"started_by"`
}
// StartExperimentHandler handler для запуска эксперимента
func (h *Handler) StartExperimentHandler(w http.ResponseWriter, r *http.Request) {
var req StartExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
// Проверяем права доступа
if !h.rbacService.HasPermission(req.UserID, rbac.PermissionExperimentStart) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Запускаем эксперимент
if err := h.experimentService.StartExperiment(ctx, req.ExperimentID, req.UserID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Получаем обновлённый эксперимент
exp, err := h.experimentService.GetExperiment(ctx, req.ExperimentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := StartExperimentResponse{
ExperimentID: exp.ID,
Status: exp.Status,
StartedAt: *exp.StartedAt,
StartedBy: req.UserID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
6. Схема базы данных
-- Таблица экспериментов (упрощённая версия)
CREATE TABLE experiments (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'draft',
variants JSONB NOT NULL,
target_segments VARCHAR(255)[],
target_platforms VARCHAR(100)[],
traffic_percentage FLOAT DEFAULT 1.0,
min_sample_size INTEGER DEFAULT 1000,
significance_level FLOAT DEFAULT 0.05,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP, -- NULL до запуска
ended_at TIMESTAMP,
created_by VARCHAR(255) NOT NULL,
updated_by VARCHAR(255),
started_by VARCHAR(255)
);
-- Индексы
CREATE INDEX idx_experiments_status ON experiments(status);
CREATE INDEX idx_experiments_created_by ON experiments(created_by);
CREATE INDEX idx_experiments_started_at ON experiments(started_at);
7. Логика перехода статусов
// StatusTransition переход статуса
type StatusTransition struct {
FromStatus ExperimentStatus
ToStatus ExperimentStatus
Action string
PerformedBy string
PerformedAt time.Time
Reason string
}
// TransitionService сервис переходов статусов
type TransitionService struct {
store TransitionStorage
validator *TransitionValidator
}
// Transition выполняет переход статуса
func (s *TransitionService) Transition(ctx context.Context, experimentID string, toStatus ExperimentStatus, performedBy, reason string) error {
exp, err := s.store.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// Проверяем возможность перехода
if !CanTransition(exp.Status, toStatus) {
return fmt.Errorf("cannot transition from %s to %s", exp.Status, toStatus)
}
// Выполняем переход
fromStatus := exp.Status
exp.Status = toStatus
exp.UpdatedAt = time.Now()
exp.UpdatedBy = performedBy
// Обрабатываем специфику перехода
if toStatus == StatusRunning && exp.StartedAt == nil {
now := time.Now()
exp.StartedAt = &now
exp.StartedBy = performedBy
}
if toStatus == StatusCompleted || toStatus == StatusStopped {
now := time.Now()
exp.EndedAt = &now
}
// Сохраняем
if err := s.store.UpdateExperiment(ctx, exp); err != nil {
return err
}
// Логируем переход
transition := &StatusTransition{
FromStatus: fromStatus,
ToStatus: toStatus,
PerformedBy: performedBy,
PerformedAt: time.Now(),
Reason: reason,
}
return s.store.SaveTransition(ctx, experimentID, transition)
}
8. Итоговая диаграмма переходов
┌──────────┐
│ Draft │
└────┬─────┘
│
Start (немедленно)
│
▼
┌──────────┐
┌────────│ Running │────────┐
│ └────┬─────┘ │
│ │ │
Pause Stop Complete
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Paused │ │ Stopped │ │Completed │
└────┬─────┘ └──────────┘ └──────────┘
│
Resume
│
▼
┌──────────┐
│ Running │
└──────────┘
9. Рекомендации
Для MVP (первая версия):
✅ Немедленный запуск при нажатии "Start"
✅ Простые статусы: Draft → Running → Completed/Stopped
✅ Возможность паузы и возобновления
Не нужно в MVP:
❌ Отложенный запуск по расписанию
❌ Очередь на запуск
❌ Автоматический запуск по условиям
❌ Массовый запуск экспериментов
Для будущих версий:
📋 Планировщик запусков
📋 Очередь с приоритетами
📋 Автоматический запуск по достижению условий
📋 Интеграция с календарём маркетинга
10. Вывод
Для текущего проектирования принимаем упрощение: эксперимент запускается немедленно. Это позволяет:
- Упростить архитектуру
- Уменьшить количество статусов
- Избежать необходимости планировщика
- Сократить время до запуска MVP
Отложенный запуск — это улучшение, которое можно добавить в будущих версиях, когда базовая функциональность будет работать.
Вопрос 19. Как разделить трафик между аналитиками и мобильными клиентами при проектировании API?
Таймкод: 00:41:21
Ответ собеседова: Правильный. Предлагается разделить API на приватную (для аналитиков - заведение экспериментов, просмотр статистики) и публичную (для клиентских приложений - получение список экспериментов). Это делается для безопасности и разного масштабирования трафика.
Правильный ответ:
Разделение API на приватный и публичный
Архитектура API Gateway:
type APIType string
const (
APITypePublic APIType = "public" // Для клиентских приложений
APITypePrivate APIType = "private" // Для аналитиков
)
type APIGateway struct {
publicHandler *PublicAPIHandler
privateHandler *PrivateAPIHandler
rateLimiter *RateLimiter
authMiddleware *AuthMiddleware
}
func (g *APIGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Определяем тип API
apiType := g.detectAPIType(r)
// Применяем соответствующий rate limiter
if !g.rateLimiter.Allow(r, apiType) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// Маршрутизируем
switch apiType {
case APITypePublic:
g.publicHandler.ServeHTTP(w, r)
case APITypePrivate:
g.privateHandler.ServeHTTP(w, r)
default:
http.Error(w, "Unknown API type", http.StatusBadRequest)
}
}
func (g *APIGateway) detectAPIType(r *http.Request) APIType {
// По пути URL
if strings.HasPrefix(r.URL.Path, "/api/v1/public/") {
return APITypePublic
}
if strings.HasPrefix(r.URL.Path, "/api/v1/private/") {
return APITypePrivate
}
// По заголовку
if r.Header.Get("X-API-Type") == "public" {
return APITypePublic
}
return APITypePrivate
}
Публичное API (для клиентов):
type PublicAPIHandler struct {
experimentService *ExperimentService
analyticsService *AnalyticsService
}
func (h *PublicAPIHandler) RegisterRoutes(mux *http.ServeMux) {
// Получение экспериментов для пользователя
mux.HandleFunc("/api/v1/public/experiments", h.GetUserExperiments)
// Получение варианта эксперимента
mux.HandleFunc("/api/v1/public/experiments/variant", h.GetVariant)
// Отправка событий (batch)
mux.HandleFunc("/api/v1/public/events/batch", h.SendBatchEvents)
// Отправка событий (single)
mux.HandleFunc("/api/v1/public/events", h.SendEvent)
}
func (h *PublicAPIHandler) GetUserExperiments(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
platform := Platform(r.Header.Get("X-Platform"))
experiments, err := h.experimentService.GetUserExperiments(r.Context(), userID, platform)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Кэширование на клиенте
w.Header().Set("Cache-Control", "private, max-age=300")
json.NewEncoder(w).Encode(experiments)
}
func (h *PublicAPIHandler) SendBatchEvents(w http.ResponseWriter, r *http.Request) {
var batch EventBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Отправляем в очередь
if err := h.analyticsService.SendBatch(r.Context(), &batch); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
Приватное API (для аналитиков):
type PrivateAPIHandler struct {
experimentService *ExperimentService
statsService *StatsService
segmentService *SegmentService
authMiddleware *AuthMiddleware
}
func (h *PrivateAPIHandler) RegisterRoutes(mux *http.ServeMux) {
// Управление экспериментами
mux.HandleFunc("/api/v1/private/experiments", h.authMiddleware.RequireAuth(h.HandleExperiments))
mux.HandleFunc("/api/v1/private/experiments/", h.authMiddleware.RequireAuth(h.HandleExperiment))
// Статистика
mux.HandleFunc("/api/v1/private/stats/", h.authMiddleware.RequireAuth(h.GetStats))
// Управление сегментами
mux.HandleFunc("/api/v1/private/segments", h.authMiddleware.RequireAuth(h.HandleSegments))
// Отчеты
mux.HandleFunc("/api/v1/private/reports/", h.authMiddleware.RequireAuth(h.GetReport))
}
func (h *PrivateAPIHandler) HandleExperiments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.ListExperiments(w, r)
case http.MethodPost:
h.CreateExperiment(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *PrivateAPIHandler) CreateExperiment(w http.ResponseWriter, r *http.Request) {
var req CreateExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
analystID := r.Context().Value("user_id").(string)
experiment, err := h.experimentService.CreateExperiment(r.Context(), req, analystID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(experiment)
}
Rate Limiting:
type RateLimiter struct {
publicLimiter *rate.Limiter
privateLimiter *rate.Limiter
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
// Публичный: 10000 RPS (много клиентов)
publicLimiter: rate.NewLimiter(rate.Limit(10000), 20000),
// Приватный: 100 RPS (мало аналитиков)
privateLimiter: rate.NewLimiter(rate.Limit(100), 200),
}
}
func (rl *RateLimiter) Allow(r *http.Request, apiType APIType) bool {
switch apiType {
case APITypePublic:
return rl.publicLimiter.Allow()
case APITypePrivate:
return rl.privateLimiter.Allow()
default:
return false
}
}
// Детальный rate limiting по пользователям
type UserRateLimiter struct {
redis *redis.Client
}
func (rl *UserRateLimiter) Allow(ctx context.Context, userID string, apiType APIType) bool {
var limit int
var window time.Duration
switch apiType {
case APITypePublic:
limit = 100 // 100 запросов
window = time.Minute // в минуту
case APITypePrivate:
limit = 1000 // 1000 запросов
window = time.Minute // в минуту
}
key := fmt.Sprintf("rate_limit:%s:%s", apiType, userID)
current, err := rl.redis.Incr(ctx, key).Result()
if err != nil {
return false
}
if current == 1 {
rl.redis.Expire(ctx, key, window)
}
return int(current) <= limit
}
Middleware для аутентификации:
type AuthMiddleware struct {
tokenValidator *TokenValidator
}
func (m *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Missing authorization", http.StatusUnauthorized)
return
}
claims, err := m.tokenValidator.Validate(token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Добавляем информацию о пользователе в контекст
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "role", claims.Role)
next(w, r.WithContext(ctx))
}
}
Маршрутизация через разные домены:
type RouterConfig struct {
PublicDomain string // experiments.company.com
PrivateDomain string // experiments-api.company.com
}
func SetupRouter(config RouterConfig) http.Handler {
publicMux := http.NewServeMux()
privateMux := http.NewServeMux()
// Публичные маршруты
publicHandler := &PublicAPIHandler{}
publicHandler.RegisterRoutes(publicMux)
// Приватные маршруты
privateHandler := &PrivateAPIHandler{}
privateHandler.RegisterRoutes(privateMux)
// Главный handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Host {
case config.PublicDomain:
publicMux.ServeHTTP(w, r)
case config.PrivateDomain:
privateMux.ServeHTTP(w, r)
default:
http.Error(w, "Unknown host", http.StatusNotFound)
}
})
}
Разные сервисы для масштабирования:
type ServiceConfig struct {
PublicService ServiceConfig
PrivateService ServiceConfig
}
type ServiceConfig struct {
Replicas int
CPU string
Memory string
MaxRequests int
}
func GetServiceConfig() ServiceConfig {
return ServiceConfig{
PublicService: ServiceConfig{
Replicas: 20, // Много реплик для публичного
CPU: "2",
Memory: "4Gi",
MaxRequests: 1000,
},
PrivateService: ServiceConfig{
Replicas: 3, // Мало реплик для приватного
CPU: "1",
Memory: "2Gi",
MaxRequests: 100,
},
}
}
Мониторинг разделения:
type APIMetrics struct {
publicRequests prometheus.Counter
privateRequests prometheus.Counter
publicLatency prometheus.Histogram
privateLatency prometheus.Histogram
}
func (m *APIMetrics) RecordRequest(apiType APIType, duration time.Duration) {
switch apiType {
case APITypePublic:
m.publicRequests.Inc()
m.publicLatency.Observe(duration.Seconds())
case APITypePrivate:
m.privateRequests.Inc()
m.privateLatency.Observe(duration.Seconds())
}
}
Схема разделения:
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Public API │ │ Private API │
│ (20 replicas) │ │ (3 replicas) │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Rate Limiter │ │ Auth + Rate │
│ (10K RPS) │ │ (100 RPS) │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Experiment │ │ Experiment │
│ Service │ │ Service │
└─────────────────┘ └─────────────────┘
Вывод:
Разделение API на публичный и приватный позволяет:
- Безопасность: Приватное API требует аутентификации
- Масштабирование: Разное количество реплик под разную нагрузку
- Rate Limiting: Разные лимиты для разных клиентов
- Мониторинг: Раздельная статистика
- SLA: Гарантии доступности для публичного API выше
Вопрос 20. Какие сервисы нужны для хранения и обработки экспериментов?
Таймкод: 00:42:36
Ответ собеседова: Правильный. Предлагается создать сервис экспериментов (хранение и управление экспериментами для аналитиков) и отдельный компонент для отдачи экспериментов клиентским приложениям, так как у них разный трафик и требования.
Правильный ответ:
Архитектура сервисов
Общая схема:
┌─────────────────────────────────────────────────────────────────┐
│ API Gateway │
└─────────────────────────────┬───────────────────────────────────┘
│
┌────────────────────┴────────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Admin Service │ │ Client Service │
│ (для аналитиков)│ │ (для клиентов) │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Experiment │ │ Experiment │
│ Storage │ │ Cache │
└────────┬────────┘ └────────┬────────┘
│ │
└────────────────┬──────────────────────┘
│
▼
┌─────────────────────┐
│ PostgreSQL │
│ (основное хранилище)│
└─────────────────────┘
Admin Service (для аналитиков):
type AdminService struct {
storage ExperimentStorage
validator *ExperimentValidator
auditService AuditService
notifier Notifier
}
func (s *AdminService) CreateExperiment(ctx context.Context, req CreateExperimentRequest, analystID string) (*Experiment, error) {
// Валидация
if err := s.validator.Validate(req); err != nil {
return nil, err
}
// Создание
experiment := &Experiment{
ID: generateID(),
Name: req.Name,
Description: req.Description,
Status: ExperimentStatusDraft,
CreatedBy: analystID,
Segments: req.Segments,
Variants: req.Variants,
PrimaryMetric: req.PrimaryMetric,
Config: req.Config,
}
// Сохранение
if err := s.storage.SaveExperiment(ctx, experiment); err != nil {
return nil, err
}
// Аудит
s.auditService.Log(ctx, AuditLog{
UserID: analystID,
Action: AuditActionCreate,
ResourceID: experiment.ID,
})
return experiment, nil
}
func (s *AdminService) UpdateExperiment(ctx context.Context, experimentID string, req UpdateExperimentRequest, analystID string) (*Experiment, error) {
exp, err := s.storage.GetExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
// Проверяем права
if !s.canModify(analystID, exp) {
return nil, ErrForbidden
}
// Обновляем поля
if req.Name != nil {
exp.Name = *req.Name
}
if req.Status != nil {
exp.Status = *req.Status
}
// ... другие поля
exp.UpdatedAt = time.Now()
if err := s.storage.UpdateExperiment(ctx, exp); err != nil {
return nil, err
}
// Инвалидируем кэш
s.notifier.NotifyExperimentChanged(experimentID)
return exp, nil
}
func (s *AdminService) GetStats(ctx context.Context, experimentID string, analystID string) (*ExperimentStats, error) {
// Проверяем доступ
if !s.hasAccess(analystID, experimentID) {
return nil, ErrForbidden
}
return s.storage.GetStats(ctx, experimentID)
}
Client Service (для клиентских приложений):
type ClientService struct {
cache *ExperimentCache
index *ExperimentIndex
segmentSvc SegmentService
}
func (s *ClientService) GetUserExperiments(ctx context.Context, userID string, platform Platform) (*UserExperimentsResponse, error) {
// Пробуем из кэша
if cached := s.cache.GetUserExperiments(userID, platform); cached != nil {
return cached, nil
}
// Получаем сегменты пользователя
segments, err := s.segmentSvc.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Находим активные эксперименты
experiments := s.index.GetActiveExperiments(segments, platform)
// Фильтруем и определяем варианты
result := &UserExperimentsResponse{
Experiments: make([]UserExperiment, 0, len(experiments)),
}
for _, exp := range experiments {
if !s.isUserInExperiment(userID, exp) {
continue
}
variant := s.determineVariant(userID, exp)
result.Experiments = append(result.Experiments, UserExperiment{
ExperimentID: exp.ID,
VariantID: variant.ID,
Config: variant.Config,
})
}
// Кэшируем
s.cache.SetUserExperiments(userID, platform, result)
return result, nil
}
func (s *ClientService) isUserInExperiment(userID string, exp *Experiment) bool {
bucket := getBucket(userID, exp.ID, 100)
return bucket < exp.TrafficPercent
}
func (s *ClientService) determineVariant(userID string, exp *Experiment) *Variant {
bucket := getBucket(userID, exp.ID, 100)
cumulative := 0
for _, variant := range exp.Variants {
cumulative += variant.TrafficPct
if bucket < cumulative {
return &variant
}
}
return &exp.Variants[0]
}
Storage слой:
type ExperimentStorage interface {
// CRUD операции
SaveExperiment(ctx context.Context, exp *Experiment) error
GetExperiment(ctx context.Context, id string) (*Experiment, error)
UpdateExperiment(ctx context.Context, exp *Experiment) error
DeleteExperiment(ctx context.Context, id string) error
// Поиск
ListExperiments(ctx context.Context, filter ExperimentFilter) ([]*Experiment, error)
GetActiveExperiments(ctx context.Context) ([]*Experiment, error)
// Статистика
SaveStats(ctx context.Context, stats *ExperimentStats) error
GetStats(ctx context.Context, experimentID string) (*ExperimentStats, error)
}
type PostgresStorage struct {
db *sql.DB
}
func (s *PostgresStorage) SaveExperiment(ctx context.Context, exp *Experiment) error {
query := `
INSERT INTO experiments (id, name, description, status, created_by, segments, variants, config)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
segmentsJSON, _ := json.Marshal(exp.Segments)
variantsJSON, _ := json.Marshal(exp.Variants)
configJSON, _ := json.Marshal(exp.Config)
_, err := s.db.ExecContext(ctx, query,
exp.ID, exp.Name, exp.Description, exp.Status,
exp.CreatedBy, segmentsJSON, variantsJSON, configJSON,
)
return err
}
func (s *PostgresStorage) GetExperiment(ctx context.Context, id string) (*Experiment, error) {
query := `
SELECT id, name, description, status, created_by, segments, variants, config, created_at, updated_at
FROM experiments
WHERE id = $1
`
var exp Experiment
var segmentsJSON, variantsJSON, configJSON []byte
err := s.db.QueryRowContext(ctx, query, id).Scan(
&exp.ID, &exp.Name, &exp.Description, &exp.Status,
&exp.CreatedBy, &segmentsJSON, &variantsJSON, &configJSON,
&exp.CreatedAt, &exp.UpdatedAt,
)
if err != nil {
return nil, err
}
json.Unmarshal(segmentsJSON, &exp.Segments)
json.Unmarshal(variantsJSON, &exp.Variants)
json.Unmarshal(configJSON, &exp.Config)
return &exp, nil
}
Cache слой:
type ExperimentCache struct {
redis *redis.Client
local *lru.Cache
}
func (c *ExperimentCache) GetUserExperiments(userID string, platform Platform) *UserExperimentsResponse {
// L1: Локальный кэш
key := fmt.Sprintf("%s:%s", userID, platform)
if val, ok := c.local.Get(key); ok {
return val.(*UserExperimentsResponse)
}
// L2: Redis
data, err := c.redis.Get(ctx, fmt.Sprintf("user_experiments:%s", key)).Result()
if err == nil {
var resp UserExperimentsResponse
if json.Unmarshal([]byte(data), &resp) == nil {
c.local.Add(key, &resp)
return &resp
}
}
return nil
}
func (c *ExperimentCache) SetUserExperiments(userID string, platform Platform, resp *UserExperimentsResponse) {
key := fmt.Sprintf("%s:%s", userID, platform)
// L1
c.local.Add(key, resp)
// L2
data, _ := json.Marshal(resp)
c.redis.Set(ctx, fmt.Sprintf("user_experiments:%s", key), data, 5*time.Minute)
}
func (c *ExperimentCache) InvalidateExperiment(experimentID string) {
// Инвалидируем все кэши, связанные с экспериментом
c.local.Purge()
// В Redis используем pub/sub для инвалидации
c.redis.Publish(ctx, "experiment_invalidation", experimentID)
}
Index для быстрого поиска:
type ExperimentIndex struct {
bySegment map[int64]map[string]*Experiment
byPlatform map[Platform]map[string]*Experiment
active map[string]*Experiment
mu sync.RWMutex
}
func (idx *ExperimentIndex) Build(ctx context.Context, storage ExperimentStorage) error {
experiments, err := storage.GetActiveExperiments(ctx)
if err != nil {
return err
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.bySegment = make(map[int64]map[string]*Experiment)
idx.byPlatform = make(map[Platform]map[string]*Experiment)
idx.active = make(map[string]*Experiment)
for _, exp := range experiments {
idx.active[exp.ID] = exp
// Индекс по сегментам
for _, segID := range exp.Segments {
if _, ok := idx.bySegment[segID]; !ok {
idx.bySegment[segID] = make(map[string]*Experiment)
}
idx.bySegment[segID][exp.ID] = exp
}
// Индекс по платформам
for _, platform := range exp.Platforms {
if _, ok := idx.byPlatform[platform]; !ok {
idx.byPlatform[platform] = make(map[string]*Experiment)
}
idx.byPlatform[platform][exp.ID] = exp
}
}
return nil
}
func (idx *ExperimentIndex) GetActiveExperiments(segments []int64, platform Platform) []*Experiment {
idx.mu.RLock()
defer idx.mu.RUnlock()
candidates := make(map[string]*Experiment)
// По сегментам
for _, segID := range segments {
if exps, ok := idx.bySegment[segID]; ok {
for id, exp := range exps {
candidates[id] = exp
}
}
}
// По платформе
if exps, ok := idx.byPlatform[platform]; ok {
for id, exp := range exps {
candidates[id] = exp
}
}
result := make([]*Experiment, 0, len(candidates))
for _, exp := range candidates {
if exp.IsCurrentlyActive() {
result = append(result, exp)
}
}
return result
}
Segment Service:
type SegmentService struct {
storage SegmentStorage
cache *SegmentCache
}
func (s *SegmentService) GetUserSegments(ctx context.Context, userID string) ([]int64, error) {
// Пробуем из кэша
if cached := s.cache.GetUserSegments(userID); cached != nil {
return cached, nil
}
// Загружаем из хранилища
segments, err := s.storage.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Кэшируем
s.cache.SetUserSegments(userID, segments)
return segments, nil
}
Stats Service:
type StatsService struct {
clickhouse *clickhouse.Conn
redis *redis.Client
}
func (s *StatsService) GetStats(ctx context.Context, experimentID string) (*ExperimentStats, error) {
query := `
SELECT
variant_id,
sum(events) as events,
sum(unique_users) as unique_users,
sum(conversions) as conversions,
sum(revenue) as revenue
FROM experiment_daily_stats
WHERE experiment_id = ?
GROUP BY variant_id
`
rows, err := s.clickhouse.Query(ctx, query, experimentID)
if err != nil {
return nil, err
}
defer rows.Close()
stats := &ExperimentStats{
ExperimentID: experimentID,
Variants: make(map[string]VariantStats),
}
for rows.Next() {
var vs VariantStats
if err := rows.Scan(&vs.VariantID, &vs.Events, &vs.UniqueUsers, &vs.Conversions, &vs.Revenue); err != nil {
continue
}
stats.Variants[vs.VariantID] = vs
}
return stats, nil
}
func (s *StatsService) RecordEvent(ctx context.Context, event *ExperimentEvent) error {
// Real-time метрики в Redis
pipe := s.redis.Pipeline()
minute := event.Timestamp.Truncate(time.Minute).Unix()
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:events:%d", event.ExperimentID, event.VariantID, minute))
pipe.PFAdd(ctx, fmt.Sprintf("exp:%s:users:%d", event.ExperimentID, minute), event.UserID)
if event.IsConversion {
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:conversions:%d", event.ExperimentID, event.VariantID, minute))
}
_, err := pipe.Exec(ctx)
return err
}
Конфигурация сервисов:
type ServiceConfig struct {
Admin AdminServiceConfig
Client ClientServiceConfig
}
type AdminServiceConfig struct {
Replicas int `yaml:"replicas"`
CPU string `yaml:"cpu"`
Memory string `yaml:"memory"`
}
type ClientServiceConfig struct {
Replicas int `yaml:"replicas"`
CPU string `yaml:"cpu"`
Memory string `yaml:"memory"`
CacheSize int `yaml:"cache_size"`
}
func DefaultConfig() ServiceConfig {
return ServiceConfig{
Admin: AdminServiceConfig{
Replicas: 3,
CPU: "1",
Memory: "2Gi",
},
Client: ClientServiceConfig{
Replicas: 20,
CPU: "2",
Memory: "4Gi",
CacheSize: 100000,
},
}
}
Вывод:
Основные сервисы:
- Admin Service — управление экспериментами (CRUD, запуск, остановка)
- Client Service — отдача экспериментов клиентам (высокий трафик)
- Storage — персистентное хранение (PostgreSQL)
- Cache — кэширование (Redis + in-memory)
- Index — быстрый поиск активных экспериментов
- Segment Service — работа с сегментами пользователей
- Stats Service — сбор и агрегация статистики
Вопрос 21. Нужно ли разделять сбор статистики для мобильных и веб-приложений?
Таймкод: 00:44:54
Ответ собеседова: Правильный. Предлагается разделить сбор статистики на отдельные коллекторы для мобильных и веб-приложений (web stats collector, mobile stats collector) из-за разного объёма трафика и разных требованиям к масштабированию.
Правильный ответ:
Разделение коллекторов статистики
Общая архитектура:
┌─────────────┐ ┌─────────────┐
│ Mobile │ │ Web │
│ Clients │ │ Clients │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Mobile │ │ Web │
│ Collector │ │ Collector │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────────────────────────┐
│ Kafka Topic │
│ (analytics.events) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Stats Aggregator │
└─────────────────────────────────┘
Mobile Stats Collector:
type MobileStatsCollector struct {
kafka *kafka.Writer
validator *EventValidator
metrics *CollectorMetrics
}
func NewMobileStatsCollector(cfg MobileCollectorConfig) *MobileStatsCollector {
return &MobileStatsCollector{
kafka: &kafka.Writer{
Addr: kafka.TCP(cfg.KafkaBrokers...),
Topic: "analytics.events",
Balancer: &kafka.Murmur2Balancer{},
// Оптимизация для батчей
BatchSize: 1000,
BatchTimeout: 100 * time.Millisecond,
Async: true,
},
validator: NewEventValidator(),
metrics: NewCollectorMetrics("mobile"),
}
}
func (c *MobileStatsCollector) HandleBatchEvents(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var batch EventBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
c.metrics.Errors.Inc()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Валидируем батч
validEvents := make([]AnalyticsEvent, 0, len(batch.Events))
for _, event := range batch.Events {
if err := c.validator.Validate(&event); err != nil {
c.metrics.InvalidEvents.Inc()
continue
}
validEvents = append(validEvents, event)
}
// Отправляем в Kafka
if err := c.sendToKafka(r.Context(), validEvents); err != nil {
c.metrics.Errors.Inc()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Метрики
c.metrics.EventsReceived.Add(float64(len(batch.Events)))
c.metrics.EventsAccepted.Add(float64(len(validEvents)))
c.metrics.BatchLatency.Observe(time.Since(start).Seconds())
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(BatchResponse{
Received: len(batch.Events),
Accepted: len(validEvents),
})
}
func (c *MobileStatsCollector) sendToKafka(ctx context.Context, events []AnalyticsEvent) error {
messages := make([]kafka.Message, 0, len(events))
for _, event := range events {
data, err := json.Marshal(event)
if err != nil {
continue
}
messages = append(messages, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Headers: []kafka.Header{
{Key: "platform", Value: []byte("mobile")},
{Key: "collector", Value: []byte("mobile")},
},
})
}
return c.kafka.WriteMessages(ctx, messages...)
}
Web Stats Collector:
type WebStatsCollector struct {
kafka *kafka.Writer
validator *EventValidator
metrics *CollectorMetrics
}
func NewWebStatsCollector(cfg WebCollectorConfig) *WebStatsCollector {
return &WebStatsCollector{
kafka: &kafka.Writer{
Addr: kafka.TCP(cfg.KafkaBrokers...),
Topic: "analytics.events",
Balancer: &kafka.Murmur2Balancer{},
// Оптимизация для одиночных событий
BatchSize: 100,
BatchTimeout: 50 * time.Millisecond,
Async: true,
},
validator: NewEventValidator(),
metrics: NewCollectorMetrics("web"),
}
}
func (c *WebStatsCollector) HandleSingleEvent(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var event AnalyticsEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
c.metrics.Errors.Inc()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Валидация
if err := c.validator.Validate(&event); err != nil {
c.metrics.InvalidEvents.Inc()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Отправляем сразу
data, _ := json.Marshal(event)
err := c.kafka.WriteMessages(r.Context(), kafka.Message{
Key: []byte(event.UserID),
Value: data,
Headers: []kafka.Header{
{Key: "platform", Value: []byte("web")},
{Key: "collector", Value: []byte("web")},
},
})
if err != nil {
c.metrics.Errors.Inc()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Метрики
c.metrics.EventsReceived.Inc()
c.metrics.EventsAccepted.Inc()
c.metrics.EventLatency.Observe(time.Since(start).Seconds())
w.WriteHeader(http.StatusOK)
}
// Beacon API handler (для надежной доставки при закрытии страницы)
func (c *WebStatsCollector) HandleBeacon(w http.ResponseWriter, r *http.Request) {
// Beacon отправляет данные как FormData
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Парсим событие
var event AnalyticsEvent
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Отправляем асинхронно (beacon не ждет ответа)
go c.sendToKafka(context.Background(), []AnalyticsEvent{event})
// Возвращаем 204 No Content (для beacon)
w.WriteHeader(http.StatusNoContent)
}
Конфигурация коллекторов:
type MobileCollectorConfig struct {
KafkaBrokers []string
Port int
MaxBatchSize int
Workers int
}
type WebCollectorConfig struct {
KafkaBrokers []string
Port int
MaxBodySize int64
Workers int
}
func DefaultMobileConfig() MobileCollectorConfig {
return MobileCollectorConfig{
KafkaBrokers: []string{"kafka:9092"},
Port: 8081,
MaxBatchSize: 1000,
Workers: 50,
}
}
func DefaultWebConfig() WebCollectorConfig {
return WebCollectorConfig{
KafkaBrokers: []string{"kafka:9092"},
Port: 8082,
MaxBodySize: 1024 * 1024, // 1MB
Workers: 100,
}
}
Rate Limiting для коллекторов:
type CollectorRateLimiter struct {
mobileLimiter *rate.Limiter
webLimiter *rate.Limiter
}
func NewCollectorRateLimiter() *CollectorRateLimiter {
return &CollectorRateLimiter{
// Мобильные: 50000 RPS (батчи по 100 событий = 500K событий/сек)
mobileLimiter: rate.NewLimiter(rate.Limit(50000), 100000),
// Веб: 10000 RPS (одиночные события)
webLimiter: rate.NewLimiter(rate.Limit(10000), 20000),
}
}
func (rl *CollectorRateLimiter) AllowMobile() bool {
return rl.mobileLimiter.Allow()
}
func (rl *CollectorRateLimiter) AllowWeb() bool {
return rl.webLimiter.Allow()
}
Метрики коллекторов:
type CollectorMetrics struct {
EventsReceived prometheus.Counter
EventsAccepted prometheus.Counter
InvalidEvents prometheus.Counter
Errors prometheus.Counter
BatchLatency prometheus.Histogram
EventLatency prometheus.Histogram
QueueSize prometheus.Gauge
}
func NewCollectorMetrics(collectorType string) *CollectorMetrics {
labels := prometheus.Labels{"collector": collectorType}
m := &CollectorMetrics{
EventsReceived: prometheus.NewCounter(prometheus.CounterOpts{
Name: "collector_events_received_total",
Help: "Total events received",
ConstLabels: labels,
}),
EventsAccepted: prometheus.NewCounter(prometheus.CounterOpts{
Name: "collector_events_accepted_total",
Help: "Total events accepted",
ConstLabels: labels,
}),
InvalidEvents: prometheus.NewCounter(prometheus.CounterOpts{
Name: "collector_invalid_events_total",
Help: "Total invalid events",
ConstLabels: labels,
}),
Errors: prometheus.NewCounter(prometheus.CounterOpts{
Name: "collector_errors_total",
Help: "Total errors",
ConstLabels: labels,
}),
BatchLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "collector_batch_latency_seconds",
Help: "Batch processing latency",
ConstLabels: labels,
Buckets: prometheus.DefBuckets,
}),
EventLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "collector_event_latency_seconds",
Help: "Event processing latency",
ConstLabels: labels,
Buckets: prometheus.DefBuckets,
}),
}
prometheus.MustRegister(
m.EventsReceived,
m.EventsAccepted,
m.InvalidEvents,
m.Errors,
m.BatchLatency,
m.EventLatency,
)
return m
}
API Gateway для маршрутизации:
type CollectorGateway struct {
mobileHandler *MobileStatsCollector
webHandler *WebStatsCollector
rateLimiter *CollectorRateLimiter
}
func (g *CollectorGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Определяем тип коллектора
platform := g.detectPlatform(r)
// Rate limiting
switch platform {
case PlatformMobile:
if !g.rateLimiter.AllowMobile() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
g.mobileHandler.HandleBatchEvents(w, r)
case PlatformWeb:
if !g.rateLimiter.AllowWeb() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
g.webHandler.HandleSingleEvent(w, r)
default:
http.Error(w, "Unknown platform", http.StatusBadRequest)
}
}
func (g *CollectorGateway) detectPlatform(r *http.Request) Platform {
// По заголовку
if r.Header.Get("X-Platform") == "web" {
return PlatformWeb
}
// По пути
if strings.HasPrefix(r.URL.Path, "/api/v1/mobile/") {
return PlatformMobile
}
if strings.HasPrefix(r.URL.Path, "/api/v1/web/") {
return PlatformWeb
}
// По Content-Type (Beacon API)
if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
return PlatformWeb
}
return PlatformMobile // default
}
Kubernetes деплой:
# Mobile Collector
apiVersion: apps/v1
kind: Deployment
metadata:
name: mobile-stats-collector
spec:
replicas: 10
selector:
matchLabels:
app: mobile-stats-collector
template:
metadata:
labels:
app: mobile-stats-collector
spec:
containers:
- name: collector
image: experiment-platform/mobile-collector:latest
ports:
- containerPort: 8081
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
---
# Web Collector
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-stats-collector
spec:
replicas: 5
selector:
matchLabels:
app: web-stats-collector
template:
metadata:
labels:
app: web-stats-collector
spec:
containers:
- name: collector
image: experiment-platform/web-collector:latest
ports:
- containerPort: 8082
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
Сравнение коллекторов:
| Параметр | Mobile Collector | Web Collector |
|---|---|---|
| Тип отправки | Батчами (50-100) | По одному |
| Пиковая нагрузка | 50K RPS | 10K RPS |
| Batch Size | 1000 | 100 |
| Timeout | 100ms | 50ms |
| Реплики | 10 | 5 |
| CPU на реплику | 2 | 1 |
| Beacon API | Нет | Да |
Вывод:
Разделение коллекторов необходимо из-за:
- Разного объема трафика: мобильные генерируют в 5-10 раз больше событий
- Разного формата отправки: батчи vs одиночные события
- Разных требований к масштабированию: разное количество реплик
- Разных оптимизаций: batch processing vs real-time
- Независимости отказов: проблема одном не влияет на другой
Вопрос 11. Что происходит после завершения эксперимента?
Таймкод: 00:20:27
Ответ собеседника: Правильный. Уточняется, что после завершения эксперимента прекращается сбор статистики. Если гипотеза подтвердилась, аналитик идёт и ставит задачу раскатить изменение на всех пользователей.
Правильный ответ:
1. Процесс завершения эксперимента
После завершения эксперимента:
1. Прекращение сбора данных
- Новые пользователи не попадают в эксперимент
- Существующие пользователи видят контрольный вариант
- Перестаём записывать события для этого эксперимента
2. Финализация статистики
- Рассчитываем итоговые метрики
- Определяем статистическую значимость
- Формируем отчёт
3. Принятие решения
- Аналитик изучает результаты
- Решение: раскатить / откатить / нейтрально
4. Действия после решения
- Раскатка победителя на 100% (ручной процесс)
- Или оставляем контрольный вариант
2. Реализация завершения
package experiment
// ExperimentLifecycle жизненный цикл эксперимента
type ExperimentLifecycle struct {
store ExperimentStorage
statsService StatisticsService
eventService EventService
notifier Notifier
}
// CompleteExperiment завершает эксперимент
func (lc *ExperimentLifecycle) CompleteExperiment(ctx context.Context, experimentID string, decision ExperimentDecision) error {
exp, err := lc.store.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
// 1. Меняем статус на Completed
exp.Status = StatusCompleted
now := time.Now()
exp.EndedAt = &now
if err := lc.store.UpdateExperiment(ctx, exp); err != nil {
return err
}
// 2. Финализируем статистику
finalStats, err := lc.statsService.FinalizeStats(ctx, experimentID)
if err != nil {
log.Printf("Failed to finalize stats: %v", err)
}
// 3. Останавливаем приём событий
if err := lc.eventService.StopCollecting(ctx, experimentID); err != nil {
log.Printf("Failed to stop event collection: %v", err)
}
// 4. Сохраняем результат
result := &ExperimentResult{
ExperimentID: experimentID,
Decision: decision,
FinalStats: finalStats,
CompletedAt: now,
CompletedBy: decision.DecidedBy,
}
if err := lc.store.SaveResult(ctx, result); err != nil {
return err
}
// 5. Уведомляем команду
if err := lc.notifier.NotifyExperimentCompleted(ctx, exp, result); err != nil {
log.Printf("Failed to notify: %v", err)
}
return nil
}
// StopCollecting останавливает сбор событий
func (s *EventService) StopCollecting(ctx context.Context, experimentID string) error {
// Устанавливаем флаг, что эксперимент не активен
// Новые события будут игнорироваться
return s.storage.MarkExperimentInactive(ctx, experimentID)
}
// IsExperimentActive проверяет, активен ли эксперимент
func (s *EventService) IsExperimentActive(experimentID string) bool {
// Проверяем в кэше
if active, exists := s.activeCache[experimentID]; exists {
return active
}
// По умолчанию не активен
return false
}
3. Финализация статистики
package stats
// StatsService сервис статистики
type StatsService struct {
clickhouse *sql.DB
cache *ristretto.Cache
}
// FinalizeStats финализирует статистику эксперимента
func (s *StatsService) FinalizeStats(ctx context.Context, experimentID string) (*FinalStats, error) {
query := `
SELECT
variant_id,
sum(exposures) as total_exposures,
sum(conversions) as total_conversions,
sum(revenue) as total_revenue,
uniq(user_id) as unique_users
FROM experiment_events
WHERE experiment_id = ?
AND timestamp >= ?
GROUP BY variant_id
`
exp, err := s.getExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
rows, err := s.clickhouse.QueryContext(ctx, query, experimentID, exp.StartedAt)
if err != nil {
return nil, err
}
defer rows.Close()
finalStats := &FinalStats{
ExperimentID: experimentID,
Variants: make(map[string]*VariantFinalStats),
CalculatedAt: time.Now(),
}
for rows.Next() {
var variantID string
var exposures, conversions, uniqueUsers int64
var revenue float64
if err := rows.Scan(&variantID, &exposures, &conversions, &revenue, &uniqueUsers); err != nil {
return nil, err
}
finalStats.Variants[variantID] = &VariantFinalStats{
VariantID: variantID,
Exposures: exposures,
Conversions: conversions,
Revenue: revenue,
UniqueUsers: uniqueUsers,
ConversionRate: float64(conversions) / float64(exposures),
}
}
// Рассчитываем статистическую значимость
finalStats.Significance = s.calculateSignificance(finalStats)
// Определяем победителя
finalStats.Winner = s.determineWinner(finalStats)
return finalStats, nil
}
// calculateSignificance рассчитывает статистическую значимость
func (s *StatsService) calculateSignificance(stats *FinalStats) *SignificanceResult {
// Находим контрольный вариант
control := stats.Variants["control"]
if control == nil {
return nil
}
result := &SignificanceResult{
ControlVariant: "control",
}
for variantID, variant := range stats.Variants {
if variantID == "control" {
continue
}
// Z-test для пропорций
zScore := s.zTest(control.ConversionRate, variant.ConversionRate,
float64(control.Exposures), float64(variant.Exposures))
pValue := s.zScoreToPValue(zScore)
result.Comparisons = append(result.Comparisons, &VariantComparison{
VariantID: variantID,
ZScore: zScore,
PValue: pValue,
IsSignificant: pValue < 0.05,
RelativeChange: (variant.ConversionRate - control.ConversionRate) / control.ConversionRate,
})
}
return result
}
4. Модель результата эксперимента
// ExperimentResult результат эксперимента
type ExperimentResult struct {
ExperimentID string `json:"experiment_id"`
Decision ExperimentDecision `json:"decision"`
FinalStats *FinalStats `json:"final_stats"`
CompletedAt time.Time `json:"completed_at"`
CompletedBy string `json:"completed_by"`
}
// FinalStats финальная статистика
type FinalStats struct {
ExperimentID string `json:"experiment_id"`
Variants map[string]*VariantFinalStats `json:"variants"`
Significance *SignificanceResult `json:"significance"`
Winner *WinnerResult `json:"winner"`
CalculatedAt time.Time `json:"calculated_at"`
}
// VariantFinalStats статистика по варианту
type VariantFinalStats struct {
VariantID string `json:"variant_id"`
Exposures int64 `json:"exposures"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
UniqueUsers int64 `json:"unique_users"`
ConversionRate float64 `json:"conversion_rate"`
}
// WinnerResult результат определения победителя
type WinnerResult struct {
VariantID string `json:"variant_id"`
IsSignificant bool `json:"is_significant"`
PValue float64 `json:"p_value"`
RelativeChange float64 `json:"relative_change"`
Confidence float64 `json:"confidence"`
}
5. Решение после завершения
// PostExperimentAction действие после эксперимента
type PostExperimentAction struct {
ExperimentID string `json:"experiment_id"`
Action ActionType `json:"action"`
PerformedBy string `json:"performed_by"`
PerformedAt time.Time `json:"performed_at"`
Reason string `json:"reason"`
}
type ActionType string
const (
ActionRollout ActionType = "rollout" // раскатить победителя
ActionRollback ActionType = "rollback" // оставить контроль
ActionIterate ActionType = "iterate" // запустить новый эксперимент
ActionNoAction ActionType = "no_action" // ничего не делать
)
// PostExperimentService сервис действий после эксперимента
type PostExperimentService struct {
store ExperimentStorage
taskService TaskService
}
// HandleDecision обрабатывает решение аналитика
func (s *PostExperimentService) HandleDecision(ctx context.Context, experimentID string, action ActionType, userID string) error {
exp, err := s.store.GetExperiment(ctx, experimentID)
if err != nil {
return err
}
switch action {
case ActionRollout:
// Создаём задачу на раскатку
return s.createRolloutTask(ctx, exp, userID)
case ActionRollback:
// Просто логируем, что оставили контроль
return s.logDecision(ctx, experimentID, action, userID, "Keeping control variant")
case ActionIterate:
// Создаём задачу на новый эксперимент
return s.createIterationTask(ctx, exp, userID)
case ActionNoAction:
return s.logDecision(ctx, experimentID, action, userID, "No action taken")
default:
return fmt.Errorf("unknown action: %s", action)
}
}
// createRolloutTask создаёт задачу на раскатку
func (s *PostExperimentService) createRolloutTask(ctx context.Context, exp *Experiment, userID string) error {
result, err := s.store.GetResult(ctx, exp.ID)
if err != nil {
return err
}
winner := result.FinalStats.Winner
if winner == nil {
return fmt.Errorf("no winner determined")
}
task := &Task{
Title: fmt.Sprintf("Rollout experiment %s: variant %s", exp.ID, winner.VariantID),
Description: s.generateRolloutDescription(exp, result),
Assignee: exp.CreatedBy,
Priority: TaskPriorityHigh,
Type: TaskTypeRollout,
Metadata: map[string]string{
"experiment_id": exp.ID,
"variant_id": winner.VariantID,
"improvement": fmt.Sprintf("%.2f%%", winner.RelativeChange*100),
},
}
return s.taskService.CreateTask(ctx, task)
}
// generateRolloutDescription генерирует описание задачи
func (s *PostExperimentService) generateRolloutDescription(exp *Experiment, result *ExperimentResult) string {
winner := result.FinalStats.Winner
control := result.FinalStats.Variants["control"]
treatment := result.FinalStats.Variants[winner.VariantID]
return fmt.Sprintf(`
# Rollout Experiment: %s
## Summary
- Experiment: %s
- Duration: %s - %s
- Winner: %s
## Results
| Metric | Control | Winner | Change |
|--------|---------|--------|--------|
| Exposures | %d | %d | - |
| Conversion Rate | %.4f | %.4f | +%.2f%% |
| Revenue | %.2f | %.2f | +%.2f%% |
## Statistical Significance
- P-value: %.4f
- Confidence: %.1f%%
## Action Required
Please rollout variant %s to 100%% of users.
`, exp.Name, exp.ID, exp.StartedAt.Format("2006-01-02"), exp.EndedAt.Format("2006-01-02"),
winner.VariantID,
control.Exposures, treatment.Exposures,
control.ConversionRate, treatment.ConversionRate, winner.RelativeChange*100,
control.Revenue, treatment.Revenue,
(treatment.Revenue-control.Revenue)/control.Revenue*100,
winner.PValue, winner.Confidence*100,
winner.VariantID)
}
6. Хранение результатов
-- Таблица результатов экспериментов
CREATE TABLE experiment_results (
id BIGSERIAL PRIMARY KEY,
experiment_id VARCHAR(255) NOT NULL,
decision VARCHAR(50) NOT NULL,
final_stats JSONB NOT NULL,
significance JSONB,
winner JSONB,
completed_at TIMESTAMP NOT NULL,
completed_by VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_results_experiment ON experiment_results(experiment_id);
-- Таблица действий после эксперимента
CREATE TABLE post_experiment_actions (
id BIGSERIAL PRIMARY KEY,
experiment_id VARCHAR(255) NOT NULL,
action VARCHAR(50) NOT NULL,
performed_by VARCHAR(255) NOT NULL,
performed_at TIMESTAMP NOT NULL,
reason TEXT,
task_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_actions_experiment ON post_experiment_actions(experiment_id);
7. Уведомления после завершения
package notifier
// ExperimentCompletedNotification уведомление о завершении
type ExperimentCompletedNotification struct {
ExperimentID string `json:"experiment_id"`
ExperimentName string `json:"experiment_name"`
Status string `json:"status"`
Winner string `json:"winner"`
Improvement float64 `json:"improvement"`
PValue float64 `json:"p_value"`
CompletedAt time.Time `json:"completed_at"`
CompletedBy string `json:"completed_by"`
}
// NotifyExperimentCompleted уведомляет о завершении эксперимента
func (s *NotificationService) NotifyExperimentCompleted(ctx context.Context, exp *Experiment, result *ExperimentResult) error {
notification := &ExperimentCompletedNotification{
ExperimentID: exp.ID,
ExperimentName: exp.Name,
Status: string(exp.Status),
CompletedAt: *exp.EndedAt,
CompletedBy: exp.UpdatedBy,
}
if result.FinalStats.Winner != nil {
notification.Winner = result.FinalStats.Winner.VariantID
notification.Improvement = result.FinalStats.Winner.RelativeChange
notification.PValue = result.FinalStats.Winner.PValue
}
// Уведомляем создателя эксперимента
if err := s.sendToUser(ctx, exp.CreatedBy, notification); err != nil {
log.Printf("Failed to notify creator: %v", err)
}
// Уведомляем команду в Slack
if err := s.sendToSlackChannel(ctx, "experiments", notification); err != nil {
log.Printf("Failed to notify Slack: %v", err)
}
return nil
}
8. Архивирование данных
// Archiver архивация данных эксперимента
type Archiver struct {
clickhouse *sql.DB
s3Client *s3.Client
}
// ArchiveExperiment архивирует данные эксперимента
func (a *Archiver) ArchiveExperiment(ctx context.Context, experimentID string) error {
// 1. Экспортируем сырые данные в S3
if err := a.exportToS3(ctx, experimentID); err != nil {
return err
}
// 2. Удаляем сырые данные из ClickHouse (через TTL)
// Данные удаляются автоматически через 90 дней
// 3. Сохраняем агрегированную статистику
if err := a.saveAggregatedStats(ctx, experimentID); err != nil {
return err
}
return nil
}
// exportToS3 экспортирует данные в S3
func (a *Archiver) exportToS3(ctx context.Context, experimentID string) error {
query := fmt.Sprintf(`
SELECT *
FROM experiment_events
WHERE experiment_id = '%s'
FORMAT JSONEachRow
`, experimentID)
// Выполняем запрос и сохраняем в S3
// ...
return nil
}
9. Итоговый процесс
┌─────────────────────────────────────────────────────────────────┐
│ Завершение эксперимента │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────┐
│ CompleteExperiment │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Stop Event │ │ Finalize │ │ Save Result │
│ Collection │ │ Statistics │ │ │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────┼───────────────────┘
│
▼
┌─────────────────┐
│ Notify Team │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Analyst │
│ Decision │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Rollout │ │ Rollback │ │ Iterate │
│ (раскатать) │ │ (оставить │ │ (новый тест) │
│ │ │ контроль) │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
10. Ключевые принципы
-
Прекращение сбора данных — после завершения новые события не записываются.
-
Финализация статистики — рассчитываем итоговые метрики и статистическую значимость.
-
Ручная раскатка — система не раскатывает победителя автоматически, это ручной процесс через создание задачи.
-
Хранение результатов — все результаты сохраняются для истории и анализа.
-
Уведомления — команда уведомляется о завершении и результатах.
-
Архивирование — сырые данные архивируются в S3, агрегированные остаются в ClickHouse.
После завершения эксперимента аналитик принимает решение на основе данных, и если гипотеза подтвердилась — создаётся задача на раскатку победившего варианта на всех пользователей.
Вопрос 22. Куда отправляется собранная статистика и кто её анализирует?
Таймкод: 00:45:31
Ответ собеседова: Правильный. Уточняется, что статистика отправляется в аналитическую платформу, которая является частью проектируемой системы. Система должна показывать воронки и базовый анализ по экспериментам.
Правильный ответ:
Архитектура аналитической платформы
Общая схема потока данных:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Collectors │────►│ Kafka │────►│ Consumer │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Real-time │ │ ClickHouse │ │ Postgres │
│ (Redis) │ │ (агрегаты) │ │ (метаданные)│
└──────┬──────┘ └──────┬──────┘ └─────────────┘
│ │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Analytics API │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Аналитик (UI) │
└─────────────────────┘
Kafka Consumer для обработки событий:
type StatsConsumer struct {
kafkaReader *kafka.Reader
redis *redis.Client
clickhouse *clickhouse.Conn
experimentSvc ExperimentService
}
func NewStatsConsumer(cfg StatsConsumerConfig) *StatsConsumer {
return &StatsConsumer{
kafkaReader: kafka.NewReader(kafka.ReaderConfig{
Brokers: cfg.KafkaBrokers,
Topic: "analytics.events",
GroupID: "stats-consumer",
MaxBytes: 10e6, // 10MB
}),
redis: redis.NewClient(&redis.Options{Addr: cfg.RedisAddr}),
clickhouse: mustConnectClickHouse(cfg.ClickHouseAddr),
experimentSvc: cfg.ExperimentService,
}
}
func (c *StatsConsumer) Start(ctx context.Context) error {
for {
msg, err := c.kafkaReader.ReadMessage(ctx)
if err != nil {
return err
}
var event AnalyticsEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
// Обрабатываем параллельно
go c.processEvent(ctx, &event)
}
}
func (c *StatsConsumer) processEvent(ctx context.Context, event *AnalyticsEvent) {
// Обновляем real-time метрики в Redis
c.updateRealtimeMetrics(ctx, event)
// Сохраняем в ClickHouse для аналитики
c.saveToClickHouse(ctx, event)
}
func (c *StatsConsumer) updateRealtimeMetrics(ctx context.Context, event *AnalyticsEvent) {
pipe := c.redis.Pipeline()
for _, exp := range event.Experiments {
minute := event.Timestamp.Truncate(time.Minute).Unix()
// События по варианту
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:events:%d",
exp.ExperimentID, exp.VariantID, minute))
// Уникальные пользователи
pipe.PFAdd(ctx, fmt.Sprintf("exp:%s:users:%d", exp.ExperimentID, minute),
event.UserID)
// Конверсии
if event.IsConversion() {
pipe.Incr(ctx, fmt.Sprintf("exp:%s:variant:%s:conversions:%d",
exp.ExperimentID, exp.VariantID, minute))
// Revenue
if revenue, ok := event.Properties["revenue"]; ok {
pipe.IncrByFloat(ctx, fmt.Sprintf("exp:%s:variant:%s:revenue:%d",
exp.ExperimentID, exp.VariantID, minute), revenue.(float64))
}
}
}
pipe.Exec(ctx)
}
func (c *StatsConsumer) saveToClickHouse(ctx context.Context, event *AnalyticsEvent) {
batch, err := c.clickhouse.PrepareBatch(ctx, `
INSERT INTO experiment_events (
experiment_id, variant_id, user_id, event_type,
timestamp, platform, properties
)
`)
if err != nil {
return
}
for _, exp := range event.Experiments {
propertiesJSON, _ := json.Marshal(event.Properties)
batch.Append(
exp.ExperimentID,
exp.VariantID,
event.UserID,
event.EventType,
event.Timestamp,
event.Platform,
string(propertiesJSON),
)
}
batch.Send()
}
ClickHouse схема:
-- Сырые события экспериментов
CREATE TABLE experiment_events (
experiment_id String,
variant_id String,
user_id String,
event_type String,
timestamp DateTime,
platform String,
properties String,
-- Индексы
INDEX idx_experiment (experiment_id, variant_id) TYPE minmax GRANULARITY 4,
INDEX idx_timestamp (timestamp) TYPE minmax GRANULARITY 4
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, timestamp);
-- Агрегированная статистика по минутам
CREATE TABLE experiment_minute_stats (
experiment_id String,
variant_id String,
minute DateTime,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (experiment_id, variant_id, minute);
-- Агрегированная статистика по дням
CREATE TABLE experiment_daily_stats (
experiment_id String,
variant_id String,
date Date,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant_id, date);
-- Materialized View для автоматической агрегации
CREATE MATERIALIZED VIEW experiment_minute_stats_mv
TO experiment_minute_stats
AS
SELECT
experiment_id,
variant_id,
toStartOfMinute(timestamp) as minute,
count() as events,
uniqExact(user_id) as unique_users,
countIf(event_type = 'conversion') as conversions,
sumIf(CAST(JSONExtractFloat(properties, 'revenue') AS Float64),
event_type = 'conversion') as revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, toStartOfMinute(timestamp);
Analytics API:
type AnalyticsService struct {
redis *redis.Client
clickhouse *clickhouse.Conn
}
type ExperimentStatsRequest struct {
ExperimentID string `json:"experiment_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Granularity string `json:"granularity"` // "minute", "hour", "day"
}
type ExperimentStatsResponse struct {
ExperimentID string `json:"experiment_id"`
Summary StatsSummary `json:"summary"`
Variants []VariantStats `json:"variants"`
Timeseries []TimeseriesPoint `json:"timeseries"`
Funnel *FunnelStats `json:"funnel,omitempty"`
}
func (s *AnalyticsService) GetExperimentStats(ctx context.Context, req ExperimentStatsRequest) (*ExperimentStatsResponse, error) {
// Получаем агрегированные данные из ClickHouse
stats, err := s.queryClickHouse(ctx, req)
if err != nil {
return nil, err
}
// Получаем real-time данные из Redis
realtime, err := s.queryRealtime(ctx, req.ExperimentID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get realtime stats")
}
// Объединяем
response := s.mergeStats(stats, realtime)
return response, nil
}
func (s *AnalyticsService) queryClickHouse(ctx context.Context, req ExperimentStatsRequest) (*ExperimentStatsResponse, error) {
query := `
SELECT
variant_id,
sum(events) as events,
sum(unique_users) as unique_users,
sum(conversions) as conversions,
sum(revenue) as revenue
FROM experiment_daily_stats
WHERE experiment_id = ?
AND date BETWEEN ? AND ?
GROUP BY variant_id
`
rows, err := s.clickhouse.Query(ctx, query,
req.ExperimentID, req.StartDate, req.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
response := &ExperimentStatsResponse{
ExperimentID: req.ExperimentID,
Variants: make([]VariantStats, 0),
}
for rows.Next() {
var vs VariantStats
if err := rows.Scan(&vs.VariantID, &vs.Events, &vs.UniqueUsers,
&vs.Conversions, &vs.Revenue); err != nil {
continue
}
// Вычисляем производные метрики
if vs.UniqueUsers > 0 {
vs.ConversionRate = float64(vs.Conversions) / float64(vs.UniqueUsers)
vs.ARPU = vs.Revenue / float64(vs.UniqueUsers)
}
response.Variants = append(response.Variants, vs)
}
return response, nil
}
func (s *AnalyticsService) queryRealtime(ctx context.Context, experimentID string) (*RealtimeStats, error) {
now := time.Now()
keys := make([]string, 5)
for i := 0; i < 5; i++ {
minute := now.Add(-time.Duration(i) * time.Minute).Truncate(time.Minute)
keys[i] = fmt.Sprintf("exp:%s:*:events:%d", experimentID, minute.Unix())
}
pipe := s.redis.Pipeline()
cmds := make([]*redis.StringSliceCmd, len(keys))
for i, key := range keys {
cmds[i] = pipe.Keys(ctx, key)
}
_, err := pipe.Exec(ctx)
if err != nil {
return nil, err
}
// Агрегируем данные
stats := &RealtimeStats{
ExperimentID: experimentID,
LastUpdated: now,
}
return stats, nil
}
Воронки (Funnel):
type FunnelService struct {
clickhouse *clickhouse.Conn
}
type FunnelRequest struct {
ExperimentID string `json:"experiment_id"`
Steps []string `json:"steps"` // ["page_view", "add_to_cart", "checkout", "purchase"]
}
type FunnelResponse struct {
ExperimentID string `json:"experiment_id"`
Variants []VariantFunnel `json:"variants"`
}
type VariantFunnel struct {
VariantID string `json:"variant_id"`
Steps []FunnelStep `json:"steps"`
}
type FunnelStep struct {
StepName string `json:"step_name"`
Users int64 `json:"users"`
Conversion float64 `json:"conversion_rate"`
}
func (s *FunnelService) GetFunnel(ctx context.Context, req FunnelRequest) (*FunnelResponse, error) {
query := `
SELECT
variant_id,
event_type,
uniqExact(user_id) as users
FROM experiment_events
WHERE experiment_id = ?
AND event_type IN ?
GROUP BY variant_id, event_type
ORDER BY variant_id,
CASE event_type
WHEN 'page_view' THEN 1
WHEN 'add_to_cart' THEN 2
WHEN 'checkout' THEN 3
WHEN 'purchase' THEN 4
END
`
rows, err := s.clickhouse.Query(ctx, query,
req.ExperimentID, req.Steps)
if err != nil {
return nil, err
}
defer rows.Close()
// Группируем по вариантам
variantData := make(map[string]map[string]int64)
for rows.Next() {
var variantID, eventType string
var users int64
if err := rows.Scan(&variantID, &eventType, &users); err != nil {
continue
}
if _, ok := variantData[variantID]; !ok {
variantData[variantID] = make(map[string]int64)
}
variantData[variantID][eventType] = users
}
// Формируем ответ
response := &FunnelResponse{
ExperimentID: req.ExperimentID,
Variants: make([]VariantFunnel, 0),
}
for variantID, data := range variantData {
funnel := VariantFunnel{
VariantID: variantID,
Steps: make([]FunnelStep, 0, len(req.Steps)),
}
var prevUsers int64
for i, stepName := range req.Steps {
users := data[stepName]
step := FunnelStep{
StepName: stepName,
Users: users,
}
if i == 0 {
step.Conversion = 1.0
} else if prevUsers > 0 {
step.Conversion = float64(users) / float64(prevUsers)
}
funnel.Steps = append(funnel.Steps, step)
prevUsers = users
}
response.Variants = append(response.Variants, funnel)
}
return response, nil
}
Statistical Analysis Service:
type StatisticalService struct{}
type StatisticalResult struct {
IsSignificant bool `json:"is_significant"`
PValue float64 `json:"p_value"`
ConfidenceInterval [2]float64 `json:"confidence_interval"`
EffectSize float64 `json:"effect_size"`
Power float64 `json:"power"`
}
func (s *StatisticalService) CompareVariants(control, treatment *VariantStats) *StatisticalResult {
// Z-test для пропорций
p1 := control.ConversionRate
p2 := treatment.ConversionRate
n1 := float64(control.UniqueUsers)
n2 := float64(treatment.UniqueUsers)
// Pooled proportion
p := (p1*n1 + p2*n2) / (n1 + n2)
// Standard error
se := math.Sqrt(p * (1 - p) * (1/n1 + 1/n2))
// Z-score
z := (p2 - p1) / se
// P-value (two-tailed)
pValue := 2 * (1 - normalCDF(math.Abs(z)))
// Confidence interval
diff := p2 - p1
margin := 1.96 * se
return &StatisticalResult{
IsSignificant: pValue < 0.05,
PValue: pValue,
ConfidenceInterval: [2]float64{diff - margin, diff + margin},
EffectSize: diff / p1, // Relative effect
Power: s.calculatePower(n1, n2, p1, p2),
}
}
func normalCDF(x float64) float64 {
return 0.5 * math.Erfc(-x/math.Sqrt(2))
}
API Handler для аналитиков:
type AnalyticsHandler struct {
analyticsService *AnalyticsService
funnelService *FunnelService
statsService *StatisticalService
}
func (h *AnalyticsHandler) GetExperimentStats(w http.ResponseWriter, r *http.Request) {
experimentID := chi.URLParam(r, "experimentID")
var req ExperimentStatsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.ExperimentID = experimentID
stats, err := h.analyticsService.GetExperimentStats(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(stats)
}
func (h *AnalyticsHandler) GetFunnel(w http.ResponseWriter, r *http.Request) {
experimentID := chi.URLParam(r, "experimentID")
var req FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.ExperimentID = experimentID
funnel, err := h.funnelService.GetFunnel(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(funnel)
}
Кто анализирует:
type AnalystRole string
const (
AnalystRoleProduct AnalystRole = "product" // Продуктовый аналитик
AnalystRoleData AnalystRole = "data" // Дата-аналитик
AnalystRoleBusiness AnalystRole = "business" // Бизнес-аналитик
)
type Analyst struct {
ID string `json:"id"`
Name string `json:"name"`
Role AnalystRole `json:"role"`
Email string `json:"email"`
}
// Продуктовый аналитик:
// - Создает эксперименты
// - Смотрит результаты
// - Принимает решения о раскатке
// Дата-аналитик:
// - Настраивает сегменты
// - Проводит глубокий анализ
// - Строит сложные воронки
// Бизнес-аналитик:
// - Смотрит общие метрики
// - Генерирует отчеты
// - Оценивает влияние на бизнес
Вывод:
Аналитическая платформа включает:
- ClickHouse — хранение и агрегация событий
- Redis — real-time метрики
- Analytics API — получение статистики
- Funnel Service — построение воронок
- Statistical Service — статистический анализ
Анализируют статистику:
- Продуктовые аналитики — создают эксперименты и принимают решения
- Дата-аналитики — глубокий анализ и сегментация
- Бизнес-аналитики — отчеты и оценка влияния
Вопрос 23. Как организовать процесс сбора и хранения статистики?
Таймкод: 00:46:58
Ответ собеседова: Правильный. Предлагается использовать очередь (например, Kafka) для временного хранения и переживания пиков активности, а затем из очереди данные вытаскиваются и складываются в БД для ведения аналитики.
Правильный ответ:
Архитектура сбора и хранения статистики
Общая схема:
┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────────────┐
│ Collectors │────►│ Kafka │────►│ Consumer Group │
└─────────────┘ └─────────────┘ └─────────────────────────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Redis │ │ ClickHouse │ │ Postgres │
│ (real-time)│ │ (агрегаты) │ │(метаданные) │
└─────────────┘ └─────────────┘ └─────────────┘
Kafka конфигурация:
type KafkaConfig struct {
Brokers []string
Topic string
NumPartitions int
ReplicationFactor int
RetentionMs int64
}
func DefaultKafkaConfig() KafkaConfig {
return KafkaConfig{
Brokers: []string{"kafka-1:9092", "kafka-2:9092", "kafka-3:9092"},
Topic: "analytics.events",
NumPartitions: 12, // Для параллельной обработки
ReplicationFactor: 3, // Для надежности
RetentionMs: 7 * 24 * 60 * 60 * 1000, // 7 дней
}
}
func CreateTopic(cfg KafkaConfig) error {
conn, err := kafka.Dial("tcp", cfg.Brokers[0])
if err != nil {
return err
}
defer conn.Close()
topicConfigs := []kafka.TopicConfig{
{
Topic: cfg.Topic,
NumPartitions: cfg.NumPartitions,
ReplicationFactor: cfg.ReplicationFactor,
ConfigEntries: []kafka.ConfigEntry{
{Key: "retention.ms", Value: strconv.FormatInt(cfg.RetentionMs, 10)},
{Key: "cleanup.policy", Value: "delete"},
{Key: "compression.type", Value: "lz4"},
},
},
}
return conn.CreateTopics(topicConfigs...)
}
Producer (Collectors):
type KafkaProducer struct {
writer *kafka.Writer
}
func NewKafkaProducer(cfg KafkaConfig) *KafkaProducer {
return &KafkaProducer{
writer: &kafka.Writer{
Addr: kafka.TCP(cfg.Brokers...),
Topic: cfg.Topic,
Balancer: &kafka.Murmur2Balancer{}, // Консистентное хеширование
BatchSize: 1000,
BatchTimeout: 100 * time.Millisecond,
Async: true,
Compression: kafka.Lz4,
RequiredAcks: kafka.RequireOne, // Баланс между надежностью и скоростью
},
}
}
func (p *KafkaProducer) SendEvent(ctx context.Context, event *AnalyticsEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
return p.writer.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID), // Ключ для партиционирования
Value: data,
Headers: []kafka.Header{
{Key: "platform", Value: []byte(event.Platform)},
{Key: "timestamp", Value: []byte(event.Timestamp.Format(time.RFC3339))},
},
})
}
func (p *KafkaProducer) SendBatch(ctx context.Context, events []AnalyticsEvent) error {
messages := make([]kafka.Message, 0, len(events))
for _, event := range events {
data, err := json.Marshal(event)
if err != nil {
continue
}
messages = append(messages, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Headers: []kafka.Header{
{Key: "platform", Value: []byte(event.Platform)},
},
})
}
return p.writer.WriteMessages(ctx, messages...)
}
Consumer Group:
type StatsConsumerGroup struct {
kafkaConfig KafkaConfig
redis *redis.Client
clickhouse *clickhouse.Conn
batchSize int
flushInterval time.Duration
}
func NewStatsConsumerGroup(cfg StatsConsumerGroupConfig) *StatsConsumerGroup {
return &StatsConsumerGroup{
kafkaConfig: KafkaConfig{
Brokers: cfg.KafkaBrokers,
Topic: "analytics.events",
},
redis: redis.NewClient(&redis.Options{Addr: cfg.RedisAddr}),
clickhouse: mustConnectClickHouse(cfg.ClickHouseAddr),
batchSize: 1000,
flushInterval: 5 * time.Second,
}
}
func (g *StatsConsumerGroup) Start(ctx context.Context, numWorkers int) error {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
g.runWorker(ctx, workerID)
}(i)
}
wg.Wait()
return nil
}
func (g *StatsConsumerGroup) runWorker(ctx context.Context, workerID int) {
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: g.kafkaConfig.Brokers,
Topic: g.kafkaConfig.Topic,
GroupID: "stats-consumer-group",
MinBytes: 10e3, // 10KB
MaxBytes: 10e6, // 10MB
CommitInterval: 1 * time.Second,
MaxWait: 500 * time.Millisecond,
})
defer reader.Close()
batch := make([]AnalyticsEvent, 0, g.batchSize)
ticker := time.NewTicker(g.flushInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
g.flush(batch)
return
case <-ticker.C:
if len(batch) > 0 {
g.flush(batch)
batch = make([]AnalyticsEvent, 0, g.batchSize)
}
default:
msg, err := reader.ReadMessage(ctx)
if err != nil {
continue
}
var event AnalyticsEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
batch = append(batch, event)
if len(batch) >= g.batchSize {
g.flush(batch)
batch = make([]AnalyticsEvent, 0, g.batchSize)
}
}
}
}
func (g *StatsConsumerGroup) flush(events []AnalyticsEvent) {
if len(events) == 0 {
return
}
// Параллельно отправляем в разные хранилища
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
g.updateRealtimeMetrics(events)
}()
wg.Add(1)
go func() {
defer wg.Done()
g.saveToClickHouse(events)
}()
wg.Wait()
}
func (g *StatsConsumerGroup) updateRealtimeMetrics(events []AnalyticsEvent) {
pipe := g.redis.Pipeline()
for _, event := range events {
for _, exp := range event.Experiments {
minute := event.Timestamp.Truncate(time.Minute).Unix()
key := fmt.Sprintf("exp:%s:variant:%s", exp.ExperimentID, exp.VariantID)
pipe.Incr(ctx, fmt.Sprintf("%s:events:%d", key, minute))
pipe.PFAdd(ctx, fmt.Sprintf("%s:users:%d", key, minute), event.UserID)
if event.IsConversion() {
pipe.Incr(ctx, fmt.Sprintf("%s:conversions:%d", key, minute))
}
}
}
pipe.Exec(ctx)
}
func (g *StatsConsumerGroup) saveToClickHouse(events []AnalyticsEvent) {
ctx := context.Background()
batch, err := g.clickhouse.PrepareBatch(ctx, `
INSERT INTO experiment_events (
experiment_id, variant_id, user_id, event_type,
timestamp, platform, properties
)
`)
if err != nil {
return
}
for _, event := range events {
for _, exp := range event.Experiments {
propertiesJSON, _ := json.Marshal(event.Properties)
err := batch.Append(
exp.ExperimentID,
exp.VariantID,
event.UserID,
event.EventType,
event.Timestamp,
event.Platform,
string(propertiesJSON),
)
if err != nil {
continue
}
}
}
batch.Send()
}
Dead Letter Queue для проблемных сообщений:
type DeadLetterQueue struct {
kafka *kafka.Writer
}
func NewDeadLetterQueue(brokers []string) *DeadLetterQueue {
return &DeadLetterQueue{
kafka: &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: "analytics.events.dlq",
},
}
}
func (dlq *DeadLetterQueue) SendFailedMessage(ctx context.Context, msg *kafka.Message, reason string) error {
msg.Headers = append(msg.Headers, kafka.Header{
Key: "failure_reason",
Value: []byte(reason),
})
msg.Headers = append(msg.Headers, kafka.Header{
Key: "failed_at",
Value: []byte(time.Now().Format(time.RFC3339)),
})
return dlq.kafka.WriteMessages(ctx, *msg)
}
Circuit Breaker для ClickHouse:
type ClickHouseWriter struct {
conn *clickhouse.Conn
breaker *gobreaker.CircuitBreaker
buffer []AnalyticsEvent
mu sync.Mutex
}
func NewClickHouseWriter(addr string) *ClickHouseWriter {
conn, _ := clickhouse.Open(&clickhouse.Options{
Addr: []string{addr},
})
breaker := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "clickhouse-writer",
MaxRequests: 100,
Interval: 10 * time.Second,
Timeout: 5 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures > 5
},
})
return &ClickHouseWriter{
conn: conn,
breaker: breaker,
buffer: make([]AnalyticsEvent, 0, 1000),
}
}
func (w *ClickHouseWriter) Write(events []AnalyticsEvent) error {
_, err := w.breaker.Execute(func() (interface{}, error) {
return nil, w.doWrite(events)
})
if err != nil {
// Буферизируем для повторной попытки
w.mu.Lock()
w.buffer = append(w.buffer, events...)
w.mu.Unlock()
}
return err
}
func (w *ClickHouseWriter) doWrite(events []AnalyticsEvent) error {
batch, err := w.conn.PrepareBatch(context.Background(), `
INSERT INTO experiment_events
`)
if err != nil {
return err
}
for _, event := range events {
for _, exp := range event.Experiments {
batch.Append(
exp.ExperimentID,
exp.VariantID,
event.UserID,
event.EventType,
event.Timestamp,
event.Platform,
)
}
}
return batch.Send()
}
// Периодически пытаемся отправить буфер
func (w *ClickHouseWriter) FlushBuffer() {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.buffer) > 0 {
if err := w.doWrite(w.buffer); err == nil {
w.buffer = make([]AnalyticsEvent, 0, 1000)
}
}
}
Мониторинг очереди:
type QueueMetrics struct {
messagesIn prometheus.Gauge
messagesOut prometheus.Counter
consumerLag prometheus.Gauge
processingTime prometheus.Histogram
errorsTotal prometheus.Counter
}
func (m *QueueMetrics) RecordLag(partition int, lag int64) {
m.consumerLag.Set(float64(lag))
}
func (m *QueueMetrics) RecordProcessing(duration time.Duration) {
m.processingTime.Observe(duration.Seconds())
}
func (m *QueueMetrics) RecordError() {
m.errorsTotal.Inc()
}
// Проверка lag'а consumer group
func CheckConsumerLag(brokers []string, groupID string) (int64, error) {
conn, err := kafka.Dial("tcp", brokers[0])
if err != nil {
return 0, err
}
defer conn.Close()
// Получаем информацию о consumer group
// и вычисляем lag
return 0, nil
}
Ретеншн и очистка данных:
type DataRetentionService struct {
clickhouse *clickhouse.Conn
}
func (s *DataRetentionService) CleanupOldData(ctx context.Context, retentionDays int) error {
// Удаляем старые данные из ClickHouse
query := fmt.Sprintf(`
ALTER TABLE experiment_events
DELETE WHERE timestamp < now() - INTERVAL %d DAY
`, retentionDays)
return s.clickhouse.Exec(ctx, query)
}
func (s *DataRetentionService) ArchiveToColdStorage(ctx context.Context, date time.Time) error {
// Экспортируем данные в S3/MinIO для долгосрочного хранения
query := `
SELECT *
FROM experiment_events
WHERE toDate(timestamp) = ?
INTO OUTFILE 's3://analytics-archive/experiments/date=%s/events.csv'
FORMAT CSV
`
return s.clickhouse.Exec(ctx, query, date, date.Format("2006-01-02"))
}
Конфигурация пайплайна:
type PipelineConfig struct {
Kafka struct {
Brokers []string `yaml:"brokers"`
Topic string `yaml:"topic"`
NumPartitions int `yaml:"num_partitions"`
} `yaml:"kafka"`
Consumer struct {
GroupID string `yaml:"group_id"`
NumWorkers int `yaml:"num_workers"`
BatchSize int `yaml:"batch_size"`
FlushInterval time.Duration `yaml:"flush_interval"`
} `yaml:"consumer"`
Redis struct {
Addr string `yaml:"addr"`
DB int `yaml:"db"`
} `yaml:"redis"`
ClickHouse struct {
Addr string `yaml:"addr"`
} `yaml:"clickhouse"`
Retention struct {
Days int `yaml:"days"`
} `yaml:"retention"`
}
func DefaultPipelineConfig() PipelineConfig {
var cfg PipelineConfig
cfg.Kafka.Brokers = []string{"kafka:9092"}
cfg.Kafka.Topic = "analytics.events"
cfg.Kafka.NumPartitions = 12
cfg.Consumer.GroupID = "stats-consumer"
cfg.Consumer.NumWorkers = 6
cfg.Consumer.BatchSize = 1000
cfg.Consumer.FlushInterval = 5 * time.Second
cfg.Redis.Addr = "redis:6379"
cfg.ClickHouse.Addr = "clickhouse:9000"
cfg.Retention.Days = 90
return cfg
}
Вывод:
Процесс сбора и хранения:
- Kafka — буфер для переживания пиков, гарантия доставки
- Consumer Group — параллельная обработка с автоматическим ребалансированием
- Redis — real-time метрики для мгновенного доступа
- ClickHouse — долгосрочное хранение и аналитика
- Dead Letter Queue — обработка проблемных сообщений
- Circuit Breaker — защита от каскадных отказов
Вопрос 12. Как приложение определяет, в какие эксперименты попадает пользователь?
Таймкод: 00:22:56
Ответ собеседника: Правильный. Уточняется, что приложение получает список экспериментов, применимых к конкретному пользователю. Система отдаёт только те эксперименты, в которые попадает данный пользователь, а не весь общий список.
Правильный ответ:
1. Два подхода к определению экспериментов
Подход 1: Серверный фильтр (рекомендуемый)
┌─────────────┐ ┌──────────────────┐
│ Клиент │ ──────► │ AB Test API │
│ │ user_id│ │
│ │ ◄────── │ Фильтрует на │
│ │ variants│ сервере │
└─────────────┘ └──────────────────┘
Подход 2: Клиентский фильтр (альтернативный)
┌─────────────┐ ┌──────────────────┐
│ Клиент │ ──────► │ AB Test API │
│ │ │ │
│ Фильтрует │ ◄────── │ Отдаёт все │
│ на клиенте │ all │ активные тесты │
└─────────────┘ └──────────────────┘
2. Серверный фильтр: архитектура
package abtest
// UserContext контекст пользователя
type UserContext struct {
UserID string `json:"user_id"`
Platform Platform `json:"platform"`
AppVersion string `json:"app_version"`
Country string `json:"country"`
Language string `json:"language"`
}
// ExperimentAssignment назначение эксперимента
type ExperimentAssignment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
Config map[string]interface{} `json:"config"`
}
// AssignmentService сервис назначения экспериментов
type AssignmentService struct {
experimentStore ExperimentStorage
segmentService SegmentService
variantStore VariantStorage
cache *ristretto.Cache
}
// GetUserAssignments возвращает эксперименты для пользователя
func (s *AssignmentService) GetUserAssignments(ctx context.Context, userCtx UserContext) ([]*ExperimentAssignment, error) {
// 1. Проверяем кэш
cacheKey := fmt.Sprintf("assignments:%s", userCtx.UserID)
if cached, found := s.cache.Get(cacheKey); found {
return cached.([]*ExperimentAssignment), nil
}
// 2. Получаем сегменты пользователя
userSegments, err := s.segmentService.GetUserSegments(ctx, userCtx.UserID)
if err != nil {
return nil, err
}
// 3. Получаем все активные эксперименты
activeExperiments, err := s.experimentStore.GetActiveExperiments(ctx)
if err != nil {
return nil, err
}
// 4. Фильтруем эксперименты для пользователя
var assignments []*ExperimentAssignment
for _, exp := range activeExperiments {
// Проверяем платформу
if !s.isPlatformMatch(exp, userCtx.Platform) {
continue
}
// Проверяем версию приложения
if !s.isVersionMatch(exp, userCtx.Platform, userCtx.AppVersion) {
continue
}
// Проверяем сегменты
if !s.isSegmentMatch(exp, userSegments) {
continue
}
// Проверяем процент трафика
if !s.isUserInTraffic(userCtx.UserID, exp.TrafficPct) {
continue
}
// Получаем или назначаем вариант
variant, err := s.getOrAssignVariant(ctx, userCtx.UserID, exp)
if err != nil {
log.Printf("Failed to assign variant for experiment %s: %v", exp.ID, err)
continue
}
assignments = append(assignments, &ExperimentAssignment{
ExperimentID: exp.ID,
VariantID: variant.ID,
Config: variant.Config,
})
}
// 5. Кэшируем результат
s.cache.SetWithTTL(cacheKey, assignments, 1, 5*time.Minute)
return assignments, nil
}
// isPlatformMatch проверяет соответствие платформы
func (s *AssignmentService) isPlatformMatch(exp *Experiment, platform Platform) bool {
if len(exp.TargetPlatforms) == 0 {
return true // нет ограничений по платформе
}
for _, p := range exp.TargetPlatforms {
if p == platform {
return true
}
}
return false
}
// isVersionMatch проверяет соответствие версии приложения
func (s *AssignmentService) isVersionMatch(exp *Experiment, platform Platform, version string) bool {
minVersion, exists := exp.MinAppVersion[platform]
if !exists {
return true // нет ограничений по версии
}
return compareVersions(version, minVersion) >= 0
}
// isSegmentMatch проверяет соответствие сегментам
func (s *AssignmentService) isSegmentMatch(exp *Experiment, userSegments []string) bool {
if len(exp.TargetSegments) == 0 {
return true // нет ограничений по сегментам
}
// Проверяем пересечение сегментов
segmentSet := make(map[string]bool)
for _, seg := range userSegments {
segmentSet[seg] = true
}
for _, targetSeg := range exp.TargetSegments {
if segmentSet[targetSeg] {
return true
}
}
return false
}
// isUserInTraffic проверяет попадание в процент трафика
func (s *AssignmentService) isUserInTraffic(userID string, trafficPct float64) bool {
if trafficPct >= 1.0 {
return true
}
// Используем детерминированный хеш
hash := fnv.New64a()
hash.Write([]byte(userID))
hashValue := float64(hash.Sum64()) / float64(math.MaxUint64)
return hashValue < trafficPct
}
3. API для получения назначений
package api
// AssignmentsHandler handler для получения назначений
type AssignmentsHandler struct {
assignmentService *abtest.AssignmentService
}
// GetAssignmentsRequest запрос на получение назначений
type GetAssignmentsRequest struct {
UserID string `json:"user_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Country string `json:"country"`
Language string `json:"language"`
}
// GetAssignmentsResponse ответ с назначениями
type GetAssignmentsResponse struct {
UserID string `json:"user_id"`
Assignments []*ExperimentAssignment `json:"assignments"`
}
// GetAssignments обрабатывает запрос на получение назначений
func (h *AssignmentsHandler) GetAssignments(w http.ResponseWriter, r *http.Request) {
var req GetAssignmentsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
userCtx := abtest.UserContext{
UserID: req.UserID,
Platform: abtest.Platform(req.Platform),
AppVersion: req.AppVersion,
Country: req.Country,
Language: req.Language,
}
// Получаем назначения
assignments, err := h.assignmentService.GetUserAssignments(ctx, userCtx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := GetAssignmentsResponse{
UserID: req.UserID,
Assignments: assignments,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
4. SDK для клиентов
package sdk
// ABTestClient клиент для работы с A/B тестами
type ABTestClient struct {
baseURL string
httpClient *http.Client
cache *localCache
}
// localCache локальный кэш клиента
type localCache struct {
assignments map[string]*CachedAssignment
mu sync.RWMutex
ttl time.Duration
}
type CachedAssignment struct {
VariantID string
Config map[string]interface{}
ExpiresAt time.Time
}
// NewABTestClient создаёт новый клиент
func NewABTestClient(baseURL string) *ABTestClient {
return &ABTestClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 100 * time.Millisecond, // жёсткий таймаут
},
cache: &localCache{
assignments: make(map[string]*CachedAssignment),
ttl: 5 * time.Minute,
},
}
}
// GetVariant возвращает вариант для эксперимента
func (c *ABTestClient) GetVariant(ctx context.Context, userID, experimentID string, userCtx UserContext) (string, map[string]interface{}, error) {
// 1. Проверяем локальный кэш
cacheKey := fmt.Sprintf("%s:%s", userID, experimentID)
if cached := c.cache.Get(cacheKey); cached != nil {
return cached.VariantID, cached.Config, nil
}
// 2. Запрашиваем у сервера
assignments, err := c.fetchAssignments(ctx, userCtx)
if err != nil {
// При ошибке возвращаем дефолтный вариант
return "control", nil, err
}
// 3. Кэшируем все назначения
for _, assignment := range assignments {
key := fmt.Sprintf("%s:%s", userID, assignment.ExperimentID)
c.cache.Set(key, &CachedAssignment{
VariantID: assignment.VariantID,
Config: assignment.Config,
ExpiresAt: time.Now().Add(c.cache.ttl),
})
}
// 4. Ищем нужный эксперимент
for _, assignment := range assignments {
if assignment.ExperimentID == experimentID {
return assignment.VariantID, assignment.Config, nil
}
}
// Эксперимент не найден — возвращаем контроль
return "control", nil, nil
}
// fetchAssignments запрашивает назначения у сервера
func (c *ABTestClient) fetchAssignments(ctx context.Context, userCtx UserContext) ([]*ExperimentAssignment, error) {
url := fmt.Sprintf("%s/api/v1/assignments", c.baseURL)
body, _ := json.Marshal(userCtx)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var result GetAssignmentsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Assignments, nil
}
// IsFeatureEnabled проверяет, включена ли фича
func (c *ABTestClient) IsFeatureEnabled(ctx context.Context, userID, featureFlag string, userCtx UserContext) bool {
variant, config, err := c.GetVariant(ctx, userID, featureFlag, userCtx)
if err != nil {
return false // при ошибке фича выключена
}
// Проверяем, включена ли фича в конфигурации
if enabled, ok := config["enabled"].(bool); ok {
return enabled
}
// По умолчанию вариант "treatment" означает включено
return variant == "treatment"
}
5. Оптимизация: batch запросы
// BatchAssignmentsRequest запрос на получение назначений для нескольких пользователей
type BatchAssignmentsRequest struct {
Users []UserContext `json:"users"`
}
// BatchAssignmentsResponse ответ с назначениями для нескольких пользователей
type BatchAssignmentsResponse struct {
Assignments map[string][]*ExperimentAssignment `json:"assignments"`
}
// GetBatchAssignments обрабатывает batch запрос
func (s *AssignmentService) GetBatchAssignments(ctx context.Context, users []UserContext) (map[string][]*ExperimentAssignment, error) {
result := make(map[string][]*ExperimentAssignment)
// Собираем уникальные сегменты
allSegments := make(map[string]bool)
segmentCache := make(map[string][]string)
for _, user := range users {
segments, err := s.segmentService.GetUserSegments(ctx, user.UserID)
if err != nil {
continue
}
segmentCache[user.UserID] = segments
for _, seg := range segments {
allSegments[seg] = true
}
}
// Получаем активные эксперименты один раз
activeExperiments, err := s.experimentStore.GetActiveExperiments(ctx)
if err != nil {
return nil, err
}
// Фильтруем для каждого пользователя
for _, user := range users {
userSegments := segmentCache[user.UserID]
var assignments []*ExperimentAssignment
for _, exp := range activeExperiments {
if s.isExperimentApplicable(exp, user, userSegments) {
variant, err := s.getOrAssignVariant(ctx, user.UserID, exp)
if err != nil {
continue
}
assignments = append(assignments, &ExperimentAssignment{
ExperimentID: exp.ID,
VariantID: variant.ID,
Config: variant.Config,
})
}
}
result[user.UserID] = assignments
}
return result, nil
}
// isExperimentApplicable проверяет применимость эксперимента
func (s *AssignmentService) isExperimentApplicable(exp *Experiment, userCtx UserContext, userSegments []string) bool {
// Проверяем платформу
if !s.isPlatformMatch(exp, userCtx.Platform) {
return false
}
// Проверяем версию
if !s.isVersionMatch(exp, userCtx.Platform, userCtx.AppVersion) {
return false
}
// Проверяем сегменты
if !s.isSegmentMatch(exp, userSegments) {
return false
}
// Проверяем трафик
if !s.isUserInTraffic(userCtx.UserID, exp.TrafficPct) {
return false
}
return true
}
6. Индексация для быстрого поиска
package index
// ExperimentIndex индекс экспериментов для быстрого поиска
type ExperimentIndex struct {
// Индекс по платформе
byPlatform map[Platform]map[string]*Experiment
// Индекс по сегментам
bySegment map[string]map[string]*Experiment
// Все активные эксперименты
activeExps map[string]*Experiment
mu sync.RWMutex
}
// NewExperimentIndex создаёт новый индекс
func NewExperimentIndex() *ExperimentIndex {
return &ExperimentIndex{
byPlatform: make(map[Platform]map[string]*Experiment),
bySegment: make(map[string]map[string]*Experiment),
activeExps: make(map[string]*Experiment),
}
}
// AddExperiment добавляет эксперимент в индекс
func (idx *ExperimentIndex) AddExperiment(exp *Experiment) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.activeExps[exp.ID] = exp
// Индексируем по платформе
for _, platform := range exp.TargetPlatforms {
if idx.byPlatform[platform] == nil {
idx.byPlatform[platform] = make(map[string]*Experiment)
}
idx.byPlatform[platform][exp.ID] = exp
}
// Индексируем по сегментам
for _, segment := range exp.TargetSegments {
if idx.bySegment[segment] == nil {
idx.bySegment[segment] = make(map[string]*Experiment)
}
idx.bySegment[segment][exp.ID] = exp
}
}
// GetRelevantExperiments возвращает эксперименты, релевантные пользователю
func (idx *ExperimentIndex) GetRelevantExperiments(platform Platform, segments []string) []*Experiment {
idx.mu.RLock()
defer idx.mu.RUnlock()
relevant := make(map[string]*Experiment)
// Эксперименты по платформе
if platformExps := idx.byPlatform[platform]; platformExps != nil {
for _, exp := range platformExps {
relevant[exp.ID] = exp
}
}
// Эксперименты по сегментам
for _, segment := range segments {
if segmentExps := idx.bySegment[segment]; segmentExps != nil {
for _, exp := range segmentExps {
relevant[exp.ID] = exp
}
}
}
result := make([]*Experiment, 0, len(relevant))
for _, exp := range relevant {
result = append(result, exp)
}
return result
}
7. Схема взаимодействия
┌─────────────────────────────────────────────────────────────────┐
│ Клиент │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ABTestClient SDK │ │
│ │ - Локальный кэш │ │
│ │ - Fallback на дефолтные значения │ │
│ │ - Таймаут 100ms │ │
│ └─────────────────────────┬───────────────────────────────┘ │
└────────────────────────────┼────────────────────────────────────┘
│
▼
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Assignment │
│ Service │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Experiment │ │ Segment │ │ Variant │
│ Store │ │ Service │ │ Store │
│ │ │ │ │ │
│ Активные тесты │ │ Сегменты │ │ Назначения │
└─────────────────┘ └─────────────────┘ └─────────────────┘
8. Кэширование
// CacheStrategy стратегия кэширования
type CacheStrategy struct {
// Серверный кэш (Redis)
redis *redis.Client
// TTL кэша
ttl time.Duration
}
// GetFromCache получает назначения из кэша
func (c *CacheStrategy) GetFromCache(ctx context.Context, userID string) ([]*ExperimentAssignment, error) {
key := fmt.Sprintf("assignments:%s", userID)
data, err := c.redis.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}
var assignments []*ExperimentAssignment
if err := json.Unmarshal(data, &assignments); err != nil {
return nil, err
}
return assignments, nil
}
// SetToCache сохраняет назначения в кэш
func (c *CacheStrategy) SetToCache(ctx context.Context, userID string, assignments []*ExperimentAssignment) error {
key := fmt.Sprintf("assignments:%s", userID)
data, err := json.Marshal(assignments)
if err != nil {
return err
}
return c.redis.Set(ctx, key, data, c.ttl).Err()
}
// InvalidateCache инвалидирует кэш при изменении эксперимента
func (c *CacheStrategy) InvalidateCache(ctx context.Context, experimentID string) error {
// Находим всех пользователей, которые участвуют в эксперименте
// и инвалидируем их кэш
// ...
return nil
}
9. Fallback стратегия
// FallbackStrategy стратегия fallback
type FallbackStrategy struct {
defaultAssignments map[string]*ExperimentAssignment
}
// GetFallback возвращает fallback назначения
func (f *FallbackStrategy) GetFallback() []*ExperimentAssignment {
result := make([]*ExperimentAssignment, 0, len(f.defaultAssignments))
for _, assignment := range f.defaultAssignments {
result = append(result, assignment)
}
return result
}
// WithFallback оборачивает вызов с fallback
func WithFallback(assignments []*ExperimentAssignment, err error, fallback *FallbackStrategy) []*ExperimentAssignment {
if err != nil {
log.Printf("Failed to get assignments, using fallback: %v", err)
return fallback.GetFallback()
}
if assignments == nil {
return fallback.GetFallback()
}
return assignments
}
10. Итоговый алгоритм
1. Клиент отправляет запрос с user_id и контекстом
2. Сервер проверяет кэш (Redis)
3. Если нет в кэше:
a. Получает сегменты пользователя
b. Получает все активные эксперименты
c. Фильтрует по:
- Платформе
- Версии приложения
- Сегментам
- Проценту трафика
d. Назначает вариант (или возвращает существующий)
e. Сохраняет в кэш
4. Возвращает только релевантные эксперименты
5. Клиент кэширует локально на 5 минут
Такой подход обеспечивает:
- Минимальную задержку — кэширование на всех уровнях
- Корректность — серверная фильтрация
- Отказоустойчивость — fallback на дефолтные значения
- Масштабируемость — индексация и batch запросы
Вопрос 24. Какие данные хранятся в хранилище статистики экспериментов?
Таймкод: 00:47:59
Ответ собеседова: Правильный. Уточняется, что в хранилище статистики складываются аналитические данные, которые представляют собой имя параметра и его числовую характеристику (например, сколько раз нажали кнопку или сколько пользователей дошли до конца воронки).
Правильный ответ:
Структура хранилища статистики
Уровни хранения данных:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Уровень 1: Raw Events │
│ (сырые события, детализированные данные) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Уровень 2: Aggregated │
│ (агрегированные данные по минутам/часам/дням) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Уровень 3: Experiment Results │
│ (финальные результаты экспериментов) │
└─────────────────────────────────────────────────────────────────────────────┘
Уровень 1: Сырые события (ClickHouse):
-- Таблица сырых событий
CREATE TABLE experiment_events (
-- Идентификация
event_id UUID DEFAULT generateUUIDv4(),
experiment_id String NOT NULL,
variant_id String NOT NULL,
user_id String NOT NULL,
-- Временная метка
timestamp DateTime64(3) NOT NULL,
-- Контекст
platform String,
session_id String,
country String,
city String,
-- Тип события и свойства
event_type String NOT NULL,
properties String, -- JSON с дополнительными данными
-- Метаданные
collector_type String, -- "mobile" или "web"
received_at DateTime64(3) DEFAULT now64(3),
-- Индексы для быстрого поиска
INDEX idx_experiment_variant (experiment_id, variant_id) TYPE minmax GRANULARITY 4,
INDEX idx_user (user_id) TYPE bloom_filter GRANULARITY 4,
INDEX idx_timestamp (timestamp) TYPE minmax GRANULARITY 4,
INDEX idx_event_type (event_type) TYPE set(100) GRANULARITY 4
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, timestamp)
TTL timestamp + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
-- Пример данных
-- event_id | experiment_id | variant_id | user_id | timestamp | event_type | properties
-- ---------|---------------|------------|---------|-----------|------------|----------
-- uuid-1 | exp_123 | control | user_42 | 2024-01-15 10:30:00 | page_view | {"page": "/checkout", "referrer": "google"}
-- uuid-2 | exp_123 | treatment | user_43 | 2024-01-15 10:31:00 | button_click | {"button": "buy_now", "position": "top"}
-- uuid-3 | exp_123 | control | user_42 | 2024-01-15 10:35:00 | conversion | {"revenue": 99.99, "product_id": "prod_123"}
Уровень 2: Арегированные данные (ClickHouse):
-- Статистика по минутам
CREATE TABLE experiment_minute_stats (
experiment_id String,
variant_id String,
minute DateTime,
-- Основные метрики
events UInt64,
unique_users UInt64,
-- Конверсии
conversions UInt64,
revenue Float64,
-- Пользовательские метрики (хранятся как Map)
custom_metrics Map(String, Float64)
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (experiment_id, variant_id, minute)
TTL minute + INTERVAL 180 DAY;
-- Статистика по часам
CREATE TABLE experiment_hourly_stats (
experiment_id String,
variant_id String,
hour DateTime,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64,
custom_metrics Map(String, Float64)
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (experiment_id, variant_id, hour)
TTL hour + INTERVAL 365 DAY;
-- Статистика по дням
CREATE TABLE experiment_daily_stats (
experiment_id String,
variant_id String,
date Date,
events UInt64,
unique_users UInt64,
conversions UInt64,
revenue Float64,
custom_metrics Map(String, Float64)
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant_id, date);
-- Materialized Views для автоматической агрегации
CREATE MATERIALIZED VIEW experiment_minute_stats_mv
TO experiment_minute_stats
AS
SELECT
experiment_id,
variant_id,
toStartOfMinute(timestamp) as minute,
count() as events,
uniqExact(user_id) as unique_users,
countIf(event_type = 'conversion') as conversions,
sumIf(
CASE
WHEN event_type = 'conversion'
THEN JSONExtractFloat(properties, 'revenue')
ELSE 0
END
) as revenue,
mapFromArrays(
['add_to_cart', 'checkout_started', 'payment_completed'],
[
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'checkout_started'),
countIf(event_type = 'payment_completed')
]
) as custom_metrics
FROM experiment_events
GROUP BY experiment_id, variant_id, toStartOfMinute(timestamp);
CREATE MATERIALIZED VIEW experiment_daily_stats_mv
TO experiment_daily_stats
AS
SELECT
experiment_id,
variant_id,
toDate(timestamp) as date,
count() as events,
uniqExact(user_id) as unique_users,
countIf(event_type = 'conversion') as conversions,
sumIf(
CASE
WHEN event_type = 'conversion'
THEN JSONExtractFloat(properties, 'revenue')
ELSE 0
END
) as revenue,
mapFromArrays(
['add_to_cart', 'checkout_started', 'payment_completed'],
[
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'checkout_started'),
countIf(event_type = 'payment_completed')
]
) as custom_metrics
FROM experiment_events
GROUP BY experiment_id, variant_id, toDate(timestamp);
Уровень 3: Результаты экспериментов (PostgreSQL):
-- Финальные результаты экспериментов
CREATE TABLE experiment_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id UUID REFERENCES experiments(id),
-- Период проведения
start_date DATE NOT NULL,
end_date DATE NOT NULL,
-- Общая статистика
total_users INTEGER NOT NULL,
total_events BIGINT NOT NULL,
-- Результаты по вариантам (JSONB для гибкости)
variant_results JSONB NOT NULL,
-- Пример структуры variant_results:
-- {
-- "control": {
-- "users": 50000,
-- "events": 150000,
-- "conversions": 2500,
-- "revenue": 249750.00,
-- "conversion_rate": 0.05,
-- "arpu": 4.995
-- },
-- "treatment": {
-- "users": 50000,
-- "events": 155000,
-- "conversions": 2750,
-- "revenue": 274725.00,
-- "conversion_rate": 0.055,
-- "arpu": 5.495
-- }
-- }
-- Статистический анализ
statistical_analysis JSONB,
-- {
-- "is_significant": true,
-- "p_value": 0.03,
-- "confidence_interval": [0.002, 0.008],
-- "effect_size": 0.10,
-- "power": 0.85
-- }
-- Воронки
funnels JSONB,
-- {
-- "main_funnel": {
-- "control": [
-- {"step": "page_view", "users": 50000, "conversion": 1.0},
-- {"step": "add_to_cart", "users": 10000, "conversion": 0.2},
-- {"step": "checkout", "users": 5000, "conversion": 0.5},
-- {"step": "purchase", "users": 2500, "conversion": 0.5}
-- ],
-- "treatment": [...]
-- }
-- }
-- Выводы
conclusion TEXT,
winner_variant VARCHAR(100),
-- Метаданные
calculated_at TIMESTAMP NOT NULL DEFAULT NOW(),
calculated_by VARCHAR(100) NOT NULL
);
-- Индексы
CREATE INDEX idx_results_experiment_id ON experiment_results(experiment_id);
CREATE INDEX idx_results_dates ON experiment_results(start_date, end_date);
CREATE INDEX idx_results_winner ON experiment_results(winner_variant);
Real-time данные (Redis):
type RealtimeStatsSchema struct {
// Ключ: exp:{experiment_id}:variant:{variant_id}:{metric}:{timestamp}
// Примеры ключей:
// exp:exp_123:variant:control:events:1705312200 -> 150
// exp:exp_123:variant:control:conversions:1705312200 -> 5
// exp:exp_123:variant:control:revenue:1705312200 -> 499.95
// exp:exp_123:users:1705312200 -> HyperLogLog
}
type RealtimeStatsService struct {
redis *redis.Client
}
func (s *RealtimeStatsService) GetRealtimeStats(ctx context.Context, experimentID string, variantID string) (*RealtimeStats, error) {
now := time.Now()
pipe := s.redis.Pipeline()
// Получаем данные за последние 5 минут
for i := 0; i < 5; i++ {
minute := now.Add(-time.Duration(i) * time.Minute).Truncate(time.Minute).Unix()
pipe.Get(ctx, fmt.Sprintf("exp:%s:variant:%s:events:%d", experimentID, variantID, minute))
pipe.Get(ctx, fmt.Sprintf("exp:%s:variant:%s:conversions:%d", experimentID, variantID, minute))
pipe pipe.Get(ctx, fmt.Sprintf("exp:%s:variant:%s:revenue:%d", experimentID, variantID, minute))
}
results, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}
stats := &RealtimeStats{
ExperimentID: experimentID,
VariantID: variantID,
LastUpdated: now,
}
// Агрегируем результаты
for _, result := range results {
if val, err := result.(*redis.StringCmd).Int64(); err == nil {
stats.Events += val
}
}
return stats, nil
}
Пользовательские метрики:
type CustomMetrics struct {
// Предопределенные метрики
PredefinedMetrics []string
// ["add_to_cart", "checkout_started", "payment_completed", "refund"]
// Динамические метрики (определяются аналитиком)
DynamicMetrics map[string]MetricDefinition
}
type MetricDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
EventType string `json:"event_type"`
Property string `json:"property,omitempty"`
Aggregation string `json:"aggregation"` // "count", "sum", "avg", "min", "max"
}
func (m *CustomMetrics) Calculate(event *AnalyticsEvent) map[string]float64 {
result := make(map[string]float64)
for name, def := range m.DynamicMetrics {
if event.EventType != def.EventType {
continue
}
switch def.Aggregation {
case "count":
result[name] = 1
case "sum":
if val, ok := event.Properties[def.Property]; ok {
result[name] = val.(float64)
}
}
}
return result
}
Пример полного набора данных эксперимента:
{
"experiment_id": "exp_123",
"experiment_name": "New checkout button color",
"start_date": "2024-01-01",
"end_date": "2024-01-14",
"total_users": 100000,
"total_events": 500000,
"variants": {
"control": {
"users": 50000,
"events": 245000,
"conversions": 2500,
"revenue": 249750.00,
"conversion_rate": 0.05,
"arpu": 4.995,
"custom_metrics": {
"add_to_cart": 10000,
"checkout_started": 5000,
"payment_completed": 2500,
"refund": 50
}
},
"treatment": {
"users": 50000,
"events": 255000,
"conversions": 2750,
"revenue": 274725.00,
"conversion_rate": 0.055,
"arpu": 5.495,
"custom_metrics": {
"add_to_cart": 11000,
"checkout_started": 5500,
"payment_completed": 2750,
"refund": 55
}
}
},
"funnels": {
"main_funnel": {
"control": [
{"step": "page_view", "users": 50000, "conversion": 1.0},
{"step": "add_to_cart", "users": 10000, "conversion": 0.2},
{"step": "checkout", "users": 5000, "conversion": 0.5},
{"step": "purchase", "users": 2500, "conversion": 0.5}
],
"treatment": [
{"step": "page_view", "users": 50000, "conversion": 1.0},
{"step": "add_to_cart", "users": 11000, "conversion": 0.22},
{"step": "checkout", "users": 5500, "conversion": 0.5},
{"step": "purchase", "users": 2750, "conversion": 0.5}
]
}
},
"statistical_analysis": {
"is_significant": true,
"p_value": 0.03,
"confidence_interval": [0.002, 0.008],
"effect_size": 0.10,
"power": 0.85
},
"conclusion": "Treatment variant shows 10% improvement in conversion rate with statistical significance",
"winner_variant": "treatment"
}
Вывод:
Хранилище статистики содержит:
- Сырые события — детализированные данные о каждом действии пользователя
- Агрегированные данные — предрассчитанные метрики по минутам/часам/дням
- Результаты экспериментов — финальные расчеты с статистическим анализом
- Воронки — последовательности действий пользователей
- Real-time метрики — текущие показатели для мониторинга
- Пользовательские метрики — гибкие определения аналитиков
Вопрос 13. Когда применяются сегменты — при входе в приложение или в реальном времени?
Таймкод: 00:24:27
Ответ собеседника: Правильный. Уточняется, что для простоты системы список экспериментов загружается на старте приложения, и дальнейшие изменения списка в ходе работы не происходят.
Правильный ответ:
1. Выбранный подход: загрузка на старте
Для первой версии системы принимаем упрощение:
- Список экспериментов загружается один раз при входе в приложение
- В течение сессии список не обновляется
- Изменения сегментов учитываются при следующем запуске приложения
Жизненный цикл:
[Запуск приложения] → [Загрузка экспериментов] → [Работа с кэшем]
↓
[Перезапуск приложения]
↓
[Обновление списка]
2. Реализация загрузки на старте
package sdk
// AppLauncher запускатель приложения с A/B тестами
type AppLauncher struct {
abTestClient *ABTestClient
cache *LocalExperimentCache
}
// LocalExperimentCache локальный кэш экспериментов
type LocalExperimentCache struct {
assignments map[string]*CachedExperiment
loadedAt time.Time
mu sync.RWMutex
}
type CachedExperiment struct {
ExperimentID string
VariantID string
Config map[string]interface{}
}
// OnAppStart вызывается при запуске приложения
func (l *AppLauncher) OnAppStart(ctx context.Context, userCtx UserContext) error {
// Загружаем эксперименты с сервера
assignments, err := l.abTestClient.FetchAssignments(ctx, userCtx)
if err != nil {
// При ошибке используем пустой кэш (все фичи выключены)
log.Printf("Failed to load experiments: %v", err)
l.cache.Clear()
return nil // Не блокируем запуск приложения
}
// Сохраняем в локальный кэш
l.cache.SetAll(assignments)
return nil
}
// GetExperiment возвращает эксперимент из локального кэша
func (l *AppLauncher) GetExperiment(experimentID string) *CachedExperiment {
return l.cache.Get(experimentID)
}
// SetAll сохраняет все назначения в кэш
func (c *LocalExperimentCache) SetAll(assignments []*ExperimentAssignment) {
c.mu.Lock()
defer c.mu.Unlock()
c.assignments = make(map[string]*CachedExperiment)
for _, a := range assignments {
c.assignments[a.ExperimentID] = &CachedExperiment{
ExperimentID: a.ExperimentID,
VariantID: a.VariantID,
Config: a.Config,
}
}
c.loadedAt = time.Now()
}
// Get возвращает эксперимент из кэша
func (c *LocalExperimentCache) Get(experimentID string) *CachedExperiment {
c.mu.RLock()
defer c.mu.RUnlock()
return c.assignments[experimentID]
}
3. Сравнение подходов
package comparison
// Подход 1: Загрузка на старте (выбранный)
type StartupLoader struct {
cache *LocalExperimentCache
}
func (l *StartupLoader) Load(ctx context.Context, userCtx UserContext) error {
// Один запрос при запуске
assignments, err := l.fetchFromServer(ctx, userCtx)
if err != nil {
return err
}
// Сохраняем в кэш
l.cache.SetAll(assignments)
// Больше не обновляем до перезапуска
return nil
}
// Подход 2: Периодическое обновление (НЕ используется)
type PeriodicRefresh struct {
cache *LocalExperimentCache
interval time.Duration
}
func (r *PeriodicRefresh) Start(ctx context.Context, userCtx UserContext) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Обновляем каждые N минут
assignments, err := r.fetchFromServer(ctx, userCtx)
if err != nil {
continue
}
r.cache.SetAll(assignments)
}
}
}
// Подход 3: Реал-тайм обновления через WebSocket (НЕ используется)
type RealtimeUpdater struct {
cache *LocalExperimentCache
wsConn *websocket.Conn
}
func (u *RealtimeUpdater) Listen(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
var update ExperimentUpdate
if err := u.wsConn.ReadJSON(&update); err != nil {
continue
}
// Обновляем конкретный эксперимент
u.cache.Set(update.ExperimentID, &update.Assignment)
}
}
}
4. Интеграция с жизненным циклом приложения
// MobileApp мобильное приложение
type MobileApp struct {
abTestLauncher *AppLauncher
userCtx UserContext
}
// OnCreate вызывается при создании приложения
func (app *MobileApp) OnCreate() {
ctx := context.Background()
// Загружаем эксперименты на старте
// Таймаут 2 секунды, чтобы не блокировать запуск
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := app.abTestLauncher.OnAppStart(ctx, app.userCtx); err != nil {
log.Printf("Failed to load experiments on startup: %v", err)
// Приложение продолжает работу с дефолтными значениями
}
}
// OnResume вызывается при возврате из фона
func (app *MobileApp) OnResume() {
// НЕ обновляем эксперименты
// Используем кэш с запуска
}
// IsFeatureEnabled проверяет фичу из локального кэша
func (app *MobileApp) IsFeatureEnabled(featureID string) bool {
exp := app.abTestLauncher.GetExperiment(featureID)
if exp == nil {
return false // Фича не найдена — выключена
}
if enabled, ok := exp.Config["enabled"].(bool); ok {
return enabled
}
return exp.VariantID == "treatment"
}
5. Веб-приложение: загрузка при инициализации
// JavaScript SDK для веб-приложения
class ABTestClient {
constructor(apiUrl, userId) {
this.apiUrl = apiUrl;
this.userId = userId;
this.cache = new Map();
this.loaded = false;
}
// Вызывается при загрузке приложения
async initialize(userContext) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const response = await fetch(`${this.apiUrl}/api/v1/assignments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: this.userId,
...userContext
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
this.cache.clear();
data.assignments.forEach(a => {
this.cache.set(a.experiment_id, a);
});
this.loaded = true;
}
} catch (error) {
console.error('Failed to load experiments:', error);
// Приложение работает с дефолтными значениями
}
}
// Получение варианта из кэша
getVariant(experimentId) {
const assignment = this.cache.get(experimentId);
return assignment ? assignment.variant_id : 'control';
}
// Проверка фичи
isFeatureEnabled(featureId) {
const assignment = this.cache.get(featureId);
if (!assignment) return false;
if (assignment.config && typeof assignment.config.enabled === 'boolean') {
return assignment.config.enabled;
}
return assignment.variant_id === 'treatment';
}
}
// Использование в React
function App() {
const [abTest] = useState(() => new ABTestClient(API_URL, getUserId()));
useEffect(() => {
// Загружаем эксперименты при монтировании
abTest.initialize({
platform: 'web',
app_version: APP_VERSION,
country: getUserCountry(),
language: getUserLanguage()
});
}, []);
return (
<div>
{abTest.isFeatureEnabled('new_checkout') && <NewCheckout />}
{abTest.isFeatureEnabled('recommendations_v2') && <RecommendationsV2 />}
</div>
);
}
6. Обработка изменения сегментов
package segment
// SegmentChangeHandler обработчик изменений сегментов
type SegmentChangeHandler struct {
// В текущей реализации изменения учитываются при следующем запуске
}
// OnSegmentChange вызывается при изменении сегментов пользователя
func (h *SegmentChangeHandler) OnSegmentChange(userID string, newSegments []string) {
// В текущей реализации НЕ делаем ничего
// Изменения будут учтены при следующем запуске приложения
log.Printf("Segments changed for user %s: %v", userID, newSegments)
// В будущих версиях можно добавить:
// 1. Отправку push-уведомления для перезапуска
// 2. Инвалидацию кэша на сервере
// 3. Периодическое обновление
}
// ServerSideCacheInvalidation инвалидация серверного кэша
type ServerSideCacheInvalidation struct {
redis *redis.Client
}
// InvalidateUserCache инвалидирует кэш пользователя
func (s *ServerSideCacheInvalidation) InvalidateUserCache(ctx context.Context, userID string) error {
key := fmt.Sprintf("assignments:%s", userID)
return s.redis.Del(ctx, key).Err()
}
7. Стратегии обновления для будущих версий
package future
// Стратегия 1: Обновление при возврате из фона
type ResumeRefreshStrategy struct {
cache *LocalExperimentCache
client *ABTestClient
maxAge time.Duration
}
func (s *ResumeRefreshStrategy) OnAppResume(ctx context.Context, userCtx UserContext) {
// Проверяем возраст кэша
if time.Since(s.cache.GetLoadedAt()) > s.maxAge {
// Обновляем если кэш старше maxAge
assignments, err := s.client.FetchAssignments(ctx, userCtx)
if err == nil {
s.cache.SetAll(assignments)
}
}
}
// Стратегия 2: Фоновое обновление
type BackgroundRefreshStrategy struct {
cache *LocalExperimentCache
client *ABTestClient
interval time.Duration
}
func (s *BackgroundRefreshStrategy) Start(ctx context.Context, userCtx UserContext) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
assignments, err := s.client.FetchAssignments(ctx, userCtx)
if err == nil {
s.cache.SetAll(assignments)
}
}
}
}
// Стратегия 3: Silent push для мобильных приложений
type SilentPushHandler struct {
cache *LocalExperimentCache
}
func (h *SilentPushHandler) HandleSilentPush(userID string) {
// Сервер отправляет silent push когда сегменты изменились
// Приложение обновляет кэш в фоне
// ...
}
8. Схема работы системы
┌─────────────────────────────────────────────────────────────────┐
│ Запуск приложения │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────┐
│ OnAppStart() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Fetch │
│ Assignments │
└────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Success │ │ Timeout │ │ Error │
│ │ │ (>2s) │ │ │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Save to │ │ Use empty │ │ Use empty │
│ cache │ │ cache │ │ cache │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
└──────────────┼──────────────┘
│
▼
┌─────────────────┐
│ App continues │
│ with cached │
│ experiments │
└─────────────────┘
│
▼
┌─────────────────┐
│ During session │
│ NO updates │
└─────────────────┘
│
▼
┌─────────────────┐
│ Next app start │
│ → refresh │
└─────────────────┘
9. Преимущества выбранного подхода
✅ Простота реализации
- Один запрос при запуске
- Нет необходимости в WebSocket/polling
- Минимальная сложность на клиенте
✅ Предсказуемость
- Пользователь видит консистентный опыт в рамках сессии
- Нет "прыгающего" интерфейса
✅ Производительность
- Нет сетевых запросов во время работы
- Мгновенный доступ к экспериментам из кэша
✅ Надёжность
- Приложение работает даже при недоступности сервера
- Graceful degradation
10. Ограничения и когда менять подход
⚠️ Ограничения:
- Изменения сегментов учитываются с задержкой (до перезапуска)
- Нельзя мгновенно исключить пользователя из эксперимента
- Нельзя добавить новый эксперимент без перезапуска
📋 Когда добавлять обновления в реальном времени:
- Критичные эксперименты (безопасность, баги)
- Эксперименты с коротким временем жизни
- Необходимость мгновенного отключения фич
📋 Рекомендация:
Для MVP — загрузка на старте оптимальна.
Для продакшена — рассмотреть гибридный подход:
- Загрузка на старте для большинства экспериментов
- Silent push для критичных изменений
Выбранный подход загрузки экспериментов на старте приложения — это оптимальное решение для первой версии системы, обеспечивающее простоту, предсказуемость и надёжность.
Вопрос 25. Какую статистику видит аналитик на странице эксперимента?
Таймкод: 00:48:50
Ответ собеседова: Правильный. Уточняется, что аналитик видит воронку в разрезе эксперимента: сколько пользователей попали в baseline, сколько в экспериментальную группу, как они проходят по шагам (экранам). Это сравнение поведения контрольной и экспериментальной групп.
Правильный ответ:
Дашборд аналитика на странице эксперимента
Структура страницы:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Эксперимент: "New checkout button color" │
│ Статус: Running | Начало: 01.01.2024 | Участники: 100,000 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ ОБЩАЯ СТАТИСТИКА │ │
│ ├─────────────────────┬─────────────────────┬─────────────────────────────┤ │
│ │ Control │ Treatment │ Difference │ │
│ │ (50,000) │ (50,000) │ │ │
│ ├─────────────────────┼─────────────────────┼─────────────────────────────┤ │
│ │ Users: 50,000 │ Users: 50,000 │ │ │
│ │ Conv: 2,500 (5.0%) │ Conv: 2,750 (5.5%) │ +250 (+10%) │ │
│ │ Revenue: $249,750 │ Revenue: $274,725 │ +$24,975 (+10%) │ │
│ │ ARPU: $4.99 │ ARPU: $5.49 │ +$0.50 (+10%) │ │
│ └─────────────────────┴─────────────────────┴─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ ВОРОНКА │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ Page View │ ████████████████████ 50,000 (100%) │ ████████████████████ 50,000 (100%) │
│ │ Add to Cart │ ██████████ 10,000 (20.0%) │ ███████████ 11,000 (22.0%) │
│ │ Checkout │ █████ 5,000 (50.0%) │ █████ 5,500 (50.0%) │
│ │ Purchase │ ██ 2,500 (50.0%) │ ██ 2,750 (50.0%) │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ СТАТИСТИЧЕСКИЙ АНАЛИЗ │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ ✓ Statistically Significant (p-value: 0.03) │ │
│ │ Confidence Interval: [0.2%, 0.8%] │ │
│ │ Statistical Power: 85% │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
API для получения данных дашборда:
type DashboardService struct {
analyticsService *AnalyticsService
funnelService *FunnelService
statsService *StatisticalService
}
type ExperimentDashboardResponse struct {
Experiment ExperimentSummary `json:"experiment"`
Stats DashboardStats `json:"stats"`
Funnel *FunnelData `json:"funnel"`
Timeseries []TimeseriesPoint `json:"timeseries"`
Significance *SignificanceResult `json:"significance"`
}
type ExperimentSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Status ExperimentStatus `json:"status"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
TotalUsers int64 `json:"total_users"`
Variants []VariantInfo `json:"variants"`
}
type VariantInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Users int64 `json:"users"`
TrafficPct int `json:"traffic_percent"`
}
type DashboardStats struct {
Control VariantStats `json:"control"`
Treatment VariantStats `json:"treatment"`
Difference StatsDifference `json:"difference"`
}
type VariantStats struct {
Users int64 `json:"users"`
Events int64 `json:"events"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
ConversionRate float64 `json:"conversion_rate"`
ARPU float64 `json:"arpu"`
CustomMetrics map[string]float64 `json:"custom_metrics"`
}
type StatsDifference struct {
ConversionRateDelta float64 `json:"conversion_rate_delta"`
ConversionRatePct float64 `json:"conversion_rate_pct"`
RevenueDelta float64 `json:"revenue_delta"`
RevenuePct float64 `json:"revenue_pct"`
}
func (s *DashboardService) GetDashboard(ctx context.Context, experimentID string) (*ExperimentDashboardResponse, error) {
// Получаем эксперимент
exp, err := s.analyticsService.GetExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
// Получаем статистику
stats, err := s.analyticsService.GetExperimentStats(ctx, experimentID)
if err != nil {
return nil, err
}
// Получаем воронку
funnel, err := s.funnelService.GetFunnel(ctx, experimentID)
if err != nil {
funnel = nil // Воронка опциональна
}
// Рассчитываем статистическую значимость
significance := s.statsService.CalculateSignificance(stats.Control, stats.Treatment)
return &ExperimentDashboardResponse{
Experiment: ExperimentSummary{
ID: exp.ID,
Name: exp.Name,
Status: exp.Status,
StartTime: exp.StartTime,
EndTime: exp.EndTime,
TotalUsers: stats.Control.Users + stats.Treatment.Users,
Variants: buildVariantInfo(exp.Variants),
},
Stats: DashboardStats{
Control: stats.Control,
Treatment: stats.Treatment,
Difference: calculateDifference(stats.Control, stats.Treatment),
},
Funnel: funnel,
Significance: significance,
}, nil
}
Endpoint для дашборда:
type DashboardHandler struct {
dashboardService *DashboardService
}
func (h *DashboardHandler) GetExperimentDashboard(w http.ResponseWriter, r *http.Request) {
experimentID := chi.URLParam(r, "experimentID")
dashboard, err := h.dashboardService.GetDashboard(r.Context(), experimentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(dashboard)
}
func (h *DashboardHandler) GetFunnelData(w http.ResponseWriter, r *http.Request) {
experimentID := chi.URLParam(r, "experimentID")
var req FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
req.Steps = []string{"page_view", "add_to_cart", "checkout", "purchase"}
}
funnel, err := h.dashboardService.funnelService.GetFunnel(r.Context(), experimentID, req.Steps)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(funnel)
}
Расчет статистической значимости:
type StatisticalService struct{}
type SignificanceResult struct {
IsSignificant bool `json:"is_significant"`
PValue float64 `json:"p_value"`
ConfidenceLevel float64 `json:"confidence_level"`
ConfidenceInterval [2]float64 `json:"confidence_interval"`
EffectSize float64 `json:"effect_size"`
Power float64 `json:"power"`
Recommendation string `json:"recommendation"`
}
func (s *StatisticalService) CalculateSignificance(control, treatment VariantStats) *SignificanceResult {
// Z-test для пропорций
p1 := control.ConversionRate
p2 := treatment.ConversionRate
n1 := float64(control.Users)
n2 := float64(treatment.Users)
// Pooled proportion
x1 := p1 * n1
x2 := p2 * n2
p := (x1 + x2) / (n1 + n2)
// Standard error
se := math.Sqrt(p * (1 - p) * (1/n1 + 1/n2))
// Z-score
z := (p2 - p1) / se
// P-value (two-tailed)
pValue := 2 * (1 - normalCDF(math.Abs(z)))
// Confidence interval (95%)
diff := p2 - p1
margin := 1.96 * se
// Effect size (relative)
effectSize := (p2 - p1) / p1
// Statistical power
power := s.calculatePower(n1, n2, p1, p2)
// Recommendation
recommendation := s.getRecommendation(pValue, power, effectSize)
return &SignificanceResult{
IsSignificant: pValue < 0.05,
PValue: pValue,
ConfidenceLevel: 0.95,
ConfidenceInterval: [2]float64{diff - margin, diff + margin},
EffectSize: effectSize,
Power: power,
Recommendation: recommendation,
}
}
func (s *StatisticalService) getRecommendation(pValue, power, effectSize float64) string {
if pValue >= 0.05 {
return "Not enough evidence to conclude. Continue experiment or increase sample size."
}
if power < 0.8 {
return "Significant but low power. Consider running longer for more confidence."
}
if effectSize > 0 {
return fmt.Sprintf("Treatment wins! %.1f%% improvement detected.", effectSize*100)
}
return fmt.Sprintf("Control wins! Treatment shows %.1f%% decrease.", -effectSize*100)
}
func normalCDF(x float64) float64 {
return 0.5 * math.Erfc(-x/math.Sqrt(2))
}
Timeseries для графиков:
type TimeseriesPoint struct {
Date time.Time `json:"date"`
Control map[string]float64 `json:"control"`
Treatment map[string]float64 `json:"treatment"`
}
func (s *AnalyticsService) GetTimeseries(ctx context.Context, experimentID string, startDate, endDate time.Time) ([]TimeseriesPoint, error) {
query := `
SELECT
date,
variant_id,
unique_users,
conversions,
revenue
FROM experiment_daily_stats
WHERE experiment_id = ?
AND date BETWEEN ? AND ?
ORDER BY date, variant_id
`
rows, err := s.clickhouse.Query(ctx, query, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
// Группируем по датам
byDate := make(map[time.Time]*TimeseriesPoint)
for rows.Next() {
var date time.Time
var variantID string
var users uint64
var conversions uint64
var revenue float64
if err := rows.Scan(&date, &variantID, &users, &conversions, &revenue); err != nil {
continue
}
if _, ok := byDate[date]; !ok {
byDate[date] = &TimeseriesPoint{
Date: date,
Control: make(map[string]float64),
Treatment: make(map[string]float64),
}
}
metrics := map[string]float64{
"users": float64(users),
"conversions": float64(conversions),
"revenue": revenue,
"conv_rate": float64(conversions) / float64(users),
}
if variantID == "control" {
byDate[date].Control = metrics
} else {
byDate[date].Treatment = metrics
}
}
// Конвертируем в слайс
result := make([]TimeseriesPoint, 0, len(byDate))
for _, point := range byDate {
result = append(result, *point)
}
// Сортируем по дате
sort.Slice(result, func(i, j int) bool {
return result[i].Date.Before(result[j].Date)
})
return result, nil
}
Формирование ответа для фронтенда:
func calculateDifference(control, treatment VariantStats) StatsDifference {
convRateDelta := treatment.ConversionRate - control.ConversionRate
var convRatePct float64
if control.ConversionRate > 0 {
convRatePct = (convRateDelta / control.ConversionRate) * 100
}
revenueDelta := treatment.Revenue - control.Revenue
var revenuePct float64
if control.Revenue > 0 {
revenuePct = (revenueDelta / control.Revenue) * 100
}
return StatsDifference{
ConversionRateDelta: convRateDelta,
ConversionRatePct: convRatePct,
RevenueDelta: revenueDelta,
RevenuePct: revenuePct,
}
}
Пример ответа API:
{
"experiment": {
"id": "exp_123",
"name": "New checkout button color",
"status": "running",
"start_time": "2024-01-01T00:00:00Z",
"total_users": 100000,
"variants": [
{"id": "control", "name": "Blue button", "users": 50000, "traffic_percent": 50},
{"id": "treatment", "name": "Red button", "users": 50000, "traffic_percent": 50}
]
},
"stats": {
"control": {
"users": 50000,
"events": 245000,
"conversions": 2500,
"revenue": 249750.00,
"conversion_rate": 0.05,
"arpu": 4.995,
"custom_metrics": {
"add_to_cart": 10000,
"checkout_started": 5000
}
},
"treatment": {
"users": 50000,
"events": 255000,
"conversions": 2750,
"revenue": 274725.00,
"conversion_rate": 0.055,
"arpu": 5.495,
"custom_metrics": {
"add_to_cart": 11000,
"checkout_started": 5500
}
},
"difference": {
"conversion_rate_delta": 0.005,
"conversion_rate_pct": 10.0,
"revenue_delta": 24975.00,
"revenue_pct": 10.0
}
},
"funnel": {
"steps": ["page_view", "add_to_cart", "checkout", "purchase"],
"control": [
{"step": "page_view", "users": 50000, "conversion": 1.0},
{"step": "add_to_cart", "users": 10000, "conversion": 0.2},
{"step": "checkout", "users": 5000, "conversion": 0.5},
{"step": "purchase", "users": 2500, "conversion": 0.5}
],
"treatment": [
{"step": "page_view", "users": 50000, "conversion": 1.0},
{"step": "add_to_cart", "users": 11000, "conversion": 0.22},
{"step": "checkout", "users": 5500, "conversion": 0.5},
{"step": "purchase", "users": 2750, "conversion": 0.5}
]
},
"significance": {
"is_significant": true,
"p_value": 0.03,
"confidence_level": 0.95,
"confidence_interval": [0.002, 0.008],
"effect_size": 0.10,
"power": 0.85,
"recommendation": "Treatment wins! 10% improvement detected."
}
}
Вывод:
На странице эксперимента аналитик видит:
- Общую статистику — количество пользователей, конверсии, выручку по вариантам
- Воронку — последовательность действий с конверсиями на каждом шаге
- Сравнение вариантов — дельта в абсолютных и относительных значениях
- Статистический анализ — p-value, доверительный интервал, power
- Рекомендацию — автоматический вывод на основе данных
- Timeseries — графики динамики метрик по дням
Вопрос 26. Нужно ли подтягивать оффлайн-события для анализа экспериментов?
Таймкод: 00:49:41
Ответ собеседова: Правильный. Уточняется, что подтягивание оффлайн-событий (например, получил ли пользователь карту) выходит за рамки системы. Это интеграция с Data Warehouse, аналогичная подгрузке сегментов. Система собирает только онлайн-события.
Правильный ответ:
Оффлайн-события в A/B тестировании
Архитектура интеграции с оффлайн-данными:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ОНЛАЙН СОБЫТИЯ │
│ (клики, просмотры, покупки на сайте) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ СИСТЕМА A/B ТЕСТИРОВАНИЯ │
│ (собирает и анализирует онлайн) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA WAREHOUSE │
│ (объединяет онлайн и оффлайн данные) │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Онлайн события │ │ Оффлайн события│ │ Сегменты пользователей │ │
│ │ (ClickHouse) │ │ (PostgreSQL) │ │ (Redis) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ АНАЛИТИЧЕСКИЙ СЛОЙ │
│ (когортный анализ, расширенная атрибуция) │
└─────────────────────────────────────────────────────────────────────────────┘
Типы оффлайн-событий:
// Оффлайн события - это события, которые происходят вне цифровых каналов
type OfflineEvent struct {
UserID string `json:"user_id"`
EventType string `json:"event_type"` // "card_issued", "card_activated", "branch_visit"
Timestamp time.Time `json:"timestamp"`
Properties map[string]interface{} `json:"properties"`
}
// Примеры оффлайн-событий для банковского приложения
var OfflineEventTypes = []string{
"card_issued", // Выдана карта
"card_activated", // Карта активирована
"card_blocked", // Карта заблокирована
"branch_visit", // Посещение отделения
"atm_withdrawal", // Снятие в банкомате
"loan_approved", // Кредит одобрен
"deposit_opened", // Вклад открыт
"insurance_purchased", // Страховка куплена
}
Сервис интеграции с Data Warehouse:
type OfflineDataIntegrationService struct {
dataWarehouse *DataWarehouseClient
analyticsDB *clickhouse.Conn
cache *redis.Client
}
type OfflineEventQuery struct {
ExperimentID string
EventType string
StartDate time.Time
EndDate time.Time
}
func (s *OfflineDataIntegrationService) GetOfflineMetrics(
ctx context.Context,
query OfflineEventQuery,
) (*OfflineMetricsResult, error) {
// Получаем список пользователей эксперимента
experimentUsers, err := s.getExperimentUsers(ctx, query.ExperimentID)
if err != nil {
return nil, err
}
// Запрашиваем оффлайн события из Data Warehouse
offlineEvents, err := s.dataWarehouse.GetOfflineEvents(ctx, DataWarehouseQuery{
UserIDs: experimentUsers,
EventTypes: []string{query.EventType},
StartDate: query.StartDate,
EndDate: query.EndDate,
})
if err != nil {
return nil, err
}
// Агрегируем по вариантам эксперимента
result := s.aggregateByVariant(experimentUsers, offlineEvents)
return result, nil
}
func (s *OfflineDataIntegrationService) aggregateByVariant(
experimentUsers []ExperimentUser,
offlineEvents []OfflineEvent,
) *OfflineMetricsResult {
// Индекс пользователей по вариантам
userVariantIndex := make(map[string]string)
for _, u := range experimentUsers {
userVariantIndex[u.UserID] = u.VariantID
}
// Агрегируем
variantStats := make(map[string]*VariantOfflineStats)
for _, event := range offlineEvents {
variantID, ok := userVariantIndex[event.UserID]
if !ok {
continue
}
if _, exists := variantStats[variantID]; !exists {
variantStats[variantID] = &VariantOfflineStats{
VariantID: variantID,
}
}
stats := variantStats[variantID]
stats.EventCount++
stats.UniqueUsers[event.UserID] = struct{}{}
// Суммируем денежные метрики
if amount, ok := event.Properties["amount"].(float64); ok {
stats.TotalAmount += amount
}
}
// Конвертируем unique users в количество
for _, stats := range variantStats {
stats.UsersCount = len(stats.UniqueUsers)
}
return &OfflineMetricsResult{
VariantStats: variantStats,
}
}
Схема данных в Data Warehouse:
-- Таблица оффлайн событий
CREATE TABLE offline_events (
event_id UUID,
user_id String,
event_type String, -- 'card_issued', 'card_activated', etc.
timestamp DateTime,
-- Детали события
amount Float64, -- Сумма (если применимо)
currency String,
branch_id String,
card_id String,
-- Метаданные
source_system String, -- 'crm', 'core_banking', 'atm_network'
loaded_at DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, event_type, timestamp);
-- Обогащенная таблица для аналитики
CREATE TABLE experiment_offline_metrics (
experiment_id String,
variant_id String,
user_id String,
event_type String,
event_timestamp DateTime,
-- Связь с онлайн событиями
experiment_entry_time DateTime,
days_since_entry UInt16,
-- Метрики
amount Float64,
-- Для когортного анализа
cohort_date Date -- Дата входа в эксперимент
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_timestamp)
ORDER BY (experiment_id, variant_id, event_type, event_timestamp);
Когортный анализ с оффлайн событиями:
type CohortAnalysisService struct {
clickhouse *clickhouse.Conn
}
type CohortQuery struct {
ExperimentID string
CohortPeriod string // "day", "week", "month"
Metric string // "card_issued", "loan_approved"
MaxPeriods int
}
type CohortResult struct {
CohortDate time.Time `json:"cohort_date"`
Periods []CohortPeriod `json:"periods"`
}
type CohortPeriod struct {
PeriodNumber int `json:"period_number"`
Users int `json:"users"`
Conversions int `json:"conversions"`
Rate float64 `json:"rate"`
}
func (s *CohortAnalysisService) GetCohortAnalysis(
ctx context.Context,
query CohortQuery,
) ([]CohortResult, error) {
sql := `
WITH experiment_entries AS (
SELECT
user_id,
variant_id,
min(timestamp) as entry_time,
toDate(min(timestamp)) as cohort_date
FROM experiment_events
WHERE experiment_id = ?
AND event_type = 'experiment_entry'
GROUP BY user_id, variant_id
),
offline_conversions AS (
SELECT
oe.user_id,
oe.event_type,
oe.timestamp as conversion_time,
oe.amount
FROM offline_events oe
WHERE oe.event_type = ?
AND oe.timestamp >= ?
AND oe.timestamp <= ?
)
SELECT
ee.cohort_date,
ee.variant_id,
dateDiff(?, ee.cohort_date, oc.conversion_time) as period,
countDistinct(ee.user_id) as total_users,
countDistinct(oc.user_id) as converted_users,
sum(oc.amount) as total_amount
FROM experiment_entries ee
LEFT JOIN offline_conversions oc ON ee.user_id = oc.user_id
GROUP BY ee.cohort_date, ee.variant_id, period
ORDER BY ee.cohort_date, period
`
// Выполняем запрос и формируем результат
// ...
return result, nil
}
Практические примеры использования оффлайн данных:
// Пример 1: Влияние онлайн эксперимента на оффлайн конверсии
type OnlineToOfflineAnalysis struct {
ExperimentID string
OnlineMetric string // "button_click", "form_submit"
OfflineMetric string // "card_issued", "branch_visit"
AttributionWindow time.Duration
}
func (a *OnlineToOfflineAnalysis) Analyze() (*AnalysisResult, error) {
// Считаем сколько пользователей, кликнувших кнопку в приложении,
// в итоге получили карту в отделении
return &AnalysisResult{
OnlineUsers: 100000,
OfflineConverters: 5000,
ConversionRate: 0.05,
AverageTimeToConversion: 7 * 24 * time.Hour,
}, nil
}
// Пример 2: Анализ по сумме транзакций
func (s *OfflineDataIntegrationService) GetTransactionAmountByVariant(
ctx context.Context,
experimentID string,
) (*TransactionAnalysis, error) {
query := `
SELECT
variant_id,
count() as transactions,
sum(amount) as total_amount,
avg(amount) as avg_amount,
quantile(0.5)(amount) as median_amount
FROM experiment_offline_metrics
WHERE experiment_id = ?
AND event_type = 'atm_withdrawal'
AND event_timestamp >= experiment_entry_time
AND event_timestamp <= experiment_entry_time + INTERVAL 30 DAY
GROUP BY variant_id
`
// Выполняем запрос
// ...
return nil, nil
}
Вывод:
Интеграция оффлайн-событий:
- Выходит за рамки системы A/B тестирования — это уровень Data Warehouse
- Аналогична подгрузке сегментов — внешняя система обогащает данные
- Включает события: выдача карты, посещение отделения, транзакции
- Позволяет считать расширенную атрибуцию — влияние онлайн на оффлайн конверсии
- Когортный анализ — отслеживание долгосрочного эффекта экспериментов
Вопрос 14. Как связана аналитика экспериментов с общей системой сбора аналитики?
Таймкод: 00:25:36
Ответ собеседника: Правильный. Уточняется, что аналитика экспериментов является модификацией существующей системы сбора аналитики. К стандартным событиям добавляется информация о том, при каком эксперименте произошло действие.
Правильный ответ:
1. Архитектура аналитики экспериментов
┌─────────────────────────────────────────────────────────────────┐
│ Клиентское приложение │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Обычное событие │ │ Событие с контекстом эксперимента│ │
│ │ │ │ │ │
│ │ button_click │ │ button_click │ │
│ │ page_view │ │ + experiment_id: "exp_123" │ │
│ │ purchase │ │ + variant_id: "treatment" │ │
│ └────────┬────────┘ └───────────┬───────────────────────┘ │
└───────────┼─────────────────────────┼───────────────────────────┘
│ │
└───────────┬─────────────┘
│
▼
┌───────────────────────┐
│ Event Collector │
│ (единый pipeline) │
└───────────┬───────────┘
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Общая │ │ Эксперимент│ │ Другие │
│ аналитика │ │ аналитика │ │ системы │
└────────────┘ └────────────┘ └────────────┘
2. Модель события с контекстом эксперимента
package analytics
// Event базовое событие аналитики
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Timestamp time.Time `json:"timestamp"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Country string `json:"country"`
Properties map[string]interface{} `json:"properties"`
}
// ExperimentContext контекст эксперимента
type ExperimentContext struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
}
// ExperimentEvent событие с контекстом эксперимента
type ExperimentEvent struct {
Event
Experiments []ExperimentContext `json:"experiments"`
}
// EventBuilder билдер событий
type EventBuilder struct {
event *ExperimentEvent
}
func NewEvent(eventType, userID string) *EventBuilder {
return &EventBuilder{
event: &ExperimentEvent{
Event: Event{
EventID: generateEventID(),
EventType: eventType,
UserID: userID,
Timestamp: time.Now(),
Properties: make(map[string]interface{}),
},
Experiments: make([]ExperimentContext, 0),
},
}
}
func (b *EventBuilder) WithExperiment(experimentID, variantID string) *ExperimentBuilder {
b.event.Experiments = append(b.event.Experiments, ExperimentContext{
ExperimentID: experimentID,
VariantID: variantID,
})
return b
}
func (b *EventBuilder) WithProperty(key string, value interface{}) *EventBuilder {
b.event.Properties[key] = value
return b
}
func (b *EventBuilder) Build() *ExperimentEvent {
return b.event
}
3. Интеграция с клиентским SDK
package sdk
// AnalyticsClient клиент аналитики
type AnalyticsClient struct {
eventCollector EventCollector
abTestClient *ABTestClient
userID string
sessionID string
}
// TrackEvent отслеживает событие с контекстом экспериментов
func (c *AnalyticsClient) TrackEvent(ctx context.Context, eventType string, properties map[string]interface{}) error {
// Создаём событие
builder := analytics.NewEvent(eventType, c.userID).
WithProperty("session_id", c.sessionID).
WithProperty("platform", c.getPlatform())
// Добавляем контекст всех активных экспериментов
experiments := c.abTestClient.GetActiveExperiments()
for _, exp := range experiments {
builder.WithExperiment(exp.ExperimentID, exp.VariantID)
}
// Добавляем свойства
for k, v := range properties {
builder.WithProperty(k, v)
}
event := builder.Build()
// Отправляем в коллектор
return c.eventCollector.Collect(ctx, event)
}
// TrackConversion отслеживает конверсию
func (c *AnalyticsClient) TrackConversion(ctx context.Context, conversionType string, value float64) error {
return c.TrackEvent(ctx, "conversion", map[string]interface{}{
"conversion_type": conversionType,
"value": value,
})
}
// TrackExposure отслеживает показ эксперимента
func (c *AnalyticsClient) TrackExposure(ctx context.Context, experimentID, variantID string) error {
return c.TrackEvent(ctx, "experiment_exposure", map[string]interface{}{
"experiment_id": experimentID,
"variant_id": variantID,
})
}
4. Серверная обработка событий
package collector
import (
"context"
"encoding/json"
)
// EventCollector коллектор событий
type EventCollector struct {
kafkaWriter *kafka.Writer
validator *EventValidator
}
// Collect собирает событие
func (c *EventCollector) Collect(ctx context.Context, event *analytics.ExperimentEvent) error {
// Валидируем событие
if err := c.validator.Validate(event); err != nil {
return err
}
// Отправляем в Kafka
data, err := json.Marshal(event)
if err != nil {
return err
}
return c.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Topic: "analytics.events",
})
}
// EventValidator валидатор событий
type EventValidator struct{}
func (v *EventValidator) Validate(event *analytics.ExperimentEvent) error {
if event.EventID == "" {
return fmt.Errorf("event_id is required")
}
if event.EventType == "" {
return fmt.Errorf("event_type is required")
}
if event.UserID == "" {
return fmt.Errorf("user_id is required")
}
return nil
}
5. Обогащение событий на сервере
package enricher
// EventEnricher обогатитель событий
type EventEnricher struct {
experimentStore ExperimentStorage
variantStore VariantStorage
}
// Enrich обогащает событие информацией об экспериментах
func (e *EventEnricher) Enrich(ctx context.Context, event *analytics.ExperimentEvent) (*analytics.ExperimentEvent, error) {
// Проверяем, что варианты соответствуют экспериментам
for i, exp := range event.Experiments {
variant, err := e.variantStore.GetVariant(ctx, event.UserID, exp.ExperimentID)
if err != nil {
continue
}
// Если вариант не совпадает с тем, что на сервере — исправляем
if variant != nil && variant.VariantID != exp.VariantID {
event.Experiments[i].VariantID = variant.VariantID
}
}
// Добавляем метаданные экспериментов
for i, exp := range event.Experiments {
experiment, err := e.experimentStore.GetExperiment(ctx, exp.ExperimentID)
if err != nil {
continue
}
// Добавляем имя эксперимента в свойства
event.Properties[fmt.Sprintf("experiment_%s_name", exp.ExperimentID)] = experiment.Name
}
return event, nil
}
6. Хранение в ClickHouse
-- Таблица событий с контекстом экспериментов
CREATE TABLE analytics_events (
event_id String,
event_type LowCardinality(String),
user_id String,
session_id String,
timestamp DateTime,
platform LowCardinality(String),
app_version LowCardinality(String),
country LowCardinality(String),
properties String, -- JSON
-- Контекст эксперимента
experiment_id String,
variant_id String,
-- Сегменты пользователя на момент события
segments Array(String),
created_at DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, event_type, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Материализованное представление для экспериментальной аналитики
CREATE MATERIALIZED VIEW experiment_events_mv
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (experiment_id, variant_id, event_type, timestamp)
AS
SELECT
experiment_id,
variant_id,
event_type,
user_id,
timestamp,
platform,
country,
properties
FROM analytics_events
WHERE experiment_id != '';
-- Агрегированная статистика по экспериментам
CREATE TABLE experiment_daily_stats (
experiment_id String,
variant_id String,
date Date,
platform LowCardinality(String),
exposures UInt64,
conversions UInt64,
revenue Float64,
unique_users UInt64,
created_at DateTime DEFAULT now()
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant_id, date, platform);
-- Материализованное представление для автоматической агрегации
CREATE MATERIALIZED VIEW experiment_daily_stats_mv
TO experiment_daily_stats
AS
SELECT
experiment_id,
variant_id,
toDate(timestamp) as date,
platform,
countIf(event_type = 'experiment_exposure') as exposures,
countIf(event_type = 'conversion') as conversions,
sumIf(toFloat64OrNull(properties['value']), event_type = 'conversion') as revenue,
uniq(user_id) as unique_users
FROM experiment_events_mv
GROUP BY experiment_id, variant_id, date, platform;
7. API для получения статистики экспериментов
package api
// ExperimentStatsHandler handler для статистики экспериментов
type ExperimentStatsHandler struct {
clickhouse *sql.DB
}
// GetExperimentStatsRequest запрос на статистику
type GetExperimentStatsRequest struct {
ExperimentID string `json:"experiment_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Platform string `json:"platform,omitempty"`
}
// ExperimentStatsResponse ответ со статистикой
type ExperimentStatsResponse struct {
ExperimentID string `json:"experiment_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Variants map[string]*VariantStats `json:"variants"`
Significance *SignificanceResult `json:"significance"`
}
// VariantStats статистика по варианту
type VariantStats struct {
VariantID string `json:"variant_id"`
Exposures int64 `json:"exposures"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
UniqueUsers int64 `json:"unique_users"`
ConversionRate float64 `json:"conversion_rate"`
}
// GetExperimentStats возвращает статистику эксперимента
func (h *ExperimentStatsHandler) GetExperimentStats(w http.ResponseWriter, r *http.Request) {
var req GetExperimentStatsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
stats, err := h.getStats(ctx, req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(stats)
}
func (h *ExperimentStatsHandler) getStats(ctx context.Context, req GetExperimentStatsRequest) (*ExperimentStatsResponse, error) {
query := `
SELECT
variant_id,
sum(exposures) as exposures,
sum(conversions) as conversions,
sum(revenue) as revenue,
sum(unique_users) as unique_users
FROM experiment_daily_stats
WHERE experiment_id = ?
AND date >= ?
AND date <= ?
GROUP BY variant_id
`
rows, err := h.clickhouse.QueryContext(ctx, query, req.ExperimentID, req.StartDate, req.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
response := &ExperimentStatsResponse{
ExperimentID: req.ExperimentID,
StartDate: req.StartDate,
EndDate: req.EndDate,
Variants: make(map[string]*VariantStats),
}
for rows.Next() {
var variantID string
var exposures, conversions, uniqueUsers int64
var revenue float64
if err := rows.Scan(&variantID, &exposures, &conversions, &revenue, &uniqueUsers); err != nil {
return nil, err
}
response.Variants[variantID] = &VariantStats{
VariantID: variantID,
Exposures: exposures,
Conversions: conversions,
Revenue: revenue,
UniqueUsers: uniqueUsers,
ConversionRate: float64(conversions) / float64(exposures),
}
}
return response, nil
}
8. Связь с общей аналитикой
package integration
// UnifiedAnalyticsService единый сервис аналитики
type UnifiedAnalyticsService struct {
eventCollector *collector.EventCollector
experimentService *experiment.ExperimentService
segmentService *segment.SegmentService
}
// TrackEvent отслеживает событие с полным контекстом
func (s *UnifiedAnalyticsService) TrackEvent(ctx context.Context, event *analytics.Event) error {
// 1. Получаем контекст экспериментов
experiments, err := s.experimentService.GetActiveExperimentsForUser(ctx, event.UserID)
if err != nil {
log.Printf("Failed to get experiments: %v", err)
}
// 2. Получаем сегменты пользователя
segments, err := s.segmentService.GetUserSegments(ctx, event.UserID)
if err != nil {
log.Printf("Failed to get segments: %v", err)
}
// 3. Создаём экспериментальное событие
expEvent := &analytics.ExperimentEvent{
Event: *event,
Experiments: make([]analytics.ExperimentContext, 0),
}
for _, exp := range experiments {
variant, err := s.experimentService.GetUserVariant(ctx, event.UserID, exp.ID)
if err != nil {
continue
}
expEvent.Experiments = append(expEvent.Experiments, analytics.ExperimentContext{
ExperimentID: exp.ID,
VariantID: variant,
})
}
// 4. Отправляем в единый pipeline
return s.eventCollector.Collect(ctx, expEvent)
}
9. Схема потока данных
┌─────────────────────────────────────────────────────────────────┐
│ Клиент │
│ │
│ User Action → TrackEvent() → Add Experiment Context → Send │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Event │
│ Collector │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Kafka │
│ analytics. │
│ events │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ General │ │ Experiment │ │ Fraud │
│ Analytics │ │ Analytics │ │ Detection │
│ │ │ │ │ │
│ Все события │ │ Только с │ │ Подозрительные │
│ │ │ experiment_id │ │ паттерны │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ ClickHouse │ │ ClickHouse │
│ general │ │ experiment │
│ events │ │ events │
└─────────────────┘ └─────────────────┘
10. Итоговая архитектура
Аналитика экспериментов = Общая аналитика + Контекст эксперимента
Ключевые принципы:
1. Единый pipeline сбора событий
2. Обогащение событий контекстом экспериментов
3. Раздельное хранение для оптимизации запросов
4. Общие метрики доступны в обеих системах
5. Возможность корреляционного анализа
Интеграция аналитики экспериментов с общей системой сбора данных позволяет использовать единый инфраструктурный стек, упрощает анализ и обеспечивает консистентность данных.
Вопрос 27. Как сервис определяет, в какие эксперименты попадает пользователь?
Таймкод: 00:52:09
Ответ собеседова: Правильный. Предлагается создать Experiment Decision сервис, который определяет принадлежность пользователя к экспериментам. Для этого ему нужен список всех экспериментов и алгоритм определения на основе сегментов.
Правильный ответ:
Сервис определения участия пользователя в экспериментах
Архитектура Experiment Decision Service:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ЗАПРОС ОТ КЛИЕНТА │
│ (user_id, context: platform, version, etc.) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXPERIMENT DECISION SERVICE │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Experiment │ │ Segment │ │ Allocation │ │
│ │ Registry │ │ Evaluator │ │ Engine │ │
│ │ (активные │ │ (проверка │ │ (распределение │ │
│ │ эксперименты) │ │ сегментов) │ │ по вариантам) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ ОТВЕТ │
│ (список экспериментов и вариантов для пользователя) │
└─────────────────────────────────────────────────────────────────────────────┘
Основные компоненты сервиса:
type ExperimentDecisionService struct {
experimentRepo ExperimentRepository
segmentService SegmentService
allocationSvc AllocationService
cache CacheLayer
}
type AssignmentRequest struct {
UserID string `json:"user_id"`
Context map[string]interface{} `json:"context"` // platform, app_version, country, etc.
}
type AssignmentResponse struct {
Assignments []ExperimentAssignment `json:"assignments"`
}
type ExperimentAssignment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
}
Алгоритм определения участия:
func (s *ExperimentDecisionService) GetAssignments(
ctx context.Context,
req AssignmentRequest,
) (*AssignmentResponse, error) {
// 1. Проверяем кэш
cacheKey := buildCacheKey(req.UserID)
if cached, err := s.cache.Get(ctx, cacheKey); err == nil {
return cached.(*AssignmentResponse), nil
}
// 2. Получаем все активные эксперименты
experiments, err := s.experimentRepo.GetActiveExperiments(ctx)
if err != nil {
return nil, err
}
// 3. Фильтруем эксперименты по критериям
var assignments []ExperimentAssignment
for _, exp := range experiments {
// Проверяем, подходит ли пользователь под таргетинг
if !s.isUserEligible(ctx, req, exp) {
continue
}
// Проверяем пересечения с другими экспериментами
if s.hasConflicts(exp, assignments) {
continue
}
// Определяем вариант
variant, err := s.allocationSvc.AssignVariant(ctx, req.UserID, exp)
if err != nil {
continue
}
assignments = append(assignments, ExperimentAssignment{
ExperimentID: exp.ID,
VariantID: variant.ID,
VariantName: variant.Name,
})
}
response := &AssignmentResponse{Assignments: assignments}
// 4. Кэшируем результат
s.cache.Set(ctx, cacheKey, response, 5*time.Minute)
return response, nil
}
func (s *ExperimentDecisionService) isUserEligible(
ctx context.Context,
req AssignmentRequest,
exp *Experiment,
) bool {
// Проверяем платформу
if len(exp.Targeting.Platforms) > 0 {
platform, _ := req.Context["platform"].(string)
if !contains(exp.Targeting.Platforms, platform) {
return false
}
}
// Проверяем версию приложения
if exp.Targeting.MinAppVersion != "" {
appVersion, _ := req.Context["app_version"].(string)
if compareVersions(appVersion, exp.Targeting.MinAppVersion) < 0 {
return false
}
}
// Проверяем сегменты пользователя
if len(exp.Targeting.SegmentIDs) > 0 {
userSegments, err := s.segmentService.GetUserSegments(ctx, req.UserID)
if err != nil {
return false
}
// Пользователь должен принадлежать хотя бы одному из сегментов
if !hasIntersection(userSegments, exp.Targeting.SegmentIDs) {
return false
}
}
// Проверяем процент трафика
if exp.Targeting.TrafficPercentage < 100 {
hash := hashUserID(req.UserID, exp.ID)
if hash%100 >= exp.Targeting.TrafficPercentage {
return false
}
}
return true
}
Проверка пересечений экспериментов:
type ConflictResolver struct {
// Карта экспериментов, которые не могут работать вместе
mutualExclusions map[string][]string
// Иерархия приоритетов
priorities map[string]int
}
func (r *ConflictResolver) hasConflicts(
newExp *Experiment,
currentAssignments []ExperimentAssignment,
) bool {
// Проверяем взаимные исключения
for _, assignment := range currentAssignments {
if r.areMutuallyExclusive(newExp.ID, assignment.ExperimentID) {
return true
}
// Проверяем пересечение по сегментам
if r.haveSegmentOverlap(newExp, assignment.ExperimentID) {
return true
}
}
return false
}
func (r *ConflictResolver) areMutuallyExclusive(exp1, exp2 string) bool {
exclusions, ok := r.mutualExclusions[exp1]
if !ok {
return false
}
return contains(exclusions, exp2)
}
Сервис аллокации:
type AllocationService struct {
salt string // Соль для детерминированного хеширования
}
func (s *AllocationService) AssignVariant(
ctx context.Context,
userID string,
exp *Experiment,
) (*Variant, error) {
// Детерминированное хеширование для стабильности
hash := s.hashUser(userID, exp.ID)
// Распределяем по вариантам пропорционально traffic allocation
cumulativePercentage := 0
for _, variant := range exp.Variants {
cumulativePercentage += variant.TrafficPercentage
if int(hash%100) < cumulativePercentage {
return &variant, nil
}
}
// Fallback на control
return &exp.Variants[0], nil
}
func (s *AllocationService) hashUser(userID, experimentID string) uint32 {
// Используем комбинацию user_id + experiment_id + salt
data := fmt.Sprintf("%s:%s:%s", userID, experimentID, s.salt)
h := fnv.New32a()
h.Write([]byte(data))
return h.Sum32()
}
Кэширование результатов:
type CacheLayer interface {
Get(ctx context.Context, key string) (interface{}, error)
Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error
Invalidate(ctx context.Context, userID string) error
}
type RedisCache struct {
client *redis.Client
}
func buildCacheKey(userID string) string {
return fmt.Sprintf("exp:assign:%s", userID)
}
func (c *RedisCache) Get(ctx context.Context, key string) (interface{}, error) {
data, err := c.client.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}
var response AssignmentResponse
if err := json.Unmarshal(data, &response); err != nil {
return nil, err
}
return &response, nil
}
func (c *RedisCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, ttl).Err()
}
API endpoint:
type ExperimentHandler struct {
decisionService *ExperimentDecisionService
}
func (h *ExperimentHandler) GetAssignments(w http.ResponseWriter, r *http.Request) {
var req AssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response, err := h.decisionService.GetAssignments(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
Пример ответа API:
{
"assignments": [
{
"experiment_id": "exp_checkout_button_color",
"variant_id": "treatment",
"variant_name": "Red button"
},
{
"experiment_id": "exp_homepage_banner",
"variant_id": "control",
"variant_name": "Original banner"
}
]
}
Вывод:
Сервис определяет участие пользователя через:
- Experiment Registry — список всех активных экспериментов
- Segment Evaluator — проверка принадлежности к сегментам
- Targeting rules — платформа, версия, процент трафика
- Conflict Resolver — исключение пересекающихся экспериментов
- Allocation Engine — детерминированное распределение по вариантам
- Кэширование — для быстрого ответа и стабильности результатов
Вопрос 15. Есть ли отличия между мобильными и веб-приложениями в работе с экспериментами?
Таймкод: 00:27:09
Ответ собеседника: Правильный. Уточняется, что в реальности отличия есть (в мобильных загрузка всех экспериментов сразу, в вебе зависит от архитектуры), но для текущего проектирования можно объединить сценарии и говорить просто о клиенте.
Правильный ответ:
1. Унифицированный подход для проектирования
Для текущего проектирования принимаем упрощение:
- Единый API для всех клиентов
- Единая логика назначения вариантов
- Единая модель данных
┌─────────────────────────────────────────────────────────────────┐
│ AB Test API │
│ (единый для всех) │
└─────────────────────────────┬───────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ iOS App │ │ Android App │ │ Web App │
│ │ │ │ │ │
│ Единый SDK │ │ Единый SDK │ │ Единый SDK │
└─────────────────┘ └─────────────────┘ └─────────────────┘
2. Единый API для всех клиентов
package api
// ClientType тип клиента
type ClientType string
const (
ClientTypeIOS ClientType = "ios"
ClientTypeAndroid ClientType = "android"
ClientTypeWeb ClientType = "web"
ClientTypeDesktop ClientType = "desktop"
)
// AssignmentsRequest унифицированный запрос
type AssignmentsRequest struct {
// Идентификатор пользователя
UserID string `json:"user_id" validate:"required"`
// Тип клиента
ClientType ClientType `json:"client_type" validate:"required"`
// Версия приложения
AppVersion string `json:"app_version" validate:"required"`
// Платформа
Platform string `json:"platform" validate:"required"`
// Дополнительный контекст
Context map[string]interface{} `json:"context,omitempty"`
}
// AssignmentsResponse унифицированный ответ
type AssignmentsResponse struct {
UserID string `json:"user_id"`
ClientType ClientType `json:"client_type"`
Assignments []*ExperimentAssignment `json:"assignments"`
ServerTime time.Time `json:"server_time"`
}
// ExperimentAssignment назначение эксперимента
type ExperimentAssignment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
Config map[string]interface{} `json:"config"`
// Метаданные для отладки
Debug *DebugInfo `json:"debug,omitempty"`
}
// DebugInfo отладочная информация
type DebugInfo struct {
ExperimentName string `json:"experiment_name"`
VariantName string `json:"variant_name"`
Bucket int `json:"bucket"`
}
3. Унифицированный SDK
package sdk
// ABTestClient унифицированный клиент
type ABTestClient struct {
baseURL string
httpClient *http.Client
config *Config
cache *Cache
}
// Config конфигурация клиента
type Config struct {
APIKey string
ClientType ClientType
UserID string
Platform string
AppVersion string
// Таймаут для запросов
Timeout time.Duration
// Интервал обновления кэша
RefreshInterval time.Duration
}
// NewClient создаёт новый клиент
func NewClient(config *Config) *ABTestClient {
return &ABTestClient{
baseURL: config.APIKey,
httpClient: &http.Client{
Timeout: config.Timeout,
},
config: config,
cache: NewCache(config.RefreshInterval),
}
}
// GetAssignments получает назначения для пользователя
func (c *ABTestClient) GetAssignments(ctx context.Context) (*AssignmentsResponse, error) {
// Проверяем кэш
if cached := c.cache.Get(); cached != nil {
return cached, nil
}
// Формируем запрос
req := &AssignmentsRequest{
UserID: c.config.UserID,
ClientType: c.config.ClientType,
AppVersion: c.config.AppVersion,
Platform: c.config.Platform,
}
// Отправляем запрос
resp, err := c.doRequest(ctx, req)
if err != nil {
return nil, err
}
// Кэшируем
c.cache.Set(resp)
return resp, nil
}
// GetVariant возвращает вариант для эксперимента
func (c *ABTestClient) GetVariant(ctx context.Context, experimentID string) (string, error) {
assignments, err := c.GetAssignments(ctx)
if err != nil {
return "control", err
}
for _, a := range assignments.Assignments {
if a.ExperimentID == experimentID {
return a.VariantID, nil
}
}
return "control", nil
}
// GetConfig возвращает конфигурацию эксперимента
func (c *ABTestClient) GetConfig(ctx context.Context, experimentID string) (map[string]interface{}, error) {
assignments, err := c.GetAssignments(ctx)
if err != nil {
return nil, err
}
for _, a := range assignments.Assignments {
if a.ExperimentID == experimentID {
return a.Config, nil
}
}
return nil, nil
}
4. Серверная логика: единая для всех
package server
// AssignmentService сервис назначений
type AssignmentService struct {
experimentStore ExperimentStorage
segmentService SegmentService
variantStore VariantStorage
}
// GetAssignments возвращает назначения для клиента
func (s *AssignmentService) GetAssignments(ctx context.Context, req *AssignmentsRequest) (*AssignmentsResponse, error) {
// Получаем сегменты пользователя
userSegments, err := s.segmentService.GetUserSegments(ctx, req.UserID)
if err != nil {
return nil, err
}
// Получаем активные эксперименты
experiments, err := s.experimentStore.GetActiveExperiments(ctx)
if err != nil {
return nil, err
}
// Фильтруем эксперименты
var assignments []*ExperimentAssignment
for _, exp := range experiments {
// Проверяем платформу
if !s.isPlatformMatch(exp, req.Platform) {
continue
}
// Проверяем версию
if !s.isVersionMatch(exp, req.ClientType, req.AppVersion) {
continue
}
// Проверяем сегменты
if !s.isSegmentMatch(exp, userSegments) {
continue
}
// Проверяем трафик
if !s.isUserInTraffic(req.UserID, exp.TrafficPct) {
continue
}
// Получаем вариант
variant, err := s.getOrAssignVariant(ctx, req.UserID, exp)
if err != nil {
continue
}
assignments = append(assignments, &ExperimentAssignment{
ExperimentID: exp.ID,
VariantID: variant.ID,
Config: variant.Config,
})
}
return &AssignmentsResponse{
UserID: req.UserID,
ClientType: req.ClientType,
Assignments: assignments,
ServerTime: time.Now(),
}, nil
}
5. Реальные отличия (для справки)
Хотя для проектирования мы объединяем сценарии, полезно понимать реальные отличия:
package differences
// MobileSpecifics особенности мобильных приложений
type MobileSpecifics struct {
// Загрузка всех экспериментов при запуске
PreloadOnLaunch bool
// Кэширование в файловой системе
PersistentCache bool
// Работа в офлайн режиме
OfflineSupport bool
// Отложенная отправка событий
EventBatching bool
}
// WebSpecifics особенности веб-приложений
type WebSpecifics struct {
// Загрузка при инициализации страницы
LoadOnPageInit bool
// Кэширование в localStorage/sessionStorage
BrowserCache bool
// SSR поддержка
ServerSideRendering bool
// Мгновенное обновление
RealtimeUpdates bool
}
// PlatformAdapter адаптер для платформы
type PlatformAdapter struct {
clientType ClientType
specifics interface{}
}
func NewPlatformAdapter(clientType ClientType) *PlatformAdapter {
switch clientType {
case ClientTypeIOS, ClientTypeAndroid:
return &PlatformAdapter{
clientType: clientType,
specifics: &MobileSpecifics{
PreloadOnLaunch: true,
PersistentCache: true,
OfflineSupport: true,
EventBatching: true,
},
}
case ClientTypeWeb:
return &PlatformAdapter{
clientType: clientType,
specifics: &WebSpecifics{
LoadOnPageInit: true,
BrowserCache: true,
ServerSideRendering: true,
RealtimeUpdates: true,
},
}
default:
return &PlatformAdapter{
clientType: clientType,
}
}
}
6. Конфигурация эксперимента с учётом платформы
package experiment
// Experiment с поддержкой платформенной конфигурации
type Experiment struct {
ID string
Name string
Description string
Status ExperimentStatus
Variants []Variant
// Платформенные настройки
PlatformConfig map[Platform]*PlatformConfig
// Общие настройки
TargetSegments []string
TrafficPct float64
MinSampleSize int
}
// PlatformConfig конфигурация для платформы
type PlatformConfig struct {
// Минимальная версия приложения
MinAppVersion string
// Платформо-специфичные варианты
VariantOverrides map[string]*VariantOverride
// Включён ли эксперимент для этой платформы
Enabled bool
}
// VariantOverride переопределение варианта для платформы
type VariantOverride struct {
// Переопределение конфигурации
ConfigOverride map[string]interface{}
// Переопределение веса
WeightOverride *float64
}
// GetVariantForPlatform возвращает вариант с учётом платформы
func (e *Experiment) GetVariantForPlatform(variantID string, platform Platform) *Variant {
variant := e.getVariant(variantID)
if variant == nil {
return nil
}
// Проверяем переопределения для платформы
if platformConfig, exists := e.PlatformConfig[platform]; exists {
if override, exists := platformConfig.VariantOverrides[variantID]; exists {
// Применяем переопределения
variant = variant.applyOverride(override)
}
}
return variant
}
7. Сравнение подходов
┌─────────────────────────────────────────────────────────────────┐
│ Для проектирования │
│ │
│ ✅ Единый API │
│ ✅ Единая логика │
│ ✅ Единая модель данных │
│ ✅ Упрощение архитектуры │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Реальные отличия (для справки) │
│ │
│ Мобильные: │
│ - Загрузка при запуске приложения │
│ - Персистентный кэш │
│ - Офлайн поддержка │
│ - Батчинг событий │
│ │
│ Веб: │
│ - Загрузка при инициализации страницы │
│ - Кэш в браузере │
│ - SSR поддержка │
│ - Возможность реал-тайм обновлений │
└─────────────────────────────────────────────────────────────────┘
8. Итоговая архитектура
┌─────────────────────────────────────────────────────────────────┐
│ AB Test API │
│ │
│ POST /api/v1/assignments │
│ { │
│ "user_id": "123", │
│ "client_type": "ios|android|web", │
│ "app_version": "1.2.3", │
│ "platform": "ios|android|web" │
│ } │
│ │
│ Response: │
│ { │
│ "assignments": [ │
│ { │
│ "experiment_id": "exp_123", │
│ "variant_id": "treatment", │
│ "config": { "enabled": true, "color": "blue" } │
│ } │
│ ] │
│ } │
└─────────────────────────────────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ iOS SDK │ │ Android SDK │ │ Web SDK │
│ │ │ │ │ │
│ Единый код │ │ Единый код │ │ Единый код │
│ + мобильные │ │ + мобильные │ │ + веб │
│ особенности │ │ особенности │ │ особенности │
└─────────────────┘ └─────────────────┘ └─────────────────┘
9. Рекомендации
Для текущего проектирования:
✅ Использовать единый API для всех клиентов
✅ Использовать единую модель данных
✅ Использовать единую логику назначения вариантов
✅ Платформа передаётся как параметр запроса
Для будущих версий:
📋 Добавить платформенные переопределения конфигурации
📋 Добавить платформенные ограничения (min version)
📋 Добавить платформенную аналитику
📋 Оптимизировать кэширование под каждую платформу
10. Вывод
Для текущего проектирования системы A/B-тестирования принимаем упрощение: единый API и единая логика для всех клиентов. Платформа передаётся как параметр запроса и учитывается при фильтрации экспериментов. Это позволяет упростить архитектуру и ускорить разработку MVP.
Вопрос 28. Какие типы детерминации (правил распределения) существуют в экспериментах?
Таймкод: 00:54:24
Ответ собеседова: Правильный. Уточняется, что есть аудиторные сегменты (по полу, продуктам, источникам трафика) и технические (по геолокации, устройству, наличию NFC). Для текущего проектирования фокус на аудиторных сегментах.
Правильный ответ:
Типы детерминации в A/B тестировании
Классификация правил распределения:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ТИПЫ ДЕТЕРМИНАЦИИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────────────┐ │
│ │ АУДИТОРНЫЕ СЕГМЕНТЫ │ │ ТЕХНИЧЕСКИЕ СЕГМЕНТЫ │ │
│ │ │ │ │ │
│ │ • Демографические │ │ • Геолокация │ │
│ │ - Пол │ │ • Устройство │ │
│ │ - Возраст │ │ • Операционная система │ │
│ │ - Доход │ │ • Браузер │ │
│ │ │ │ • Наличие NFC │ │
│ │ • Продуктовые │ │ • Версия приложения │ │
│ │ - Тип подписки │ │ • Размер экрана │ │
│ │ - История покупок │ │ • Тип подключения │ │
│ │ - Активность │ │ │ │
│ │ │ └─────────────────────────────────────┘ │
│ │ • Поведенческие │ │
│ │ - Частота использования │ ┌─────────────────────────────────────┐ │
│ │ - Источник трафика │ │ ПОВЕДЕНЧЕСКИЕ ТРИГГЕРЫ │ │
│ │ - Канал привлечения │ │ │ │
│ │ │ │ • Первый визит │ │
│ └─────────────────────────────┘ │ • Достигнут лимит │ │
│ │ • Выполнено действие │ │
│ │ • Время на сайте │ │
│ │ • Количество страниц │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Реализация типов детерминации:
// Базовый интерфейс для всех типов детерминации
type TargetingRule interface {
Evaluate(ctx context.Context, user *User, context map[string]interface{}) (bool, error)
GetType() TargetingRuleType
}
type TargetingRuleType string
const (
TargetingTypeAudience TargetingRuleType = "audience"
TargetingTypeTechnical TargetingRuleType = "technical"
TargetingTypeBehavioral TargetingRuleType = "behavioral"
TargetingTypeCustom TargetingRuleType = "custom"
)
// Аудиторные сегменты
type AudienceSegmentRule struct {
SegmentIDs []string `json:"segment_ids"`
segmentSvc SegmentService
}
func (r *AudienceSegmentRule) Evaluate(
ctx context.Context,
user *User,
context map[string]interface{},
) (bool, error) {
// Получаем сегменты пользователя
userSegments, err := r.segmentSvc.GetUserSegments(ctx, user.ID)
if err != nil {
return false, err
}
// Проверяем пересечение
return hasIntersection(userSegments, r.SegmentIDs), nil
}
// Технические правила
type TechnicalRule struct {
Platforms []string `json:"platforms,omitempty"`
MinAppVersion string `json:"min_app_version,omitempty"`
MaxAppVersion string `json:"max_app_version,omitempty"`
DeviceTypes []string `json:"device_types,omitempty"`
Countries []string `json:"countries,omitempty"`
HasNFC *bool `json:"has_nfc,omitempty"`
}
func (r *TechnicalRule) Evaluate(
ctx context.Context,
user *User,
context map[string]interface{},
) (bool, error) {
// Проверка платформы
if len(r.Platforms) > 0 {
platform, _ := context["platform"].(string)
if !contains(r.Platforms, platform) {
return false, nil
}
}
// Проверка версии приложения
if r.MinAppVersion != "" {
appVersion, _ := context["app_version"].(string)
if compareVersions(appVersion, r.MinAppVersion) < 0 {
return false, nil
}
}
// Проверка NFC
if r.HasNFC != nil {
hasNFC, _ := context["has_nfc"].(bool)
if hasNFC != *r.HasNFC {
return false, nil
}
}
// Проверка геолокации
if len(r.Countries) > 0 {
country, _ := context["country"].(string)
if !contains(r.Countries, country) {
return false, nil
}
}
return true, nil
}
// Поведенческие триггеры
type BehavioralRule struct {
EventType string `json:"event_type"`
MinOccurrences int `json:"min_occurrences,omitempty"`
MaxOccurrences int `json:"max_occurrences,omitempty"`
TimeWindow time.Duration `json:"time_window,omitempty"`
eventStore EventStore
}
func (r *BehavioralRule) Evaluate(
ctx context.Context,
user *User,
context map[string]interface{},
) (bool, error) {
// Получаем события пользователя за период
events, err := r.eventStore.GetUserEvents(ctx, user.ID, r.EventType, r.TimeWindow)
if err != nil {
return false, err
}
count := len(events)
if r.MinOccurrences > 0 && count < r.MinOccurrences {
return false, nil
}
if r.MaxOccurrences > 0 && count > r.MaxOccurrences {
return false, nil
}
return true, nil
}
Комбинирование правил:
type TargetingConfig struct {
Rules []TargetingRule `json:"rules"`
Operator string `json:"operator"` // "and" или "or"
}
func (c *TargetingConfig) Evaluate(
ctx context.Context,
user *User,
context map[string]interface{},
) (bool, error) {
if c.Operator == "and" {
// Все правила должны быть true
for _, rule := range c.Rules {
result, err := rule.Evaluate(ctx, user, context)
if err != nil || !result {
return false, err
}
}
return true, nil
}
// "or" — хотя бы одно правило true
for _, rule := range c.Rules {
result, err := rule.Evaluate(ctx, user, context)
if err != nil {
return false, err
}
if result {
return true, nil
}
}
return false, nil
}
Примеры конфигураций экспериментов:
// Пример 1: Эксперимент для премиум пользователей на iOS
var PremiumIOSTargeting = TargetingConfig{
Operator: "and",
Rules: []TargetingRule{
&TechnicalRule{
Platforms: []string{"ios"},
MinAppVersion: "3.0.0",
},
&AudienceSegmentRule{
SegmentIDs: []string{"premium_users", "active_30d"},
},
},
}
// Пример 2: Эксперимент для новых пользователей с NFC
var NewUserNFCTargeting = TargetingConfig{
Operator: "and",
Rules: []TargetingRule{
&AudienceSegmentRule{
SegmentIDs: []string{"new_users"},
},
&TechnicalRule{
HasNFC: boolPtr(true),
},
&BehavioralRule{
EventType: "app_install",
MaxOccurrences: 1,
TimeWindow: 7 * 24 * time.Hour,
},
},
}
// Пример 3: Эксперимент для определенного региона или источника трафика
var RegionOrSourceTargeting = TargetingConfig{
Operator: "or",
Rules: []TargetingRule{
&TechnicalRule{
Countries: []string{"US", "CA", "GB"},
},
&AudienceSegmentRule{
SegmentIDs: []string{"organic_traffic", "referral_partners"},
},
},
}
Хранение конфигураций в БД:
-- Таблица экспериментов
CREATE TABLE experiments (
id UUID PRIMARY KEY,
name String,
status Enum('draft', 'running', 'paused', 'completed'),
-- Конфигурация таргетинга в JSON
targeting_config String, -- JSON с правилами
-- Метаданные
created_at DateTime,
updated_at DateTime,
created_by String
) ENGINE = MergeTree()
ORDER BY id;
-- Таблица сегментов
CREATE TABLE segments (
id UUID PRIMARY KEY,
name String,
type Enum('audience', 'technical', 'behavioral'),
-- Описание сегмента
description String,
-- Для аудиторных сегментов - SQL условие
sql_condition String,
-- Для технических - JSON с параметрами
technical_params String,
-- Для поведенческих - тип события и параметры
event_type String,
event_params String,
created_at DateTime
) ENGINE = MergeTree()
ORDER BY id;
-- Связь пользователей и сегментов
CREATE TABLE user_segments (
user_id String,
segment_id UUID,
assigned_at DateTime,
-- Для кэширования
expires_at DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, segment_id);
Сервис оценки сегментов:
type SegmentEvaluationService struct {
segmentRepo SegmentRepository
userRepo UserRepository
eventStore EventStore
}
func (s *SegmentEvaluationService) EvaluateUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
// Получаем все сегменты
segments, err := s.segmentRepo.GetAllActive(ctx)
if err != nil {
return nil, err
}
var matchedSegments []string
for _, segment := range segments {
matched, err := s.evaluateSegment(ctx, userID, segment)
if err != nil {
continue // Пропускаем ошибки для отдельных сегментов
}
if matched {
matchedSegments = append(matchedSegments, segment.ID)
}
}
return matchedSegments, nil
}
func (s *SegmentEvaluationService) evaluateSegment(
ctx context.Context,
userID string,
segment *Segment,
) (bool, error) {
switch segment.Type {
case SegmentTypeAudience:
return s.evaluateAudienceSegment(ctx, userID, segment)
case SegmentTypeTechnical:
return s.evaluateTechnicalSegment(ctx, userID, segment)
case SegmentTypeBehavioral:
return s.evaluateBehavioralSegment(ctx, userID, segment)
default:
return false, fmt.Errorf("unknown segment type: %s", segment.Type)
}
}
Вывод:
Типы детерминации в экспериментах:
- Аудиторные сегменты — демография, продуктовые признаки, источники трафика
- Технические сегменты — платформа, устройство, геолокация, наличие NFC
- Поведенческие триггеры — действия пользователя, частота, время
- Комбинированные правила — AND/OR логика для сложных условий
- Приоритет аудиторных сегментов — для текущего проектирования фокус на них
Вопрос 29. Как организовано хранилище сегментов и в каком форматом там данные?
Таймкод: 00:56:53
Ответ собеседова: Правильный. Хранилище сегментов содержит соответствие между User ID и списком номеров сегментов, к которым он относится. Это предрассчитанные данные (не вычисляемые на лету), чтобы быстро отвечать на запросы. Формат: User ID -> коллекция номеров сегментов.
Правильный ответ:
Архитектура хранилища сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ ХРАНИЛИЩЕ СЕГМЕНТОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Формат данных: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ User ID │ [SegmentID_1, SegmentID_2, ..., SegmentID_N] │ │
│ ├───────────┼─────────────────────────────────────────────────────────┤ │
│ │ user_123 │ [seg_premium, seg_ios, seg_us, seg_active_30d] │ │
│ │ user_456 │ [seg_free, seg_android, seg_eu, seg_new_user] │ │
│ │ user_789 │ [seg_premium, seg_ios, seg_uk, seg_churn_risk] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Принцип: ПРЕДРАССЧИТАННЫЕ данные (не вычисляются на лету) │
│ Цель: Быстрый ответ на запросы (O(1) для чтения) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Реализация хранилища:
// Структура данных в хранилище
type UserSegmentMapping struct {
UserID string `json:"user_id" db:"user_id"`
SegmentIDs []string `json:"segment_ids" db:"segment_ids"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
}
// Интерфейс хранилища
type SegmentStore interface {
// Получить сегменты пользователя
GetUserSegments(ctx context.Context, userID string) ([]string, error)
// Установить сегменты пользователя
SetUserSegments(ctx context.Context, userID string, segments []string) error
// Добавить пользователя в сегмент
AddUserToSegment(ctx context.Context, userID string, segmentID string) error
// Удалить пользователя из сегмента
RemoveUserFromSegment(ctx context.Context, userID string, segmentID string) error
// Проверить принадлежность к сегменту
IsUserInSegment(ctx context.Context, userID string, segmentID string) (bool, error)
// Массовое получение
GetUsersSegmentsBatch(ctx context.Context, userIDs []string) (map[string][]string, error)
}
Реализация на Redis:
type RedisSegmentStore struct {
client *redis.Client
ttl time.Duration
}
func NewRedisSegmentStore(client *redis.Client, ttl time.Duration) *RedisSegmentStore {
return &RedisSegmentStore{
client: client,
ttl: ttl,
}
}
// Ключ для хранения сегментов пользователя
func segmentKey(userID string) string {
return fmt.Sprintf("user:segments:%s", userID)
}
func (s *RedisSegmentStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
key := segmentKey(userID)
// Используем Set для хранения сегментов
segments, err := s.client.SMembers(ctx, key).Result()
if err != nil {
return nil, fmt.Errorf("failed to get segments for user %s: %w", userID, err)
}
return segments, nil
}
func (s *RedisSegmentStore) SetUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
key := segmentKey(userID)
pipe := s.client.Pipeline()
// Удаляем старые данные
pipe.Del(ctx, key)
// Добавляем новые сегменты
if len(segments) > 0 {
members := make([]interface{}, len(segments))
for i, seg := range segments {
members[i] = seg
}
pipe.SAdd(ctx, key, members...)
}
// Устанавливаем TTL
pipe.Expire(ctx, key, s.ttl)
_, err := pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to set segments for user %s: %w", userID, err)
}
return nil
}
func (s *RedisSegmentStore) AddUserToSegment(
ctx context.Context,
userID string,
segmentID string,
) error {
key := segmentKey(userID)
pipe := s.client.Pipeline()
pipe.SAdd(ctx, key, segmentID)
pipe.Expire(ctx, key, s.ttl)
_, err := pipe.Exec(ctx)
return err
}
func (s *RedisSegmentStore) RemoveUserFromSegment(
ctx context.Context,
userID string,
segmentID string,
) error {
key := segmentKey(userID)
return s.client.SRem(ctx, key, segmentID).Err()
}
func (s *RedisSegmentStore) IsUserInSegment(
ctx context.Context,
userID string,
segmentID string,
) (bool, error) {
key := segmentKey(userID)
return s.client.SIsMember(ctx, key, segmentID).Result()
}
// Массовое получение сегментов
func (s *RedisSegmentStore) GetUsersSegmentsBatch(
ctx context.Context,
userIDs []string,
) (map[string][]string, error) {
pipe := s.client.Pipeline()
cmds := make(map[string]*redis.StringSliceCmd)
for _, userID := range userIDs {
key := segmentKey(userID)
cmds[userID] = pipe.SMembers(ctx, key)
}
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}
result := make(map[string][]string)
for userID, cmd := range cmds {
segments, err := cmd.Result()
if err == nil {
result[userID] = segments
}
}
return result, nil
}
Реализация на ClickHouse:
type ClickHouseSegmentStore struct {
conn *sql.DB
}
// Схема таблицы
const createTableSQL = `
CREATE TABLE IF NOT EXISTS user_segments (
user_id String,
segment_id String,
assigned_at DateTime DEFAULT now(),
expires_at DateTime
) ENGINE = ReplacingMergeTree(assigned_at)
ORDER BY (user_id, segment_id)
PARTITION BY toYYYYMM(assigned_at)
TTL expires_at
SETTINGS index_granularity = 8192;
`
// Индекс для быстрого поиска
const createIndexSQL = `
CREATE TABLE IF NOT EXISTS user_segments_index (
user_id String,
segment_ids Array(String),
updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id
SETTINGS index_granularity = 8192;
`
func (s *ClickHouseSegmentStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
query := `
SELECT groupArray(segment_id) as segments
FROM user_segments
WHERE user_id = ?
AND (expires_at = toDateTime(0) OR expires_at > now())
`
var segments []string
err := s.conn.QueryRowContext(ctx, query, userID).Scan(&segments)
if err != nil {
return nil, err
}
return segments, nil
}
func (s *ClickHouseSegmentStore) SetUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
// Начинаем транзакцию
tx, err := s.conn.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Удаляем старые сегменты
_, err = tx.ExecContext(ctx,
"DELETE FROM user_segments WHERE user_id = ?",
userID,
)
if err != nil {
return err
}
// Вставляем новые сегменты
if len(segments) > 0 {
query := `
INSERT INTO user_segments (user_id, segment_id, expires_at)
VALUES
`
values := make([]interface{}, 0, len(segments)*3)
placeholders := make([]string, 0, len(segments))
for _, seg := range segments {
placeholders = append(placeholders, "(?, ?, toDateTime(0))")
values = append(values, userID, seg)
}
query += strings.Join(placeholders, ",")
_, err = tx.ExecContext(ctx, query, values...)
if err != nil {
return err
}
}
return tx.Commit()
}
Сервис предрасчёта сегментов:
type SegmentCalculationService struct {
segmentStore SegmentStore
segmentRepo SegmentRepository
userRepo UserRepository
eventBus EventBus
}
// Пересчёт сегментов для пользователя
func (s *SegmentCalculationService) RecalculateUserSegments(
ctx context.Context,
userID string,
) error {
// Получаем все активные сегменты
segments, err := s.segmentRepo.GetAllActive(ctx)
if err != nil {
return err
}
// Получаем данные пользователя
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
// Определяем принадлежность к сегментам
var matchedSegments []string
for _, segment := range segments {
matched, err := s.evaluateSegment(ctx, user, segment)
if err != nil {
continue
}
if matched {
matchedSegments = append(matchedSegments, segment.ID)
}
}
// Сохраняем в хранилище
return s.segmentStore.SetUserSegments(ctx, userID, matchedSegments)
}
// Массовый пересчёт
func (s *SegmentCalculationService) RecalculateAllSegments(
ctx context.Context,
) error {
// Получаем всех пользователей пачями
batchSize := 1000
offset := 0
for {
users, err := s.userRepo.GetBatch(ctx, offset, batchSize)
if err != nil {
return err
}
if len(users) == 0 {
break
}
// Параллельный пересчёт
var wg sync.WaitGroup
errChan := make(chan error, len(users))
semaphore := make(chan struct{}, 20) // Лимит параллелизма
for _, user := range users {
wg.Add(1)
semaphore <- struct{}{}
go func(u *User) {
defer wg.Done()
defer func() { <-semaphore }()
if err := s.RecalculateUserSegments(ctx, u.ID); err != nil {
errChan <- err
}
}(user)
}
wg.Wait()
close(errChan)
// Обработка ошибок
for err := range errChan {
log.Printf("Error recalculating segments: %v", err)
}
offset += batchSize
}
return nil
}
Обработка событий для обновления сегментов:
type SegmentEventHandler struct {
calcService *SegmentCalculationService
}
func (h *SegmentEventHandler) HandleUserEvent(
ctx context.Context,
event *UserEvent,
) error {
switch event.Type {
case "user.updated", "user.action":
// Пересчитываем сегменты при изменении данных
return h.calcService.RecalculateUserSegments(ctx, event.UserID)
case "segment.definition.updated":
// При изменении определения сегмента пересчитываем всех
return h.calcService.RecalculateAllSegments(ctx)
default:
return nil
}
}
Кэширование и производительность:
type CachedSegmentStore struct {
underlying SegmentStore
localCache *ristretto.Cache
ttl time.Duration
}
func NewCachedSegmentStore(
underlying SegmentStore,
maxSize int64,
) *CachedSegmentStore {
cache, _ := ristretto.NewCache(&ristretto.Config{
NumCounters: maxSize * 10,
MaxCost: maxSize,
BufferItems: 64,
})
return &CachedSegmentStore{
underlying: underlying,
localCache: cache,
ttl: 5 * time.Minute,
}
}
func (c *CachedSegmentStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
// Проверяем локальный кэш
if val, found := c.localCache.Get(userID); found {
return val.([]string), nil
}
// Получаем из основного хранилища
segments, err := c.underlying.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// Кэшируем локально
c.localCache.Set(userID, segments, int64(len(segments)))
return segments, nil
}
Мониторинг и метрики:
type SegmentMetrics struct {
cacheHitRate prometheus.Counter
cacheMissRate prometheus.Counter
recalcDuration prometheus.Histogram
segmentsCount prometheus.Gauge
}
func (m *SegmentMetrics) RecordGet(userID string, fromCache bool) {
if fromCache {
m.cacheHitRate.Inc()
} else {
m.cacheMissRate.Inc()
}
}
func (m *SegmentMetrics) RecordRecalculation(duration time.Duration) {
m.recalcDuration.Observe(duration.Seconds())
}
func (m *SegmentMetrics) UpdateSegmentsCount(count int) {
m.segmentsCount.Set(float64(count))
}
Вывод:
Хранилище сегментов организовано как:
- Формат:
User ID -> [SegmentID_1, SegmentID_2, ..., SegmentID_N] - Принцип: Предрассчитанные данные для быстрого чтения
- Хранение: Redis (основной) + ClickHouse (аналитика)
- Кэширование: Многоуровневое (Redis + локальный кэш)
- Обновление: По событиям + периодический пересчёт
- Производительность: O(1) для чтения сегментов пользователя
Вопрос 16. Кто навешивает сегменты на пользователей?
Таймкод: 00:28:55
Ответ собеседника: Правильный. Уточняется, что обычно это отдельный аналитик, который готовит сегментацию по клиентам. Для простоты можно считать, что это тот же аналитик, но в реальности продуктовому аналитику лучше, чтобы сегменты были заранее подготовлены.
Правильный ответ:
1. Роли в работе с сегментами
┌─────────────────────────────────────────────────────────────────┐
│ Подготовка сегментов │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Data Analyst │ │ Data Engineer │ │ Product │ │
│ │ │ │ │ │ Analyst │ │
│ │ Определяет │───►│ Реализует │───►│ Использует │ │
│ │ логику │ │ в DWH │ │ в A/B │ │
│ │ сегментации │ │ │ │ тестах │ │
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2. Процесс работы с сегментами
package segment
// SegmentCreator создатель сегментов
type SegmentCreator struct {
dwhClient *DWHClient
segmentStore SegmentStorage
validator *SegmentValidator
}
// CreateSegmentRequest запрос на создание сегмента
type CreateSegmentRequest struct {
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
// SQL запрос или правила для определения сегмента
Definition SegmentDefinition `json:"definition"`
}
// SegmentDefinition определение сегмента
type SegmentDefinition struct {
// Тип определения
Type DefinitionType `json:"type"`
// SQL запрос (для SQL-based сегментов)
SQLQuery string `json:"sql_query,omitempty"`
// Правила (для rule-based сегментов)
Rules []SegmentRule `json:"rules,omitempty"`
// Источник данных
DataSource DataSource `json:"data_source"`
}
type DefinitionType string
const (
DefinitionTypeSQL DefinitionType = "sql"
DefinitionTypeRule DefinitionType = "rule"
DefinitionTypeList DefinitionType = "list"
)
// CreateSegment создаёт новый сегмент
func (c *SegmentCreator) CreateSegment(ctx context.Context, req *CreateSegmentRequest) (*Segment, error) {
// Валидируем запрос
if err := c.validator.ValidateCreateRequest(req); err != nil {
return nil, err
}
// Проверяем, что запрос возвращает данные
userCount, err := c.estimateSegmentSize(ctx, req.Definition)
if err != nil {
return nil, fmt.Errorf("failed to estimate segment size: %w", err)
}
// Создаём сегмент
segment := &Segment{
ID: generateSegmentID(),
Name: req.Name,
Description: req.Description,
Definition: req.Definition,
CreatedBy: req.CreatedBy,
CreatedAt: time.Now(),
UserCount: userCount,
Status: SegmentStatusActive,
}
// Сохраняем
if err := c.segmentStore.Save(ctx, segment); err != nil {
return nil, err
}
// Запускаем первичное заполнение
go c.populateSegment(context.Background(), segment)
return segment, nil
}
// estimateSegmentSize оценивает размер сегмента
func (c *SegmentCreator) estimateSegmentSize(ctx context.Context, definition SegmentDefinition) (int, error) {
switch definition.Type {
case DefinitionTypeSQL:
query := fmt.Sprintf("SELECT COUNT(DISTINCT user_id) FROM (%s) as subquery", definition.SQLQuery)
var count int
err := c.dwhClient.QueryRowContext(ctx, query).Scan(&count)
return count, err
case DefinitionTypeRule:
// Оценка на основе правил
return c.estimateFromRules(ctx, definition.Rules)
default:
return 0, fmt.Errorf("unsupported definition type: %s", definition.Type)
}
}
// populateSegment заполняет сегмент пользователями
func (c *SegmentCreator) populateSegment(ctx context.Context, segment *Segment) error {
switch segment.Definition.Type {
case DefinitionTypeSQL:
return c.populateFromSQL(ctx, segment)
case DefinitionTypeRule:
return c.populateFromRules(ctx, segment)
default:
return fmt.Errorf("unsupported definition type: %s", segment.Definition.Type)
}
}
// populateFromSQL заполняет сегмент из SQL запроса
func (c *SegmentCreator) populateFromSQL(ctx context.Context, segment *Segment) error {
rows, err := c.dwhClient.QueryContext(ctx, segment.Definition.SQLQuery)
if err != nil {
return err
}
defer rows.Close()
batchSize := 10000
batch := make([]int64, 0, batchSize)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return err
}
batch = append(batch, userID)
if len(batch) >= batchSize {
if err := c.segmentStore.AddUsersToSegment(ctx, segment.ID, batch); err != nil {
return err
}
batch = batch[:0]
}
}
if len(batch) > 0 {
if err := c.segmentStore.AddUsersToSegment(ctx, segment.ID, batch); err != nil {
return err
}
}
return rows.Err()
}
3. Роли и права доступа
package rbac
// SegmentPermissions права доступа к сегментам
type SegmentPermissions struct {
// Кто может создавать сегменты
CanCreateSegment []string
// Кто может просматривать сегменты
CanViewSegment []string
// Кто может редактировать сегменты
CanEditSegment []string
// Кто может удалять сегменты
CanDeleteSegment []string
// Кто может использовать сегменты в экспериментах
CanUseSegment []string
}
// DefaultSegmentPermissions стандартные права
var DefaultSegmentPermissions = SegmentPermissions{
CanCreateSegment: {"data_analyst", "data_engineer", "admin"},
CanViewSegment: {"data_analyst", "data_engineer", "product_analyst", "admin"},
CanEditSegment: {"data_analyst", "data_engineer", "admin"},
CanDeleteSegment: {"data_engineer", "admin"},
CanUseSegment: {"product_analyst", "data_analyst", "admin"},
}
// SegmentRBACService сервис управления доступом к сегментам
type SegmentRBACService struct {
permissions SegmentPermissions
userRoles map[string][]string
}
// CanCreateSegment проверяет право на создание сегмента
func (s *SegmentRBACService) CanCreateSegment(userID string) bool {
return s.hasAnyRole(userID, s.permissions.CanCreateSegment)
}
// CanUseSegment проверяет право на использование сегмента
func (s *SegmentRBACService) CanUseSegment(userID string) bool {
return s.hasAnyRole(userID, s.permissions.CanUseSegment)
}
func (s *SegmentRBACService) hasAnyRole(userID string, roles []string) bool {
userRoles, exists := s.userRoles[userID]
if !exists {
return false
}
for _, role := range roles {
for _, userRole := range userRoles {
if role == userRole {
return true
}
}
}
return false
}
4. Процесс подготовки сегментов
package workflow
// SegmentPreparationWorkflow процесс подготовки сегментов
type SegmentPreparationWorkflow struct {
stages []SegmentStage
}
type SegmentStage struct {
Name string
Description string
Assignee string // роль
Status StageStatus
CompletedAt *time.Time
CompletedBy string
}
// NewSegmentWorkflow создаёт процесс подготовки сегмента
func NewSegmentWorkflow() *SegmentPreparationWorkflow {
return &SegmentPreparationWorkflow{
stages: []SegmentStage{
{
Name: "definition",
Description: "Определение логики сегмента",
Assignee: "data_analyst",
Status: StageStatusPending,
},
{
Name: "review",
Description: "Ревью логики сегмента",
Assignee: "senior_data_analyst",
Status: StageStatusPending,
},
{
Name: "implementation",
Description: "Реализация в DWH",
Assignee: "data_engineer",
Status: StageStatusPending,
},
{
Name: "validation",
Description: "Валидация результатов",
Assignee: "data_analyst",
Status: StageStatusPending,
},
{
Name: "deployment",
Description: "Развёртывание в систему A/B-тестирования",
Assignee: "data_engineer",
Status: StageStatusPending,
},
},
}
}
// WorkflowService сервис управления процессами
type WorkflowService struct {
store WorkflowStorage
}
// AdvanceStage продвигает процесс на следующий этап
func (s *WorkflowService) AdvanceStage(ctx context.Context, segmentID string, stageName string, userID string) error {
workflow, err := s.store.GetWorkflow(ctx, segmentID)
if err != nil {
return err
}
// Находим текущий этап
currentStage := workflow.getCurrentStage()
if currentStage == nil {
return fmt.Errorf("no current stage found")
}
// Проверяем права
if !s.canAdvanceStage(userID, currentStage) {
return fmt.Errorf("user %s cannot advance stage %s", userID, currentStage.Name)
}
// Завершаем текущий этап
now := time.Now()
currentStage.Status = StageStatusCompleted
currentStage.CompletedAt = &now
currentStage.CompletedBy = userID
// Активируем следующий этап
nextStage := workflow.getNextStage()
if nextStage != nil {
nextStage.Status = StageStatusInProgress
}
return s.store.UpdateWorkflow(ctx, workflow)
}
func (s *WorkflowService) canAdvanceStage(userID string, stage *SegmentStage) bool {
// Проверяем, что пользователь имеет нужную роль
// ...
return true
}
5. Хранение сегментов в DWH
-- Таблица определений сегментов
CREATE TABLE segment_definitions (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
description TEXT,
definition_type VARCHAR(50) NOT NULL,
sql_query TEXT,
rules JSONB,
data_source VARCHAR(255),
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) DEFAULT 'draft'
);
-- Таблица маппинга user_id -> segment_id
CREATE TABLE user_segment_mapping (
user_id BIGINT NOT NULL,
segment_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, segment_id)
);
CREATE INDEX idx_segment_users ON user_segment_mapping(segment_id, user_id);
-- Процесс подготовки сегментов
CREATE TABLE segment_workflows (
segment_id VARCHAR(255) PRIMARY KEY,
current_stage VARCHAR(100),
stages JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Логи изменений сегментов
CREATE TABLE segment_audit_log (
id BIGSERIAL PRIMARY KEY,
segment_id VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
performed_by VARCHAR(255) NOT NULL,
performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
details JSONB
);
6. API для управления сегментами
package api
// SegmentHandler handler для управления сегментами
type SegmentHandler struct {
segmentService *segment.SegmentService
rbacService *rbac.SegmentRBACService
}
// CreateSegmentRequest запрос на создание сегмента
type CreateSegmentRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Definition SegmentDefinition `json:"definition" validate:"required"`
}
// SegmentDefinition определение сегмента
type SegmentDefinition struct {
Type string `json:"type" validate:"required,oneof=sql rule list"`
SQLQuery string `json:"sql_query,omitempty"`
Rules []SegmentRule `json:"rules,omitempty"`
UserList []int64 `json:"user_list,omitempty"`
}
// SegmentRule правило сегмента
type SegmentRule struct {
Field string `json:"field" validate:"required"`
Operator string `json:"operator" validate:"required"`
Value interface{} `json:"value" validate:"required"`
}
// CreateSegment создаёт новый сегмент
func (h *SegmentHandler) CreateSegment(w http.ResponseWriter, r *http.Request) {
var req CreateSegmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
userID := getUserIDFromContext(ctx)
// Проверяем права
if !h.rbacService.CanCreateSegment(userID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Создаём сегмент
segment, err := h.segmentService.CreateSegment(ctx, &segment.CreateSegmentRequest{
Name: req.Name,
Description: req.Description,
CreatedBy: userID,
Definition: segment.SegmentDefinition{
Type: segment.DefinitionType(req.Type),
SQLQuery: req.SQLQuery,
Rules: convertRules(req.Rules),
},
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(segment)
}
// GetSegmentUsers возвращает пользователей сегмента
func (h *SegmentHandler) GetSegmentUsers(w http.ResponseWriter, r *http.Request) {
segmentID := r.URL.Query().Get("segment_id")
ctx := r.Context()
userID := getUserIDFromContext(ctx)
// Проверяем права
if !h.rbacService.CanViewSegment(userID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
users, err := h.segmentService.GetSegmentUsers(ctx, segmentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(users)
}
7. Типичные сегменты
package examples
// Примеры типичных сегментов
var (
// Новые пользователи
NewUsersSegment = &SegmentDefinition{
Type: DefinitionTypeSQL,
SQLQuery: `
SELECT user_id
FROM users
WHERE registration_date >= CURRENT_DATE - INTERVAL '30 days'
`,
}
// Активные пользователи
ActiveUsersSegment = &SegmentDefinition{
Type: DefinitionTypeSQL,
SQLQuery: `
SELECT DISTINCT user_id
FROM user_sessions
WHERE session_date >= CURRENT_DATE - INTERVAL '7 days'
`,
}
// Платящие пользователи
PayingUsersSegment = &SegmentDefinition{
Type: DefinitionTypeSQL,
SQLQuery: `
SELECT DISTINCT user_id
FROM payments
WHERE payment_date >= CURRENT_DATE - INTERVAL '90 days'
`,
}
// Пользователи с высоким LTV
HighLTVUsersSegment = &SegmentDefinition{
Type: DefinitionTypeSQL,
SQLQuery: `
SELECT user_id
FROM (
SELECT user_id, SUM(amount) as ltv
FROM payments
GROUP BY user_id
) as user_ltv
WHERE ltv > 1000
`,
}
// Мобильные пользователи
MobileUsersSegment = &SegmentDefinition{
Type: DefinitionTypeRule,
Rules: []SegmentRule{
{
Field: "last_platform",
Operator: "in",
Value: []string{"ios", "android"},
},
},
}
)
8. Схема процесса
┌─────────────────────────────────────────────────────────────────┐
│ Процесс подготовки сегментов │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Data Analyst │
│ Определяет │
│ логику │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Senior Analyst │
│ Ревью │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Data Engineer │
│ Реализует │
│ в DWH │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Data Analyst │
│ Валидирует │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Data Engineer │
│ Загружает │
│ в AB System │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Product │
│ Analyst │
│ Использует │
└─────────────────┘
9. Итоговая архитектура
┌─────────────────────────────────────────────────────────────────┐
│ DWH (Data Warehouse) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Таблицы │ │ SQL запросы │ │ ETL │ │
│ │ с данными │───►│ для сегментов │───►│ процессы │ │
│ └─────────────────┘ └─────────────────┘ └──────┬──────┘ │
└─────────────────────────────────────────────────────────┼───────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ AB Test System │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Segment │ │ User-Segment │ │ Segment │ │
│ │ Definitions │ │ Mapping │ │ Service │ │
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
10. Ключевые принципы
-
Разделение ролей: Data Analyst определяет логику, Data Engineer реализует, Product Analyst использует.
-
Предподготовка сегментов: Сегменты должны быть готовы до создания эксперимента.
-
Валидация: Каждый сегмент проходит ревью и валидацию перед использованием.
-
Версионирование: Все изменения сегментов логируются.
-
Автоматическое обновление: Сегменты периодически обновляются (обычно раз в день).
Для текущего проектирования принимаем упрощение: сегменты подготавливаются заранее и загружаются в систему A/B-тестирования. Продуктовый аналитик использует уже готовые сегменты при создании экспериментов.
Вопрос 17. Какие атрибуты эксперимента указывает аналитик при создании?
Таймкод: 00:32:21
Ответ собеседника: Правильный. Уточняется, что аналитик указывает наименование эксперимента, мета-информацию (дата, описание) и конфигурацию эксперимента в формате JSON, которая может содержать различные настройки в зависимости от типа эксперимента.
Правильный ответ:
1. Полная модель атрибутов эксперимента
package experiment
// Experiment полная модель эксперимента
type Experiment struct {
// === Основная информация ===
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Hypothesis string `json:"hypothesis"`
// === Мета-информация ===
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedBy string `json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
// === Статус ===
Status ExperimentStatus `json:"status"`
// === Варианты ===
Variants []Variant `json:"variants"`
// === Таргетинг ===
TargetSegments []string `json:"target_segments"`
TargetPlatforms []Platform `json:"target_platforms"`
TrafficPct float64 `json:"traffic_percentage"`
// === Статистические параметры ===
MinSampleSize int `json:"min_sample_size"`
SignificanceLevel float64 `json:"significance_level"`
PowerLevel float64 `json:"power_level"`
// === Конфигурация ===
Config ExperimentConfig `json:"config"`
// === Метрики ===
PrimaryMetric Metric `json:"primary_metric"`
SecondaryMetrics []Metric `json:"secondary_metrics"`
GuardrailMetrics []Metric `json:"guardrail_metrics"`
}
// Variant вариант эксперимента
type Variant struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Weight float64 `json:"weight"` // доля трафика (0.0 - 1.0)
Config map[string]interface{} `json:"config"`
IsControl bool `json:"is_control"`
}
// ExperimentConfig конфигурация эксперимента
type ExperimentConfig struct {
// Тип эксперимента
Type ExperimentType `json:"type"`
// Длительность
Duration DurationConfig `json:"duration"`
// Платформенные настройки
PlatformConfig map[Platform]*PlatformConfig `json:"platform_config"`
// Дополнительные настройки
Custom map[string]interface{} `json:"custom"`
}
type ExperimentType string
const (
ExperimentTypeAB ExperimentType = "ab"
ExperimentTypeABN ExperimentType = "abn"
ExperimentTypeMVT ExperimentType = "mvt"
ExperimentTypeFeatureFlag ExperimentType = "feature_flag"
)
// DurationConfig конфигурация длительности
type DurationConfig struct {
MinDuration time.Duration `json:"min_duration"`
MaxDuration time.Duration `json:"max_duration"`
TargetDuration time.Duration `json:"target_duration"`
}
// PlatformConfig конфигурация для платформы
type PlatformConfig struct {
Enabled bool `json:"enabled"`
MinAppVersion string `json:"min_app_version"`
}
// Metric метрика
type Metric struct {
ID string `json:"id"`
Name string `json:"description"`
Type MetricType `json:"type"`
Aggregation AggregationType `json:"aggregation"`
}
type MetricType string
const (
MetricTypeConversion MetricType = "conversion"
MetricTypeRevenue MetricType = "revenue"
MetricTypeRetention MetricType = "retention"
MetricTypeCustom MetricType = "custom"
)
type AggregationType string
const (
AggregationTypeSum AggregationType = "sum"
AggregationTypeAverage AggregationType = "avg"
AggregationTypeCount AggregationType = "count"
AggregationTypeUnique AggregationType = "unique"
)
2. Упрощённая модель для MVP
package mvp
// SimpleExperiment упрощённая модель для MVP
type SimpleExperiment struct {
// Основная информация
ID string `json:"id"`
Name string `json:"name" validate:"required,max=500"`
Description string `json:"description"`
Hypothesis string `json:"hypothesis"`
// Варианты
Variants []SimpleVariant `json:"variants" validate:"required,min=2,max=10"`
// Таргетинг
TargetSegments []string `json:"target_segments"`
TargetPlatforms []string `json:"target_platforms"`
TrafficPct float64 `json:"traffic_percentage" validate:"min=0,max=1"`
// Конфигурация (JSON)
Config map[string]interface{} `json:"config"`
// Метаданные
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// SimpleVariant упрощённый вариант
type SimpleVariant struct {
ID string `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Config map[string]interface{} `json:"config"`
}
3. API для создания эксперимента
package api
// CreateExperimentRequest запрос на создание эксперимента
type CreateExperimentRequest struct {
// Основная информация
Name string `json:"name" validate:"required,max=500"`
Description string `json:"description"`
Hypothesis string `json:"hypothesis"`
// Варианты
Variants []VariantRequest `json:"variants" validate:"required,min=2,max=10"`
// Таргетинг
TargetSegments []string `json:"target_segments"`
TargetPlatforms []string `json:"target_platforms"`
TrafficPct float64 `json:"traffic_percentage" validate:"min=0.01,max=1"`
// Статистические параметры
MinSampleSize int `json:"min_sample_size" validate:"min=100"`
SignificanceLevel float64 `json:"significance_level" validate:"min=0.01,max=0.1"`
// Конфигурация
Config map[string]interface{} `json:"config"`
// Метрики
PrimaryMetricID string `json:"primary_metric_id"`
SecondaryMetricIDs []string `json:"secondary_metric_ids"`
GuardrailMetricIDs []string `json:"guardrail_metric_ids"`
}
// VariantRequest запрос на создание варианта
type VariantRequest struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Weight float64 `json:"weight" validate:"min=0,max=1"`
Config map[string]interface{} `json:"config"`
}
// CreateExperimentResponse ответ на создание эксперимента
type CreateExperimentResponse struct {
ExperimentID string `json:"experiment_id"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
}
// CreateExperimentHandler handler для создания эксперимента
func (h *Handler) CreateExperimentHandler(w http.ResponseWriter, r *http.Request) {
var req CreateExperimentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
userID := getUserIDFromContext(ctx)
// Валидируем запрос
if err := h.validator.Validate(req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Создаём эксперимент
exp, err := h.experimentService.CreateExperiment(ctx, &experiment.CreateExperimentRequest{
Name: req.Name,
Description: req.Description,
Hypothesis: req.Hypothesis,
Variants: convertVariants(req.Variants),
TargetSegments: req.TargetSegments,
TargetPlatforms: convertPlatforms(req.TargetPlatforms),
TrafficPct: req.TrafficPct,
MinSampleSize: req.MinSampleSize,
SignificanceLevel: req.SignificanceLevel,
Config: req.Config,
CreatedBy: userID,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := CreateExperimentResponse{
ExperimentID: exp.ID,
Name: exp.Name,
Status: string(exp.Status),
CreatedAt: exp.CreatedAt,
CreatedBy: exp.CreatedBy,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
4. Валидация эксперимента
package validator
import (
"fmt"
"math"
)
// ExperimentValidator валидатор эксперимента
type ExperimentValidator struct {
segmentService SegmentService
}
// Validate валидирует эксперимент
func (v *ExperimentValidator) Validate(exp *Experiment) error {
var errors []string
// Проверяем имя
if exp.Name == "" {
errors = append(errors, "name is required")
}
if len(exp.Name) > 500 {
errors = append(errors, "name must be less than 500 characters")
}
// Проверяем варианты
if len(exp.Vari
Вопрос 30. Как выглядит полный сценарий работы мобильного приложения с системой экспериментов от старта до отправки аналитики?
Таймкод: 00:59:44
Ответ собеседова: Правильный. При старте мобильное приложение получает список экспериментов через Experiment Decision сервис, который обращается к хранилищу сегментов для определения принадлежности пользователя к экспериментам. Далее приложение отправляет аналитические события через коллектор в Kafka, откуда они попадают в аналитическую БД.
Правильный ответ:
Полный сценарий работы мобильного приложения с системой экспериментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПОЛНЫЙ ЦИКЛ РАБОТЫ С ЭКСПЕРИМЕНТАМИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ 1. START │───▶│ 2. DECISION │───▶│ 3. APPLY │───▶│ 4. TRACK │ │
│ │ │ │ │ │ │ │ │ │
│ │ App │ │ Get │ │ Show │ │ Send events │ │
│ │ launch │ │ experiments │ │ variant │ │ to collector │ │
│ └──────────┘ └──────────────┘ └─────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ User ID │ │ Check │ │ Render UI │ │ Kafka │ │
│ │ Device │ │ segments │ │ with flags │ │ ClickHouse │ │
│ │ Info │ │ Assign │ │ │ │ Analytics │ │
│ └──────────┘ └──────────────┘ └─────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Этап 1: Запуск приложения (App Launch)
// Мобильное приложение при старте
type AppLaunchRequest struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
Platform string `json:"platform"` // ios, android
AppVersion string `json:"app_version"`
Country string `json:"country"`
Locale string `json:"locale"`
ExtraParams map[string]string `json:"extra_params"`
}
// Инициализация экспериментального SDK
type ExperimentSDK struct {
client *http.Client
baseURL string
userID string
deviceID string
cache *ExperimentCache
eventQueue *EventQueue
}
func (sdk *ExperimentSDK) Initialize(ctx context.Context, req AppLaunchRequest) error {
// Сохраняем контекст пользователя
sdk.userID = req.UserID
sdk.deviceID = req.DeviceID
// Загружаем эксперименты с сервера
experiments, err := sdk.FetchExperiments(ctx, req)
if err != nil {
// При ошибке используем кэшированные данные
experiments = sdk.cache.GetCachedExperiments(req.UserID)
}
// Кэшируем результат
sdk.cache.SetCachedExperiments(req.UserID, experiments)
// Отправляем событие инициализации
sdk.trackEvent(ExperimentEvent{
Type: "sdk_initialized",
UserID: req.UserID,
Timestamp: time.Now(),
Payload: map[string]interface{}{
"experiments_count": len(experiments),
"platform": req.Platform,
"app_version": req.AppVersion,
},
})
return nil
}
Этап 2: Получение экспериментов (Experiment Decision)
// Experiment Decision Service
type ExperimentDecisionService struct {
experimentRepo ExperimentRepository
segmentStore SegmentStore
variantService VariantAssignmentService
metrics *DecisionMetrics
}
type ExperimentDecisionRequest struct {
UserID string `json:"user_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Country string `json:"country"`
DeviceType string `json:"device_type"`
Context map[string]interface{} `json:"context"`
}
type ExperimentDecisionResponse struct {
Experiments []UserExperiment `json:"experiments"`
RequestID string `json:"request_id"`
Timestamp time.Time `json:"timestamp"`
}
type UserExperiment struct {
ExperimentID string `json:"experiment_id"`
ExperimentName string `json:"experiment_name"`
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
Flags map[string]interface{} `json:"flags"`
Parameters map[string]interface{} `json:"parameters"`
}
func (s *ExperimentDecisionService) GetExperimentsForUser(
ctx context.Context,
req ExperimentDecisionRequest,
) (*ExperimentDecisionResponse, error) {
startTime := time.Now()
defer func() {
s.metrics.RecordDecisionDuration(time.Since(startTime))
}()
// 1. Получаем сегменты пользователя
userSegments, err := s.segmentStore.GetUserSegments(ctx, req.UserID)
if err != nil {
s.metrics.RecordSegmentFetchError()
return nil, fmt.Errorf("failed to get user segments: %w", err)
}
// 2. Получаем все активные эксперименты
activeExperiments, err := s.experimentRepo.GetActiveExperiments(ctx)
if err != nil {
s.metrics.RecordExperimentFetchError()
return nil, fmt.Errorf("failed to get active experiments: %w", err)
}
// 3. Фильтруем эксперименты по сегментам и таргетингу
var userExperiments []UserExperiment
for _, exp := range activeExperiments {
// Проверяем принадлежность к сегментам
if !s.isUserInExperimentSegments(exp, userSegments) {
continue
}
// Проверяем технические параметры
if !s.matchesTechnicalTargeting(exp, req) {
continue
}
// Проверяем статус эксперимента
if exp.Status != "running" {
continue
}
// Проверяем временные ограничения
if !s.isWithinTimeRange(exp) {
continue
}
// 4. Определяем вариант для пользователя
variant, err := s.variantService.AssignVariant(ctx, exp, req.UserID)
if err != nil {
s.metrics.RecordVariantAssignmentError(exp.ID)
continue
}
userExperiments = append(userExperiments, UserExperiment{
ExperimentID: exp.ID,
ExperimentName: exp.Name,
VariantID: variant.ID,
VariantName: variant.Name,
Flags: variant.Flags,
Parameters: variant.Parameters,
})
}
// 5. Логируем решение
s.metrics.RecordDecision(len(userExperiments), len(activeExperiments))
return &ExperimentDecisionResponse{
Experiments: userExperiments,
RequestID: generateRequestID(),
Timestamp: time.Now(),
}, nil
}
func (s *ExperimentDecisionService) isUserInExperimentSegments(
exp *Experiment,
userSegments []string,
) bool {
if len(exp.TargetSegments) == 0 {
return true // Нет ограничений по сегментам
}
// Проверяем пересечение сегментов
for _, seg := range exp.TargetSegments {
if contains(userSegments, seg) {
return true
}
}
return false
}
func (s *ExperimentDecisionService) matchesTechnicalTargeting(
exp *Experiment,
req ExperimentDecisionRequest,
) bool {
targeting := exp.TechnicalTargeting
// Проверка платформы
if len(targeting.Platforms) > 0 {
if !contains(targeting.Platforms, req.Platform) {
return false
}
}
// Проверка версии приложения
if targeting.MinAppVersion != "" {
if compareVersions(req.AppVersion, targeting.MinAppVersion) < 0 {
return false
}
}
// Проверка страны
if len(targeting.Countries) > 0 {
if !contains(targeting.Countries, req.Country) {
return false
}
}
return true
}
Этап 3: Применение вариантов (Apply Variant)
// Применение эксперимента в коде
type ExperimentApplier struct {
experiments map[string]*UserExperiment
flagStore *FlagStore
}
func (a *ExperimentApplier) ApplyExperiments(experiments []UserExperiment) {
for _, exp := range experiments {
a.experiments[exp.ExperimentID] = &exp
// Применяем флаги
for key, value := range exp.Flags {
a.flagStore.Set(key, value)
}
// Применяем параметры
for key, value := range exp.Parameters {
a.flagStore.SetParam(key, value)
}
}
}
// Использование в UI
type CheckoutScreen struct {
experimentApplier *ExperimentApplier
}
func (s *CheckoutScreen) Render() {
// Проверяем флаг нового дизайна
if s.experimentApplier.GetFlag("new_checkout_design") == true {
s.renderNewDesign()
} else {
s.renderOldDesign()
}
// Получаем параметр скидки
discountPercent := s.experimentApplier.GetParam("discount_percent")
if discountPercent != nil {
s.applyDiscount(discountPercent.(float64))
}
// Отслеживаем показ экрана
s.trackScreenView("checkout")
}
func (s *ExperimentApplier) GetFlag(key string) interface{} {
// Получаем флаг из активного эксперимента
for _, exp := range s.experiments {
if val, ok := exp.Flags[key]; ok {
return val
}
}
return nil
}
func (s *ExperimentApplier) GetParam(key string) interface{} {
// Получаем параметр из активного эксперимента
for _, exp := range s.experiments {
if val, ok := exp.Parameters[key]; ok {
return val
}
}
return nil
}
Этап 4: Отправка аналитики (Event Tracking)
// События экспериментов
type ExperimentEvent struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id"`
ExperimentID string `json:"experiment_id,omitempty"`
VariantID string `json:"variant_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Payload map[string]interface{} `json:"payload"`
}
// Типы событий
const (
EventExperimentViewed = "experiment_viewed"
EventExperimentAction = "experiment_action"
EventGoalCompleted = "goal_completed"
EventScreenView = "screen_view"
EventButtonClick = "button_click"
EventPurchase = "purchase"
EventError = "error"
)
// Трекер событий
type EventTracker struct {
eventQueue *EventQueue
kafka *KafkaProducer
userID string
deviceID string
sessionID string
}
func (t *EventTracker) TrackExperimentView(
experimentID string,
variantID string,
metadata map[string]interface{},
) {
event := ExperimentEvent{
EventID: generateEventID(),
EventType: EventExperimentViewed,
UserID: t.userID,
DeviceID: t.deviceID,
SessionID: t.sessionID,
ExperimentID: experimentID,
VariantID: variantID,
Timestamp: time.Now(),
Platform: "mobile",
AppVersion: "1.0.0",
Payload: metadata,
}
// Добавляем в очередь
t.eventQueue.Push(event)
// Если очередь заполнена - отправляем
if t.eventQueue.Size() >= 10 {
t.Flush()
}
}
func (t *EventTracker) TrackGoal(
goalName string,
experimentID string,
variantID string,
value float64,
) {
event := ExperimentEvent{
EventID: generateEventID(),
EventType: EventGoalCompleted,
UserID: t.userID,
DeviceID: t.deviceID,
SessionID: t.sessionID,
ExperimentID: experimentID,
VariantID: variantID,
Timestamp: time.Now(),
Platform: "mobile",
AppVersion: "1.0.0",
Payload: map[string]interface{}{
"goal_name": goalName,
"value": value,
},
}
t.eventQueue.Push(event)
t.Flush() // Цели отправляем сразу
}
func (t *EventTracker) Flush() {
events := t.eventQueue.Drain()
if len(events) == 0 {
return
}
// Отправляем в Kafka
for _, event := range events {
t.kafka.ProduceAsync("experiment_events", event)
}
}
Этап 5: Коллектор и обработка событий
// Event Collector Service
type EventCollectorService struct {
kafka *KafkaConsumer
clickhouse *ClickHouseClient
validator *EventValidator
metrics *CollectorMetrics
}
func (s *EventCollectorService) Start(ctx context.Context) error {
return s.kafka.Consume(ctx, "experiment_events", func(msg *kafka.Message) error {
// 1. Парсим событие
var event ExperimentEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
s.metrics.RecordParseError()
return nil // Пропускаем битые события
}
// 2. Валидируем
if err := s.validator.Validate(&event); err != nil {
s.metrics.RecordValidationError()
return nil
}
// 3. Обогащаем данными
enrichedEvent := s.enrichEvent(&event)
// 4. Сохраняем в ClickHouse
if err := s.clickhouse.InsertEvent(ctx, enrichedEvent); err != nil {
s.metrics.RecordInsertError()
return err // Повторяем попытку
}
s.metrics.RecordSuccess()
return nil
})
}
func (s *EventCollectorService) enrichEvent(event *ExperimentEvent) *EnrichedEvent {
return &EnrichedEvent{
ExperimentEvent: *event,
// Обогащение данными
Country: s.resolveCountry(event.DeviceID),
DeviceModel: s.resolveDeviceModel(event.DeviceID),
OSVersion: s.resolveOSVersion(event.DeviceID),
// Временные метки
HourOfDay: event.Timestamp.Hour(),
DayOfWeek: int(event.Timestamp.Weekday()),
IsWeekend: event.Timestamp.Weekday() == time.Saturday ||
event.Timestamp.Weekday() == time.Sunday,
// Данные о сессии
SessionNumber: s.getSessionNumber(event.UserID),
TimeInSession: s.getTimeInSession(event.SessionID),
}
}
Этап 6: Хранение в ClickHouse
-- Таблица событий экспериментов
CREATE TABLE experiment_events (
-- Идентификаторы
event_id UUID,
event_type LowCardinality(String),
user_id String,
device_id String,
session_id String,
-- Данные эксперимента
experiment_id String,
variant_id String,
-- Временные метки
timestamp DateTime,
event_date Date DEFAULT toDate(timestamp),
-- Платформа
platform LowCardinality(String),
app_version LowCardinality(String),
-- Геолокация
country LowCardinality(String),
device_model LowCardinality(String),
os_version LowCardinality(String),
-- Время
hour UInt8,
day_of_week UInt8,
is_weekend UInt8,
-- Сессия
session_number UInt32,
time_in_session UInt32,
-- Дополнительные данные
payload String, -- JSON
-- Индексы
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 3,
INDEX idx_experiment experiment_id TYPE bloom_filter GRANULARITY 3
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_type, timestamp)
TTL event_date + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Материализованное представление для агрегации
CREATE MATERIALIZED VIEW experiment_daily_stats
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_date, event_type)
AS SELECT
experiment_id,
variant_id,
event_date,
event_type,
count() as event_count,
uniqExact(user_id) as unique_users,
uniqExact(session_id) as unique_sessions
FROM experiment_events
GROUP BY experiment_id, variant_id, event_date, event_type;
Этап 7: Аналитика и отчёты
// Сервис аналитики
type AnalyticsService struct {
clickhouse *ClickHouseClient
}
type ExperimentStats struct {
ExperimentID string `json:"experiment_id"`
VariantStats []VariantStats `json:"variant_stats"`
TotalUsers int64 `json:"total_users"`
TotalEvents int64 `json:"total_events"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
ConversionRate map[string]float64 `json:"conversion_rate"`
}
type VariantStats struct {
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
Users int64 `json:"users"`
Events int64 `json:"events"`
ConversionRate float64 `json:"conversion_rate"`
Revenue float64 `json:"revenue"`
AvgOrderValue float64 `json:"avg_order_value"`
}
func (s *AnalyticsService) GetExperimentStats(
ctx context.Context,
experimentID string,
startDate, endDate time.Time,
) (*ExperimentStats, error) {
query := `
SELECT
variant_id,
uniqExact(user_id) as users,
count() as events,
sumIf(1, event_type = 'goal_completed') as goals,
sumIf(toFloat64(payload.value), event_type = 'purchase') as revenue
FROM experiment_events
WHERE experiment_id = ?
AND event_date BETWEEN ? AND ?
GROUP BY variant_id
`
rows, err := s.clickhouse.Query(ctx, query, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
stats := &ExperimentStats{
ExperimentID: experimentID,
StartDate: startDate,
EndDate: endDate,
}
for rows.Next() {
var vs VariantStats
var goals int64
err := rows.Scan(
&vs.VariantID,
&vs.Users,
&vs.Events,
&goals,
&vs.Revenue,
)
if err != nil {
return nil, err
}
if vs.Users > 0 {
vs.ConversionRate = float64(goals) / float64(vs.Users) * 100
}
if goals > 0 {
vs.AvgOrderValue = vs.Revenue / float64(goals)
}
stats.VariantStats = append(stats.VariantStats, vs)
stats.TotalUsers += vs.Users
stats.TotalEvents += vs.Events
}
return stats, nil
}
Полная диаграмма потока данных:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПОТОК ДАННЫХ ЭКСПЕРИМЕНТОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Mobile App Backend Storage │
│ ───────── ─────── ─────── │
│ │
│ ┌─────────┐ │
│ │ START │ │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ Fetch │────▶│ Experiment │────▶│ Segment │ │
│ │ Experiments │ │ Decision │ │ Store │ │
│ │ │◀────│ Service │◀────│ (Redis) │ │
│ └─────────────┘ └─────────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Apply │ │
│ │ Variants │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ Track │────▶│ Event │────▶│ Kafka │ │
│ │ Events │ │ Collector │ │ │ │
│ └─────────────┘ └─────────────────┘ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ ClickHouse │ │
│ │ Analytics DB │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Reports & │ │
│ │ Dashboards │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Полный сценарий работы:
- Запуск приложения → Инициализация SDK, сбор контекста пользователя
- Experiment Decision → Запрос к сервису, проверка сегментов, определение вариантов
- Применение вариантов → Отображение UI согласно флагам и параметрам
- Трекинг событий → Сбор событий в очередь, пакетная отправка
- Коллектор событий → Валидация, обогащение, сохранение
- Хранение → Kafka → ClickHouse для аналитики
- Аналитика → Агрегация, отчёты, дашборды
Вопрос 18. Как отличается отгрузка аналитики в мобильных и веб-приложениях?
Таймкод: 00:37:54
Ответ собеседника: Правильный. Уточняется, что мобильное приложение может накапливать данные в оффлайне и отправлять пачками (бандлами), а веб-приложение отправляет события по одному в реальном времени.
Правильный ответ:
1. Сравнение подходов к отгрузке аналитики
┌─────────────────────────────────────────────────────────────────┐
│ Мобильные приложения │
│ │
│ ✅ Батчинг событий │
│ ✅ Офлайн-накопление │
│ ✅ Отправка при подключении к сети │
│ ✅ Сжатие данных │
│ ✅ Приоритизация событий │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Веб-приложения │
│ │
│ ✅ Отправка в реальном времени │
│ ✅ Нет офлайн-режима (обычно) │
│ ✅ Использование sendBeacon для финализации │
│ ✅ Более простая реализация │
└─────────────────────────────────────────────────────────────────┘
2. Мобильный SDK: батчинг и офлайн-накопление
package mobile
import (
"sync"
"time"
)
// BatchEventCollector коллектор событий с батчингом
type BatchEventCollector struct {
db *LocalDatabase
apiClient *APIClient
config BatchConfig
mu sync.Mutex
timer *time.Timer
isUploading bool
}
// BatchConfig конфигурация батчинга
type BatchConfig struct {
MaxBatchSize int // максимальный размер батча
MaxQueueSize int // максимальный размер очереди
UploadInterval time.Duration // интервал отправки
RetryInterval time.Duration // интервал повтора при ошибке
MaxRetries int // максимальное количество попыток
CompressionType string // тип сжатия (gzip, none)
}
// Event событие для отправки
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Payload map[string]interface{} `json:"payload"`
Attempts int `json:"attempts"`
}
// TrackEvent добавляет событие в очередь
func (c *BatchEventCollector) TrackEvent(ctx context.Context, eventType string, payload map[string]interface{}) error {
c.mu.Lock()
defer c.mu.Unlock()
// Проверяем размер очереди
queueSize, err := c.db.GetEventCount()
if err != nil {
return err
}
if queueSize >= c.config.MaxQueueSize {
// Удаляем старые события
if err := c.db.DeleteOldestEvents(100); err != nil {
return err
}
}
// Создаём событие
event := &Event{
ID: generateID(),
Type: eventType,
Timestamp: time.Now(),
UserID: c.getUserID(),
SessionID: c.getSessionID(),
Payload: payload,
}
// Сохраняем в локальную БД
if err := c.db.SaveEvent(event); err != nil {
return err
}
// Проверяем, нужно ли отправить батч
if queueSize+1 >= c.config.MaxBatchSize {
go c.UploadBatch(context.Background())
}
return nil
}
// UploadBatch отправляет батч событий
func (c *BatchEventCollector) UploadBatch(ctx context.Context) error {
c.mu.Lock()
if c.isUploading {
c.mu.Unlock()
return nil
}
c.isUploading = true
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.isUploading = false
c.mu.Unlock()
}()
// Получаем события из локальной БД
events, err := c.db.GetEvents(c.config.MaxBatchSize)
if err != nil {
return err
}
if len(events) == 0 {
return nil
}
// Проверяем подключение к сети
if !c.isNetworkAvailable() {
return fmt.Errorf("network unavailable")
}
// Отправляем
if err := c.sendEvents(ctx, events); err != nil {
// Увеличиваем счётчик попыток
for _, event := range events {
event.Attempts++
c.db.UpdateEvent(event)
}
// Планируем повторную отправку
time.AfterFunc(c.config.RetryInterval, func() {
c.UploadBatch(context.Background())
})
return err
}
// Удаляем отправленные события
eventIDs := make([]string, len(events))
for i, event := range events {
eventIDs[i] = event.ID
}
return c.db.DeleteEvents(eventIDs)
}
// sendEvents отправляет события на сервер
func (c *BatchEventCollector) sendEvents(ctx context.Context, events []*Event) error {
// Сжимаем данные
data, err := c.compress(events)
if err != nil {
return err
}
// Отправляем
return c.apiClient.PostEvents(ctx, data)
}
// compress сжимает события
func (c *BatchEventCollector) compress(events []*Event) ([]byte, error) {
data, err := json.Marshal(events)
if err != nil {
return nil, err
}
if c.config.CompressionType == "gzip" {
return gzipCompress(data)
}
return data, nil
}
// StartPeriodicUpload запускает периодическую отправку
func (c *BatchEventCollector) StartPeriodicUpload() {
c.timer = time.NewTimer(c.config.UploadInterval)
go func() {
for {
select {
case <-c.timer.C:
c.UploadBatch(context.Background())
c.timer.Reset(c.config.UploadInterval)
}
}
}()
}
3. Локальная база данных для мобильных приложений
package storage
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
// LocalDatabase локальная база данных
type LocalDatabase struct {
db *sql.DB
}
// InitDB инициализирует базу данных
func InitDB(dbPath string) (*LocalDatabase, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
// Создаём таблицу событий
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
timestamp DATETIME NOT NULL,
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
CREATE INDEX IF NOT EXISTS idx_events_attempts ON events(attempts);
`)
if err != nil {
return nil, err
}
return &LocalDatabase{db: db}, nil
}
// SaveEvent сохраняет событие
func (ldb *LocalDatabase) SaveEvent(event *Event) error {
payload, err := json.Marshal(event.Payload)
if err != nil {
return err
}
_, err = ldb.db.Exec(
"INSERT INTO events (id, type, timestamp, user_id, session_id, payload, attempts) VALUES (?, ?, ?, ?, ?, ?, ?)",
event.ID, event.Type, event.Timestamp, event.UserID, event.SessionID, string(payload), event.Attempts,
)
return err
}
// GetEvents получает события для отправки
func (ldb *LocalDatabase) GetEvents(limit int) ([]*Event, error) {
rows, err := ldb.db.Query(
"SELECT id, type, timestamp, user_id, session_id, payload, attempts FROM events ORDER BY timestamp ASC LIMIT ?",
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var events []*Event
for rows.Next() {
var event Event
var payload string
err := rows.Scan(&event.ID, &event.Type, &event.Timestamp, &event.UserID, &event.SessionID, &payload, &event.Attempts)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(payload), &event.Payload); err != nil {
return nil, err
}
events = append(events, &event)
}
return events, nil
}
// DeleteEvents удаляет отправленные события
func (ldb *LocalDatabase) DeleteEvents(ids []string) error {
tx, err := ldb.db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("DELETE FROM events WHERE id = ?")
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
if _, err := stmt.Exec(id); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// DeleteOldestEvents удаляет самые старые события
func (ldb *LocalDatabase) DeleteOldestEvents(limit int) error {
_, err := ldb.db.Exec(
"DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY timestamp ASC LIMIT ?)",
limit,
)
return err
}
// GetEventCount возвращает количество событий
func (ldb *LocalDatabase) GetEventCount() (int, error) {
var count int
err := ldb.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&count)
return count, err
}
4. Веб-SDK: отправка в реальном времени
// web-sdk.js
class WebAnalyticsClient {
constructor(config) {
this.apiUrl = config.apiUrl;
this.apiKey = config.apiKey;
this.userId = config.userId;
this.sessionId = this.getOrCreateSessionId();
this.queue = [];
this.isOnline = navigator.onLine;
// Слушаем изменения сети
window.addEventListener('online', () => this.onOnline());
window.addEventListener('offline', () => this.onOffline());
// Отправляем события при закрытии страницы
window.addEventListener('beforeunload', () => this.onBeforeUnload());
// Периодическая отправка
setInterval(() => this.flush(), 5000);
}
// Отслеживание события
track(eventType, properties = {}) {
const event = {
id: this.generateId(),
type: eventType,
timestamp: new Date().toISOString(),
user_id: this.userId,
session_id: this.sessionId,
properties: properties
};
if (this.isOnline) {
// Отправляем сразу
this.sendEvent(event);
} else {
// Сохраняем в localStorage для офлайн-режима
this.saveToLocalStorage(event);
}
}
// Отправка одного события
async sendEvent(event) {
try {
const response = await fetch(`${this.apiUrl}/api/v1/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(event)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('Failed to send event:', error);
// Сохраняем для повторной отправки
this.saveToLocalStorage(event);
}
}
// Отправка батча событий
async flush() {
if (!this.isOnline || this.queue.length === 0) {
return;
}
const events = [...this.queue];
this.queue = [];
try {
const response = await fetch(`${this.apiUrl}/api/v1/events/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({ events })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('Failed to send batch:', error);
// Возвращаем события в очередь
this.queue = [...events, ...this.queue];
}
}
// Обработка онлайн-режима
onOnline() {
this.isOnline = true;
this.flushLocalStorage();
}
// Обработка офлайн-режима
onOffline() {
this.isOnline = false;
}
// Обработка закрытия страницы
onBeforeUnload() {
// Используем sendBeacon для надёжной отправки
const events = [...this.queue, ...this.getLocalStorageEvents()];
if (events.length > 0) {
const blob = new Blob(
[JSON.stringify({ events })],
{ type: 'application/json' }
);
navigator.sendBeacon(`${this.apiUrl}/api/v1/events/batch`, blob);
}
}
// Сохранение в localStorage
saveToLocalStorage(event) {
const events = this.getLocalStorageEvents();
events.push(event);
localStorage.setItem('analytics_queue', JSON.stringify(events));
}
// Получение событий из localStorage
getLocalStorageEvents() {
const data = localStorage.getItem('analytics_queue');
return data ? JSON.parse(data) : [];
}
// Отправка событий из localStorage
async flushLocalStorage() {
const events = this.getLocalStorageEvents();
if (events.length === 0) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/api/v1/events/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({ events })
});
if (response.ok) {
localStorage.removeItem('analytics_queue');
}
} catch (error) {
console.error('Failed to flush localStorage:', error);
}
}
generateId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
getOrCreateSessionId() {
let sessionId = sessionStorage.getItem('analytics_session_id');
if (!sessionId) {
sessionId = this.generateId();
sessionStorage.setItem('analytics_session_id', sessionId);
}
return sessionId;
}
}
5. Серверный API для приёма событий
package api
// EventHandler handler для событий
type EventHandler struct {
eventService *event.Service
validator *EventValidator
}
// EventRequest запрос на отправку события
type EventRequest struct {
ID string `json:"id"`
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Properties map[string]interface{} `json:"properties"`
}
// BatchEventRequest запрос на отправку батча
type BatchEventRequest struct {
Events []EventRequest `json:"events"`
}
// TrackEvent обрабатывает одно событие
func (h *EventHandler) TrackEvent(w http.ResponseWriter, r *http.Request) {
var req EventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Валидируем
if err := h.validator.Validate(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Сохраняем
if err := h.eventService.SaveEvent(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// TrackEventBatch обрабатывает батч событий
func (h *EventHandler) TrackEventBatch(w http.ResponseWriter, r *http.Request) {
var req BatchEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Валидируем каждое событие
for _, event := range req.Events {
if err := h.validator.Validate(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// Сохраняем батч
if err := h.eventService.SaveEventBatch(r.Context(), req.Events); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
6. Сравнительная таблица
┌────────────────────┬──────────────────────────┬──────────────────────────┐
│ Аспект │ Мобильные │ Веб │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Отправка │ Батчами (10-100 событий) │ По одному или малыми │
│ │ │ батчами │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Офлайн-режим │ Полная поддержка │ Ограниченная │
│ │ (SQLite, очередь) │ (localStorage) │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Сжатие │ Gzip обязательно │ Опционально │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Интервал отправки │ 30-60 секунд │ 5-10 секунд │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ При закрытии │ Автоматически │ sendBeacon │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Размер батча │ 10-100 событий │ 1-10 событий │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Надёжность │ Высокая (retry логик) │ Средняя │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Потребление трафика│ Минимальное │ Среднее │
└────────────────────┴──────────────────────────┴──────────────────────────┘
7. Приоритизация событий
package priority
// EventPriority приоритет события
type EventPriority int
const (
PriorityCritical EventPriority = iota // Покупки, ошибки
PriorityHigh // Конверсии
PriorityMedium // Взаимодействия
PriorityLow // Просмотры
)
// PriorityQueue очередь с приоритетами
type PriorityQueue struct {
queues map[Priority][]*Event
mu sync.Mutex
}
// Enqueue добавляет событие в очередь
func (pq *PriorityQueue) Enqueue(event *Event, priority Priority) {
pq.mu.Lock()
defer pq.mu.Unlock()
pq.queues[priority] = append(pq.queues[priority], event)
}
// Dequeue извлекает событие с наивысшим приоритетом
func (pq *PriorityQueue) Dequeue() *Event {
pq.mu.Lock()
defer pq.mu.Unlock()
for priority := PriorityCritical; priority <= PriorityLow; priority++ {
if len(pq.queues[priority]) > 0 {
event := pq.queues[priority][0]
pq.queues[priority] = pq.queues[priority][1:]
return event
}
}
return nil
}
// GetBatch получает батч с учётом приоритетов
func (pq *PriorityQueue) GetBatch(size int) []*Event {
pq.mu.Lock()
defer pq.mu.Unlock()
batch := make([]*Event, 0, size)
// Сначала критические события
for priority := PriorityCritical; priority <= PriorityLow && len(batch) < size; priority++ {
available := len(pq.queues[priority])
toTake := min(available, size-len(batch))
batch = append(batch, pq.queues[priority][:toTake]...)
pq.queues[priority] = pq.queues[priority][toTake:]
}
return batch
}
8. Итоговая архитектура
┌─────────────────────────────────────────────────────────────────┐
│ Мобильные приложения │
│ │
│ [App] → [Batch Collector] → [Local DB] → [Network Manager] │
│ ↓ │
│ [Retry Logic] │
│ ↓ │
│ [Compression] │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Веб-приложения │
│ │
│ [Browser] → [Event Queue] → [sendBeacon / fetch] │
│ ↓ │
│ [localStorage] │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Event │
│ Collector │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Kafka │
└─────────────────┘
9. Рекомендации
📱 Мобильные приложения:
✅ Использовать батчинг (минимум 10 событий)
✅ Обязательно сжимать данные (gzip)
✅ Реализовать retry с экспоненциальным backoff
✅ Ограничить размер очереди (1000 событий)
✅ Удалять события старше 7 дней
🌐 Веб-приложения:
✅ Использовать sendBeacon для финализации
✅ Периодическая отправка каждые 5-10 секунд
✅ Сохранять в localStorage при офлайне
✅ Ограничить размер батча (10 событий)
✅ Обрабатывать beforeunload событие
Ключевое отличие: мобильные приложения накапливают события локально и отправляют батчами с поддержкой офлайн-режима, тогда как веб-приложения отправляют события практически в реальном времени с минимальной буферизацией.
Вопрос 31. Какие технологии предлагается использовать для каждого компонента системы?
Таймкод: 01:00:53
Ответ собеседова: Правильный. Experiment Service - PostgreSQL (простая таблица, мало данных), сбор аналитики - Kafka (для буферизации и сохранения порядка событий), аналитическая БД - ClickHouse (аналитические запросы, срезы, материализованные представления), хранилище сегментов - Cassandra или MongoDB (key-value, горизонтальное масштабирование).
Правильный ответ:
Технологический стек системы экспериментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ ТЕХНОЛОГИЧЕСКИЙ СТЕК │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Компонент Технология Обоснование │
│ ───────── ────────── ─────────── │
│ │
│ Experiment Service PostgreSQL Структурированные │
│ данные, ACID, │
│ мало записей │
│ │
│ Event Collector Kafka Буферизация, │
│ порядок событий, │
│ высокая пропускная │
│ способность │
│ │
│ Analytics DB ClickHouse Аналитические запросы, │
│ колоночное хранение, │
│ материализованные │
│ представления │
│ │
│ Segment Store Redis/Cassandra Key-value, быстрый │
│ доступ, горизонтальное │
│ масштабирование │
│ │
│ Feature Flags Redis + etcd Быстрый доступ, │
│ консистентность │
│ │
│ API Gateway Kong/Nginx Маршрутизация, │
│ rate limiting, │
│ authentication │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Experiment Service - PostgreSQL
-- Таблица экспериментов
CREATE TABLE experiments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft', -- draft, running, paused, completed
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
-- Таргетинг
target_segments UUID[] DEFAULT '{}',
target_platforms VARCHAR[] DEFAULT '{}',
target_countries VARCHAR[] DEFAULT '{}',
min_app_version VARCHAR(50),
-- Настройки
traffic_percent INTEGER DEFAULT 100, -- Процент трафика
min_sample_size INTEGER DEFAULT 1000,
significance_level DECIMAL DEFAULT 0.05,
-- Метаданные
metadata JSONB DEFAULT '{}',
-- Ограничения
CONSTRAINT valid_status CHECK (status IN ('draft', 'running', 'paused', 'completed')),
CONSTRAINT valid_traffic CHECK (traffic_percent BETWEEN 0 AND 100)
);
-- Таблица вариантов
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id UUID NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
is_control BOOLEAN DEFAULT FALSE,
traffic_weight INTEGER NOT NULL DEFAULT 50, -- Вес для распределения
-- Флаги и параметры
flags JSONB DEFAULT '{}',
parameters JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT valid_weight CHECK (traffic_weight BETWEEN 0 AND 100)
);
-- Таблица целей
CREATE TABLE goals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id UUID NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
event_type VARCHAR(100) NOT NULL, -- purchase, click, view и т.д.
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Индексы
CREATE INDEX idx_experiments_status ON experiments(status);
CREATE INDEX idx_experiments_created_by ON experiments(created_by);
CREATE INDEX idx_variants_experiment_id ON variants(experiment_id);
CREATE INDEX idx_goals_experiment_id ON goals(experiment_id);
-- Для быстрого поиска по статусу и датам
CREATE INDEX idx_experiments_status_dates ON experiments(status, started_at, ended_at);
Почему PostgreSQL:
- Структурированные данные с чёткой схемой
- ACID-транзакции для консистентности
- Мало записей (тысячи, не миллионы)
- Сложные запросы с JOIN
- Зрелость и надёжность
2. Event Collector - Kafka
// Конфигурация Kafka
type KafkaConfig struct {
Brokers []string `json:"brokers"`
Topic string `json:"topic"`
NumPartitions int `json:"num_partitions"`
ReplicationFactor int `json:"replication_factor"`
RetentionMs int64 `json:"retention_ms"`
CompressionType string `json:"compression_type"`
}
func DefaultKafkaConfig() KafkaConfig {
return KafkaConfig{
Brokers: []string{"kafka-1:9092", "kafka-2:9092", "kafka-3:9092"},
Topic: "experiment_events",
NumPartitions: 12,
ReplicationFactor: 3,
RetentionMs: 7 * 24 * 60 * 60 * 1000, // 7 дней
CompressionType: "snappy",
}
}
// Producer для отправки событий
type EventProducer struct {
producer sarama.AsyncProducer
topic string
}
func NewEventProducer(config KafkaConfig) (*EventProducer, error) {
saramaConfig := sarama.NewConfig()
saramaConfig.Producer.RequiredAcks = sarama.WaitForAll
saramaConfig.Producer.Retry.Max = 3
saramaConfig.Producer.Compression = sarama.CompressionSnappy
saramaConfig.Producer.Return.Successes = true
producer, err := sarama.NewAsyncProducer(config.Brokers, saramaConfig)
if err != nil {
return nil, fmt.Errorf("failed to create producer: %w", err)
}
return &EventProducer{
producer: producer,
topic: config.Topic,
}, nil
}
func (p *EventProducer) Produce(event *ExperimentEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
// Ключ - user_id для сохранения порядка событий пользователя
key := sarama.StringEncoder(event.UserID)
msg := &sarama.ProducerMessage{
Topic: p.topic,
Key: key,
Value: sarama.ByteEncoder(data),
Headers: []sarama.RecordHeader{
{Key: []byte("experiment_id"), Value: []byte(event.ExperimentID)},
{Key: []byte("event_type"), Value: []byte(event.EventType)},
},
}
p.producer.Input() <- msg
return nil
}
// Consumer для обработки событий
type EventConsumer struct {
consumer sarama.ConsumerGroup
handler *EventHandler
}
func NewEventConsumer(config KafkaConfig, groupID string, handler *EventHandler) (*EventConsumer, error) {
saramaConfig := sarama.NewConfig()
saramaConfig.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRoundRobin
saramaConfig.Consumer.Offsets.Initial = sarama.OffsetOldest
consumer, err := sarama.NewConsumerGroup(config.Brokers, groupID, saramaConfig)
if err != nil {
return nil, err
}
return &EventConsumer{
consumer: consumer,
handler: handler,
}, nil
}
func (c *EventConsumer) Start(ctx context.Context) error {
for {
err := c.consumer.Consume(ctx, []string{"experiment_events"}, c.handler)
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
}
}
Почему Kafka:
- Высокая пропускная способность (миллионы событий/сек)
- Сохранение порядка событий (по ключу user_id)
- Буферизация при пиковых нагрузках
- Репликация и отказоустойчивость
- Возможность переобработки событий
3. Analytics DB - ClickHouse
-- Основная таблица событий
CREATE TABLE experiment_events (
-- Идентификаторы
event_id UUID,
event_type LowCardinality(String),
user_id String,
device_id String,
session_id String,
-- Данные эксперимента
experiment_id String,
variant_id String,
-- Время
timestamp DateTime,
event_date Date DEFAULT toDate(timestamp),
-- Платформа
platform LowCardinality(String),
app_version LowCardinality(String),
-- Геолокация
country LowCardinality(String),
device_model LowCardinality(String),
-- Время суток
hour UInt8,
day_of_week UInt8,
is_weekend UInt8,
-- Данные сессии
session_number UInt32,
time_in_session UInt32,
-- Метрики
revenue Decimal64(2),
-- Дополнительно
payload String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_type, timestamp)
TTL event_date + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Материализованное представление: ежедневная статистика
CREATE MATERIALIZED VIEW experiment_daily_stats
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_date, event_type)
AS SELECT
experiment_id,
variant_id,
event_date,
event_type,
count() as event_count,
uniqExact(user_id) as unique_users,
uniqExact(session_id) as unique_sessions,
sum(revenue) as total_revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, event_date, event_type;
-- Материализованное представление: конверсии
CREATE MATERIALIZED VIEW experiment_conversions
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_date)
AS SELECT
experiment_id,
variant_id,
event_date,
uniqExactIf(user_id, event_type = 'experiment_viewed') as viewed_users,
uniqExactIf(user_id, event_type = 'goal_completed') as converted_users,
uniqExactIf(user_id, event_type = 'purchase') as purchasing_users,
sumIf(revenue, event_type = 'purchase') as total_revenue
FROM experiment_events
GROUP BY experiment_id, variant_id, event_date;
Почему ClickHouse:
- Колоночное хранение для аналитики
- Быстрые агрегации на больших объёмах
- Материализованные представления
- Сжатие данных
- Горизонтальное масштабирование
4. Segment Store - Redis + Cassandra
// Redis для горячих данных
type RedisSegmentStore struct {
client *redis.Client
ttl time.Duration
}
func (s *RedisSegmentStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
key := fmt.Sprintf("user:segments:%s", userID)
segments, err := s.client.SMembers(ctx, key).Result()
if err != nil {
return nil, err
}
return segments, nil
}
// Cassandra для долгосрочного хранения
type CassandraSegmentStore struct {
session *gocql.Session
}
func NewCassandraSegmentStore(hosts []string) (*CassandraSegmentStore, error) {
cluster := gocql.NewCluster(hosts...)
cluster.Keyspace = "experiments"
cluster.Consistency = gocql.Quorum
session, err := cluster.CreateSession()
if err != nil {
return nil, err
}
return &CassandraSegmentStore{session: session}, nil
}
func (s *CassandraSegmentStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
var segments []string
query := "SELECT segment_id FROM user_segments WHERE user_id = ?"
iter := s.session.Query(query, userID).Iter()
var segmentID string
for iter.Scan(&segmentID) {
segments = append(segments, segmentID)
}
if err := iter.Close(); err != nil {
return nil, err
}
return segments, nil
}
func (s *CassandraSegmentStore) SetUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
// Удаляем старые данные
if err := s.session.Query(
"DELETE FROM user_segments WHERE user_id = ?",
userID,
).Exec(); err != nil {
return err
}
// Вставляем новые
batch := s.session.NewBatch(gocql.LoggedBatch)
for _, seg := range segments {
batch.Query(
"INSERT INTO user_segments (user_id, segment_id, updated_at) VALUES (?, ?, ?)",
userID, seg, time.Now(),
)
}
return s.session.ExecuteBatch(batch)
}
Схема Cassandra:
CREATE KEYSPACE IF NOT EXISTS experiments
WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3
};
CREATE TABLE IF NOT EXISTS user_segments (
user_id text,
segment_id text,
updated_at timestamp,
PRIMARY KEY (user_id, segment_id)
) WITH CLUSTERING ORDER BY (segment_id ASC)
AND compaction = {'class': 'LeveledCompactionStrategy'}
AND compression = {'sstable_compression': 'LZ4Compressor'};
CREATE TABLE IF NOT EXISTS segment_users (
segment_id text,
user_id text,
updated_at timestamp,
PRIMARY KEY (segment_id, user_id)
) WITH CLUSTERING ORDER BY (user_id ASC);
Почему Redis + Cassandra:
- Redis: быстрый доступ для горячих данных (< 1ms)
- Cassandra: горизонтальное масштабирование, высокая доступность
- Комбинация: Redis как кэш, Cassandra как source of truth
5. Feature Flags - Redis + etcd
// Сервис feature flags
type FeatureFlagService struct {
redis *redis.Client
etcd *etcdclientv3.Client
cache *ristretto.Cache
}
func (s *FeatureFlagService) GetFlag(
ctx context.Context,
flagName string,
) (*FeatureFlag, error) {
// Проверяем локальный кэш
if val, found := s.cache.Get(flagName); found {
return val.(*FeatureFlag), nil
}
// Проверяем Redis
key := fmt.Sprintf("flag:%s", flagName)
data, err := s.redis.Get(ctx, key).Bytes()
if err == nil {
var flag FeatureFlag
if err := json.Unmarshal(data, &flag); err == nil {
s.cache.Set(flagName, &flag, 1)
return &flag, nil
}
}
// Получаем из etcd
resp, err := s.etcd.Get(ctx, fmt.Sprintf("/flags/%s", flagName))
if err != nil {
return nil, err
}
if len(resp.Kvs) == 0 {
return nil, ErrFlagNotFound
}
var flag FeatureFlag
if err := json.Unmarshal(resp.Kvs[0].Value, &flag); err != nil {
return nil, err
}
// Обновляем кэш
s.redis.Set(ctx, key, resp.Kvs[0].Value, 5*time.Minute)
s.cache.Set(flagName, &flag, 1)
return &flag, nil
}
Почему Redis + etcd:
- Redis: быстрый доступ к флагам
- etcd: консистентность, watch для обновлений в реальном времени
6. API Gateway - Kong/Nginx
# Конфигурация Kong
_format_version: "3.0"
services:
- name: experiment-service
url: http://experiment-service:8080
routes:
- name: experiments
paths:
- /api/v1/experiments
methods: [GET, POST, PUT, DELETE]
plugins:
- name: rate-limiting
config:
minute: 100
policy: redis
redis_host: redis
- name: jwt
- name: prometheus
- name: decision-service
url: http://decision-service:8080
routes:
- name: decision
paths:
- /api/v1/decision
methods: [POST]
plugins:
- name: rate-limiting
config:
minute: 1000
policy: redis
- name: prometheus
- name: event-collector
url: http://event-collector:8080
routes:
- name: events
paths:
- /api/v1/events
methods: [POST]
plugins:
- name: rate-limiting
config:
minute: 5000
policy: redis
- name: request-size-limiting
config:
allowed_payload_size: 128
Почему Kong/Nginx:
- Маршрутизация запросов
- Rate limiting
- Authentication/Authorization
- Load balancing
- Метрики и мониторинг
Итоговая архитектура:
┌─────────────────────────────────────────────────────────────────────────────┐
│ АРХИТЕКТУРА СТЕКА │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ API Gateway (Kong) │ │
│ │ Rate Limiting, Auth, Routing │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Experiment │ │ Decision │ │ Event │ │
│ │ Service │ │ Service │ │ Collector │ │
│ │ │ │ │ │ │ │
│ │ PostgreSQL │ │ Redis (cache) │ │ Kafka │ │
│ │ │ │ Cassandra │ │ │ │
│ │ │ │ etcd │ │ ClickHouse │ │
│ └─────────────┘ └─────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Выбор технологий:
| Компонент | Технология | Почему |
|---|---|---|
| Experiment Service | PostgreSQL | ACID, структурированные данные, мало записей |
| Event Bus | Kafka | Высокая пропускная способность, порядок событий |
| Analytics | ClickHouse | Колоночное хранение, быстрые агрегации |
| Segment Store | Redis + Cassandra | Быстрый доступ + горизонтальное масштабирование |
| Feature Flags | Redis + etcd | Быстрый доступ + консистентность |
| API Gateway | Kong/Nginx | Rate limiting, маршрутизация, метрики |
Вопрос 19. Как разделить трафик между аналитиками и мобильными клиентами при проектировании API?
Таймкод: 00:41:21
Ответ собеседника: Правильный. Предлагается разделить API на приватную (для аналитиков - заведение экспериментов, просмотр статистики) и публичную (для клиентских приложений - получение список экспериментов). Это делается для безопасности и разного масштабирования трафика.
Правильный ответ:
1. Архитектура разделения API
┌─────────────────────────────────────────────────────────────────────────────┐
│ API Gateway │
│ │
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Private API │ │ Public API │ │
│ │ (для аналитиков) │ │ (для клиентов) │ │
│ │ │ │ │ │
│ │ • Создание экспериментов │ │ • Получение конфигурации │ │
│ │ • Управление статусом │ │ • Отправка событий │ │
│ │ • Просмотр статистики │ │ • Проверка feature flags │ │
│ │ • Управление сегментами │ │ │ │
│ │ │ │ │ │
│ │ Auth: JWT + RBAC │ │ Auth: API Key / Token │ │
│ │ Rate Limit: 100 req/min │ │ Rate Limit: 10000 req/min │ │
│ │ Scaling: 2-5 инстансов │ │ Scaling: 10-100 инстансов │ │
│ └──────────────────────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Реализация API Gateway
package gateway
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// Gateway API Gateway
type Gateway struct {
privateHandler http.Handler
publicHandler http.Handler
authService *auth.Service
rateLimiter *ratelimit.Limiter
}
// NewGateway создаёт новый Gateway
func NewGateway(privateBackend, publicBackend string, authService *auth.Service) (*Gateway, error) {
privateURL, err := url.Parse(privateBackend)
if err != nil {
return nil, err
}
publicURL, err := url.Parse(publicBackend)
if err != nil {
return nil, err
}
return &Gateway{
privateHandler: httputil.NewSingleHostReverseProxy(privateURL),
publicHandler: httputil.NewSingleHostReverseProxy(publicURL),
authService: authService,
rateLimiter: ratelimit.NewLimiter(),
}, nil
}
// ServeHTTP обрабатывает запросы
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Определяем тип API
if isPrivateAPI(r) {
g.handlePrivate(w, r)
} else {
g.handlePublic(w, r)
}
}
// isPrivateAPI определяет, является ли запрос приватным
func isPrivateAPI(r *http.Request) bool {
// Приватные API начинаются с /api/v1/admin/
return strings.HasPrefix(r.URL.Path, "/api/v1/admin/")
}
// handlePrivate обрабатывает приватные запросы
func (g *Gateway) handlePrivate(w http.ResponseWriter, r *http.Request) {
// Аутентификация
userID, err := g.authService.AuthenticateJWT(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Проверяем роль
if !g.authService.HasRole(userID, "analyst", "admin") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Rate limiting
if !g.rateLimiter.Allow(userID, ratelimit.PrivateAPILimit) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// Добавляем userID в контекст
ctx := context.WithValue(r.Context(), "user_id", userID)
g.privateHandler.ServeHTTP(w, r.WithContext(ctx))
}
// handlePublic обрабатывает публичные запросы
func (g *Gateway) handlePublic(w http.ResponseWriter, r *http.Request) {
// Аутентификация по API Key
apiKey := r.Header.Get("X-API-Key")
if !g.authService.ValidateAPIKey(apiKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Rate limiting по API Key
if !g.rateLimiter.Allow(apiKey, ratelimit.PublicAPILimit) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
g.publicHandler.ServeHTTP(w, r)
}
3. Rate Limiter
package ratelimit
import (
"sync"
"time"
)
// Limit лимиты для разных типов API
type Limit struct {
Requests int
Window time.Duration
}
var (
PrivateAPILimit = Limit{
Requests: 100,
Window: time.Minute,
}
PublicAPILimit = Limit{
Requests: 10000,
Window: time.Minute,
}
)
// Limiter rate limiter
type Limiter struct {
mu sync.RWMutex
clients map[string]*ClientLimiter
}
// ClientLimiter лимитер для клиента
type ClientLimiter struct {
tokens int
lastReset time.Time
limit Limit
}
// NewLimiter создаёт новый лимитер
func NewLimiter() *Limiter {
l := &Limiter{
clients: make(map[string]*ClientLimiter),
}
// Очищаем устаревшие записи
go l.cleanup()
return l
}
// Allow проверяет, можно ли выполнить запрос
func (l *Limiter) Allow(clientID string, limit Limit) bool {
l.mu.Lock()
defer l.mu.Unlock()
cl, exists := l.clients[clientID]
if !exists {
cl = &ClientLimiter{
tokens: limit.Requests,
lastReset: time.Now(),
limit: limit,
}
l.clients[clientID] = cl
}
// Сбрасываем токены если прошёл интервал
if time.Since(cl.lastReset) >= cl.limit.Window {
cl.tokens = cl.limit.Requests
cl.lastReset = time.Now()
}
if cl.tokens > 0 {
cl.tokens--
return true
}
return false
}
// cleanup очищает устаревшие записи
func (l *Limiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
l.mu.Lock()
for id, cl := range l.clients {
if time.Since(cl.lastReset) > cl.limit.Window*2 {
delete(l.clients, id)
}
}
l.mu.Unlock()
}
}
4. Private API (для аналитиков)
package privateapi
// PrivateHandler handler для приватного API
type PrivateHandler struct {
experimentService *experiment.Service
segmentService *segment.Service
statsService *stats.Service
}
// RegisterRoutes регистрирует маршруты
func (h *PrivateHandler) RegisterRoutes(r *chi.Router) {
r.Route("/api/v1/admin", func(r chi.Router) {
// Аутентификация
r.Use(authMiddleware)
r.Use(rbacMiddleware)
// Эксперименты
r.Route("/experiments", func(r chi.Router) {
r.Get("/", h.ListExperiments)
r.Post("/", h.CreateExperiment)
r.Get("/{id}", h.GetExperiment)
r.Put("/{id}", h.UpdateExperiment)
r.Delete("/{id}", h.DeleteExperiment)
r.Post("/{id}/start", h.StartExperiment)
r.Post("/{id}/stop", h.StopExperiment)
r.Get("/{id}/stats", h.GetExperimentStats)
r.Get("/{id}/stats/daily", h.GetDailyStats)
})
// Сегменты
r.Route("/segments", func(r chi.Router) {
r.Get("/", h.ListSegments)
r.Post("/", h.CreateSegment)
r.Get("/{id}", h.GetSegment)
r.Put("/{id}", h.UpdateSegment)
r.Delete("/{id}", h.DeleteSegment)
r.Get("/{id}/users", h.GetSegmentUsers)
})
// Отчёты
r.Route("/reports", func(r chi.Router) {
r.Get("/experiments", h.GetExperimentsReport)
r.Get("/metrics", h.GetMetricsReport)
})
})
}
// CreateExperiment создаёт эксперимент
func (h *PrivateHandler) CreateExperiment(w http.ResponseWriter, r *http.Request) {
var req experiment.CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userID := r.Context().Value("user_id").(string)
req.CreatedBy = userID
exp, err := h.experimentService.Create(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(exp)
}
// GetExperimentStats возвращает статистику эксперимента
func (h *PrivateHandler) GetExperimentStats(w http.ResponseWriter, r *http.Request) {
experimentID := chi.URLParam(r, "id")
stats, err := h.statsService.GetStats(r.Context(), experimentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(stats)
}
5. Public API (для клиентских приложений)
package publicapi
// PublicHandler handler для публичного API
type PublicHandler struct {
experimentService *experiment.Service
eventService *event.Service
cache *cache.Cache
}
// RegisterRoutes регистрирует маршруты
func (h *PublicHandler) RegisterRoutes(r *chi.Router) {
r.Route("/api/v1", func(r chi.Router) {
// API Key middleware
r.Use(apiKeyMiddleware)
// Конфигурация экспериментов для пользователя
r.Get("/experiments/config", h.GetExperimentsConfig)
// Feature flags
r.Get("/features/{key}", h.GetFeatureFlag)
// Отправка событий
r.Post("/events", h.TrackEvent)
r.Post("/events/batch", h.TrackEventBatch)
// Health check
r.Get("/health", h.HealthCheck)
})
}
// GetExperimentsConfig возвращает конфигурацию экспериментов для пользователя
func (h *PublicHandler) GetExperimentsConfig(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
platform := r.URL.Query().Get("platform")
// Проверяем кэш
cacheKey := fmt.Sprintf("config:%s:%s", userID, platform)
if cached, ok := h.cache.Get(cacheKey); ok {
json.NewEncoder(w).Encode(cached)
return
}
// Получаем активные эксперименты
experiments, err := h.experimentService.GetActiveForUser(r.Context(), userID, platform)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Формируем конфигурацию
config := make(map[string]interface{})
for _, exp := range experiments {
variant := exp.GetVariantForUser(userID)
if variant != nil {
config[exp.ID] = map[string]interface{}{
"variant_id": variant.ID,
"variant_name": variant.Name,
"config": variant.Config,
}
}
}
// Кэшируем
h.cache.Set(cacheKey, config, 30*time.Second)
json.NewEncoder(w).Encode(config)
}
// TrackEventBatch обрабатывает батч событий
func (h *PublicHandler) TrackEventBatch(w http.ResponseWriter, r *http.Request) {
var req event.BatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Отправляем в асинхронную очередь
if err := h.eventService.EnqueueBatch(r.Context(), req.Events); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}
6. Middleware для аутентификации
package middleware
import (
"net/http"
"strings"
)
// authMiddleware проверяет JWT токен
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := validateToken(token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "roles", claims.Roles)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// rbacMiddleware проверяет роли пользователя
func rbacMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles, ok := r.Context().Value("roles").([]string)
if !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Проверяем, что у пользователя есть нужная роль
if !hasRequiredRole(roles) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// apiKeyMiddleware проверяет API Key
func apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
http.Error(w, "Missing API key", http.StatusUnauthorized)
return
}
if !validateAPIKey(apiKey) {
http.Error(w, "Invalid API key", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
7. Конфигурация масштабирования
# docker-compose.yml
version: '3.8'
services:
# API Gateway
gateway:
image: ab-platform/gateway:latest
ports:
- "8080:8080"
environment:
- PRIVATE_BACKEND=http://private-api:8081
- PUBLIC_BACKEND=http://public-api:8082
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpu: 0.5
# Private API (для аналитиков)
private-api:
image: ab-platform/private-api:latest
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
deploy:
replicas: 2
resources:
limits:
memory: 1G
cpu: 1.0
# Public API (для клиентских приложений)
public-api:
image: ab-platform/public-api:latest
environment:
- REDIS_HOST=redis
- KAFKA_BROKERS=kafka:9092
deploy:
replicas: 10
resources:
limits:
memory: 512M
cpu: 0.5
# Event Collector
event-collector:
image: ab-platform/event-collector:latest
environment:
- KAFKA_BROKERS=kafka:9092
- CLICKHOUSE_HOST=clickhouse
deploy:
replicas: 5
8. Nginx конфигурация
# nginx.conf
upstream gateway {
least_conn;
server gateway-1:8080;
server gateway-2:8080;
}
server {
listen 80;
server_name api.ab-platform.com;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=private:10m rate=100r/m;
limit_req_zone $http_x_api_key zone=public:10m rate=10000r/m;
# Private API
location /api/v1/admin/ {
limit_req zone=private burst=20 nodelay;
# Только для авторизованных пользователей
if ($http_authorization = "") {
return 401;
}
proxy_pass http://gateway;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Public API
location /api/v1/ {
limit_req zone=public burst=100 nodelay;
proxy_pass http://gateway;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-API-Key $http_x_api_key;
}
}
9. Мониторинг и метрики
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// Запросы по типу API
apiRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ab_api_requests_total",
Help: "Total number of API requests",
}, []string{"api_type", "method", "endpoint", "status"})
// Время ответа
apiRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "ab_api_request_duration_seconds",
Help: "API request duration in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"api_type", "method", "endpoint"})
// Rate limit hits
rateLimitHits = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ab_rate_limit_hits_total",
Help: "Total number of rate limit hits",
}, []string{"api_type", "client_id"})
// Active connections
activeConnections = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "ab_active_connections",
Help: "Number of active connections",
}, []string{"api_type"})
)
// MetricsMiddleware middleware для сбора метрик
func MetricsMiddleware(apiType string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &statusResponseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
duration := time.Since(start).Seconds()
apiRequestsTotal.WithLabelValues(
apiType,
r.Method,
r.URL.Path,
fmt.Sprintf("%d", wrapped.status),
).Inc()
apiRequestDuration.WithLabelValues(
apiType,
r.Method,
r.URL.Path,
).Observe(duration)
})
}
}
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
10. Сравнение Private и Public API
┌────────────────────┬──────────────────────────┬──────────────────────────┐
│ Характеристика │ Private API │ Public API │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Аудитория │ Аналитики, админы │ Мобильные и веб-клиенты │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Аутентификация │ JWT + RBAC │ API Key │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Rate Limit │ 100 req/min │ 10000 req/min │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Инстансы │ 2-5 │ 10-100 │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Кэширование │ Минимальное │ Агрессивное (30 сек) │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Задержка │ < 500ms │ < 50ms │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Доступ к БД │ Прямой │ Через кэш │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Логирование │ Полное │ Минимальное │
├────────────────────┼──────────────────────────┼──────────────────────────┤
│ Версионирование │ /api/v1/admin/... │ /api/v1/... │
└────────────────────┴──────────────────────────┴──────────────────────────┘
11. Ключевые принципы
-
Разделение ответственности: Private API для управления, Public API для получения данных.
-
Безопасность: JWT + RBAC для аналитиков, API Key для клиентов.
-
Масштабирование: Public API масштабируется горизонтально в 10-100 раз больше.
-
Rate Limiting: Разные лимиты для разных типов клиентов.
-
Кэширование: Public API активно использует кэширование (Redis).
-
Мониторинг: Отдельные метрики для каждого типа API.
-
Изоляция: Ошибки в Private API не влияют на Public API и наоборот.
Вопрос 32. Как обеспечить прилипание пользователя к эксперименту (sticky assignment)?
Таймкод: 01:03:47
Ответ собеседова: Неполный. Предлагается два подхода: 1) Хранение пары User ID + ID эксперимента в отдельном хранилище (данные можно предрасчитать). 2) Создание сегмента пользователей, попавших в тестовый вариант эксперимента. Второй вариант предпочтительнее, так как не требует отдельного компонента.
Правильный ответ:
Прилипание пользователя к эксперименту (Sticky Assignment)
Прилипание (stickiness) — это гарантия, что пользователь всегда будет видеть один и тот же вариант эксперимента при всех своих визитах.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ЗАЧЕМ НУЖНО ПРИЛИПАНИЕ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Без прилипания: С прилипанием: │
│ │
│ Визит 1: User A → Variant A Визит 1: User A → Variant A │
│ Визит 2: User A → Variant B Визит 2: User A → Variant A │
│ Визит 3: User A → Variant A Визит 3: User A → Variant A │
│ │
│ Проблема: Пользователь видит Решение: Пользователь всегда │
│ разные варианты → плохой UX + видит один вариант → │
│ некорректная статистика корректные метрики │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Подход 1: Хранение назначений (Assignment Store)
// Модель назначения
type VariantAssignment struct {
ID uint64 `gorm:"primaryKey"`
UserID string `gorm:"index:idx_user_exp,unique;size:255"`
ExperimentID string `gorm:"index:idx_user_exp,unique;size:255"`
VariantID string `gorm:"size:255"`
AssignedAt time.Time
ExpiresAt time.Time
IsActive bool `gorm:"index"`
}
// Сервис назначений
type AssignmentService struct {
db *gorm.DB
redis *redis.Client
}
func (s *AssignmentService) GetOrCreateAssignment(
ctx context.Context,
userID string,
experimentID *Experiment,
) (*VariantAssignment, error) {
// 1. Проверяем кэш Redis
cacheKey := fmt.Sprintf("assignment:%s:%s", userID, experimentID.ID)
cached, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
var assignment VariantAssignment
if json.Unmarshal([]byte(cached), &assignment) == nil {
return &assignment, nil
}
}
// 2. Проверяем базу данных
var assignment VariantAssignment
err = s.db.Where("user_id = ? AND experiment_id = ? AND is_active = ?",
userID, experimentID.ID, true).
First(&assignment).Error
if err == nil {
// Найдено существующее назначение
s.cacheAssignment(ctx, cacheKey, &assignment)
return &assignment, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("database error: %w", err)
}
// 3. Создаём новое назначение
variantID := s.determineVariant(userID, experimentID)
assignment = VariantAssignment{
UserID: userID,
ExperimentID: experimentID.ID,
VariantID: variantID,
AssignedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30 дней
IsActive: true,
}
if err := s.db.Create(&assignment).Error; err != nil {
return nil, fmt.Errorf("failed to create assignment: %w", err)
}
s.cacheAssignment(ctx, cacheKey, &assignment)
return &assignment, nil
}
func (s *AssignmentService) determineVariant(
userID string,
experiment *Experiment,
) string {
// Детерминированный алгоритм на основе userID
// Гарантирует, что один и тот же пользователь всегда попадёт в тот же вариант
hash := fnv.New32a()
hash.Write([]byte(userID + experiment.ID))
hashValue := hash.Sum32()
// Распределение по весам вариантов
totalWeight := 0
for _, v := range experiment.Variants {
totalWeight += v.TrafficWeight
}
normalized := hashValue % uint32(totalWeight)
cumulative := uint32(0)
for _, v := range experiment.Variants {
cumulative += uint32(v.TrafficWeight)
if normalized < cumulative {
return v.ID
}
}
// Fallback на контрольный вариант
for _, v := range experiment.Variants {
if v.IsControl {
return v.ID
}
}
return experiment.Variants[0].ID
}
func (s *AssignmentService) cacheAssignment(
ctx context.Context,
key string,
assignment *VariantAssignment,
) {
data, _ := json.Marshal(assignment)
s.redis.Set(ctx, key, data, 5*time.Minute)
}
Подход 2: Сегментный подход (Segment-based)
// Сервис сегментного прилипания
type SegmentStickinessService struct {
segmentRepo SegmentRepository
experimentRepo ExperimentRepository
}
// При старте эксперимента создаём сегменты для каждого варианта
func (s *SegmentStickinessService) InitializeExperimentSegments(
ctx context.Context,
experiment *Experiment,
) error {
for _, variant := range experiment.Variants {
segmentName := fmt.Sprintf("exp_%s_var_%s", experiment.ID, variant.ID)
// Создаём пустой сегмент
segment := &Segment{
Name: segmentName,
Description: fmt.Sprintf("Users in variant %s of experiment %s",
variant.Name, experiment.Name),
Type: "experiment_variant",
ExperimentID: experiment.ID,
VariantID: variant.ID,
}
if err := s.segmentRepo.Create(ctx, segment); err != nil {
return fmt.Errorf("failed to create segment: %w", err)
}
}
return nil
}
// При первом запросе пользователя добавляем его в сегмент
func (s *SegmentStickinessService) AssignUserToSegment(
ctx context.Context,
userID string,
experiment *Experiment,
) (string, error) {
// Проверяем, есть ли пользователь уже в каком-то сегменте эксперимента
existingSegment, err := s.segmentRepo.FindUserSegmentInExperiment(
ctx, userID, experiment.ID,
)
if err == nil && existingSegment != nil {
// Пользователь уже назначен
return existingSegment.VariantID, nil
}
// Определяем вариант
variantID := s.determineVariant(userID, experiment)
segmentName := fmt.Sprintf("exp_%s_var_%s", experiment.ID, variantID)
// Добавляем пользователя в сегмент
if err := s.segmentRepo.AddUserToSegment(ctx, userID, segmentName); err != nil {
return "", fmt.Errorf("failed to add user to segment: %w", err)
}
return variantID, nil
}
// При принятии решения проверяем сегменты
func (s *SegmentStickinessService) CheckUserAssignment(
ctx context.Context,
userID string,
experimentID string,
) (string, bool) {
segment, err := s.segmentRepo.FindUserSegmentInExperiment(
ctx, userID, experimentID,
)
if err != nil {
return "", false
}
return segment.VariantID, true
}
Подход 3: Детерминированное хеширование (Stateless)
// Без хранения состояния - чисто на основе хеша
type StatelessAssignmentService struct{}
func (s *StatelessAssignmentService) GetVariant(
userID string,
experiment *Experiment,
) string {
// Комбинируем userID + experimentID + salt
// Соль может быть датой начала эксперимента для репродуцируемости
salt := experiment.StartedAt.Format("2006-01-02")
input := fmt.Sprintf("%s:%s:%s", userID, experiment.ID, salt)
// Используем стабильный хеш
hash := md5.Sum([]byte(input))
hashInt := binary.BigEndian.Uint32(hash[:4])
// Нормализуем в диапазон [0, 100)
bucket := hashInt % 100
// Находим вариант по бакету
cumulative := uint32(0)
for _, variant := range experiment.Variants {
cumulative += uint32(variant.TrafficWeight)
if bucket < cumulative {
return variant.ID
}
}
// Fallback
return experiment.Variants[0].ID
}
// Преимущества:
// - Не требует хранилища
// - Мгновенное решение
// - Горизонтально масштабируемо
//
// Недостатки:
// - Нельзя изменить назначение без изменения эксперимента
// - Сложнее делать динамическое перераспределение
Сравнение подходов:
┌─────────────────────────────────────────────────────────────────────────────┐
│ СРАВНЕНИЕ ПОДХОДОВ К ПРИЛИПАНИЮ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Assignment Store Segments Stateless Hash │
│ ───────── ──────────────── ──────── ────────────── │
│ │
│ Сложность Средняя Высокая Низкая │
│ Хранилище Да (Redis/DB) Да (сегменты) Нет │
│ Производительность Высокая Средняя Максимальная │
│ Гибкость Высокая Высокая Низкая │
│ Масштабируемость Средняя Высокая Максимальная │
│ Откат изменений Легко Сложно Невозможно │
│ Аудит Полный Полный Ограниченный │
│ │
│ Рекомендация: ✓ Лучший баланс ✓ Если уже ✓ Для простых │
│ хранить назначения есть сегменты экспериментов │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Рекомендуемая гибридная реализация:
// Комбинированный сервис
type HybridStickinessService struct {
stateless *StatelessAssignmentService
assignment *AssignmentService
segments *SegmentStickinessService
}
func (s *HybridStickinessService) GetVariant(
ctx context.Context,
userID string,
experiment *Experiment,
) (string, error) {
// 1. Сначала проверяем явное назначение (для откатов и исключений)
assignment, err := s.assignment.GetAssignment(ctx, userID, experiment.ID)
if err == nil && assignment != nil {
return assignment.VariantID, nil
}
// 2. Проверяем сегменты (если используется сегментный подход)
variantID, found := s.segments.CheckUserAssignment(ctx, userID, experiment.ID)
if found {
return variantID, nil
}
// 3. Используем детерминированный хеш
variantID = s.stateless.GetVariant(userID, experiment)
// 4. Сохраняем назначение для будущих запросов
s.assignment.SaveAssignment(ctx, userID, experiment.ID, variantID)
return variantID, nil
}
// Метод для принудительного переназначения (например, при отмене эксперимента)
func (s *HybridStickinessService) OverrideAssignment(
ctx context.Context,
userID string,
experimentID string,
variantID string,
) error {
return s.assignment.CreateOverride(ctx, userID, experimentID, variantID)
}
SQL-схема для Assignment Store:
-- Таблица назначений
CREATE TABLE variant_assignments (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
variant_id VARCHAR(255) NOT NULL,
-- Метаданные
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
-- Источник назначения
source VARCHAR(50) DEFAULT 'auto', -- auto, manual, override
-- Уникальный индекс
CONSTRAINT uq_user_experiment UNIQUE (user_id, experiment_id)
);
-- Индексы
CREATE INDEX idx_assignments_user ON variant_assignments(user_id);
CREATE INDEX idx_assignments_experiment ON variant_assignments(experiment_id);
CREATE INDEX idx_assignments_active ON variant_assignments(is_active) WHERE is_active = TRUE;
-- Для быстрого поиска по пользователю
CREATE INDEX idx_assignments_user_active ON variant_assignments(user_id, is_active)
WHERE is_active = TRUE;
-- Таблица аудита изменений
CREATE TABLE assignment_audit (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
experiment_id VARCHAR(255) NOT NULL,
old_variant_id VARCHAR(255),
new_variant_id VARCHAR(255) NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
changed_by VARCHAR(255),
reason TEXT
);
Вывод:
Для production-системы рекомендуется гибридный подход:
- Детерминированный хеш как основной механизм (быстро, без хранилища)
- Assignment Store для кэширования и возможности переопределения
- Сегменты как дополнительный инструмент для сложных сценариев
Это обеспечивает:
- Высокую производительность
- Возможность откатов и исключений
- Полный аудит изменений
- Горизонтальное масштабирование
Вопрос 33. Какие онлайн-сегменты можно было бы добавить и как они работают?
Таймкод: 01:06:36
Ответ собеседова: Правильный. Уточняется, что при загрузке списка экспериментов на каждое действие (как в вебе) потребовались бы онлайн-сегменты на основе поведения в реальном времени. Для этого нужен обработчик потока событий из Kafka, который считает онлайн-сегменты и складывает их в Cassandra. Это позволяет запускать цепочки экспериментов и учитывать поведение.
Правильный ответ:
Онлайн-сегменты (Real-time Segments)
Онлайн-сегменты — это динамически вычисляемые группы пользователей на основе их поведения в реальном времени, в отличие от офлайн-сегментов, которые рассчитываются заранее.
┌─────────────────────────────────────────────────────────────────────────────┐
│ АРХИТЕКТУРА ОНЛАЙН-СЕГМЕНТОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Пользователь │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ События (Events) │ │
│ │ page_view, click, purchase, search, add_to_cart, scroll │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Kafka │ │
│ │ Topic: user_events │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Stream Processor │ │
│ │ (Kafka Streams / Flink / Custom Service) │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ Счётчики и Агрегации │ │ │
│ │ │ - Количество покупок за последние N дней │ │ │
│ │ │ - Частота визитов │ │ │
│ │ │ - Средний чек │ │ │
│ │ │ - Время на сайте │ │ │
│ │ │ - Глубина просмотра │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Хранилище сегментов │ │
│ │ (Redis + Cassandra / ScyllaDB) │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Decision Service │ │
│ │ Проверяет принадлежность к сегментам │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Типы онлайн-сегментов:
1. Поведенческие сегменты (Behavioral)
// Определение поведенческого сегмента
type BehavioralSegment struct {
ID string
Name string
Description string
// Условия
Conditions []SegmentCondition
// Временное окно
TimeWindow time.Duration
// Агрегация
Aggregation AggregationType // count, sum, avg, unique_count
}
type SegmentCondition struct {
EventType string // purchase, page_view, click
Filters map[string]string // category=electronics, price>100
Count int // минимум событий
Operator string // gte, lte, eq
}
// Примеры сегментов
var BehavioralSegments = []BehavioralSegment{
{
ID: "high_intent_buyers",
Name: "Высокий намерение покупки",
Description: "Пользователи, которые добавили товар в корзину, но не купили",
TimeWindow: 24 * time.Hour,
Conditions: []SegmentCondition{
{EventType: "add_to_cart", Count: 1, Operator: "gte"},
{EventType: "purchase", Count: 0, Operator: "eq"},
},
},
{
ID: "bounce_users",
Name: "Отскоки",
Description: "Пользователи, которые ушли после одной страницы",
TimeWindow: 30 * time.Minute,
Conditions: []SegmentCondition{
{EventType: "page_view", Count: 1, Operator: "eq"},
{EventType: "session_duration_seconds", Count: 10, Operator: "lte"},
},
},
{
ID: "repeat_visitors",
Name: "Повторные посетители",
Description: "Пользователи с 3+ визитами за неделю",
TimeWindow: 7 * 24 * time.Hour,
Conditions: []SegmentCondition{
{EventType: "page_view", Count: 3, Operator: "gte"},
},
},
{
ID: "big_spenders",
Name: "Крупные покупатели",
Description: "Средний чек > 5000 за последний месяц",
TimeWindow: 30 * 24 * time.Hour,
Aggregation: "avg",
Conditions: []SegmentCondition{
{EventType: "purchase", Filters: map[string]string{"amount": "5000"}, Operator: "gt"},
},
},
}
2. Сегменты воронки (Funnel-based)
// Определение сегмента воронки
type FunnelSegment struct {
ID string
Name string
Steps []FunnelStep
TimeWindow time.Duration
}
type FunnelStep struct {
EventType string
Filters map[string]string
IsCompleted bool
}
// Пример: Пользователи, которые начали оформление, но не завершили
var CheckoutAbandoners = FunnelSegment{
ID: "checkout_abandoners",
Name: "Брошенные корзины",
TimeWindow: 2 * time.Hour,
Steps: []FunnelStep{
{EventType: "checkout_started", IsCompleted: true},
{EventType: "payment_info_entered", IsCompleted: false},
{EventType: "purchase_completed", IsCompleted: false},
},
}
// Реализация проверки
func (fs *FunnelSegment) Evaluate(
ctx context.Context,
userID string,
eventStore EventStore,
) (bool, error) {
// Получаем события пользователя за временное окно
events, err := eventStore.GetUserEvents(
ctx,
userID,
time.Now().Add(-fs.TimeWindow),
time.Now(),
)
if err != nil {
return false, err
}
// Проверяем каждое событие воронки
stepIndex := 0
for _, event := range events {
if stepIndex >= len(fs.Steps) {
break
}
step := fs.Steps[stepIndex]
if event.Type == step.EventType && matchesFilters(event, step.Filters) {
if step.IsCompleted {
stepIndex++
} else {
// Нужный шаг не завершён — пользователь в сегменте
return true, nil
}
}
}
return false, nil
}
3. Сегменты на основе частоты (Frequency-based)
// Сервис подсчёта частоты
type FrequencySegmentService struct {
redis *redis.Client
}
func (s *FrequencySegmentService) TrackEvent(
ctx context.Context,
userID string,
eventType string,
) error {
// Ключ для подсчёта за разные периоды
now := time.Now()
// Подсчёт за день
dayKey := fmt.Sprintf("freq:%s:%s:day:%s",
userID, eventType, now.Format("2006-01-02"))
s.redis.Incr(ctx, dayKey)
s.redis.Expire(ctx, dayKey, 48*time.Hour)
// Подсчёт за неделю (используем HyperLogLog для уникальных дней)
weekKey := fmt.Sprintf("freq:%s:%s:week:%s",
userID, eventType, getWeekNumber(now))
s.redis.PFAdd(ctx, weekKey, now.Format("2006-01-02"))
s.redis.Expire(ctx, weekKey, 14*24*time.Hour)
return nil
}
func (s *FrequencySegmentService) IsInSegment(
ctx context.Context,
userID string,
segment FrequencySegment,
) (bool, error) {
// Получаем подсчёт за период
key := fmt.Sprintf("freq:%s:%s:%s:%s",
userID, segment.EventType,
segment.Period, getCurrentPeriod(segment.Period))
count, err := s.redis.Get(ctx, key).Int64()
if err != nil {
return false, err
}
switch segment.Operator {
case "gte":
return count >= int64(segment.Threshold), nil
case "lte":
return count <= int64(segment.Threshold), nil
case "eq":
return count == int64(segment.Threshold), nil
default:
return false, fmt.Errorf("unknown operator: %s", segment.Operator)
}
}
4. Контекстные сегменты (Contextual)
// Сегменты на основе контекста визита
type ContextualSegment struct {
ID string
Name string
Conditions ContextConditions
}
type ContextConditions struct {
// Геолокация
Countries []string
Cities []string
// Устройство
Platforms []string // ios, android, web
DeviceTypes []string // mobile, desktop, tablet
// Время
TimeOfDay *TimeRange
DayOfWeek []int // 0-6, 0 = воскресенье
// Реферал
ReferrerDomains []string
UTMParameters map[string]string
// Версия приложения
AppVersion *VersionConstraint
}
func (cs *ContextualSegment) Evaluate(ctx context.Context, event *UserEvent) bool {
// Проверяем страну
if len(cs.Conditions.Countries) > 0 {
if !contains(cs.Conditions.Countries, event.Context.Country) {
return false
}
}
// Проверяем платформу
if len(cs.Conditions.Platforms) > 0 {
if !contains(cs.Conditions.Platforms, event.Context.Platform) {
return false
}
}
// Проверяем время суток
if cs.Conditions.TimeOfDay != nil {
hour := event.Timestamp.Hour()
if hour < cs.Conditions.TimeOfDay.Start || hour > cs.Conditions.TimeOfDay.End {
return false
}
}
// Проверяем день недели
if len(cs.Conditions.DayOfWeek) > 0 {
day := int(event.Timestamp.Weekday())
if !containsInt(cs.Conditions.DayOfWeek, day) {
return false
}
}
return true
}
5. Предиктивные сегменты (Predictive)
// Сегменты на основе ML-моделей
type PredictiveSegment struct {
ID string
Name string
ModelID string // Идентификатор ML-модели
Threshold float64 // Порог вероятности
}
type MLModelClient interface {
Predict(ctx context.Context, features map[string]float64) (float64, error)
}
type PredictiveSegmentService struct {
mlClient MLModelClient
featureStore FeatureStore
}
func (s *PredictiveSegmentService) Evaluate(
ctx context.Context,
userID string,
segment *PredictiveSegment,
) (bool, error) {
// Получаем фичи пользователя
features, err := s.featureStore.GetUserFeatures(ctx, userID)
if err != nil {
return false, err
}
// Получаем предсказание модели
probability, err := s.mlClient.Predict(ctx, features)
if err != nil {
return false, err
}
// Проверяем порог
return probability >= segment.Threshold, nil
}
// Примеры предиктивных сегментов:
// - "Склонны к оттоку" (churn probability > 0.7)
// - "Высокий LTV" (predicted LTV > percentile_90)
// - "Склонны к покупке" (purchase probability > 0.5)
Реализация Stream Processor:
// Обработчик потока событий
type SegmentStreamProcessor struct {
consumer sarama.ConsumerGroup
segmentRepo SegmentRepository
eventStore EventStore
redis *redis.Client
}
func (p *SegmentStreamProcessor) ProcessEvent(ctx context.Context, event *UserEvent) error {
// 1. Сохраняем событие
if err := p.eventStore.Save(ctx, event); err != nil {
return fmt.Errorf("failed to save event: %w", err)
}
// 2. Обновляем счётчики в Redis
if err := p.updateCounters(ctx, event); err != nil {
return fmt.Errorf("failed to update counters: %w", err)
}
// 3. Проверяем условия сегментов
segments, err := p.segmentRepo.GetActiveSegments(ctx)
if err != nil {
return fmt.Errorf("failed to get segments: %w", err)
}
for _, segment := range segments {
matches, err := p.evaluateSegment(ctx, event.UserID, segment)
if err != nil {
log.Error("Failed to evaluate segment",
"segment_id", segment.ID,
"error", err)
continue
}
if matches {
// Добавляем пользователя в сегмент
if err := p.segmentRepo.AddUserToSegment(ctx, event.UserID, segment.ID); err != nil {
log.Error("Failed to add user to segment",
"user_id", event.UserID,
"segment_id", segment.ID,
"error", err)
}
}
}
return nil
}
func (p *SegmentStreamProcessor) updateCounters(ctx context.Context, event *UserEvent) error {
pipe := p.redis.Pipeline()
// Обновляем различные счётчики
now := event.Timestamp
// Счётчик событий по типам
eventKey := fmt.Sprintf("events:%s:%s:count",
event.UserID, event.Type)
pipe.Incr(ctx, eventKey)
pipe.Expire(ctx, eventKey, 30*24*time.Hour)
// Время последнего события
lastSeenKey := fmt.Sprintf("user:%s:last_seen", event.UserID)
pipe.Set(ctx, lastSeenKey, now.Unix(), 30*24*time.Hour)
// Сессия
if event.SessionID != "" {
sessionKey := fmt.Sprintf("session:%s:events", event.SessionID)
pipe.RPush(ctx, sessionKey, event.ID)
pipe.Expire(ctx, sessionKey, 24*time.Hour)
}
_, err := pipe.Exec(ctx)
return err
}
func (p *SegmentStreamProcessor) evaluateSegment(
ctx context.Context,
userID string,
segment *Segment,
) (bool, error) {
switch segment.Type {
case "behavioral":
return p.evaluateBehavioralSegment(ctx, userID, segment)
case "frequency":
return p.evaluateFrequencySegment(ctx, userID, segment)
case "contextual":
return p.evaluateContextualSegment(ctx, userID, segment)
case "predictive":
return p.evaluatePredictiveSegment(ctx, userID, segment)
default:
return false, fmt.Errorf("unknown segment type: %s", segment.Type)
}
}
Cassandra-схема для онлайн-сегментов:
-- Сегменты пользователей (обновляется в реальном времени)
CREATE TABLE user_segments_realtime (
user_id text,
segment_id text,
entered_at timestamp,
expires_at ttl,
source text, -- 'auto', 'manual', 'ml'
confidence float, -- Уверенность для ML-сегментов
PRIMARY KEY (user_id, segment_id)
) WITH default_time_to_live = 2592000; -- 30 дней TTL
-- Обратный индекс: пользователи в сегменте
CREATE TABLE segment_members (
segment_id text,
user_id text,
entered_at timestamp,
PRIMARY KEY (segment_id, user_id)
);
-- Счётчики событий (обновляется в реальном времени)
CREATE TABLE user_event_counters (
user_id text,
event_type text,
period_type text, -- 'hour', 'day', 'week', 'month'
period text, -- '2024-01-15', '2024-W03'
count counter,
sum_value counter,
PRIMARY KEY ((user_id, event_type, period_type), period)
);
-- Агрегированные метрики пользователя
CREATE TABLE user_metrics (
user_id text PRIMARY KEY,
total_purchases counter,
total_revenue counter,
last_seen timestamp,
session_count counter,
avg_session_duration float,
updated_at timestamp
);
Примеры использования онлайн-сегментов:
┌─────────────────────────────────────────────────────────────────────────────┐
│ СЦЕНАРИИ ИСПОЛЬЗОВАНИЯ ОНЛАЙН-СЕГМЕНТОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Ретаргетинг │
│ Сегмент: "Добавил в корзину, но не купил за 24ч" │
│ Эксперимент: Показать скидку 10% на брошенные товары │
│ │
│ 2. Персонализация │
│ Сегмент: "Новый пользователь (первый визит)" │
│ Эксперимент: Показать welcome-тур вместо стандартной главной │
│ │
│ 3. Частота показов │
│ Сегмент: "Видел баннер 3+ раза, но не кликнул" │
│ Эксперимент: Скрыть баннер, показать альтернативу │
│ │
│ 4. Предиктивные │
│ Сегмент: "Вероятность покупки > 70%" │
│ Эксперимент: Показать премиум-предложение │
│ │
│ 5. Контекстные │
│ Сегмент: "Мобильный iOS, вечер (18-23), Москва" │
│ Эксперимент: Вечерняя доставка за 1 час │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Онлайн-сегменты позволяют:
- Реагировать на поведение пользователя в реальном времени
- Создавать сложные цепочки экспериментов
- Персонализировать опыт на основе текущего контекста
- Использовать ML-модели для предиктивной сегментации
Ключевые компоненты:
- Kafka для потока событий
- Stream Processor для вычисления сегментов
- Redis для горячих счётчиков
- Cassandra для долгосрочного хранения
Вопрос 20. Какие сервисы нужны для хранения и обработки экспериментов?
Таймкод: 00:42:36
Ответ собеседника: Правильный. Предлагается создать сервис экспериментов (хранение и управление экспериментами для аналитиков) и отдельный компонент для отдачи экспериментов клиентским приложениям, так как у них разный трафик и требования.
Правильный ответ:
1. Архитектура сервисов экспериментальной платформы
┌─────────────────────────────────────────────────────────────────────────────┐
│ Экспериментальная платформа │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис экспериментов │ │
│ │ (Experiment Service) │ │
│ │ │ │
│ │ • Создание/редактирование экспериментов │ │
│ │ • Управление статусом (draft → running → stopped) │ │
│ │ • Управление вариантами и конфигурациями │ │
│ │ • Распределение трафика │ │
│ │ • Целевая аудитория (сегменты) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис конфигурации │ │
│ │ (Config Service) │ │
│ │ │ │
│ │ • Отдача конфигурации экспериментов клиентам │ │
│ │ • Кэширование (Redis) │ │
│ │ • Высокая доступность │ │
│ │ • Edge caching (CDN) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис событий │ │
│ │ (Event Service) │ │
│ │ │ │
│ │ • Приём событий от клиентов │ │
│ │ • Валидация и обогащение │ │
│ │ • Отправка в Kafka │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис статистики │ │
│ │ (Stats Service) │ │
│ │ │ │
│ │ • Агрегация событий │ │
│ │ • Расчёт метрик (conversion, revenue) │ │
│ │ • Статистическая значимость │ │
│ │ • Построение отчётов │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис сегментации │ │
│ │ (Segment Service) │ │
│ │ │ │
│ │ • Управление сегментами пользователей │ │
│ │ • Проверка вхождения в сегмент │ │
│ │ • Обновление сегментов (batch + realtime) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сервис нотификаций │ │
│ │ (Notification Service) │ │
│ │ │ │
│ │ • Уведомления о смене статуса эксперимента │ │
│ │ • Алерты при аномалиях в метриках │ │
│ │ • Webhook для интеграций │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Сервис экспериментов (Experiment Service)
package experiment
import (
"context"
"time"
)
// Experiment эксперимент
type Experiment struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Status ExperimentStatus `json:"status" db:"status"`
Platform string `json:"platform" db:"platform"` // web, ios, android
OwnerID string `json:"owner_id" db:"owner_id"`
// Настройки трафика
TrafficPercent int `json:"traffic_percent" db:"traffic_percent"` // 0-100
// Варианты
Variants []*Variant `json:"variants"`
// Целевая аудитория
SegmentIDs []string `json:"segment_ids"`
// Метрики
PrimaryMetric string `json:"primary_metric" db:"primary_metric"`
SecondaryMetrics []string `json:"secondary_metrics" db:"secondary_metrics"`
// Время
StartAt *time.Time `json:"start_at" db:"start_at"`
EndAt *time.Time `json:"end_at" db:"end_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ExperimentStatus статус эксперимента
type ExperimentStatus string
const (
StatusDraft ExperimentStatus = "draft"
StatusRunning ExperimentStatus = "running"
StatusPaused ExperimentStatus = "paused"
StatusStopped ExperimentStatus = "stopped"
StatusArchived ExperimentStatus = "archived"
)
// Variant вариант эксперимента
type Variant struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
IsControl bool `json:"is_control" db:"is_control"`
Percent int `json:"percent" db:"percent"` // 0-100
Config map[string]interface{} `json:"config" db:"config"`
}
// Service сервис экспериментов
type Service struct {
repo Repository
cache *redis.Client
eventBus *eventbus.Publisher
segmentSvc *segment.Service
}
// Create создаёт эксперимент
func (s *Service) Create(ctx context.Context, req *CreateRequest) (*Experiment, error) {
// Валидация
if err := s.validate(req); err != nil {
return nil, err
}
exp := &Experiment{
ID: generateID(),
Name: req.Name,
Description: req.Description,
Status: StatusDraft,
Platform: req.Platform,
OwnerID: req.OwnerID,
TrafficPercent: req.TrafficPercent,
Variants: req.Variants,
SegmentIDs: req.SegmentIDs,
PrimaryMetric: req.PrimaryMetric,
SecondaryMetrics: req.SecondaryMetrics,
StartAt: req.StartAt,
EndAt: req.EndAt,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Сохраняем в БД
if err := s.repo.Create(ctx, exp); err != nil {
return nil, err
}
// Публикуем событие
s.eventBus.Publish(ctx, "experiment.created", exp)
return exp, nil
}
// Start запускает эксперимент
func (s *Service) Start(ctx context.Context, experimentID string) error {
exp, err := s.repo.Get(ctx, experimentID)
if err != nil {
return err
}
if exp.Status != StatusDraft && exp.Status != StatusPaused {
return fmt.Errorf("cannot start experiment in status %s", exp.Status)
}
// Проверяем сегменты
for _, segmentID := range exp.SegmentIDs {
if _, err := s.segmentSvc.Get(ctx, segmentID); err != nil {
return fmt.Errorf("segment %s not found", segmentID)
}
}
// Обновляем статус
exp.Status = StatusRunning
exp.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, exp); err != nil {
return err
}
// Инвалидируем кэш
s.cache.Del(ctx, fmt.Sprintf("experiment:%s", experimentID))
s.cache.Del(ctx, "experiments:active")
// Публикуем событие
s.eventBus.Publish(ctx, "experiment.started", exp)
return nil
}
// GetVariantForUser возвращает вариант для пользователя
func (s *Service) GetVariantForUser(ctx context.Context, experimentID, userID string) (*Variant, error) {
exp, err := s.getExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
if exp.Status != StatusRunning {
return nil, nil
}
// Определяем вариант на основе хеша
return s.assignVariant(exp, userID), nil
}
// assignVariant назначает вариант пользователю
func (s *Service) assignVariant(exp *Experiment, userID string) *Variant {
// Используем детерминированный хеш
hash := hashUserID(userID, exp.ID)
cumulativePercent := 0
for _, variant := range exp.Variants {
cumulativePercent += variant.Percent
if hash < cumulativePercent {
return variant
}
}
// Fallback на контрольный вариант
for _, variant := range exp.Variants {
if variant.IsControl {
return variant
}
}
return nil
}
// hashUserID хеширует userID для детерминированного распределения
func hashUserID(userID, experimentID string) int {
h := fnv.New32a()
h.Write([]byte(userID + ":" + experimentID))
return int(h.Sum32() % 100)
}
// validate валидирует запрос на создание
func (s *Service) validate(req *CreateRequest) error {
// Проверяем сумму процентов вариантов
totalPercent := 0
controlCount := 0
for _, v := range req.Variants {
totalPercent += v.Percent
if v.IsControl {
controlCount++
}
}
if totalPercent != 100 {
return fmt.Errorf("variant percent sum must be 100, got %d", totalPercent)
}
if controlCount != 1 {
return fmt.Errorf("exactly one control variant required, got %d", controlCount)
}
if req.TrafficPercent < 1 || req.TrafficPercent > 100 {
return fmt.Errorf("traffic percent must be between 1 and 100")
}
return nil
}
3. Сервис конфигурации (Config Service)
package config
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// ConfigService сервис конфигурации
type ConfigService struct {
redis *redis.Client
experimentSvc *experiment.Service
localCache *lru.Cache
}
// UserConfig конфигурация пользователя
type UserConfig struct {
UserID string `json:"user_id"`
Platform string `json:"platform"`
Experiments map[string]ExperimentConfig `json:"experiments"`
Features map[string]bool `json:"features"`
UpdatedAt time.Time `json:"updated_at"`
}
// ExperimentConfig конфигурация эксперимента для пользователя
type ExperimentConfig struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
Config map[string]interface{} `json:"config"`
}
// GetUserConfig возвращает конфигурацию для пользователя
func (s *ConfigService) GetUserConfig(ctx context.Context, userID, platform string) (*UserConfig, error) {
cacheKey := fmt.Sprintf("config:%s:%s", userID, platform)
// 1. Проверяем локальный кэш
if config, ok := s.localCache.Get(cacheKey); ok {
return config.(*UserConfig), nil
}
// 2. Проверяем Redis
data, err := s.redis.Get(ctx, cacheKey).Bytes()
if err == nil {
var config UserConfig
if err := json.Unmarshal(data, &config); err == nil {
s.localCache.Add(cacheKey, &config)
return &config, nil
}
}
// 3. Генерируем конфигурацию
config, err := s.generateConfig(ctx, userID, platform)
if err != nil {
return nil, err
}
// 4. Сохраняем в Redis
data, _ = json.Marshal(config)
s.redis.Set(ctx, cacheKey, data, 30*time.Second)
// 5. Сохраняем в локальный кэш
s.localCache.Add(cacheKey, config)
return config, nil
}
// generateConfig генерирует конфигурацию для пользователя
func (s *ConfigService) generateConfig(ctx context.Context, userID, platform string) (*UserConfig, error) {
// Получаем активные эксперименты
experiments, err := s.experimentSvc.GetActiveByPlatform(ctx, platform)
if err != nil {
return nil, err
}
config := &UserConfig{
UserID: userID,
Platform: platform,
Experiments: make(map[string]ExperimentConfig),
Features: make(map[string]bool),
UpdatedAt: time.Now(),
}
// Для каждого эксперимента определяем вариант
for _, exp := range experiments {
// Проверяем, попадает ли пользователь в сегмент
if !s.isUserInSegments(ctx, userID, exp.SegmentIDs) {
continue
}
// Получаем вариант
variant, err := s.experimentSvc.GetVariantForUser(ctx, exp.ID, userID)
if err != nil || variant == nil {
continue
}
config.Experiments[exp.ID] = ExperimentConfig{
ExperimentID: exp.ID,
VariantID: variant.ID,
VariantName: variant.Name,
Config: variant.Config,
}
}
return config, nil
}
// InvalidateCache инвалидирует кэш при изменении эксперимента
func (s *ConfigService) InvalidateCache(ctx context.Context, experimentID string) error {
// Получаем все ключи с паттерном
iter := s.redis.Scan(ctx, 0, "config:*", 0).Iterator()
keysToDelete := make([]string, 0)
for iter.Next(ctx) {
keysToDelete = append(keysToDelete, iter.Val())
// Удаляем батчами по 1000
if len(keysToDelete) >= 1000 {
s.redis.Del(ctx, keysToDelete...)
keysToDelete = keysToDelete[:0]
}
}
if len(keysToDelete) > 0 {
s.redis.Del(ctx, keysToDelete...)
}
// Очищаем локальный кэш
s.localCache.Purge()
return nil
}
4. Сервис событий (Event Service)
package event
import (
"context"
"time"
"github.com/segmentio/kafka-go"
)
// Event событие
type Event struct {
ID string `json:"id"`
Type string `json:"type"` // exposure, conversion, custom
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform"`
ExperimentID string `json:"experiment_id,omitempty"`
VariantID string `json:"variant_id,omitempty"`
Properties map[string]interface{} `json:"properties"`
Timestamp time.Time `json:"timestamp"`
}
// Service сервис событий
type Service struct {
kafkaWriter *kafka.Writer
validator *Validator
enricher *Enricher
}
// TrackEvent обрабатывает событие
func (s *Service) TrackEvent(ctx context.Context, event *Event) error {
// 1. Валидация
if err := s.validator.Validate(event); err != nil {
return err
}
// 2. Обогащение
s.enricher.Enrich(ctx, event)
// 3. Отправка в Kafka
data, err := json.Marshal(event)
if err != nil {
return err
}
return s.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Topic: "events",
})
}
// TrackBatch обрабатывает батч событий
func (s *Service) TrackBatch(ctx context.Context, events []*Event) error {
messages := make([]kafka.Message, 0, len(events))
for _, event := range events {
// Валидация
if err := s.validator.Validate(event); err != nil {
continue // Пропускаем невалидные события
}
// Обогащение
s.enricher.Enrich(ctx, event)
data, err := json.Marshal(event)
if err != nil {
continue
}
messages = append(messages, kafka.Message{
Key: []byte(event.UserID),
Value: data,
Topic: "events",
})
}
return s.kafkaWriter.WriteMessages(ctx, messages...)
}
// Validator валидатор событий
type Validator struct {
requiredFields []string
validTypes map[string]bool
}
// Validate валидирует событие
func (v *Validator) Validate(event *Event) error {
if event.UserID == "" {
return fmt.Errorf("user_id is required")
}
if event.Type == "" {
return fmt.Errorf("type is required")
}
if !v.validTypes[event.Type] {
return fmt.Errorf("invalid event type: %s", event.Type)
}
return nil
}
// Enricher обогатитель событий
type Enricher struct {
geoIP *geoip.Reader
deviceDB *device.Database
}
// Enrich обогащает событие
func (e *Enricher) Enrich(ctx context.Context, event *Event) {
// Добавляем geo данные
if ip, ok := event.Properties["ip"].(string); ok {
if record, err := e.geoIP.Country(net.ParseIP(ip)); err == nil {
event.Properties["country"] = record.Country.IsoCode
}
}
// Добавляем информацию об устройстве
if ua, ok := event.Properties["user_agent"].(string); ok {
if device := e.deviceDB.Parse(ua); device != nil {
event.Properties["device_type"] = device.Type
event.Properties["os"] = device.OS
event.Properties["browser"] = device.Browser
}
}
}
5. Сервис статистики (Stats Service)
package stats
import (
"context"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
// StatsService сервис статистики
type StatsService struct {
ch clickhouse.Conn
}
// ExperimentStats статистика эксперимента
type ExperimentStats struct {
ExperimentID string `json:"experiment_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Variants []VariantStats `json:"variants"`
Significance *SignificanceResult `json:"significance,omitempty"`
}
// VariantStats статистика варианта
type VariantStats struct {
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
IsControl bool `json:"is_control"`
Users int64 `json:"users"`
Exposures int64 `json:"exposures"`
Conversions int64 `json:"conversions"`
ConversionRate float64 `json:"conversion_rate"`
Revenue float64 `json:"revenue"`
Metrics map[string]float64 `json:"metrics"`
}
// GetExperimentStats возвращает статистику эксперимента
func (s *StatsService) GetExperimentStats(ctx context.Context, experimentID string, startDate, endDate time.Time) (*ExperimentStats, error) {
query := `
SELECT
variant_id,
variant_name,
is_control,
uniq(user_id) as users,
countIf(event_type = 'exposure') as exposures,
countIf(event_type = 'conversion') as conversions,
conversions / exposures as conversion_rate,
sumIf(toFloat64OrNull(properties['revenue']), event_type = 'conversion') as revenue
FROM events
WHERE experiment_id = ?
AND timestamp BETWEEN ? AND ?
GROUP BY variant_id, variant_name, is_control
`
rows, err := s.ch.Query(ctx, query, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
stats := &ExperimentStats{
ExperimentID: experimentID,
StartDate: startDate,
EndDate: endDate,
Variants: make([]VariantStats, 0),
}
for rows.Next() {
var vs VariantStats
if err := rows.Scan(
&vs.VariantID,
&vs.VariantName,
&vs.IsControl,
&vs.Users,
&vs.Exposures,
&vs.Conversions,
&vs.ConversionRate,
&vs.Revenue,
); err != nil {
return nil, err
}
stats.Variants = append(stats.Variants, vs)
}
// Рассчитываем статистическую значимость
if len(stats.Variants) >= 2 {
stats.Significance = s.calculateSignificance(stats.Variants)
}
return stats, nil
}
// SignificanceResult результат проверки значимости
type SignificanceResult struct {
IsSignificant bool `json:"is_significant"`
PValue float64 `json:"p_value"`
Confidence float64 `json:"confidence"`
Uplift float64 `json:"uplift"` // относительное изменение к контролю
}
// calculateSignificance рассчитывает статистическую значимость
func (s *StatsService) calculateSignificance(variants []VariantStats) *SignificanceResult {
// Находим контрольный вариант
var control *VariantStats
var treatment *VariantStats
for i := range variants {
if variants[i].IsControl {
control = &variants[i]
} else {
treatment = &variants[i]
}
}
if control == nil || treatment == nil {
return nil
}
// Z-тест для пропорций
p1 := control.ConversionRate
p2 := treatment.ConversionRate
n1 := float64(control.Exposures)
n2 := float64(treatment.Exposures)
// Объединённая пропорция
p := (p1*n1 + p2*n2) / (n1 + n2)
// Стандартная ошибка
se := math.Sqrt(p * (1 - p) * (1/n1 + 1/n2))
// Z-статистика
z := (p2 - p1) / se
// P-value (двусторонний тест)
pValue := 2 * (1 - normalCDF(math.Abs(z)))
// Доверительный интервал 95%
confidence := 0.95
// Uplift
uplift := (p2 - p1) / p1
return &SignificanceResult{
IsSignificant: pValue < 0.05,
PValue: pValue,
Confidence: confidence,
Uplift: uplift,
}
}
6. Сервис сегментации (Segment Service)
package segment
import (
"context"
"time"
)
// Segment сегмент пользователей
type Segment struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Rules []Rule `json:"rules" db:"rules"`
UserCount int64 `json:"user_count" db:"user_count"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Rule правило сегмента
type Rule struct {
Field string `json:"field"` // country, platform, custom_property
Operator string `json:"operator"` // eq, neq, gt, lt, in, not_in
Value interface{} `json:"value"`
}
// Service сервис сегментации
type Service struct {
repo Repository
userRepo user.Repository
cache *redis.Client
}
// IsUserInSegment проверяет, входит ли пользователь в сегмент
func (s *Service) IsUserInSegment(ctx context.Context, userID, segmentID string) (bool, error) {
// Проверяем кэш
cacheKey := fmt.Sprintf("segment:%s:%s", segmentID, userID)
if cached, err := s.cache.Get(ctx, cacheKey).Bool(); err == nil {
return cached, nil
}
// Получаем сегмент
segment, err := s.repo.Get(ctx, segmentID)
if err != nil {
return false, err
}
// Получаем данные пользователя
user, err := s.userRepo.Get(ctx, userID)
if err != nil {
return false, err
}
// Проверяем правила
result := s.evaluateRules(user, segment.Rules)
// Кэшируем результат
s.cache.Set(ctx, cacheKey, result, 5*time.Minute)
return result, nil
}
// evaluateRules оценивает правила сегмента
func (s *Service) evaluateRules(user *user.User, rules []Rule) bool {
for _, rule := range rules {
if !s.evaluateRule(user, rule) {
return false // AND логика - все правила должны быть true
}
}
return true
}
// evaluateRule оценивает одно правило
func (s *Service) evaluateRule(user *user.User, rule Rule) bool {
// Получаем значение поля пользователя
fieldValue := s.getFieldValue(user, field)
switch rule.Operator {
case "eq":
return fieldValue == rule.Value
case "neq":
return fieldValue != rule.Value
case "gt":
return compare(fieldValue, rule.Value) > 0
case "lt":
return compare(fieldValue, rule.Value) < 0
case "in":
return contains(rule.Value, fieldValue)
case "not_in":
return !contains(rule.Value, fieldValue)
default:
return false
}
}
// RefreshSegment обновляет сегмент (batch операция)
func (s *Service) RefreshSegment(ctx context.Context, segmentID string) error {
segment, err := s.repo.Get(ctx, segmentID)
if err != nil {
return err
}
// Получаем всех пользователей
users, err := s.userRepo.GetAll(ctx)
if err != nil {
return err
}
// Фильтруем по правилам
matchedUsers := make([]string, 0)
for _, user := range users {
if s.evaluateRules(user, segment.Rules) {
matchedUsers = append(matchedUsers, user.ID)
}
}
// Обновляем счётчик
segment.UserCount = int64(len(matchedUsers))
segment.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, segment); err != nil {
return err
}
// Инвалидируем кэш
s.invalidateSegmentCache(ctx, segmentID)
return nil
}
7. Схема базы данных
-- Эксперименты
CREATE TABLE experiments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
platform VARCHAR(20) NOT NULL,
owner_id UUID NOT NULL,
traffic_percent INTEGER NOT NULL DEFAULT 100,
primary_metric VARCHAR(100),
start_at TIMESTAMP WITH TIME ZONE,
end_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_status CHECK (status IN ('draft', 'running', 'paused', 'stopped', 'archived')),
CONSTRAINT chk_traffic CHECK (traffic_percent BETWEEN 1 AND 100)
);
-- Варианты экспериментов
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
experiment_id UUID NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
is_control BOOLEAN NOT NULL DEFAULT FALSE,
percent INTEGER NOT NULL,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_percent CHECK (percent BETWEEN 0 AND 100)
);
-- Сегменты
CREATE TABLE segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
rules JSONB NOT NULL DEFAULT '[]',
user_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Связь экспериментов и сегментов
CREATE TABLE experiment_segments (
experiment_id UUID NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
segment_id UUID NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
PRIMARY KEY (experiment_id, segment_id)
);
-- События (ClickHouse)
CREATE TABLE events (
id String,
type LowCardinality(String),
user_id String,
session_id String,
platform LowCardinality(String),
experiment_id Nullable(String),
variant_id Nullable(String),
properties Map(String, String),
timestamp DateTime64(3)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, timestamp);
8. Взаимодействие сервисов
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Analyst │────▶│ Private │────▶│ Experiment │
│ (UI) │ │ API │ │ Service │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ PostgreSQL │
└─────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile │────▶│ Public │────▶│ Config │
│ Client │ │ API │ │ Service │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ Redis │
└─────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Any │────▶│ Event │────▶│ Kafka │
│ Client │ │ Service │ └──────┬──────┘
└─────────────┘ └─────────────┘ │
▼
┌─────────────┐
│ ClickHouse │
└──────┬──────┘
│
▼
┌─────────────┐
│ Stats │
│ Service │
└─────────────┘
9. Итоговая сводка сервисов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сервисы экспериментальной платформы │
├────────────────────┬────────────────────────────────────────────────────────┤
│ Experiment Service │ Управление экспериментами (CRUD, статусы, варианты) │
├────────────────────┼────────────────────────────────────────────────────────┤
│ Config Service │ Отдача конфигурации клиентам (кэширование, edge) │
├────────────────────┼────────────────────────────────────────────────────────┤
│ Event Service │ Приём и обработка событий (валидация, обогащение) │
├────────────────────┼────────────────────────────────────────────────────────┤
│ Stats Service │ Агрегация и анализ статистики (ClickHouse, значимость)│
├────────────────────┼────────────────────────────────────────────────────────┤
│ Segment Service │ Управление сегментами пользователей (правила, кеш) │
├────────────────────┼────────────────────────────────────────────────────────┤
│ Notification Svc │ Уведомления и алерты (статусы, аномалии) │
└────────────────────┴────────────────────────────────────────────────────────┘
Каждый сервис решает свою задачу и может масштабироваться независимо. Experiment Service и Config Service разделены, потому что у них разные паттерны нагрузки: запись vs чтение, низкий трафик vs высокий трафик.
Вопрос 34. Какие альтернативы Cassandra рассматриваются для хранения профилей пользователей (сегментов)?
Таймкод: 01:12:47
Ответ собеседова: Правильный. Уточняется, что рассматривались Cassandra, Foundation DB и собственный stateful сервис с шардингом. Также упоминается Aerospike как коммерческая альтернатива. Учитывались два разреза данных: по User ID получать сегменты, и по сегментам находить все User ID для маркетинговых рассылок.
Правильный ответ:
Альтернативы Cassandra для хранения профилей пользователей
При выборе хранилища для профилей пользователей и сегментов важно учитывать два основных паттерна доступа:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПАТТЕРНЫ ДОСТУПА К ДАННЫМ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Прямой доступ (Point Lookup) │
│ User ID → [segment1, segment2, segment3] │
│ Задержка: < 10ms │
│ Нагрузка: Высокая (каждый запрос к сервису) │
│ │
│ 2. Обратный доступ (Reverse Lookup) │
│ Segment ID → [user1, user2, user3, ...] │
│ Задержка: < 100ms │
│ Нагрузка: Средняя (маркетинговые рассылки, отчёты) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. ScyllaDB (рекомендуемая альтернатива)
// ScyllaDB — совместимая с Cassandra замена на C++
// Преимущества: в 10 раз выше throughput, ниже latency
// Подключение через стандартный gocql
import (
"github.com/gocql/gocql"
)
type ScyllaProfileStore struct {
session *gocql.Session
}
func NewScyllaProfileStore(hosts []string) (*ScyllaProfileStore, error) {
cluster := gocql.NewCluster(hosts...)
cluster.Keyspace = "user_profiles"
cluster.Consistency = gocql.LocalQuorum
cluster.Timeout = 5 * time.Second
// Оптимизация для ScyllaDB
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(
gocql.RoundRobinHostPolicy(),
)
session, err := cluster.CreateSession()
if err != nil {
return nil, err
}
return &ScyllaProfileStore{session: session}, nil
}
// Получение сегментов пользователя
func (s *ScyllaProfileStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
var segments []string
query := "SELECT segment_id FROM user_segments WHERE user_id = ?"
iter := s.session.Query(query, userID).WithContext(ctx).Iter()
var segmentID string
for iter.Scan(&segmentID) {
segments = append(segments, segmentID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get segments: %w", err)
}
return segments, nil
}
// Получение пользователей в сегменте (с пагинацией)
func (s *ScyllaProfileStore) GetSegmentMembers(
ctx context.Context,
segmentID string,
pageSize int,
pagingState []byte,
) ([]string, []byte, error) {
query := `SELECT user_id FROM segment_members
WHERE segment_id = ?`
q := s.session.Query(query, segmentID).
WithContext(ctx).
PageSize(pageSize).
PageState(pagingState)
iter := q.Iter()
var userIDs []string
var userID string
for iter.Scan(&userID) {
userIDs = append(userIDs, userID)
}
newPagingState := iter.PageState()
if err := iter.Close(); err != nil {
return nil, nil, fmt.Errorf("failed to get members: %w", err)
}
return userIDs, newPagingState, nil
}
2. Aerospike (in-memory + persistence)
// Aerospike — гибридное хранилище (RAM + SSD)
// Преимущества: сверхнизкая latency, автоматическая репликация
import (
as "github.com/aerospike/aerospike-client-go"
)
type AerospikeProfileStore struct {
client *as.Client
}
func NewAerospikeProfileStore(host string, port int) (*AerospikeProfileStore, error) {
client, err := as.NewClient(host, port)
if err != nil {
return nil, err
}
return &AerospikeProfileStore{client: client}, nil
}
// Структура записи в Aerospike
type UserProfileRecord struct {
UserID string `as:"user_id"`
Segments []string `as:"segments"`
UpdatedAt int64 `as:"updated_at"`
Version int `as:"version"`
}
// Получение сегментов пользователя
func (s *AerospikeProfileStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
key, err := as.NewKey("profiles", "user_segments", userID)
if err != nil {
return nil, err
}
record, err := s.client.Get(nil, key)
if err != nil {
return nil, fmt.Errorf("failed to get record: %w", err)
}
if record == nil {
return nil, nil // Пользователь не найден
}
segments, ok := record.Bins["segments"].([]string)
if !ok {
return nil, fmt.Errorf("invalid segments format")
}
return segments, nil
}
// Обновление сегментов (атомарно)
func (s *AerospikeProfileStore) UpdateUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
key, err := as.NewKey("profiles", "user_segments", userID)
if err != nil {
return err
}
bins := as.BinMap{
"segments": segments,
"updated_at": time.Now().Unix(),
}
// Используем optimistic locking через версию
policy := as.NewWritePolicy(0, 0)
policy.GenerationPolicy = as.EXPECT_GEN_EQUAL
return s.client.Put(policy, key, bins)
}
// Запрос пользователей по сегменту (через secondary index)
func (s *AerospikeProfileStore) GetUsersInSegment(
ctx context.Context,
segmentID string,
) ([]string, error) {
stmt := as.NewStatement("profiles", "user_segments")
stmt.SetFilter(as.NewContainsFilter("segments", as.ITYPE_LIST, segmentID))
recordSet, err := s.client.Query(nil, stmt)
if err != nil {
return nil, err
}
var userIDs []string
for res := range recordSet.Results() {
if res.Err != nil {
return nil, res.Err
}
if record := res.Record; record != nil {
if userID, ok := record.Bins["user_id"].(string); ok {
userIDs = append(userIDs, userID)
}
}
}
return userIDs, nil
}
3. FoundationDB (ACID + масштабируемость)
// FoundationDB — распределённая БД с ACID-транзакциями
// Преимущества: строгая консистентность, предсказуемая производительность
import (
"github.com/apple/foundationdb/bindings/go/src/fdb"
)
type FoundationDBProfileStore struct {
db fdb.Database
}
func NewFoundationDBProfileStore(clusterFile string) (*FoundationDBProfileStore, error) {
fdb.MustAPIVersion(710)
db, err := fdb.OpenDatabase(clusterFile)
if err != nil {
return nil, err
}
return &FoundationDBProfileStore{db: db}, nil
}
// Ключ: user_segments:{user_id} → [segment1, segment2, ...]
// Ключ: segment_members:{segment_id}:{user_id} → true
func (s *FoundationDBProfileStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
key := fdb.Key(fmt.Sprintf("user_segments:%s", userID))
result, err := s.db.ReadTransact(func(tr fdb.ReadTransaction) (interface{}, error) {
return tr.Get(key).MustGet(), nil
})
if err != nil {
return nil, err
}
data := result.([]byte)
var segments []string
if err := json.Unmarshal(data, &segments); err != nil {
return nil, err
}
return segments, nil
}
// Атомарное обновление сегментов пользователя
func (s *FoundationDBProfileStore) UpdateUserSegments(
ctx context.Context,
userID string,
segments []string,
segmentChanges map[string]bool, // segment_id → add/remove
) error {
_, err := s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
userKey := fdb.Key(fmt.Sprintf("user_segments:%s", userID))
// Обновляем список сегментов пользователя
data, _ := json.Marshal(segments)
tr.Set(userKey, data)
// Обратный индекс
for segmentID, add := range segmentChanges {
memberKey := fdb.Key(fmt.Sprintf("segment_members:%s:%s", segmentID, userID))
if add {
tr.Set(memberKey, []byte{1})
} else {
tr.Clear(memberKey)
}
}
return nil, nil
})
return err
}
// Получение пользователей в сегменте (range scan)
func (s *FoundationDBProfileStore) GetSegmentMembers(
ctx context.Context,
segmentID string,
limit int,
) ([]string, error) {
prefix := fmt.Sprintf("segment_members:%s:", segmentID)
beginKey := fdb.Key(prefix)
endKey := beginKey.FDBKeyInc() // Префикс + 1
result, err := s.db.ReadTransact(func(tr fdb.ReadTransaction) (interface{}, error) {
rangeResult := tr.GetRange(fdb.KeyRange{
Begin: beginKey,
End: endKey,
}, fdb.RangeOptions{Limit: limit})
var userIDs []string
for _, kv := range rangeResult {
// Извлекаем user_id из ключа
keyStr := string(kv.Key)
parts := strings.Split(keyStr, ":")
if len(parts) >= 3 {
userIDs = append(userIDs, parts[2])
}
}
return userIDs, nil
})
if err != nil {
return nil, err
}
return result.([]string), nil
}
4. Собственный stateful сервис с шардингом
// Собственный сервис для максимального контроля
type ShardConfig struct {
TotalShards int
Nodes []string
}
type ShardedProfileStore struct {
shards []*Shard
shardFunc func(userID string) int
}
type Shard struct {
ID int
Address string
Client *grpc.Client
isHealthy bool
mu sync.RWMutex
}
func NewShardedProfileStore(config ShardConfig) *ShardedProfileStore {
store := &ShardedProfileStore{
shardFunc: func(userID string) int {
hash := fnv.New32a()
hash.Write([]byte(userID))
return int(hash.Sum32()) % config.TotalShards
},
}
for i, addr := range config.Nodes {
shard := &Shard{
ID: i,
Address: addr,
}
store.shards = append(store.shards, shard)
}
return store
}
func (s *ShardedProfileStore) getShard(userID string) *Shard {
shardID := s.shardFunc(userID)
return s.shards[shardID]
}
// Получение сегментов пользователя
func (s *ShardedProfileStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
shard := s.getShard(userID)
req := &pb.GetSegmentsRequest{
UserId: userID,
}
resp, err := shard.Client.GetSegments(ctx, req)
if err != nil {
return nil, fmt.Errorf("shard %d error: %w", shard.ID, err)
}
return resp.Segments, nil
}
// Обновление сегментов (с eventual consistency для обратного индекса)
func (s *ShardedProfileStore) UpdateUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
shard := s.getShard(userID)
req := &pb.UpdateSegmentsRequest{
UserId: userID,
Segments: segments,
}
_, err := shard.Client.UpdateSegments(ctx, req)
return err
}
// Получение пользователей в сегенте (scatter-gather)
func (s *ShardedProfileStore) GetSegmentMembers(
ctx context.Context,
segmentID string,
) ([]string, error) {
var wg sync.WaitGroup
resultCh := make(chan []string, len(s.shards))
errCh := make(chan error, len(s.shards))
for _, shard := range s.shards {
wg.Add(1)
go func(shard *Shard) {
defer wg.Done()
req := &pb.GetSegmentMembersRequest{
SegmentId: segmentID,
}
resp, err := shard.Client.GetSegmentMembers(ctx, req)
if err != nil {
errCh <- err
return
}
resultCh <- resp.UserIds
}(shard)
}
go func() {
wg.Wait()
close(resultCh)
close(errCh)
}()
var allUserIDs []string
for userIDs := range resultCh {
allUserIDs = append(allUserIDs, userIDs...)
}
// Проверяем ошибки
select {
case err := <-errCh:
if err != nil {
return nil, err
}
default:
}
return allUserIDs, nil
}
Сравнение альтернатив:
┌─────────────────────────────────────────────────────────────────────────────┐
│ СРАВНЕНИЕ АЛЬТЕРНАТИВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий ScyllaDB Aerospike FoundationDB Custom Service │
│ ───────── ──────── ───────── ──────────── ────────────── │
│ │
│ Latency (p99) 5-10ms 1-5ms 10-20ms 5-15ms │
│ Throughput Высокая Очень выс. Средняя Зависит │
│ Consistency Eventual Eventual Strong Настраиваемая │
│ ACID ✗ ✗ ✓ ✓ │
│ Сложность Низкая Низкая Высокая Высокая │
│ Операционные Средние Высокие Высокие Высокие │
│ расходы │
│ Масштабируемость ✓✓ ✓✓ ✓ ✓ (ручная) │
│ Обратный индекс ✓ ✓ ✓ ✓ │
│ TTL ✓ ✓ ✗ ✓ │
│ Экосистема ✓✓ ✓ ✓ ✗ │
│ │
│ Рекомендация: ✓✓ Лучший ✓ Для ✓ Для ✓ Если нужен │
│ баланс latency консистентности полный контроль │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Рекомендуемая архитектура с двумя паттернами доступа:
// Комбинированное решение: Redis (горячие) + ScyllaDB (персистентные)
type HybridProfileStore struct {
redis *redis.ClusterClient
scylla *ScyllaProfileStore
cacheTTL time.Duration
}
func NewHybridProfileStore(redisAddrs []string, scyllaHosts []string) (*HybridProfileStore, error) {
redisCluster := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: redisAddrs,
})
scyllaStore, err := NewScyllaProfileStore(scyllaHosts)
if err != nil {
return nil, err
}
return &HybridProfileStore{
redis: redisCluster,
scylla: scyllaStore,
cacheTTL: 5 * time.Minute,
}, nil
}
// Получение сегментов пользователя (с кэшированием)
func (s *HybridProfileStore) GetUserSegments(
ctx context.Context,
userID string,
) ([]string, error) {
cacheKey := fmt.Sprintf("user_segments:%s", userID)
// 1. Проверяем Redis
cached, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
var segments []string
if json.Unmarshal([]byte(cached), &segments) == nil {
return segments, nil
}
}
// 2. Загружаем из ScyllaDB
segments, err := s.scylla.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// 3. Кэшируем в Redis
if len(segments) > 0 {
data, _ := json.Marshal(segments)
s.redis.Set(ctx, cacheKey, data, s.cacheTTL)
}
return segments, nil
}
// Обновление сегментов (invalidate cache)
func (s *HybridProfileStore) UpdateUserSegments(
ctx context.Context,
userID string,
segments []string,
) error {
// 1. Обновляем ScyllaDB
if err := s.scylla.UpdateUserSegments(ctx, userID, segments); err != nil {
return err
}
// 2. Инвалидируем кэш
cacheKey := fmt.Sprintf("user_segments:%s", userID)
s.redis.Del(ctx, cacheKey)
return nil
}
// Получение пользователей в сегенте (с кэшированием и пагинацией)
func (s *HybridProfileStore) GetSegmentMembers(
ctx context.Context,
segmentID string,
pageSize int,
pagingState []byte,
) ([]string, []byte, error) {
// Для обратного индекса используем ScyllaDB напрямую
// (кэширование сложнее из-за большого объёма данных)
return s.scylla.GetSegmentMembers(ctx, segmentID, pageSize, pagingState)
}
Вывод:
Для production-системы рекомендуется гибридный подход:
- ScyllaDB как основное хранилище (совместимость с Cassandra, лучшая производительность)
- Redis Cluster для кэширования горячих данных (прямой доступ по User ID)
- Materialized Views в ScyllaDB для обратного индекса (Segment ID → User IDs)
Это обеспечивает:
- Latency < 10ms для прямого доступа
- Поддержку обоих паттернов доступа
- Горизонтальное масштабирование
- Операционные расходы ниже,чем у Aerospike/FoundationDB
Вопрос 21. Нужно ли разделять сбор статистики для мобильных и веб-приложений?
Таймкод: 00:44:54
Ответ собеседника: Правильный. Предлагается разделить сбор статистики на отдельные коллекторы для мобильных и веб-приложений (web stats collector, mobile stats collector) из-за разного объёма трафика и разных требований к масштабированию.
Правильный ответ:
1. Обоснование разделения
Разделение коллекторов статистики для мобильных и веб-приложений — это правильное архитектурное решение. Вот почему:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение характеристик трафика │
├────────────────────┬──────────────────────────┬─────────────────────────────┤
│ Характеристика │ Web │ Mobile │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Объём событий │ Меньше (10-100 на сессию)│ Больше (100-1000 на сессию) │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Частота отправки │ Реал-тайм (WebSocket) │ Пакетная отправка (batch) │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Размер payload │ Маленький │ Большой (batch до 100 evt) │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Паттерн нагрузки │ Равномерный │ Пики (утро, вечер) │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Требования к │ Низкая (< 50ms) │ Средняя (< 200ms) │
│ задержке │ │ │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Масштабирование │ Вертикальное │ Горизонтальное │
├────────────────────┼──────────────────────────┼─────────────────────────────┤
│ Retry логика │ Простая │ Сложная (офлайн очередь) │
└────────────────────┴──────────────────────────┴─────────────────────────────┘
2. Архитектура разделённых коллекторов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event Collection Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Web Clients │ │
│ │ (10-100 events/session) │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Web Event Collector │ │
│ │ │ │
│ │ • WebSocket connections │ │
│ │ • Real-time processing │ │
│ │ • Small payloads │ │
│ │ • Low latency requirement │ │
│ │ • 2-5 instances │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Kafka │ │
│ │ Topic: web-events │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Mobile Clients │ │
│ │ (100-1000 events/session) │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Mobile Event Collector │ │
│ │ │ │
│ │ • HTTP POST with batch │ │
│ │ • Queue-based processing │ │
│ │ • Large payloads (up to 100 events) │ │
│ │ • Medium latency tolerance │ │
│ │ • 10-50 instances │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Kafka │ │
│ │ Topic: mobile-events │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Event Aggregator │ │
│ │ (unified processing) │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ClickHouse │ │
│ │ (unified storage) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Web Event Collector
package webcollector
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/segmentio/kafka-go"
)
// WebCollector коллектор событий из веб-приложений
type WebCollector struct {
upgrader websocket.Upgrader
kafkaWriter *kafka.Writer
sessions *SessionManager
metrics *Metrics
}
// Session WebSocket сессия
type Session struct {
ID string
UserID string
Platform string
Conn *websocket.Conn
Send chan []byte
CreatedAt time.Time
}
// SessionManager менеджер сессий
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*Session
}
// NewWebCollector создаёт новый коллектор
func NewWebCollector(kafkaBrokers []string) *WebCollector {
return &WebCollector{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // В проде нужна проверка
},
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
kafkaWriter: &kafka.Writer{
Addr: kafka.TCP(kafkaBrokers...),
Topic: "web-events",
Balancer: &kafka.LeastBytes{},
Async: true,
},
sessions: NewSessionManager(),
metrics: NewMetrics(),
}
}
// HandleWebSocket обрабатывает WebSocket соединение
func (c *WebCollector) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := c.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
session := &Session{
ID: generateSessionID(),
UserID: r.URL.Query().Get("user_id"),
Platform: "web",
Conn: conn,
Send: make(chan []byte, 256),
CreatedAt: time.Now(),
}
c.sessions.Add(session)
c.metrics.Sessions.Inc()
// Запускаем горутины для чтения и записи
go c.readPump(session)
go c.writePump(session)
}
// readPump читает сообщения от клиента
func (c *WebCollector) readPump(session *Session) {
defer func() {
c.sessions.Remove(session.ID)
session.Conn.Close()
c.metrics.Sessions.Dec()
}()
session.Conn.SetReadLimit(4096) // Маленький лимит для веба
session.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
session.Conn.SetPongHandler(func(string) error {
session.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := session.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
// Парсим событие
var event Event
if err := json.Unmarshal(message, &event); err != nil {
c.metrics.Errors.Inc()
continue
}
// Обогащаем событие
event.SessionID = session.ID
event.UserID = session.UserID
event.Platform = "web"
event.Timestamp = time.Now()
// Отправляем в Kafka
c.sendToKafka(event)
c.metrics.EventsReceived.Inc()
}
}
// writePump отправляет сообщения клиенту
func (c *WebCollector) writePump(session *Session) {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
session.Conn.Close()
}()
for {
select {
case message, ok := <-session.Send:
session.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := session.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
session.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// sendToKafka отправляет событие в Kafka
func (c *WebCollector) sendToKafka(event Event) {
data, _ := json.Marshal(event)
err := c.kafkaWriter.WriteMessages(context.Background(), kafka.Message{
Key: []byte(event.UserID),
Value: data,
Time: event.Timestamp,
})
if err != nil {
c.metrics.KafkaErrors.Inc()
log.Printf("Kafka write error: %v", err)
}
}
4. Mobile Event Collector
package mobilecollector
import (
"net/http"
"time"
"github.com/segmentio/kafka-go"
)
// MobileCollector коллектор событий из мобильных приложений
type MobileCollector struct {
kafkaWriter *kafka.Writer
buffer *EventBuffer
metrics *Metrics
}
// BatchRequest запрос с батчем событий
type BatchRequest struct {
Events []Event `json:"events"`
Platform string `json:"platform"` // ios, android
AppVersion string `json:"app_version"`
DeviceID string `json:"device_id"`
}
// EventBuffer буфер для пакетной отправки в Kafka
type EventBuffer struct {
mu sync.Mutex
events []kafka.Message
size int
maxSize max
Вопрос 35. Можно ли оптимизировать аналитику, добавляя весь контекст экспериментов в сессию пользователя, а не в каждое событие?
Таймкод: 01:15:40
Ответ собеседова: Правильный. Уточняется, что это хороший вариант оптимизации. При получении списка экспериментов на старте можно запомнить прилипшие эксперименты для сессии и не прикреплять их к каждому событию, что сэкономит объём данных при хранении.
Правильный ответ:
Оптимизация аналитики через сессионный контекст
Да, это одна из ключевых оптимизаций для систем аналитики с экспериментами. Идея заключается в разделении стабильного контекста (привязан к сессии) и динамического контекста (уникален для каждого события).
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПРОБЛЕМА: ИЗБЫТОЧНЫЕ ДАННЫЕ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Без оптимизации (каждое событие содержит полный контекст): │
│ │
│ { │
│ "event_id": "evt_001", │
│ "user_id": "user_123", │
│ "event_type": "page_view", │
│ "timestamp": "2024-01-15T10:30:00Z", │
│ "experiments": { ← Дублируется! │
│ "exp_button_color": "variant_a", │
│ "exp_homepage_layout": "variant_b", │
│ "exp_checkout_flow": "control" │
│ }, │
│ "segments": ["segment_1", "segment_2", "segment_3"], ← Дублируется! │
│ "device": "mobile", ← Дублируется! │
│ "platform": "ios", ← Дублируется! │
│ "app_version": "2.5.1" ← Дублируется! │
│ } │
│ │
│ Размер: ~500 байт × 1000 событий/сессию = 500 КБ на сессию │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ С оптимизацией (контекст в сессии): │
│ │
│ Сессия: { │
│ "session_id": "sess_456", │
│ "experiments": {...}, ← Один раз │
│ "segments": [...], ← Один раз │
│ "device": "mobile", ← Один раз │
│ "platform": "ios" ← Один раз │
│ } │
│ │
│ Событие: { │
│ "event_id": "evt_001", │
│ "session_id": "sess_456", ← Ссылка на сессию │
│ "event_type": "page_view", │
"timestamp": "2024-01-15T10:30:00Z", │
│ "page_url": "/products/123" ← Только уникальные данные │
│ } │
│ │
│ Размер: ~100 байт × 1000 событий + 400 байт сессия = 100 КБ на сессию │
│ Экономия: 80% │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Реализация сессионного контекста:
// Модель данных
type SessionContext struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
StartedAt time.Time `json:"started_at"`
Experiments map[string]string `json:"experiments"` // experiment_id → variant
Segments []string `json:"segments"`
Device string `json:"device"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Country string `json:"country"`
City string `json:"city"`
UTMParams map[string]string `json:"utm_params"`
ExpiresAt time.Time `json:"expires_at"`
}
type OptimizedEvent struct {
EventID string `json:"event_id"`
SessionID string `json:"session_id"` // Ссылка на сессию
EventType string `json:"event_type"`
Timestamp time.Time `json:"timestamp"`
Properties map[string]interface{} `json:"properties"` // Только уникальные данные
}
// Сервис управления сессиями
type SessionContextService struct {
redis *redis.Client
sessionTTL time.Duration
}
func NewSessionContextService(redisAddr string) *SessionContextService {
return &SessionContextService{
redis: redis.NewClient(&redis.Options{Addr: redisAddr}),
sessionTTL: 30 * time.Minute,
}
}
// Создание сессии с полным контекстом
func (s *SessionContextService) CreateSession(
ctx context.Context,
userID string,
experiments map[string]string,
segments []string,
deviceInfo DeviceInfo,
) (*SessionContext, error) {
session := &SessionContext{
SessionID: generateSessionID(),
UserID: userID,
StartedAt: time.Now(),
Experiments: experiments,
Segments: segments,
Device: deviceInfo.Device,
Platform: deviceInfo.Platform,
AppVersion: deviceInfo.AppVersion,
Country: deviceInfo.Country,
City: deviceInfo.City,
UTMParams: deviceInfo.UTMParams,
ExpiresAt: time.Now().Add(s.sessionTTL),
}
// Сохраняем в Redis
key := fmt.Sprintf("session:%s", session.SessionID)
data, err := json.Marshal(session)
if err != nil {
return nil, err
}
if err := s.redis.Set(ctx, key, data, s.sessionTTL).Err(); err != nil {
return nil, err
}
return session, nil
}
// Получение контекста сессии
func (s *SessionContextService) GetSession(
ctx context.Context,
sessionID string,
) (*SessionContext, error) {
key := fmt.Sprintf("session:%s", sessionID)
data, err := s.redis.Get(ctx, key).Result()
if err != nil {
return nil, err
}
var session SessionContext
if err := json.Unmarshal([]byte(data), &session); err != nil {
return nil, err
}
return &session, nil
}
// Продление сессии
func (s *SessionContextService) TouchSession(
ctx context.Context,
sessionID string,
) error {
key := fmt.Sprintf("session:%s", sessionID)
return s.redis.Expire(ctx, key, s.sessionTTL).Err()
}
// Обновление экспериментов в сессии (если эксперимент изменился)
func (s *SessionContextService) UpdateSessionExperiments(
ctx context.Context,
sessionID string,
newExperiments map[string]string,
) error {
session, err := s.GetSession(ctx, sessionID)
if err != nil {
return err
}
// Обновляем только изменившиеся эксперiments
for expID, variant := range newExperiments {
if currentVariant, exists := session.Experiments[expID]; !exists || currentVariant != variant {
session.Experiments[expID] = variant
}
}
// Сохраняем обратно
key := fmt.Sprintf("session:%s", sessionID)
data, _ := json.Marshal(session)
return s.redis.Set(ctx, key, data, s.sessionTTL).Err()
}
Оптимизированный серсобытий:
// Сервис обработки событий
type OptimizedEventService struct {
sessionService *SessionContextService
eventStore EventStore
kafkaProducer sarama.SyncProducer
}
// Отправка события (оптимизированная)
func (s *OptimizedEventService) TrackEvent(
ctx context.Context,
sessionID string,
eventType string,
properties map[string]interface{},
) error {
// 1. Валидируем сессию (быстро, из Redis)
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
return fmt.Errorf("invalid session: %w", err)
}
// 2. Создаём компактное событие
event := &OptimizedEvent{
EventID: generateEventID(),
SessionID: sessionID,
EventType: eventType,
Timestamp: time.Now(),
Properties: properties,
}
// 3. Продлеваем сессию
s.sessionService.TouchSession(ctx, sessionID)
// 4. Отправляем в Kafka (без дублирования контекста)
return s.sendToKafka(ctx, event)
}
// Обогащение события при аналитике (join с сессией)
func (s *OptimizedEventService) EnrichEvent(
ctx context.Context,
event *OptimizedEvent,
) (*EnrichedEvent, error) {
// Загружаем контекст сессии
session, err := s.sessionService.GetSession(ctx, event.SessionID)
if err != nil {
return nil, fmt.Errorf("session not found: %w", err)
}
// Обогащаем событие
enriched := &EnrichedEvent{
EventID: event.EventID,
SessionID: event.SessionID,
UserID: session.UserID,
EventType: event.EventType,
Timestamp: event.Timestamp,
Properties: event.Properties,
// Добавляем контекст из сессии
Experiments: session.Experiments,
Segments: session.Segments,
Device: session.Device,
Platform: session.Platform,
AppVersion: session.AppVersion,
Country: session.Country,
City: session.City,
}
return enriched, nil
}
Обработка граничных случаев:
// 1. Эксперимент начался в середине сессии
func (s *OptimizedEventService) HandleMidSessionExperiment(
ctx context.Context,
sessionID string,
experimentID string,
variant string,
eventTimestamp time.Time,
) error {
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
return err
}
// Добавляем эксперимент с временной меткой
session.Experiments[experimentID] = variant
// Сохраняем информацию о времени назначения
if session.ExperimentTimestamps == nil {
session.ExperimentTimestamps = make(map[string]time.Time)
}
session.ExperimentTimestamps[experimentID] = eventTimestamp
return s.sessionService.SaveSession(ctx, session)
}
// 2. Сегмент изменился во время сессии
func (s *OptimizedEventService) HandleSegmentChange(
ctx context.Context,
sessionID string,
segmentID string,
added bool,
) error {
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
return err
}
if added {
// Проверяем, нет ли уже сегмента
for _, s := range session.Segments {
if s == segmentID {
return nil // Уже есть
}
}
session.Segments = append(session.Segments, segmentID)
} else {
// Удаляем сегмент
newSegments := make([]string, 0, len(session.Segments))
for _, s := range session.Segments {
if s != segmentID {
newSegments = append(newSegments, s)
}
}
session.Segments = newSegments
}
return s.sessionService.SaveSession(ctx, session)
}
// 3. Пользователь залогинился во время сессии
func (s *OptimizedEventService) HandleLogin(
ctx context.Context,
sessionID string,
userID string,
) error {
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
return err
}
// Привязываем сессию к пользователю
session.UserID = userID
// Загружаем актуальные сегменты пользователя
segments, err := s.segmentService.GetUserSegments(ctx, userID)
if err == nil {
session.Segments = segments
}
return s.sessionService.SaveSession(ctx, session)
}
Схема хранения в ClickHouse:
-- Таблица сессий (компактная)
CREATE TABLE sessions (
session_id String,
user_id String,
started_at DateTime,
ended_at DateTime,
-- Контекст сессии
experiments Map(String, String),
segments Array(String),
device LowCardinality(String),
platform LowCardinality(String),
app_version LowCardinality(String),
country LowCardinality(String),
city LowCardinality(String),
-- Временные метки назначения экспериментов
experiment_timestamps Map(String, DateTime),
-- Метрики
event_count UInt32,
duration_seconds UInt32
)
ENGINE = MergeTree()
ORDER BY (user_id, started_at)
TTL ended_at + INTERVAL 90 DAY;
-- Таблица событий (очень компактная)
CREATE TABLE events (
event_id String,
session_id String, -- JOIN с sessions
event_type LowCardinality(String),
timestamp DateTime,
properties String -- JSON с уникальными данными
)
ENGINE = MergeTree()
ORDER BY (session_id, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Материализованное представление для аналитики
CREATE MATERIALIZED VIEW events_enriched
ENGINE = MergeTree()
ORDER BY (user_id, timestamp)
AS SELECT
e.event_id,
e.session_id,
s.user_id,
e.event_type,
e.timestamp,
e.properties,
s.experiments,
s.segments,
s.device,
s.platform,
s.app_version,
s.country,
s.city
FROM events e
LEFT JOIN sessions s ON e.session_id = s.session_id;
Экономия ресурсов:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ЭКОНОМИЯ РЕСУРСОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Метрика Без оптимизации С оптимизацией Экономия │
│ ─────── ──────────────── ────────────── ──────── │
│ │
│ Размер события 500 байт 100 байт 80% │
│ Событий в сессии 1000 1000 - │
│ Данных на сессию 500 КБ 100 КБ + 4 КБ 80% │
│ Сессий в день 1 млн 1 млн - │
│ Трафик Kafka 500 ГБ/день 104 ГБ/день 79% │
│ Хранение ClickHouse 45 ТБ/год 9 ТБ/год 80% │
│ Стоимость хранения $10,000/мес $2,000/мес 80% │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Потенциальные проблемы и решения:
// Проблема 1: Устаревание контекста сессии
// Решение: Версионирование + TTL
type VersionedSessionContext struct {
SessionContext
Version int `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
}
// Проблема 2: Потеря сессии (Redis down)
// Решение: Fallback на полные события
func (s *OptimizedEventService) TrackEventWithFallback(
ctx context.Context,
sessionID string,
eventType string,
properties map[string]interface{},
fallbackContext *SessionContext,
) error {
// Пробуем загрузить сессию
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
// Fallback: используем переданный контекст
if fallbackContext != nil {
session = fallbackContext
log.Warn("Using fallback context", "session_id", sessionID)
} else {
return fmt.Errorf("session not found and no fallback: %w", err)
}
}
return s.TrackEvent(ctx, sessionID, eventType, properties)
}
// Проблема 3: Согласованность при изменении контекста
// Решение: Event sourcing для критичных изменений
type SessionEvent struct {
SessionID string `json:"session_id"`
Type string `json:"type"` // experiment_assigned, segment_added
Data string `json:"data"`
Timestamp time.Time `json:"timestamp"`
SequenceNum int64 `json:"sequence_num"`
}
func (s *SessionContextService) ApplySessionEvent(
ctx context.Context,
event *SessionEvent,
) error {
session, err := s.GetSession(ctx, event.SessionID)
if err != nil {
return err
}
switch event.Type {
case "experiment_assigned":
var data struct {
ExperimentID string `json:"experiment_id"`
Variant string `json:"variant"`
}
json.Unmarshal([]byte(event.Data), &data)
session.Experiments[data.ExperimentID] = data.Variant
case "segment_added":
var data struct {
SegmentID string `json:"segment_id"`
}
json.Unmarshal([]byte(event.Data), &data)
session.Segments = append(session.Segments, data.SegmentID)
}
return s.SaveSession(ctx, session)
}
Вывод:
Оптимизация через сессионный контекст — это рекомендуемый подход для систем с большим количеством экспериментов:
- Экономия 80% на хранении и трафике
- Простота аналитики через JOIN по session_id
- Гибкость при изменении контекста во время сессии
- Масштабируемость — сессии можно кэшировать в Redis
Ключевые моменты:
- Использовать Redis для горячих сессий
- Версионировать изменения контекста
- Предусмотреть fallback на случай потери сессии
- Применять TTL для автоматической очистки
Вопрос 22. Куда отправляется собранная статистика и кто её анализирует?
Таймкод: 00:45:31
Ответ собеседника: Правильный. Уточняется, что статистика отправляется в аналитическую платформу, которая является частью проектируемой системы. Система должна показывать воронки и базовый анализ по экспериментам.
Правильный ответ:
1. Полный поток данных от сбора до анализа
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Flow Architecture │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Clients │────▶│ Collectors │────▶│ Kafka │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Stream Processing │ │
│ │ (Kafka Streams / Flink) │ │
│ │ │ │
│ │ • Дедупликация событий │ │
│ │ • Валидация и фильтрация │ │
│ │ • Обогащение (geo, device) │ │
│ │ • Агрегация в реальном времени │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ ClickHouse │ │ Redis │ │
│ │ (Raw Events) │ │ (Real-time) │ │
│ │ │ │ │ │
│ │ • Все события │ │ • Текущие метрики │ │
│ │ • Исторические │ │ • Счётчики │ │
│ │ данные │ │ • Кэш агрегаций │ │
│ └─────────┬───────────┘ └─────────┬───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Analytics Service │ │
│ │ │ │
│ │ • Расчёт метрик (conversion, retention) │ │
│ │ • Построение воронок │ │
│ │ • A/B тест анализ (статистическая значимость) │ │
│ │ • Когортный анализ │ │
│ └─────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Dashboard / API │ │
│ │ │ │
│ │ • Визуализация для аналитиков │ │
│ │ • API для интеграций │ │
│ │ • Отчёты и алерты │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Хранение данных в ClickHouse
-- Таблица сырых событий
CREATE TABLE events_raw (
event_id UUID,
event_type LowCardinality(String),
user_id String,
session_id String,
platform LowCardinality(String),
app_version LowCardinality(String),
device_id String,
experiment_id Nullable(String),
variant_id Nullable(String),
-- Гео данные
country LowCardinality(String),
city LowCardinality(String),
-- Устройство
device_type LowCardinality(String),
os LowCardinality(String),
os_version LowCardinality(String),
browser LowCardinality(String),
-- Свойства события
properties Map(String, String),
-- Время
client_timestamp DateTime64(3),
server_timestamp DateTime64(3),
-- Партиционирование и сортировка
event_date Date DEFAULT toDate(client_timestamp)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (platform, event_type, user_id, client_timestamp)
TTL event_date + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Агрегированная таблица для быстрых запросов
CREATE TABLE events_hourly (
hour DateTime,
platform LowCardinality(String),
experiment_id String,
variant_id String,
event_type LowCardinality(String),
-- Метрики
total_events UInt64,
unique_users UInt64,
unique_sessions UInt64,
-- Свойства
avg_duration Float64,
sum_revenue Float64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (platform, experiment_id, variant_id, event_type, hour);
-- Materialized View для автоматической агрегации
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
toStartOfHour(client_timestamp) as hour,
platform,
experiment_id,
variant_id,
event_type,
count() as total_events,
uniq(user_id) as unique_users,
uniq(session_id) as unique_sessions,
avg(toFloat64OrNull(properties['duration'])) as avg_duration,
sum(toFloat64OrNull(properties['revenue'])) as sum_revenue
FROM events_raw
WHERE experiment_id IS NOT NULL
GROUP BY hour, platform, experiment_id, variant_id, event_type;
3. Analytics Service
package analytics
import (
"context"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
// AnalyticsService сервис аналитики
type AnalyticsService struct {
ch clickhouse.Conn
cache *redis.Client
calculator *MetricsCalculator
}
// Funnel воронка
type Funnel struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []FunnelStep `json:"steps"`
ExperimentID string `json:"experiment_id,omitempty"`
}
// FunnelStep шаг воронки
type FunnelStep struct {
Name string `json:"name"`
EventType string `json:"event_type"`
Filter map[string]string `json:"filter,omitempty"`
}
// FunnelResult результат анализа воронки
type FunnelResult struct {
ExperimentID string `json:"experiment_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Steps []FunnelStepResult `json:"steps"`
Variants []VariantFunnelResult `json:"variants"`
}
// FunnelStepResult результат шага воронки
type FunnelStepResult struct {
StepName string `json:"step_name"`
Users int64 `json:"users"`
ConversionRate float64 `json:"conversion_rate"` // от предыдущего шага
OverallRate float64 `json:"overall_rate"` // от первого шага
}
// GetFunnel возвращает данные воронки
func (s *AnalyticsService) GetFunnel(ctx context.Context, funnel Funnel, startDate, endDate time.Time) (*FunnelResult, error) {
cacheKey := fmt.Sprintf("funnel:%s:%s:%s",
funnel.ID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
// Проверяем кэш
if cached, err := s.getFromCache(ctx, cacheKey); err == nil {
return cached, nil
}
// Строим запрос
result := &FunnelResult{
ExperimentID: funnel.ExperimentID,
StartDate: startDate,
EndDate: endDate,
}
// Для каждого варианта считаем воронку
variants := []string{"control", "treatment"}
for _, variant := range variants {
variantResult, err := s.calculateVariantFunnel(ctx, funnel, variant, startDate, endDate)
if err != nil {
return nil, err
}
result.Variants = append(result.Variants, *variantResult)
}
// Кэшируем результат
s.setCache(ctx, cacheKey, result, 5*time.Minute)
return result, nil
}
// calculateVariantFunnel считает воронку для варианта
func (s *AnalyticsService) calculateVariantFunnel(ctx context.Context, funnel Funnel, variant string, startDate, endDate time.Time) (*VariantFunnelResult, error) {
query := `
WITH
? AS funnel_steps,
variant_users AS (
SELECT DISTINCT user_id
FROM events_raw
WHERE experiment_id = ?
AND variant_id = ?
AND client_timestamp BETWEEN ? AND ?
)
SELECT
step_name,
count(DISTINCT e.user_id) as users
FROM (
SELECT
user_id,
event_type,
client_timestamp,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY client_timestamp) as step_num
FROM events_raw
WHERE user_id IN (SELECT user_id FROM variant_users)
AND event_type IN ?
AND client_timestamp BETWEEN ? AND ?
) e
JOIN funnel_steps fs ON e.event_type = fs.event_type
GROUP BY step_name
ORDER BY MIN(e.step_num)
`
// Выполняем запрос
rows, err := s.ch.Query(ctx, query, /* params */)
if err != nil {
return nil, err
}
defer rows.Close()
// Обрабатываем результаты
var steps []FunnelStepResult
var totalUsers int64
for rows.Next() {
var step FunnelStepResult
if err := rows.Scan(&step.StepName, &step.Users); err != nil {
return nil, err
}
if len(steps) == 0 {
totalUsers = step.Users
step.ConversionRate = 100.0
step.OverallRate = 100.0
} else {
prevUsers := steps[len(steps)-1].Users
step.ConversionRate = float64(step.Users) / float64(prevUsers) * 100
step.OverallRate = float64(step.Users) / float64(totalUsers) * 100
}
steps = append(steps, step)
}
return &VariantFunnelResult{
VariantID: variant,
Steps: steps,
}, nil
}
// ExperimentReport отчёт по эксперименту
type ExperimentReport struct {
ExperimentID string `json:"experiment_id"`
ExperimentName string `json:"experiment_name"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Status string `json:"status"`
Variants []VariantReport `json:"variants"`
Significance *SignificanceResult `json:"significance,omitempty"`
// Метрики
PrimaryMetric MetricResult `json:"primary_metric"`
Metrics []MetricResult `json:"metrics"`
// Воронки
Funnels []FunnelResult `json:"funnels,omitempty"`
}
// VariantReport отчёт по варианту
type VariantReport struct {
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
IsControl bool `json:"is_control"`
Users int64 `json:"users"`
Sessions int64 `json:"sessions"`
}
// MetricResult результат метрики
type MetricResult struct {
Name string `json:"name"`
ControlValue float64 `json:"control_value"`
TreatmentValue float64 `json:"treatment_value"`
Uplift float64 `json:"uplift"` // Процент изменения
PValue float64 `json:"p_value"` // Статистическая значимость
IsSignificant bool `json:"is_significant"`
}
// GetExperimentReport возвращает полный отчёт по эксперименту
func (s *AnalyticsService) GetExperimentReport(ctx context.Context, experimentID string, startDate, endDate time.Time) (*ExperimentReport, error) {
// Получаем данные эксперимента
exp, err := s.getExperiment(ctx, experimentID)
if err != nil {
return nil, err
}
report := &ExperimentReport{
ExperimentID: experimentID,
ExperimentName: exp.Name,
StartDate: startDate,
EndDate: endDate,
Status: string(exp.Status),
}
// Считаем пользователей по вариантам
variants, err := s.getVariantStats(ctx, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
report.Variants = variants
// Считаем основную метрику
primaryMetric, err := s.calculateMetric(ctx, experimentID, exp.PrimaryMetric, startDate, endDate)
if err != nil {
return nil, err
}
report.PrimaryMetric = *primaryMetric
// Считаем дополнительные метрики
for _, metricName := range exp.SecondaryMetrics {
metric, err := s.calculateMetric(ctx, experimentID, metricName, startDate, endDate)
if err != nil {
continue
}
report.Metrics = append(report.Metrics, *metric)
}
// Проверяем статистическую значимость
if len(variants) >= 2 {
report.Significance = s.calculateSignificance(variants, *primaryMetric)
}
return report, nil
}
// calculateMetric считает метрику
func (s *AnalyticsService) calculateMetric(ctx context.Context, experimentID, metricName string, startDate, endDate time.Time) (*MetricResult, error) {
query := `
SELECT
variant_id,
CASE ?
WHEN 'conversion_rate' THEN
countIf(event_type = 'conversion') / countIf(event_type = 'exposure')
WHEN 'average_revenue' THEN
sumIf(toFloat64OrNull(properties['revenue']), event_type = 'conversion') /
countIf(event_type = 'exposure')
WHEN 'retention_d1' THEN
-- Retention на день 1
countIf(event_type = 'return', properties['days_since_exposure'] = '1') /
countIf(event_type = 'exposure')
ELSE 0
END as metric_value
FROM events_raw
WHERE experiment_id = ?
AND client_timestamp BETWEEN ? AND ?
AND variant_id IS NOT NULL
GROUP BY variant_id
`
rows, err := s.ch.Query(ctx, query, metricName, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var controlValue, treatmentValue float64
for rows.Next() {
var variantID string
var value float64
if err := rows.Scan(&variantID, &value); err != nil {
return nil, err
}
if variantID == "control" {
controlValue = value
} else {
treatmentValue = value
}
}
uplift := (treatmentValue - controlValue) / controlValue * 100
return &MetricResult{
Name: metricName,
ControlValue: controlValue,
TreatmentValue: treatmentValue,
Uplift: uplift,
}, nil
}
4. API для дашборда
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// DashboardHandler обработчик дашборда
type DashboardHandler struct {
analytics *analytics.Service
experiments *experiment.Service
}
// RegisterRoutes регистрирует маршруты
func (h *DashboardHandler) RegisterRoutes(r *gin.Engine) {
api := r.Group("/api/v1")
{
// Эксперименты
api.GET("/experiments", h.ListExperiments)
api.GET("/experiments/:id", h.GetExperiment)
api.GET("/experiments/:id/report", h.GetExperimentReport)
// Метрики
api.GET("/experiments/:id/metrics", h.GetMetrics)
api.GET("/experiments/:id/funnel", h.GetFunnel)
// Воронки
api.POST("/funnels/analyze", h.AnalyzeFunnel)
// Когорты
api.GET("/experiments/:id/cohorts", h.GetCohorts)
// Алерты
api.GET("/alerts", h.GetAlerts)
api.POST("/alerts", h.CreateAlert)
}
}
// GetExperimentReport возвращает отчёт по эксперименту
func (h *DashboardHandler) GetExperimentReport(c *gin.Context) {
experimentID := c.Param("id")
startDate, _ := time.Parse("2006-01-02", c.Query("start_date"))
endDate, _ := time.Parse("2006-01-02", c.Query("end_date"))
if startDate.IsZero() {
startDate = time.Now().AddDate(0, 0, -30)
}
if endDate.IsZero() {
endDate = time.Now()
}
report, err := h.analytics.GetExperimentReport(c.Request.Context(), experimentID, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
// GetFunnel возвращает данные воронки
func (h *DashboardHandler) GetFunnel(c *gin.Context) {
experimentID := c.Param("id")
var funnel Funnel
if err := c.ShouldBindJSON(&funnel); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
funnel.ExperimentID = experimentID
startDate, _ := time.Parse("2006-01-02", c.Query("start_date"))
endDate, _ := time.Parse("2006-01-02", c.Query("end_date"))
result, err := h.analytics.GetFunnel(c.Request.Context(), funnel, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// AnalyzeFunnel анализирует воронку
func (h *DashboardHandler) AnalyzeFunnel(c *gin.Context) {
var req struct {
Funnel Funnel `json:"funnel" binding:"required"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
startDate, _ := time.Parse("2006-01-02", req.StartDate)
endDate, _ := time.Parse("2006-01-02", req.EndDate)
result, err := h.analytics.GetFunnel(c.Request.Context(), req.Funnel, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
5. Кто анализирует статистику
┌─────────────────────────────────────────────────────────────────────────────┐
│ Роли и ответственности │
├────────────────────┬────────────────────────────────────────────────────────┤
│ │ │
│ Data Analysts │ • Создают и настраивают эксперименты │
│ │ • Определяют метрики и гипотезы │
│ │ • Анализируют результаты A/B тестов │
│ │ • Строят воронки и когорты │
│ │ • Готовят отчёты для бизнеса │
│ │ │
├────────────────────┼────────────────────────────────────────────────────────┤
│ │ │
│ Product Managers │ • Формулируют гипотезы │
│ │ • Определяют целевые метрики │
│ │ • Принимают решения на основе данных │
│ │ • Мониторят здоровый метрики │
│ │ │
├────────────────────┼────────────────────────────────────────────────────────┤
│ │ │
│ Data Engineers │ • Поддерживают пайплайн данных │
│ │ • Оптимизируют запросы к ClickHouse │
│ │ • Мониторят качество данных │
│ │ • Настраивают алерты │
│ │ │
├────────────────────┼────────────────────────────────────────────────────────┤
│ │ │
│ Developers │ • Интегрируют SDK в приложения │
│ │ • Настраивают трекинг событий │
│ │ • Создают кастомные метрики │
│ │ • Тестируют эксперименты │
│ │ │
└────────────────────┴────────────────────────────────────────────────────────┘
6. Итоговая схема
┌─────────────────────────────────────────────────────────────────────────────┐
│ Analytics Platform │
│ │
│ Data Sources Storage Processing Presentation │
│ ───────────── ─────── ────────── ──────────── │
│ │
│ Web Events ──┐ │
│ ├──▶ Kafka ──▶ ClickHouse ──▶ Analytics ──▶ Dashboard │
│ Mobile Events┘ │ │ Service (UI) │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Redis Materialized API + │
│ (Cache) Views Reports │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Собранная статистика проходит через коллекторы → Kafka → ClickHouse, где агрегируется и становится доступной для анализа через Analytics Service. Аналитики, продакт-менеджеры и разработчики используют дашборд для просмотра результатов экспериментов, воронок и метрик.
Вопрос 36. Как соотносится получившаяся архитектура с реальной системой компании?
Таймкод: 01:16:52
Ответ собеседова: Правильный. Уточняется, что концептуально похоже на реальную систему. В реальности кубиков больше и она сложнее. API Gateway скрывает отдельные сервисы. Рабочее место аналитика скрыто и имеет аутентификацию/авторизацию для внутренних пользователей. Статистик коллектор достаточно простой.
Правильный ответ:
Сравнение проектной архитектуры с реальной системой
Концептуально архитектура, спроектированная на интервью, действительно близка к реальным системам аналитики уровня крупных компаний (Яндекс, Ozon, Wildberries и т.д.). Однако реальная система всегда сложнее из-за множества дополнительных требований.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПРОЕКТНАЯ vs РЕАЛЬНАЯ АРХИТЕКТУРА │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Проектная архитектура: │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ SDK │───▶│ Collector│───▶│ Kafka │───▶│ Storage │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Реальная архитектура: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ API Gateway │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Mobile │ │Web │ │Server │ │Partner │ │Internal │ │ │
│ │ │SDK │ │SDK │ │SDK │ │API │ │Tools │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼────────────┼────────────┼────────────┼────────────┼────────┘ │
│ └────────────┴────────────┴────────────┴────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Load Balancer │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌─────────────────────────────────┼─────────────────────────────────┐ │
│ │ Event Ingestion Layer │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Collector│ │Collector│ │Collector│ │Collector│ │ │
│ │ │(mobile) │ │(web) │ │(server) │ │(partner)│ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼────────────┼────────────┼────────────┼───────────────────┘ │
│ └────────────┴────────────┴────────────┘ │
│ │ │
│ ┌─────────────────────────────────┼─────────────────────────────────┐ │
│ │ Kafka Cluster │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │events │ │raw │ │validated│ │enriched │ │ │
│ │ │(raw) │ │(mobile) │ │(web) │ │(server) │ │ │
│ │ └────┬────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────┼───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────┼───────────────────────────────────────────────────────────┐ │
│ │ │ Stream Processing │ │
│ │ ┌────▼────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Flink/ │ │Flink/ │ │Flink/ │ │Flink/ │ │ │
│ │ │Spark │ │Spark │ │Spark │ │Spark │ │ │
│ │ │(validate│ │(enrich) │ │(session)│ │(anomaly)│ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼────────────┼────────────┼────────────┼───────────────────┘ │
│ └────────────┴────────────┴────────────┘ │
│ │ │
│ ┌─────────────────────────────────┼─────────────────────────────────┐ │
│ │ Storage Layer │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ClickHouse│ │Redis │ │HDFS/S3 │ │Greenplum│ │ │
│ │ │(hot) │ │(cache) │ │(cold) │ │(reports)│ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Services Layer │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Segment │ │Experiment│ │Profile │ │Notific. │ │ │
│ │ │Service │ │Service │ │Service │ │Service │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Analytics Workspace │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Dashboard│ │SQL │ │Report │ │Admin │ │ │
│ │ │UI │ │Editor │ │Builder │ │Panel │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Ключевые отличия реальной системы:
1. API Gateway и маршрутизация
// Реальная система: несколько SDK и точек входа
type GatewayConfig struct {
// Разные эндпоинты для разных клиентов
MobileEndpoint string // /v1/mobile/events
WebEndpoint string // /v1/web/events
ServerEndpoint string // /v1/server/events
PartnerEndpoint string // /v1/partner/events
// Rate limiting по типам клиентов
RateLimits map[string]RateLimitConfig
}
type RateLimitConfig struct {
RequestsPerSecond int
BurstSize int
QuotaPerDay int64
}
// API Gateway выполняет:
// 1. Аутентификацию (API keys, OAuth, mTLS)
// 2. Rate limiting
// 3. Валидацию схемы событий
// 4. Маршрутизацию к нужному коллектору
// 5. Трансформацию протоколов (REST → gRPC → Kafka)
func (g *APIGateway) HandleEvent(w http.ResponseWriter, r *http.Request) {
// 1. Определяем тип клиента
clientType := g.identifyClient(r)
// 2. Проверяем rate limit
if !g.rateLimiter.Allow(clientType, r.RemoteAddr) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// 3. Валидируем событие
event, err := g.validateEvent(r, clientType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 4. Добавляем метаданные
event.IngestedAt = time.Now()
event.ClientType = clientType
event.SourceIP = r.RemoteAddr
// 5. Маршрутизируем к нужному коллектору
g.routeToCollector(event)
w.WriteHeader(http.StatusAccepted)
}
2. Статистик коллектор — проще, чем кажется
// Реальность: коллектор — это простой stateless сервис
type StatsCollector struct {
kafkaProducer sarama.AsyncProducer
validator EventValidator
metrics MetricsReporter
}
func (c *StatsCollector) Collect(ctx context.Context, event *Event) error {
// 1. Валидация (минимум проверок для скорости)
if err := c.validator.Validate(event); err != nil {
c.metrics.IncInvalid(event.EventType)
return err
}
// 2. Сериализация
data, err := json.Marshal(event)
if err != nil {
return err
}
// 3. Отправка в Kafka (асинхронно для скорости)
c.kafkaProducer.Input() <- &sarama.ProducerMessage{
Topic: c.getTopic(event),
Key: sarama.StringEncoder(event.UserID),
Value: sarama.ByteEncoder(data),
Headers: []sarama.RecordHeader{
{Key: []byte("client_type"), Value: []byte(event.ClientType)},
{Key: []byte("ingested_at"), Value: []byte(event.IngestedAt.Format(time.RFC3339))},
},
}
c.metrics.IncCollected(event.EventType)
return nil
}
// Коллектор НЕ делает:
// - Обогащение данных (это делает stream processing)
// - Сегментацию (это отдельный сервис)
// - Сессии (это отдельный сервис)
// - Валидацию бизнес-логики (это делает downstream)
3. Analytics Workspace — внутренний инструмент
// Реальная система: сложный UI с авторизацией
type AnalyticsWorkspace struct {
authService AuthService
queryService QueryService
reportService ReportService
dashboardService DashboardService
adminService AdminService
}
// Аутентификация и авторизация
type AuthMiddleware struct {
jwtValidator JWTValidator
roleService RoleService
}
func (m *AuthMiddleware) RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
claims, err := m.jwtValidator.Validate(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Проверяем роли пользователя
userRoles, err := m.roleService.GetRoles(c, claims.UserID)
if err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
hasRole := false
for _, role := range roles {
if contains(userRoles, role) {
hasRole = true
break
}
}
if !hasRole {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
c.Set("user_id", claims.UserID)
c.Set("roles", userRoles)
c.Next()
}
}
// Роли в системе
const (
RoleViewer = "viewer" // Только просмотр дашбордов
RoleAnalyst = "analyst" // Создание запросов и отчётов
RoleDataEngineer = "data_engineer" // Управление пайплайнами
RoleAdmin = "admin" // Полный доступ
)
// Пример API рабочего места аналитика
func (ws *AnalyticsWorkspace) RegisterRoutes(r *gin.Engine) {
// Дашборды
dashboards := r.Group("/api/v1/dashboards")
dashboards.Use(ws.authMiddleware.RequireRole(RoleViewer, RoleAnalyst, RoleAdmin))
{
dashboards.GET("", ws.dashboardService.List)
dashboards.GET("/:id", ws.dashboardService.Get)
dashboards.POST("", ws.authMiddleware.RequireRole(RoleAnalyst, RoleAdmin), ws.dashboardService.Create)
dashboards.PUT("/:id", ws.authMiddleware.RequireRole(RoleAnalyst, RoleAdmin), ws.dashboardService.Update)
dashboards.DELETE("/:id", ws.authMiddleware.RequireRole(RoleAdmin), ws.dashboardService.Delete)
}
// SQL Editor
queries := r.Group("/api/v1/queries")
queries.Use(ws.authMiddleware.RequireRole(RoleAnalyst, RoleAdmin))
{
queries.POST("/execute", ws.queryService.Execute)
queries.POST("/save", ws.queryService.Save)
queries.GET("/history", ws.queryService.History)
}
// Отчёты
reports := r.Group("/api/v1/reports")
reports.Use(ws.authMiddleware.RequireRole(RoleAnalyst, RoleAdmin))
{
reports.GET("", ws.reportService.List)
reports.POST("", ws.reportService.Create)
reports.GET("/:id/download", ws.reportService.Download)
}
// Администрирование
admin := r.Group("/api/v1/admin")
admin.Use(ws.authMiddleware.RequireRole(RoleAdmin))
{
admin.GET("/users", ws.adminService.ListUsers)
admin.POST("/users/:id/roles", ws.adminService.UpdateRoles)
admin.GET("/audit-log", ws.adminService.AuditLog)
}
}
4. Дополнительные компоненты реальной системы
// Сервис управления экспериментами
type ExperimentManagementService struct {
experimentStore ExperimentStore
flagService FeatureFlagService
notificationSvc NotificationService
}
func (s *ExperimentManagementService) CreateExperiment(
ctx context.Context,
req *CreateExperimentRequest,
) (*Experiment, error) {
// 1. Валидация параметров эксперимента
if err := s.validateExperiment(req); err != nil {
return nil, err
}
// 2. Проверка на конфликты с другими экспериментами
conflicts, err := s.checkConflicts(ctx, req)
if err != nil {
return nil, err
}
if len(conflicts) > 0 {
return nil, fmt.Errorf("conflicts detected: %v", conflicts)
}
// 3. Создание эксперимента
experiment := &Experiment{
ID: generateID(),
Name: req.Name,
Description: req.Description,
Hypothesis: req.Hypothesis,
// Параметры
Variants: req.Variants,
Traffic: req.TrafficPercentage,
Targeting: req.TargetingRules,
// Метрики
PrimaryMetric: req.PrimaryMetric,
SecondaryMetrics: req.SecondaryMetrics,
GuardrailMetrics: req.GuardrailMetrics,
// Статус
Status: ExperimentStatusDraft,
CreatedBy: getUserID(ctx),
CreatedAt: time.Now(),
// Планируемые даты
PlannedStart: req.PlannedStart,
PlannedEnd: req.PlannedEnd,
}
// 4. Сохранение
if err := s.experimentStore.Save(ctx, experiment); err != nil {
return nil, err
}
// 5. Уведомление команды
s.notificationSvc.NotifyExperimentCreated(ctx, experiment)
return experiment, nil
}
// Сервис детекции аномалий
type AnomalyDetectionService struct {
clickhouse *sql.DB
alertService AlertService
slackClient SlackClient
}
func (s *AnomalyDetectionService) DetectAnomalies(ctx context.Context) error {
// Проверяем ключевые метрики на аномалии
queries := []struct {
Name string
Query string
Threshold float64
}{
{
Name: "event_volume_drop",
Query: `
SELECT
count() as current_count,
avg(count) OVER (ORDER BY hour ROWS BETWEEN 24 PRECEDING AND 1 PRECEDING) as avg_count
FROM events
WHERE timestamp >= now() - INTERVAL 1 HOUR
`,
Threshold: 0.5, // Падение на 50%
},
{
Name: "error_rate_spike",
Query: `
SELECT
countIf(level = 'error') / count() as error_rate
FROM events
WHERE timestamp >= now() - INTERVAL 5 MINUTE
`,
Threshold: 0.1, // Более 10% ошибок
},
{
Name: "latency_spike",
Query: `
SELECT
quantile(0.99)(processing_time_ms) as p99_latency
FROM events
WHERE timestamp >= now() - INTERVAL 5 MINUTE
`,
Threshold: 1000, // p99 > 1 секунды
},
}
for _, q := range queries {
value, err := s.executeQuery(ctx, q.Query)
if err != nil {
continue
}
if s.isAnomaly(value, q.Threshold) {
s.alertService.SendAlert(ctx, Alert{
Type: "anomaly",
Severity: "critical",
Metric: q.Name,
Value: value,
Threshold: q.Threshold,
Timestamp: time.Now(),
})
}
}
return nil
}
5. Мониторинг и observability
// Реальная система: обширный мониторинг
type MonitoringStack struct {
prometheus *prometheus.Registry
grafana GrafanaClient
jaeger jaeger.Client
elk ElasticClient
}
// Метрики системы
type SystemMetrics struct {
// Ingestion metrics
EventsIngested *prometheus.CounterVec
EventsValidated *prometheus.CounterVec
EventsRejected *prometheus.CounterVec
IngestionLatency *prometheus.HistogramVec
// Processing metrics
ProcessingLag *prometheus.GaugeVec
ProcessingErrors *prometheus.CounterVec
ProcessingLatency *prometheus.HistogramVec
// Storage metrics
StorageWrites *prometheus.CounterVec
StorageReads *prometheus.CounterVec
StorageErrors *prometheus.CounterVec
// Service metrics
ServiceLatency *prometheus.HistogramVec
ServiceErrors *prometheus.CounterVec
CacheHitRate *prometheus.GaugeVec
}
func (m *SystemMetrics) Register() {
m.prometheus.MustRegister(
m.EventsIngested,
m.EventsValidated,
m.EventsRejected,
m.IngestionLatency,
m.ProcessingLag,
m.ProcessingErrors,
m.ProcessingLatency,
m.StorageWrites,
m.StorageReads,
m.StorageErrors,
m.ServiceLatency,
m.ServiceErrors,
m.CacheHitRate,
)
}
// Алерты
type AlertRule struct {
Name string
Expression string
Duration time.Duration
Severity string
Annotations map[string]string
}
var DefaultAlertRules = []AlertRule{
{
Name: "HighErrorRate",
Expression: `rate(events_rejected_total[5m]) / rate(events_ingested_total[5m]) > 0.05`,
Duration: 5 * time.Minute,
Severity: "warning",
Annotations: map[string]string{
"summary": "High event rejection rate detected",
},
},
{
Name: "KafkaLagHigh",
Expression: `kafka_consumer_group_lag > 100000`,
Duration: 10 * time.Minute,
Severity: "critical",
Annotations: map[string]string{
"summary": "Kafka consumer lag is too high",
},
},
{
Name: "ClickHouseSlow",
Expression: `histogram_quantile(0.99, rate(clickhouse_query_duration_seconds_bucket[5m])) > 10`,
Duration: 5 * time.Minute,
Severity: "warning",
Annotations: map[string]string{
"summary": "ClickHouse p99 query time > 10s",
},
},
}
Сводная таблица отличий:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПРОЕКТ vs РЕАЛЬНОСТЬ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Компонент Проект Реальность │
│ ───────── ────── ────────── │
│ │
│ SDK 1 универсальный 5-10 (iOS, Android, Web, │
│ Server, Partner, etc.) │
│ │
│ Коллектор 1 общий 3-5 специализированных │
│ (по типу клиента) │
│ │
│ Kafka 1 топик 10-20 топиков │
│ (raw, validated, enriched) │
│ │
│ Stream Processing Простой Enrich Сложный пайплайн │
│ Flink (validate → enrich → sessionize │
│ → segment → anomaly detect) │
│ │
│ Хранение ClickHouse ClickHouse + Redis + S3 │
│ (hot) + Greenplum + HDFS │
│ │
│ Сервисы 3-4 основных 15-20 микросервисов │
│ │
│ Analytics UI Простой дашборд Сложный внутренний │
│ инструмент с RBAC │
│ │
│ Мониторинг Базовый Полный стек │
│ (Prometheus + Grafana + │
│ Jaeger + ELK + PagerDuty) │
│ │
│ SLA 99.9% 99.95% - 99.99% │
│ │
│ Команда 3-5 человек 15-30 человек │
│ (проект) (поддержка и развитие) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Проектная архитектура правильно отражает ключевые компоненты и потоки данных. Реальная система отличается:
- Количеством компонентов — больше специализированных сервисов
- Сложностью обработки — многоэтапные пайплайны вместо простого enrichment
- Операционной зрелостью — мониторинг, алерты, disaster recovery
- Безопасностью — RBAC, аудит, шифрование
- Масштабом — десятки топиков Kafka, сотни таблиц в ClickHouse
Концептуально архитектура верна и может быть реализована с учётом описанных ограничений.
Вопрос 23. Как организовать процесс сбора и хранения статистики?
Таймкод: 00:46:58
Ответ собеседника: Правильный. Предлагается использовать очередь (например, Kafka) для временного хранения и переживания пиков активности, а затем из очереди данные вытаскиваются и складываются в БД для ведения аналитики.
Правильный ответ:
1. Полная архитектура сбора и хранения статистики
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event Collection & Storage Pipeline │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Client Layer │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Web SDK │ │ Mobile SDK │ │ Backend │ │ │
│ │ │ (JS) │ │ (iOS/Andr) │ │ Events │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────┼────────────────┼────────────────┼────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Collection Layer │ │
│ │ │ │
│ │ ┌─────────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Web Collector │ │ Mobile Collector │ │ │
│ │ │ (WebSocket/gRPC) │ │ (HTTP POST / Batch) │ │ │
│ │ └───────────┬─────────────┘ └───────────┬─────────────┘ │ │
│ └──────────────┼──────────────────────────────┼───────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Message Queue Layer │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Apache Kafka │ │ │
│ │ │ │ │ │
│ │ │ Topics: │ │ │
│ │ │ • web-events (10 partitions) │ │ │
│ │ │ • mobile-events (20 partitions) │ │ │
│ │ │ • backend-events (5 partitions) │ │ │
│ │ │ • experiment-events (5 partitions) │ │ │
│ │ └─────────────────────────────┬───────────────────────────────┘ │ │
│ └────────────────────────────────┼────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Processing Layer │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Stream Processor (Kafka Streams) │ │ │
│ │ │ │ │ │
│ │ │ • Дедупликация (по event_id) │ │ │
│ │ │ • Валидация схемы │ │ │
│ │ │ • Обогащение (geo, device info) │ │ │
│ │ │ • Фильтрация ботов │ │ │
│ │ │ • Агрегация в реальном времени │ │ │
│ │ └─────────────────────────────┬───────────────────────────────┘ │ │
│ └────────────────────────────────┼────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ClickHouse │ │ Redis │ │ PostgreSQL │ │
│ │ (Raw Events) │ │ (Real-time) │ │ (Metadata) │ │
│ │ │ │ │ │ │ │
│ │ • Все события │ │ • Счётчики │ │ • Эксперименты │ │
│ │ • Агрегации │ │ • Кэш │ │ • Пользователи │ │
│ │ • История │ │ • Сессии │ │ • Настройки │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Kafka конфигурация
package kafka
import (
"github.com/segmentio/kafka-go"
"github.com/segmentio/kafka-go/sasl/scram"
)
// KafkaConfig конфигурация Kafka
type KafkaConfig struct {
Brokers []string
Username string
Password string
}
// CreateTopics создаёт необходимые топики
func CreateTopics(config KafkaConfig) error {
conn, err := kafka.Dial("tcp", config.Brokers[0])
if err != nil {
return err
}
defer conn.Close()
topics := []kafka.TopicConfig{
{
Topic: "web-events",
NumPartitions: 10,
ReplicationFactor: 3,
ConfigEntries: []kafka.ConfigEntry{
{Key: "retention.ms", Value: "604800000"}, // 7 дней
{Key: "cleanup.policy", Value: "delete"},
{Key: "compression.type", Value: "lz4"},
{Key: "min.insync.replicas", Value: "2"},
},
},
{
Topic: "mobile-events",
NumPartitions: 20,
ReplicationFactor: 3,
ConfigEntries: []kafka.ConfigEntry{
{Key: "retention.ms", Value: "604800000"},
{Key: "cleanup.policy", Value: "delete"},
{Key: "compression.type", Value: "lz4"},
{Key: "min.insync.replicas", Value: "2"},
},
},
{
Topic: "experiment-events",
NumPartitions: 5,
ReplicationFactor: 3,
ConfigEntries: []kafka.ConfigEntry{
{Key: "retention.ms", Value: "2592000000"}, // 30 дней
{Key: "cleanup.policy", Value: "delete"},
{Key: "compression.type", Value: "lz4"},
},
},
}
return conn.CreateTopics(topics...)
}
// NewWriter создаёт Kafka writer
func NewWriter(config KafkaConfig, topic string) *kafka.Writer {
var dialer *kafka.Dialer
if config.Username != "" {
mechanism, _ := scram.Mechanism(scram.SHA256, config.Username, config.Password)
dialer = &kafka.Dialer{
SASLMechanism: mechanism,
}
}
return &kafka.Writer{
Addr: kafka.TCP(config.Brokers...),
Topic: topic,
Balancer: &kafka.Murmur2Balancer{},
BatchSize: 100,
BatchTimeout: 10 * time.Millisecond,
Async: false,
Dialer: dialer,
}
}
// NewReader создаёт Kafka reader
func NewReader(config KafkaConfig, topic, groupID string) *kafka.Reader {
var dialer *kafka.Dialer
if config.Username != "" {
mechanism, _ := scram.Mechanism(scram.SHA256, config.Username, config.Password)
dialer = &kafka.Dialer{
SASLMechanism: mechanism,
}
}
return kafka.NewReader(kafka.ReaderConfig{
Brokers: config.Brokers,
Topic: topic,
GroupID: groupID,
Dialer: dialer,
MinBytes: 1e3, // 1KB
MaxBytes: 10e6, // 10MB
CommitInterval: time.Second,
StartOffset: kafka.FirstOffset,
})
}
3. Stream Processor
package processor
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
)
// StreamProcessor процессор потока событий
type StreamProcessor struct {
reader *kafka.Reader
clickhouse clickhouse.Conn
redis *redis.Client
geoService *GeoService
botDetector *BotDetector
metrics *Metrics
}
// ProcessedEvent обработанное событие
type ProcessedEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
DeviceID string `json:"device_id"`
ExperimentID *string `json:"experiment_id,omitempty"`
VariantID *string `json:"variant_id,omitempty"`
// Гео данные (обогащённые)
Country string `json:"country"`
City string `json:"city"`
IP string `json:"ip"`
// Устройство (обогащённые)
DeviceType string `json:"device_type"`
OS string `json:"os"`
OSVersion string `json:"os_version"`
Browser string `json:"browser"`
// Свойства
Properties map[string]string `json:"properties"`
// Время
ClientTimestamp time.Time `json:"client_timestamp"`
ServerTimestamp time.Time `json:"server_timestamp"`
// Флаги
IsBot bool `json:"is_bot"`
}
// Start запускает обработку
func (p *StreamProcessor) Start(ctx context.Context) error {
batchSize := 1000
flushInterval := 5 * time.Second
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
batch := make([]ProcessedEvent, 0, batchSize)
for {
select {
case <-ctx.Done():
// Сохраняем оставшиеся события
if len(batch) > 0 {
p.flushToClickHouse(ctx, batch)
}
return ctx.Err()
case <-ticker.C:
if len(batch) > 0 {
p.flushToClickHouse(ctx, batch)
batch = batch[:0]
}
default:
// Читаем сообщение
msg, err := p.reader.FetchMessage(ctx)
if err != nil {
p.metrics.ReadErrors.Inc()
continue
}
// Парсим событие
var rawEvent RawEvent
if err := json.Unmarshal(msg.Value, &rawEvent); err != nil {
p.metrics.ParseErrors.Inc()
continue
}
// Обрабатываем событие
processed, err := p.processEvent(ctx, rawEvent)
if err != nil {
p.metrics.ProcessErrors.Inc()
continue
}
// Пропускаем ботов
if processed.IsBot {
p.metrics.BotsFiltered.Inc()
p.reader.CommitMessages(ctx, msg)
continue
}
batch = append(batch, *processed)
// Обновляем счётчики в Redis
p.updateCounters(ctx, processed)
// Фиксируем прогресс
if len(batch) >= batchSize {
p.flushToClickHouse(ctx, batch)
p.reader.CommitMessages(ctx, msg)
batch = batch[:0]
}
}
}
}
// processEvent обрабатывает одно событие
func (p *StreamProcessor) processEvent(ctx context.Context, raw RawEvent) (*ProcessedEvent, error) {
event := &ProcessedEvent{
ID: raw.ID,
EventType: raw.EventType,
UserID: raw.UserID,
SessionID: raw.SessionID,
Platform: raw.Platform,
AppVersion: raw.AppVersion,
DeviceID: raw.DeviceID,
ExperimentID: raw.ExperimentID,
VariantID: raw.VariantID,
Properties: raw.Properties,
ClientTimestamp: raw.Timestamp,
ServerTimestamp: time.Now(),
}
// Дедупликация
if p.isDuplicate(ctx, event.ID) {
return nil, ErrDuplicateEvent
}
// Обогащение гео данными
if raw.IP != "" {
geo, err := p.geoService.Lookup(ctx, raw.IP)
if err == nil {
event.Country = geo.Country
event.City = geo.City
event.IP = raw.IP
}
}
// Определение устройстua
if raw.UserAgent != "" {
device := p.parseUserAgent(raw.UserAgent)
event.DeviceType = device.Type
event.OS = device.OS
event.OSVersion = device.OSVersion
event.Browser = device.Browser
}
// Проверка на бота
event.IsBot = p.botDetector.IsBot(raw.UserAgent, raw.IP)
return event, nil
}
// isDuplicate проверяет дубликат события
func (p *StreamProcessor) isDuplicate(ctx context.Context, eventID string) bool {
key := fmt.Sprintf("event:%s", eventID)
exists, _ := p.redis.Exists(ctx, key).Result()
if exists > 0 {
return true
}
// Помечаем событие как обработанное на 24 часа
p.redis.Set(ctx, key, "1", 24*time.Hour)
return false
}
// flushToClickHouse сохраняет батч в ClickHouse
func (p *StreamProcessor) flushToClickHouse(ctx context.Context, events []ProcessedEvent) {
if len(events) == 0 {
return
}
batch, err := p.ch.PrepareBatch(ctx, "INSERT INTO events_raw")
if err != nil {
p.metrics.ClickHouseErrors.Inc()
return
}
for _, event := range events {
err := batch.Append(
event.ID,
event.EventType,
event.UserID,
event.SessionID,
event.Platform,
event.AppVersion,
event.DeviceID,
event.ExperimentID,
event.VariantID,
event.Country,
event.City,
event.DeviceType,
event.OS,
event.OSVersion,
event.Browser,
event.Properties,
event.ClientTimestamp,
event.ServerTimestamp,
)
if err != nil {
p.metrics.ClickHouseErrors.Inc()
}
}
if err := batch.Send(); err != nil {
p.metrics.ClickHouseErrors.Inc()
} else {
p.metrics.EventsProcessed.Add(float64(len(events)))
}
}
// updateCounters обновляет счётчики в Redis
func (p *StreamProcessor) updateCounters(ctx context.Context, event *ProcessedEvent) {
pipe := p.redis.Pipeline()
hour := event.ServerTimestamp.Format("2006-01-02-15")
// Общий счётчик событий
pipe.Incr(ctx, fmt.Sprintf("stats:events:%s:%s", event.Platform, hour))
// Счётчик уникальных пользователей
pipe.PFAdd(ctx, fmt.Sprintf("stats:users:%s:%s", event.Platform, hour), event.UserID)
// Счётчик по эксперименту
if event.ExperimentID != nil {
pipe.Incr(ctx, fmt.Sprintf("stats:exp:%s:%s:%s", *event.ExperimentID, *event.VariantID, hour))
pipe.PFAdd(ctx, fmt.Sprintf("stats:exp:users:%s:%s:%s", *event.ExperimentID, *event.VariantID, hour), event.UserID)
}
pipe.Exec(ctx)
}
4. ClickHouse схема
-- Основная таблица событий
CREATE TABLE events_raw (
event_id UUID,
event_type LowCardinality(String),
user_id String,
session_id String,
platform LowCardinality(String),
app_version LowCardinality(String),
device_id String,
experiment_id Nullable(String),
variant_id Nullable(String),
-- Гео данные
country LowCardinality(String) DEFAULT '',
city LowCardinality(String) DEFAULT '',
-- Устройство
device_type LowCardinality(String) DEFAULT '',
os LowCardinality(String) DEFAULT '',
os_version LowCardinality(String) DEFAULT '',
browser LowCardinality(String) DEFAULT '',
-- Свойства
properties Map(String, String),
-- Время
client_timestamp DateTime64(3),
server_timestamp DateTime64(3),
-- Дата для партиционирования
event_date Date DEFAULT toDate(client_timestamp)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (platform, event_type, user_id, client_timestamp)
TTL event_date + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Агрегированная таблица по часам
CREATE TABLE events_hourly (
hour DateTime,
platform LowCardinality(String),
experiment_id String,
variant_id String,
event_type LowCardinality(String),
total_events UInt64,
unique_users UInt64,
unique_sessions UInt64,
avg_duration Float64,
sum_revenue Float64 DEFAULT 0
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (platform, experiment_id, variant_id, event_type, hour)
TTL hour + INTERVAL 1 YEAR;
-- Materialized View для автоматической агрегации
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
toStartOfHour(client_timestamp) as hour,
platform,
experiment_id,
variant_id,
event_type,
count() as total_events,
uniqExact(user_id) as unique_users,
uniqExact(session_id) as unique_sessions,
avg(toFloat64OrNull(properties['duration'])) as avg_duration,
sum(toFloat64OrNull(properties['revenue'])) as sum_revenue
FROM events_raw
WHERE event_date >= today() - 7
GROUP BY hour, platform, experiment_id, variant_id, event_type;
5. Consumer Group для масштабирования
package consumer
import (
"context"
"sync"
)
// ConsumerGroup группа потребителей
type ConsumerGroup struct {
readers []*kafka.Reader
processor *processor.StreamProcessor
numWorkers int
}
// NewConsumerGroup создаёт группу потребителей
func NewConsumerGroup(config kafka.KafkaConfig, topic string, numWorkers int, processor *processor.StreamProcessor) *ConsumerGroup {
cg := &ConsumerGroup{
processor: processor,
numWorkers: numWorkers,
}
for i := 0; i < numWorkers; i++ {
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: config.Brokers,
Topic: topic,
GroupID: fmt.Sprintf("%s-processors", topic),
MinBytes: 1e3,
MaxBytes: 10e6,
CommitInterval: time.Second,
})
cg.readers = append(cg.readers, reader)
}
return cg
}
// Start запускает все воркеры
func (cg *ConsumerGroup) Start(ctx context.Context) {
var wg sync.WaitGroup
for i, reader := range cg.readers {
wg.Add(1)
go func(id int, r *kafka.Reader) {
defer wg.Done()
processor := cg.processor.WithReader(r)
if err := processor.Start(ctx); err != nil {
log.Printf("Worker %d stopped: %v", id, err)
}
}(i, reader)
}
wg.Wait()
}
// Stop останавливает всех потребителей
func (cg *ConsumerGroup) Stop() {
for _, reader := range cg.readers {
reader.Close()
}
}
6. Мониторинг пайплайна
package monitoring
// PipelineMetrics метрики пайплайна
type PipelineMetrics struct {
// События
EventsReceived prometheus.Counter
EventsProcessed prometheus.Counter
EventsDropped prometheus.Counter
// Ошибки
ReadErrors prometheus.Counter
ParseErrors prometheus.Counter
ProcessErrors prometheus.Counter
ClickHouseErrors prometheus.Counter
// Производительiveness
ProcessingTime prometheus.Histogram
BatchSize prometheus.Histogram
KafkaLag prometheus.Gauge
// Фильтрация
BotsFiltered prometheus.Counter
Duplicates prometheus.Counter
}
// CheckLag проверяет отставание от Kafka
func (m *PipelineMetrics) CheckLag(ctx context.Context, reader *kafka.Reader) {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
stats := reader.Stats()
m.KafkaLag.Set(float64(stats.Lag))
if stats.Lag > 10000 {
log.Warnf("High Kafka lag: %d", stats.Lag)
}
}
}
}()
}
7. Итоговая схема потока данных
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Client ──▶ Collector ──▶ Kafka ──▶ Stream Processor ──▶ ClickHouse │
│ │
│ • WebSocket • 10 parts • Дедупликация • Raw events │
│ • HTTP POST • 3 replicas • Валидация • Hourly aggregates │
│ • Batch • 7 days • Обогащение • Daily aggregates │
│ retention • Фиботов │
│ • Агрегация в Redis │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Ключевые моменты:
- Kafka как буфер для сглаживания пиков нагрузки
- Stream Processor для обработки и обогащения событий
- ClickHouse для хранения и быстрой аналитики
- Redis для кэширования и реал-тайм метрик
- Consumer Groups для горизонтального масштабирования
Вопрос 24. Какие данные хранятся в хранилище статистики экспериментов?
Таймкод: 00:47:59
Ответ собеседника: Правильный. Уточняется, что в хранилище статистики складываются аналитические данные, которые представляют собой имя параметра и его числовую характеристику (например, сколько раз нажали кнопку или сколько пользователей дошли до конца воронки).
Правильный ответ:
1. Модель данных хранилища статистики
┌─────────────────────────────────────────────────────────────────────────────┐
│ Experiment Statistics Data Model │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Raw Events │ │
│ │ │ │
│ │ • Каждое действие пользователя │ │
│ │ • Полная детализация │ │
│ │ • Используется для детального анализа │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Aggregated Metrics │ │
│ │ │ │
│ │ • Предрасчитанные метрики │ │
│ │ • Группировка по времени, вариантам, сегментам │ │
│ │ • Используется для дашбордов и отчётов │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Experiment Results │ │
│ │ │ │
│ │ • Итоговые результаты экспериментов │ │
│ │ • Статистическая значимость │ │
│ │ • Доверительные интервалы │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Типы хранимых данных
┌─────────────────────────────────────────────────────────────────────────────┐
│ Types of Stored Data │
├──────────────────────────┬──────────────────────────────────────────────────┤
│ │ │
│ Event-Level Data │ • Отдельные события пользователей │
│ │ • Клики, просмотры, конверсии │
│ │ • Время, место, устройство │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ User-Level Data │ • Агрегированные данные по пользователю │
│ │ • Количество сессий, событий │
│ │ • Общая выручка, время в приложении │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Session-Level Data │ • Данные по сессиям │
│ │ • Длительность, глубина просмотра │
│ │ • Путь пользователя │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Experiment-Level Data │ • Агрегированные метрики по эксперименту │
│ │ • Conversion rate, revenue per user │
│ │ • Статистическая значимость │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Funnel-Level Data │ • Данные по воронкам │
│ │ • Конверсия на каждом шаге │
│ │ • Drop-off rate │
│ │ │
└──────────────────────────┴──────────────────────────────────────────────────┘
3. Структура хранения в ClickHouse
-- Таблица сырых событий
CREATE TABLE events_raw (
-- Идентификаторы
event_id UUID,
event_type LowCardinality(String), -- 'click', 'view', 'purchase', 'experiment_exposure'
user_id String,
session_id String,
device_id String,
-- Эксперимент
experiment_id Nullable(String),
variant_id Nullable(String), -- 'control', 'treatment_a', 'treatment_b'
-- Контекст
platform LowCardinality(String), -- 'web', 'ios', 'android'
app_version LowCardinality(String),
country LowCardinality(String),
city LowCardinality(String),
device_type LowCardinality(String), -- 'mobile', 'desktop', 'tablet'
os LowCardinality(String),
os_version LowCardinality(String),
browser LowCardinality(String),
-- Свойства события (гибкая схема)
properties Map(String, String), -- {button_id: "buy", price: "99.99"}
-- Временные метки
client_timestamp DateTime64(3), -- Время на клиенте
server_timestamp DateTime64(3), -- Время на сервере
event_date Date DEFAULT toDate(client_timestamp)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_type, user_id, client_timestamp)
TTL event_date + INTERVAL 2 YEAR;
-- Агрегированные метрики по часам
CREATE TABLE experiment_metrics_hourly (
hour DateTime,
experiment_id String,
variant_id String,
platform LowCardinality(String),
-- События
total_events UInt64,
unique_users UInt64,
unique_sessions UInt64,
-- Конверсии
conversions UInt64,
conversion_rate AggregateFunction(avg, Float64),
-- Выручка
total_revenue AggregateFunction(sum, Float64),
avg_revenue AggregateFunction(avg, Float64),
revenue_per_user AggregateFunction(avg, Float64),
-- Поведенческие метрики
avg_session_duration AggregateFunction(avg, Float64),
avg_events_per_user AggregateFunction(avg, Float64),
-- Retention
retention_d1 AggregateFunction(uniq, String),
retention_d7 AggregateFunction(uniq, String),
retention_d30 AggregateFunction(uniq, String)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (experiment_id, variant_id, platform, hour);
-- Фunnel data
CREATE TABLE funnel_results (
calculation_time DateTime,
experiment_id String,
variant_id String,
funnel_id String,
step_number UInt8,
step_name String,
step_event_type String,
users_count UInt64,
conversion_rate Float64, -- Конверсия от предыдущего шага
overall_rate Float64, -- Конверсия от первого шага
drop_off_rate Float64 -- Процент отвалившихся
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(calculation_time)
ORDER BY (experiment_id, variant_id, funnel_id, step_number);
-- Результаты статистических тестов
CREATE TABLE statistical_results (
calculation_time DateTime,
experiment_id String,
metric_name String,
control_value Float64,
treatment_value Float64,
uplift Float64,
p_value Float64,
confidence_level Float64,
confidence_interval_low Float64,
confidence_interval_high Float64,
is_significant Bool,
sample_size_control UInt64,
sample_size_treatment UInt64,
test_type LowCardinality(String), -- 't-test', 'chi-squared', 'mann-whitney'
power Float64 -- Статистическая мощность
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(calculation_time)
ORDER BY (experiment_id, metric_name);
4. Go структуры для работы с данными
package storage
// Event сырое событие
type Event struct {
ID string `json:"id" ch:"event_id"`
EventType string `json:"event_type" ch:"event_type"`
UserID string `json:"user_id" ch:"user_id"`
SessionID string `json:"session_id" ch:"session_id"`
DeviceID string `json:"device_id" ch:"device_id"`
ExperimentID *string `json:"experiment_id,omitempty" ch:"experiment_id"`
VariantID *string `json:"variant_id,omitempty" ch:"variant_id"`
Platform string `json:"platform" ch:"platform"`
AppVersion string `json:"app_version" ch:"app_version"`
Country string `json:"country" ch:"country"`
City string `json:"city" ch:"city"`
DeviceType string `json:"device_type" ch:"device_type"`
OS string `json:"os" ch:"os"`
OSVersion string `json:"os_version" ch:"os_version"`
Browser string `json:"browser" ch:"browser"`
Properties map[string]string `json:"properties" ch:"properties"`
ClientTimestamp time.Time `json:"client_timestamp" ch:"client_timestamp"`
ServerTimestamp time.Time `json:"server_timestamp" ch:"server_timestamp"`
}
// ExperimentMetrics агрегированные метрики эксперимента
type ExperimentMetrics struct {
Hour time.Time `json:"hour" ch:"hour"`
ExperimentID string `json:"experiment_id" ch:"experiment_id"`
VariantID string `json:"variant_id" ch:"variant_id"`
Platform string `json:"platform" ch:"platform"`
TotalEvents uint64 `json:"total_events" ch:"total_events"`
UniqueUsers uint64 `json:"unique_users" ch:"unique_users"`
UniqueSessions uint64 `json:"unique_sessions" ch:"unique_sessions"`
Conversions uint64 `json:"conversions" ch:"conversions"`
ConversionRate float64 `json:"conversion_rate" ch:"conversion_rate"`
TotalRevenue float64 `json:"total_revenue" ch:"total_revenue"`
AvgRevenue float64 `json:"avg_revenue" ch:"avg_revenue"`
RevenuePerUser float64 `json:"revenue_per_user" ch:"revenue_per_user"`
AvgSessionDuration float64 `json:"avg_session_duration" ch:"avg_session_duration"`
AvgEventsPerUser float64 `json:"avg_events_per_user" ch:"avg_events_per_user"`
}
// FunnelStep шаг воронки
type FunnelStep struct {
StepNumber uint8 `json:"step_number"`
StepName string `json:"step_name"`
StepEventType string `json:"step_event_type"`
UsersCount uint64 `json:"users_count"`
ConversionRate float64 `json:"conversion_rate"` // От предыдущего шага
OverallRate float64 `json:"overall_rate"` // От первого шага
DropOffRate float64 `json:"drop_off_rate"`
}
// StatisticalResult результат статистического теста
type StatisticalResult struct {
CalculationTime time.Time `json:"calculation_time"`
ExperimentID string `json:"experiment_id"`
MetricName string `json:"metric_name"`
ControlValue float64 `json:"control_value"`
TreatmentValue float64 `json:"treatment_value"`
Uplift float64 `json:"uplift"`
PValue float64 `json:"p_value"`
ConfidenceLevel float64 `json:"confidence_level"`
ConfidenceIntervalLow float64 `json:"confidence_interval_low"`
ConfidenceIntervalHigh float64 `json:"confidence_interval_high"`
IsSignificant bool `json:"is_significant"`
SampleSizeControl uint64 `json:"sample_size_control"`
SampleSizeTreatment uint64 `json:"sample_size_treatment"`
TestType string `json:"test_type"`
Power float64 `json:"power"`
}
// CohortData данные когорты
type CohortData struct {
CohortDate time.Time `json:"cohort_date"`
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
Day0Users uint64 `json:"day0_users"` // Размер когорты
Day1Users uint64 `json:"day1_users"` // Вернулись на день 1
Day7Users uint64 `json:"day7_users"` // Вернулись на день 7
Day30Users uint64 `json:"day30_users"` // Вернулись на день 30
Day1Retention float64 `json:"day1_retention"`
Day7Retention float64 `json:"day7_retention"`
Day30Retention float64 `json:"day30_retention"`
}
5. Примеры запросов для анализа
-- Метрики эксперимента по вариантам
SELECT
variant_id,
platform,
sum(total_events) as total_events,
sum(unique_users) as unique_users,
sum(conversions) as conversions,
sum(conversions) / sum(unique_users) as conversion_rate,
sum(total_revenue) as total_revenue,
sum(total_revenue) / sum(unique_users) as revenue_per_user
FROM experiment_metrics_hourly
WHERE experiment_id = 'exp_123'
AND hour >= '2024-01-01'
AND hour < '2024-02-01'
GROUP BY variant_id, platform
ORDER BY variant_id, platform;
-- Динамика конверсии по дням
SELECT
toDate(hour) as date,
variant_id,
sum(unique_users) as users,
sum(conversions) as conversions,
sum(conversions) / sum(unique_users) * 100 as conversion_rate
FROM experiment_metrics_hourly
WHERE experiment_id = 'exp_123'
GROUP BY date, variant_id
ORDER BY date, variant_id;
-- Retention по когортам
SELECT
toDate(client_timestamp) as cohort_date,
variant_id,
count(DISTINCT user_id) as cohort_size,
countIf(DISTINCT user_id, client_timestamp >= cohort_date + INTERVAL 1 DAY) / cohort_size as d1_retention,
countIf(DISTINCT user_id, client_timestamp >= cohort_date + INTERVAL 7 DAY) / cohort_size as d7_retention
FROM events_raw
WHERE experiment_id = 'exp_123'
AND event_type = 'experiment_exposure'
GROUP BY cohort_date, variant_id;
-- Сегментация по устройствам
SELECT
device_type,
os,
variant_id,
count(DISTINCT user_id) as users,
countIf(event_type = 'conversion') / count(DISTINCT user_id) as conversion_rate
FROM events_raw
WHERE experiment_id = 'exp_123'
GROUP BY device_type, os, variant_id;
6. Сервис работы с хранилищем
package storage
import (
"context"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
// StatisticsStore хранилище статистики
type StatisticsStore struct {
ch clickhouse.Conn
}
// GetExperimentMetrics возвращает метрики эксперимента
func (s *StatisticsStore) GetExperimentMetrics(ctx context.Context, experimentID string, startDate, endDate time.Time) ([]ExperimentMetrics, error) {
query := `
SELECT
hour,
variant_id,
platform,
sum(total_events) as total_events,
sum(unique_users) as unique_users,
sum(unique_sessions) as unique_sessions,
sum(conversions) as conversions,
avgMerge(conversion_rate) as conversion_rate,
sumMerge(total_revenue) as total_revenue,
avgMerge(avg_revenue) as avg_revenue,
avgMerge(revenue_per_user) as revenue_per_user
FROM experiment_metrics_hourly
WHERE experiment_id = ?
AND hour BETWEEN ? AND ?
GROUP BY hour, variant_id, platform
ORDER BY hour, variant_id, platform
`
rows, err := s.ch.Query(ctx, query, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var metrics []ExperimentMetrics
for rows.Next() {
var m ExperimentMetrics
if err := rows.Scan(
&m.Hour, &m.VariantID, &m.Platform,
&m.TotalEvents, &m.UniqueUsers, &m.UniqueSessions,
&m.Conversions, &m.ConversionRate,
&m.TotalRevenue, &m.AvgRevenue, &m.RevenuePerUser,
); err != nil {
return nil, err
}
metrics = append(metrics, m)
}
return metrics, nil
}
// GetFunnelResults возвращает результаты воронки
func (s *StatisticsStore) GetFunnelResults(ctx context.Context, experimentID, funnelID string) ([]FunnelStep, error) {
query := `
SELECT
step_number,
step_name,
step_event_type,
users_count,
conversion_rate,
overall_rate,
drop_off_rate
FROM funnel_results
WHERE experiment_id = ?
AND funnel_id = ?
AND calculation_time = (
SELECT MAX(calculation_time)
FROM funnel_results
WHERE experiment_id = ? AND funnel_id = ?
)
ORDER BY step_number
`
rows, err := s.ch.Query(ctx, query, experimentID, funnelID, experimentID, funnelID)
if err != nil {
return nil, err
}
defer rows.Close()
var steps []FunnelStep
for rows.Next() {
var step FunnelStep
if err := rows.Scan(
&step.StepNumber, &step.StepName, &step.StepEventType,
&step.UsersCount, &step.ConversionRate, &step.OverallRate, &step.DropOffRate,
); err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
// GetStatisticalResults возвращает результаты статистических тестов
func (s *StatisticsStore) GetStatisticalResults(ctx context.Context, experimentID string) ([]StatisticalResult, error) {
query := `
SELECT
calculation_time,
metric_name,
control_value,
treatment_value,
uplift,
p_value,
confidence_level,
confidence_interval_low,
confidence_interval_high,
is_significant,
sample_size_control,
sample_size_treatment,
test_type,
power
FROM statistical_results
WHERE experiment_id = ?
ORDER BY calculation_time DESC
`
rows, err := s.ch.Query(ctx, query, experimentID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []StatisticalResult
for rows.Next() {
var r StatisticalResult
if err := rows.Scan(
&r.CalculationTime, &r.MetricName,
&r.ControlValue, &r.TreatmentValue, &r.Uplift,
&r.PValue, &r.ConfidenceLevel,
&r.ConfidenceIntervalLow, &r.ConfidenceIntervalHigh,
&r.IsSignificant, &r.SampleSizeControl, &r.SampleSizeTreatment,
&r.TestType, &r.Power,
); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}
7. Итоговая структура хранилища
┌─────────────────────────────────────────────────────────────────────────────┐
│ Experiment Statistics Storage │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ events_raw │ │
│ │ ───────── │ │
│ │ • Все события с полной детализацией │ │
│ │ • Используется для детального анализа │ │
│ │ • TTL: 2 года │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ experiment_metrics_hourly │ │
│ │ ────────────────────────── │ │
│ │ • Агрегированные метрики по часам │ │
│ │ • Conversion rate, revenue, retention │ │
│ │ • Используется для дашбордов │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ funnel_results │ │
│ │ ────────────── │ │
│ │ • Результаты анализа воронок │ │
│ │ • Конверсия по шагам │ │
│ │ • Используется для анализа воронок │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ statistical_results │ │
│ │ ───────────────────── │ │
│ │ • Результаты статистических тестов │ │
│ │ • P-value, confidence intervals │ │
│ │ • Используется для принятия решений │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: В хранилище статистики экспериментов хранятся:
- Сырые события — каждое действие пользователя с полным контекстом (время, устройство, гео, свойства)
- Агрегированные метрики — предрасчитанные показатели по часам/дням (conversion rate, revenue, retention)
- Результаты воронок — конверсия на каждом шаге, drop-off rate
- Статистические результаты — p-value, доверительные интервалы, статистическая значимость
- Когортные данные — retention по дням для разных когорт пользователей
Вопрос 37. Какой технологический стек используется и почему именно он?
Таймкод: 01:19:25
Ответ собеседова: Правильный. Уточняется, что целевое решение включает Kubernetes, сервисы в контейнерах, Kafka, ClickHouse, Cassandra, Redis. Компания придерживается подхода использования стандартных инструментов платформы. Выбранные технологии соответствуют экспертизе компании и предоставляются в рамках платформы.
Правильный ответ:
Технологический стек аналитической платформы
┌─────────────────────────────────────────────────────────────────────────────┐
│ ТЕХНОЛОГИЧЕСКИЙ СТЕК │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Инфраструктура │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Kubernetes │ │ Docker │ │ Helm │ │ │
│ │ │ (оркестрация)│ │ (контейнеры)│ │ (деплой) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Брокер сообщений │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Kafka │ │ │
│ │ │ - Event streaming │ │ │
│ │ │ - Буферизация событий │ │ │
│ │ │ - Репликация и партиционирование │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Хранилища данных │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ ClickHouse │ │ Cassandra │ │ Redis │ │ │
│ │ │ (аналитика) │ │ (профили) │ │ (кэш) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Языки разработки │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Go │ │ Java │ │ Python │ │ │
│ │ │ (сервисы) │ │ (Flink/Spark)│ │ (ML/скрипты)│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Обоснование выбора технологий:
1. Kubernetes + Docker — оркестрация контейнеров
# Пример deployment для коллектора событий
apiVersion: apps/v1
kind: Deployment
metadata:
name: event-collector
namespace: analytics
spec:
replicas: 3
selector:
matchLabels:
app: event-collector
template:
metadata:
labels:
app: event-collector
spec:
containers:
- name: collector
image: registry.company.io/analytics/collector:v1.2.3
ports:
- containerPort: 8080
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
env:
- name: KAFKA_BROKERS
valueFrom:
configMapKeyRef:
name: kafka-config
key: brokers
- name: CLICKHOUSE_DSN
valueFrom:
secretKeyRef:
name: clickhouse-credentials
key: dsn
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
# Graceful shutdown для завершения обработки событий
terminationGracePeriodSeconds: 60
Почему Kubernetes:
- Автоматическое масштабирование (HPA) под нагрузку
- Self-healing — перезапупавшихся подов
- Rolling updates без downtime
- Управление конфигурацией через ConfigMap/Secret
- Мультизональность и disaster recovery
2. Apache Kafka — брокер сообщений
// Конфигурация Kafka producer для коллектора
type KafkaConfig struct {
Brokers []string
Topic string
Partitioner string // "hash" для партиционирования по user_id
Compression string // "snappy" для экономии трафика
RequiredAcks sarama.RequiredAcks // WaitForAll для надёжности
MaxRetries int
RetryBackoff time.Duration
}
func NewKafkaProducer(cfg KafkaConfig) (sarama.AsyncProducer, error) {
config := sarama.NewConfig()
config.Producer.RequiredAcks = cfg.RequiredAcks
config.Producer.Retry.Max = cfg.MaxRetries
config.Producer.Retry.Backoff = cfg.RetryBackoff
config.Producer.Compression = sarama.CompressionSnappy
config.Producer.Partitioner = sarama.NewHashPartitioner
config.Producer.Return.Successes = false // Для скорости
config.Producer.Return.Errors = true
producer, err := sarama.NewAsyncProducer(cfg.Brokers, config)
if err != nil {
return nil, fmt.Errorf("failed to create producer: %w", err)
}
// Обработка ошибок в фоне
go func() {
for err := range producer.Errors() {
log.Error().Err(err).Msg("kafka producer error")
metrics.KafkaErrors.Inc()
}
}()
return producer, nil
}
Почему Kafka:
- Высокая пропускная способность (миллионы событий/сек)
- Гарантия доставки (at-least-once, exactly-once)
- Сохранение порядка событий в партиции
- Возможность переиграть события (consumer groups)
- Репликация между дата-центрами
3. ClickHouse — аналитическое хранилище
-- Создание таблицы событий в ClickHouse
CREATE TABLE events ON CLUSTER '{cluster}'
(
-- Идентификаторы
event_id UUID,
user_id UInt64,
session_id String,
-- Метаданные события
event_type LowCardinality(String),
event_name LowCardinality(String),
platform LowCardinality(String), -- ios, android, web
app_version LowCardinality(String),
-- Временные метки
client_timestamp DateTime64(3, 'UTC'),
server_timestamp DateTime64(3, 'UTC'),
processed_at DateTime64(3, 'UTC'),
-- Геолокация
country LowCardinality(String),
city LowCardinality(String),
-- Контекст
device String,
os LowCardinality(String),
browser LowCardinality(String),
-- Пользовательские свойства
properties String, -- JSON с дополнительными данными
-- Сегменты (обновляются batch-процессом)
segments Array(UInt32),
-- Индексы
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4,
INDEX idx_event_type event_type TYPE bloom_filter GRANULARITY 4,
INDEX idx_properties properties TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(client_timestamp)
ORDER BY (user_id, event_type, client_timestamp)
TTL client_timestamp + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Материализованное представление для агрегации
CREATE MATERIALIZED VIEW events_hourly_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (event_type, platform, hour)
AS SELECT
toStartOfHour(client_timestamp) as hour,
event_type,
platform,
count() as event_count,
uniq(user_id) as unique_users
FROM events
GROUP BY hour, event_type, platform;
Почему ClickHouse:
- Колоночное хранение — быстрая агрегация
- Сжатие данных в 10-100 раз
- Векторизованное выполнение запросов
- Поддержка SQL
- Горизонтальное масштабирование (шардирование)
- TTL для автоматического удаления старых данных
4. Redis — кэширование
// Сервис кэширования сегментов пользователей
type SegmentCache struct {
client *redis.ClusterClient
ttl time.Duration
}
func NewSegmentCache(addrs []string, ttl time.Duration) *SegmentCache {
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: addrs,
PoolSize: 100,
MinIdleConns: 10,
ReadTimeout: 100 * time.Millisecond,
WriteTimeout: 100 * time.Millisecond,
})
return &SegmentCache{
client: client,
ttl: ttl,
}
}
// Получение сегментов пользователя из кэша
func (c *SegmentCache) GetUserSegments(ctx context.Context, userID uint64) ([]uint32, error) {
key := fmt.Sprintf("user:%d:segments", userID)
data, err := c.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, ErrCacheMiss
}
if err != nil {
return nil, err
}
var segments []uint32
if err := msgpack.Unmarshal(data, &segments); err != nil {
return nil, err
}
return segments, nil
}
// Сохранение сегментов в кэш
func (c *SegmentCache) SetUserSegments(ctx context.Context, userID uint64, segments []uint32) error {
key := fmt.Sprintf("user:%d:segments", userID)
data, err := msgpack.Marshal(segments)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, c.ttl).Err()
}
// Инвалидация кэша при изменении сегментов
func (c *SegmentCache) InvalidateUserSegments(ctx context.Context, userID uint64) error {
key := fmt.Sprintf("user:%d:segments", userID)
return c.client.Del(ctx, key).Err()
}
Почему Redis:
- Субмиллисекундная задержка
- Кластерный режим для масштабирования
- Богатые структуры данных (sets, sorted sets, hashes)
- Pub/Sub для real-time уведомлений
- TTL для автоматической инвалидации
5. Cassandra — хранилище профилей
// Модель профиля пользователя в Cassandra
type UserProfile struct {
UserID uint64 `json:"user_id"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
SegmentIDs []uint32 `json:"segment_ids"`
Properties map[string]string `json:"properties"`
EventCount int64 `json:"event_count"`
SessionsCount int32 `json:"sessions_count"`
UpdatedAt time.Time `json:"updated_at"`
}
// CQL для создания таблицы
const createProfileTableCQL = `
CREATE TABLE IF NOT EXISTS analytics.user_profiles (
user_id bigint PRIMARY KEY,
first_seen timestamp,
last_seen timestamp,
segment_ids list<int>,
properties map<text, text>,
event_count bigint,
sessions_count int,
updated_at timestamp
) WITH bloom_filter_fp_chance = 0.01
AND caching = {'keys': 'ALL', 'rows_per_partition': '100'}
AND comment = 'User profiles for analytics platform'
AND compaction = {'class': 'LeveledCompactionStrategy'}
AND compression = {'sstable_compression': 'LZ4Compressor'};
`
// Репозиторий для работы с профилями
type ProfileRepository struct {
session *gocql.Session
}
func (r *ProfileRepository) GetProfile(ctx context.Context, userID uint64) (*UserProfile, error) {
query := `SELECT user_id, first_seen, last_seen, segment_ids, properties,
event_count, sessions_count, updated_at
FROM analytics.user_profiles WHERE user_id = ?`
profile := &UserProfile{}
err := r.session.Query(query, userID).WithContext(ctx).Scan(
&profile.UserID,
&profile.FirstSeen,
&profile.LastSeen,
&profile.SegmentIDs,
&profile.Properties,
&profile.EventCount,
&profile.SessionsCount,
&profile.UpdatedAt,
)
if err == gocql.ErrNotFound {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return profile, nil
}
func (r *ProfileRepository) UpsertProfile(ctx context.Context, profile *UserProfile) error {
query := `INSERT INTO analytics.user_profiles
(user_id, first_seen, last_seen, segment_ids, properties,
event_count, sessions_count, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
return r.session.Query(query,
profile.UserID,
profile.FirstSeen,
profile.LastSeen,
profile.SegmentIDs,
profile.Properties,
profile.EventCount,
profile.SessionsCount,
time.Now(),
).WithContext(ctx).Exec()
}
Почему Cassandra:
- Линейное масштабирование
- Multi-DC репликация из коробки
- Tunable consistency
- Отсутствие single point of failure
- Оптимальна для записи и чтения по ключу
6. Go — основной язык разработки
// Пример сервиса обработки событий на Go
type EventProcessor struct {
kafkaConsumer sarama.ConsumerGroup
clickhouse *sql.DB
redis *redis.ClusterClient
segmentService SegmentService
metrics MetricsCollector
}
func (p *EventProcessor) ProcessEvents(ctx context.Context) error {
handler := &consumerGroupHandler{
processor: p,
}
return p.kafkaConsumer.Consume(ctx, []string{"events.raw"}, handler)
}
type consumerGroupHandler struct {
processor *EventProcessor
}
func (h *processor) Setup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *processor) Cleanup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *processor) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
batch := make([]*Event, 0, 1000)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case msg, ok := <-claim.Messages():
if !ok {
h.flush(batch)
return nil
}
event, err := parseEvent(msg)
if err != nil {
log.Error().Err(err).Msg("failed to parse event")
session.MarkMessage(msg, "")
continue
}
batch = append(batch, event)
if len(batch) >= 1000 {
h.flush(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
h.flush(batch)
batch = batch[:0]
}
case <-session.Context().Done():
h.flush(batch)
return nil
}
}
}
func (h *processor) flush(events []*Event) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Обогащение событий
enriched := h.processor.enrichEvents(ctx, events)
// Запись в ClickHouse
if err := h.processor.batchInsert(ctx, enriched); err != nil {
log.Error().Err(err).Msg("failed to insert events")
return
}
// Обновление кэша сегментов
for _, event := range enriched {
if event.HasSegmentChange {
h.processor.redis.InvalidateUserSegments(ctx, event.UserID)
}
}
}
Почему Go:
- Высокая производительность при низком потреблении ресурсов
- Отличная поддержка конкурентности (goroutines, channels)
- Быстрая компиляция
- Статическая типизация
- Богатая стандартная библиотека
- Простота развёртывания (один бинарник)
Сводная таблица обоснований:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ОБОСНОВАНИЕ ВЫБОРА │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Технология Задача Почему выбрана │
│ ────────── ────── ────────────── │
│ │
│ Kubernetes Оркестрация Auto-scaling, self-healing, │
│ rolling updates │
│ │
│ Kafka Event streaming Высокая пропускная способность, │
│ гарантия доставки, replay │
│ │
│ ClickHouse Аналитика Колоночное хранение, быстрая │
│ агрегация, TTL │
│ │
│ Redis Кэширование Субмиллисекундная задержка, │
│ богатые структуры данных │
│ │
│ Cassandra Профили Multi-DC, линейное │
│ масштабирование, нет SPOF │
│ │
│ Go Сервисы Производительность, │
│ конкурентность, простота │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Выбранный стек представляет собой проверенную комбинацию технологий, оптимальную для построения высоконагруженной аналитической платформы. Каждый компонент решает свою задачу:
- Kubernetes обеспечивает инфраструктурную гибкость
- Kafka — надёжную доставку событий
- ClickHouse — быструю аналитику
- Redis — низкозатратное кэширование
- Cassandra — масштабируемое хранение профилей
- Go — эффективную разработку сервисов
Все технологии являются стандартом индустрии и хорошо интегрируются друг с другом.
Вопрос 25. Какую статистику видит аналитик на странице эксперимента?
Таймкод: 00:48:50
Ответ собеседника: Правильный. Уточняется, что аналитик видит воронку в разрезе эксперимента: сколько пользователей попали в baseline, сколько в экспериментальную группу, как они проходят по шагам (экранам). Это сравнение поведения контрольной и экспериментальной групп.
Правильный ответ:
1. Обзор дашборда эксперимента
┌─────────────────────────────────────────────────────────────────────────────┐
│ Experiment Dashboard - exp_123 │
│ "New Checkout Flow Test" │
│ Status: 🟢 Running | Day 7 of 14 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Key Metrics Summary │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Control │ │ Treatment A │ │ Treatment B │ │ Winner │ │ │
│ │ │ (50%) │ │ (25%) │ │ (25%) │ │ │ │ │
│ │ ├─────────────┤ ├─────────────┤ ├─────────────┤ ├────────────┤ │ │
│ │ │ Users: 50K │ │ Users: 25K │ │ Users: 25K │ │ │ │ │
│ │ │ Conv: 3.2% │ │ Conv: 3.8% │ │ Conv: 3.5% │ │ Treatment A│ │ │
│ │ │ Revenue:125K│ │ Revenue:68K │ │ Revenue:61K │ │ +18.7% │ │ │
│ │ │ ARPU: $2.50 │ │ ARPU: $2.72 │ │ ARPU: $2.44 │ │ p=0.023 ✅ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Conversion Funnel Comparison │ │
│ │ │ │
│ │ Step 1: Page View │ │
│ │ ████████████████████████████████████████ 50,000 (100%) │ │
│ │ ██████████████████████████ 25,000 (100%) │ │
│ │ ██████████████████████████ 25,000 (100%) │ │
│ │ │ │
│ │ Step 2: Add to Cart │ │
│ │ ████████████████████ 20,000 (40.0%) │ │
│ │ ██████████████████████ 12,500 (50.0%) +25% │ │
│ │ ████████████████████ 11,250 (45.0%) +12.5% │ │
│ │ │ │
│ │ Step 3: Checkout Started │ │
│ │ ████████████ 10,000 (20.0%) │ │
│ │ ██████████████ 7,500 (30.0%) +50% │ │
│ │ █████████████ 6,750 (27.0%) +35% │ │
│ │ │ │
│ │ Step 4: Purchase Complete │ │
│ │ █████ 1,600 (3.2%) │ │
│ │ ██████ 950 (3.8%) +18.7% ✅ │ │
│ │ ██████ 875 (3.5%) +9.4% │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Детальные метрики на дашборде
┌─────────────────────────────────────────────────────────────────────────────┐
│ Detailed Metrics │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ A. Conversion Metrics │ │
│ │ │ │
│ │ Metric │ Control │ Treat A │ Treat B │ Best │ Sig │ │
│ │ ────────────────────┼─────────┼─────────┼─────────┼────────┼───────│ │
│ │ Conversion Rate │ 3.20% │ 3.80% │ 3.50% │ A +18% │ ✅ │ │
│ │ Add to Cart Rate │ 40.00% │ 50.00% │ 45.00% │ A +25% │ ✅ │ │
│ │ Checkout Rate │ 20.00% │ 30.00% │ 27.00% │ A +50% │ ✅ │ │
│ │ Purchase Rate │ 3.20% │ 3.80% │ 3.50% │ A +18% │ ✅ │ │
│ │ Cart Abandonment │ 60.00% │ 50.00% │ 55.00% │ A -10% │ ✅ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ B. Revenue Metrics │ │
│ │ │ │
│ │ Metric │ Control │ Treat A │ Treat B │ Best │ Sig │ │
│ │ ────────────────────┼─────────┼─────────┼─────────┼────────┼───────│ │
│ │ Total Revenue │ $125,000│ $68,000 │ $61,000│ Control│ - │ │
│ │ ARPU │ $2.50 │ $2.72 │ $2.44 │ A +8% │ ✅ │ │
│ │ AOV │ $78.12 │ $71.58 │ $69.71 │ Control│ - │ │
│ │ Revenue per Session │ $1.25 │ $1.36 │ $1.22 │ A +8% │ ✅ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ C. Engagement Metrics │ │
│ │ │ │
│ │ Metric │ Control │ Treat A │ Treat B │ Best │ Sig │ │
│ │ ────────────────────┼─────────┼─────────┼─────────┼────────┼───────│ │
│ │ Avg Session Duration│ 4:32 │ 5:15 │ 4:48 │ A +16% │ ✅ │ │
│ │ Pages per Session │ 3.2 │ 3.8 │ 3.5 │ A +18% │ ✅ │ │
│ │ Bounce Rate │ 45.2% │ 38.5% │ 42.1% │ A -15% │ ✅ │
│ │ Return Rate (D1) │ 32.1% │ 38.5% │ 35.2% │ A +20% │ ✅ │
│ │ Return Rate (D7) │ 18.5% │ 22.1% │ 19.8% │ A +19% │ ✅ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Статистическая значимость
┌─────────────────────────────────────────────────────────────────────────────┐
│ Statistical Significance │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Metric: Conversion Rate (Control vs Treatment A) │ │
│ │ │ │
│ │ Control: 3.20% (1,600 / 50,000) │ │
│ │ Treatment A: 3.80% (950 / 25,000) │ │
│ │ │ │
│ │ Uplift: +18.75% │ │
│ │ P-value: 0.023 │ │
│ │ Confidence Level: 95% │ │
│ │ CI: [+2.1%, +35.4%] │ │
│ │ Power: 0.82 │ │
│ │ │ │
│ │ ✅ Statistically Significant (p < 0.05) │ │
│ │ │ │
│ │ Required sample size: 45,000 per variant │ │
│ │ Current sample size: 50,000 control, 25,000 treatment │ │
│ │ Progress: 100% control, 56% treatment │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Metric: ARPU (Control vs Treatment A) │ │
│ │ │ │
│ │ Control: $2.50 ± $0.15 │ │
│ │ Treatment A: $2.72 ± $0.18 │ │
│ │ │ │
│ │ Uplift: +8.8% │ │
│ │ P-value: 0.041 │ │
│ │ Confidence Level: 95% │ │
│ │ CI: [+0.4%, +17.2%] │ │
│ │ │ │
│ │ ✅ Statistically Significant (p < 0.05) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Сегментация данных
┌─────────────────────────────────────────────────────────────────────────────┐
│ Segmentation Analysis │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ By Platform │ │
│ │ │ │
│ │ Platform │ Variant │ Users │ Conv Rate │ ARPU │ vs Control │ │
│ │ ─────────┼─────────┼────────┼───────────┼────────┼────────────────│ │
│ │ Web │ Control │ 20,000 │ 2.8% │ $2.30 │ - │ │
│ │ Web │ Treat A │ 10,000 │ 3.5% │ $2.65 │ +25% ✅ │ │
│ │ iOS │ Control │ 18,000 │ 3.5% │ $2.80 │ - │ │
│ │ iOS │ Treat A │ 9,000 │ 4.1% │ $3.10 │ +17% ✅ │ │
│ │ Android │ Control │ 12,000 │ 3.1% │ $2.20 │ - │ │
│ │ Android │ Treat A │ 6,000 │ 3.4% │ $2.35 │ +10% ⚠️ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ By User Type │ │
│ │ │ │
│ │ User Type │ Variant │ Users │ Conv Rate │ Revenue │ vs Control │ │
│ │ ────────────┼─────────┼────────┼───────────┼─────────┼────────────│ │
│ │ New Users │ Control │ 25,000 │ 2.5% │ $62,50 │ - │ │
│ │ New Users │ Treat A │ 12,500 │ 3.2% │ $40,00 │ +28% ✅ │ │
│ │ Returning │ Control │ 25,000 │ 4.0% │ $87,50 │ - │ │
│ │ Returning │ Treat A │ 12,500 │ 4.5% │ $56,25 │ +12% ✅ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ By Geography │ │
│ │ │ │
│ │ Country │ Variant │ Users │ Conv Rate │ vs Control │ │
│ │ ─────────┼─────────┼────────┼───────────┼────────────────────────│ │
│ │ US │ Control │ 20,000 │ 3.5% │ - │ │
│ │ US │ Treat A │ 10,000 │ 4.2% │ +20% ✅ │ │
│ │ UK │ Control │ 15,000 │ 3.0% │ - │ │
│ │ UK │ Treat A │ 7,500 │ 3.4% │ +13% ✅ │ │
│ │ DE │ Control │ 10,000 │ 2.8% │ - │ │
│ │ DE │ Treat A │ 5,000 │ 3.0% │ +7% ⚠️ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Временная динамика
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time Series Analysis │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Conversion Rate by Day │ │
│ │ │ │
│ │ 4.0% │ │ │
│ │ │ ╱── Treatment A │ │
│ │ 3.5% │ ●────●────● │ │
│ │ │ ● │ │
│ │ 3.0% │ ●────●────●────● │ │
│ │ │ ─────── Control │ │
│ │ 2.5% │ │ │
│ │ │ │ │
│ │ 2.0% │ │ │
│ │ └──┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬── │ │
│ │ D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 D11 D12 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Cumulative Users by Day │ │
│ │ │ │
│ │ 60K │ ╱── Treatment A │ │
│ │ │ ●────●───● │ │
│ │ 50K │ ●────●───● │ │
│ │ │ ●────●───● ─── Control │ │
│ │ 40K │ ●────●───● │ │
│ │ │●───● │ │
│ │ 30K │ │ │
│ │ └──┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬── │ │
│ │ D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 D11 D12 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Retention анализ
┌─────────────────────────────────────────────────────────────────────────────┐
│ Retention Analysis │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Cohort Retention (Users who returned) │ │
│ │ │ │
│ │ Day │ Control │ Treat A │ Treat B │ Best Variant │ │
│ │ ────────┼─────────┼─────────┼─────────┼──────────────────────────│ │
│ │ D0 │ 100% │ 100% │ 100% │ - │ │
│ │ D1 │ 32.1% │ 38.5% │ 35.2% │ A +20% ✅ │ │
│ │ D3 │ 25.4% │ 30.2% │ 27.8% │ A +19% ✅ │ │
│ │ D7 │ 18.5% │ 22.1% │ 19.8% │ A +19% ✅ │ │
│ │ D14 │ 12.3% │ 15.2% │ 13.5% │ A +23% ✅ │ │
│ │ D30 │ 8.1% │ 10.5% │ 9.2% │ A +30% ✅ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Retention Curve │ │
│ │ │ │
│ │ 100% │ ●──────────●──────────●──────────●──────────● │ │
│ │ │ │ │
│ │ 40% │ │ │
│ │ │ │ │
│ │ 20% │ │ │
│ │ │ │ │
│ │ 0% └──┬──────────┬──────────┬──────────┬──────────┬──────────┬── │ │
│ │ D0 D1 D3 D7 D14 D30 │ │
│ │ │ │
│ │ Legend: ─── Control ─── Treatment A ─── Treatment B │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Go API для получения данных дашборда
package api
// ExperimentDashboard данные для дашборда эксперимента
type ExperimentDashboard struct {
ExperimentID string `json:"experiment_id"`
ExperimentName string `json:"experiment_name"`
Status string `json:"status"`
StartDate time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date,omitempty"`
DaysRunning int `json:"days_running"`
Summary ExperimentSummary `json:"summary"`
Funnel *FunnelComparison `json:"funnel,omitempty"`
Metrics []MetricComparison `json:"metrics"`
Segments []SegmentAnalysis `json:"segments"`
TimeSeries []TimeSeriesPoint `json:"time_series"`
Retention *RetentionAnalysis `json:"retention,omitempty"`
}
// ExperimentSummary сводка по эксперименту
type ExperimentSummary struct {
Variants []VariantSummary `json:"variants"`
Winner *string `json:"winner,omitempty"`
}
// VariantSummary сводка по варианту
type VariantSummary struct {
VariantID string `json:"variant_id"`
UsersCount int64 `json:"users_count"`
ConvRate float64 `json:"conv_rate"`
Revenue float64 `json:"revenue"`
ARPU float64 `json:"arpu"`
IsControl bool `json:"is_control"`
IsWinner bool `json:"is_winner"`
Uplift float64 `json:"uplift"` // Относительно control
PValue float64 `json:"p_value"`
IsSignificant bool `json:"is_significant"`
}
// FunnelComparison сравнение воронок
type FunnelComparison struct {
Steps []FunnelStepComparison `json:"steps"`
}
// FunnelStepComparison сравнение шага воронки
type FunnelStepComparison struct {
StepName string `json:"step_name"`
EventTypes []string `json:"event_types"`
Variants []FunnelStepVariant `json:"variants"`
}
// FunnelStepVariant данные варианта на шаге воронки
type FunnelStepVariant struct {
VariantID string `json:"variant_id"`
UsersCount int64 `json:"users_count"`
StepRate float64 `json:"step_rate"` // Конверсия от предыдущего шага
OverallRate float64 `json:"overall_rate"` // Конверсия от первого шага
DropOffRate float64 `json:"drop_off_rate"`
}
// MetricComparison сравнение метрик
type MetricComparison struct {
MetricName string `json:"metric_name"`
MetricType string `json:"metric_type"` // conversion, revenue, engagement
Variants []MetricVariant `json:"variants"`
Statistics *StatisticalResult `json:"statistics,omitempty"`
}
// MetricVariant значение метрики для варианта
type MetricVariant struct {
VariantID string `json:"variant_id"`
Value float64 `json:"value"`
Change float64 `json:"change"` // Относительно control
IsSignificant bool `json:"is_significant"`
}
// SegmentAnalysis анализ по сегментам
type SegmentAnalysis struct {
SegmentType string `json:"segment_type"` // platform, country, user_type
SegmentValue string `json:"segment_value"`
Variants []VariantSummary `json:"variants"`
}
// TimeSeriesPoint точка временного ряда
type TimeSeriesPoint struct {
Date time.Time `json:"date"`
Metrics map[string]float64 `json:"metrics"` // variant_id -> value
}
// RetentionAnalysis анализ retention
type RetentionAnalysis struct {
Cohorts []RetentionCohort `json:"cohorts"`
}
// RetentionCohort когорта retention
type RetentionCohort struct {
VariantID string `json:"variant_id"`
CohortDate time.Time `json:"cohort_date"`
CohortSize int64 `json:"cohort_size"`
Day1Retention float64 `json:"d1_retention"`
Day3Retention float64 `json:"d3_retention"`
Day7Retention float64 `json:"d7_retention"`
Day14Retention float64 `json:"d14_retention"`
Day30Retention float64 `json:"d30_retention"`
}
// GetExperimentDashboard возвращает данные для дашборда
func (s *Service) GetExperimentDashboard(ctx context.Context, experimentID string) (*ExperimentDashboard, error) {
// Получаем эксперимент
exp, err := s.experimentStore.Get(ctx, experimentID)
if err != nil {
return nil, err
}
// Получаем метрики
metrics, err := s.statsStore.GetExperimentMetrics(ctx, experimentID, time.Now().AddDate(0, 0, -30), time.Now())
if err != nil {
return nil, err
}
// Получаем воронку
funnel, err := s.statsStore.GetFunnelResults(ctx, experimentID, exp.FunnelID)
if err != nil {
return nil, err
}
// Получаем retention
retention, err := s.statsStore.GetRetentionAnalysis(ctx, experimentID)
if err != nil {
return nil, err
}
// Получаем сегментацию
segments, err := s.statsStore.GetSegmentAnalysis(ctx, experimentID)
if err != nil {
return nil, err
}
// Собираем дашборд
dashboard := &ExperimentDashboard{
ExperimentID: exp.ID,
ExperimentName: exp.Name,
Status: string(exp.Status),
StartDate: exp.StartDate,
DaysRunning: int(time.Since(exp.StartDate).Hours() / 24),
Summary: buildSummary(metrics),
Funnel: buildFunnelComparison(funnel),
Metrics: buildMetricComparisons(metrics),
Segments: segments,
Retention: retention,
}
return dashboard, nil
}
8. SQL запросы для дашборда
-- Сводка по вариантам
SELECT
variant_id,
count(DISTINCT user_id) as users_count,
countIf(event_type = 'purchase') / users_count as conv_rate,
sumIf(toFloat64OrNull(properties['revenue']), event_type = 'purchase') as total_revenue,
total_revenue / users_count as arpu
FROM events_raw
WHERE experiment_id = 'exp_123'
AND event_type IN ('experiment_exposure', 'purchase')
GROUP BY variant_id;
-- Воронка по вариантам
SELECT
variant_id,
countIf(event_type = 'page_view') as step1_users,
countIf(event_type = 'add_to_cart') as step2_users,
countIf(event_type = 'checkout_started') as step3_users,
countIf(event_type = 'purchase') as step4_users,
step2_users / step1_users as step2_rate,
step3_users / step2_users as step3_rate,
step4_users / step3_users as step4_rate
FROM events_raw
WHERE experiment_id = 'exp_123'
GROUP BY variant_id;
-- Временной ряд конверсии
SELECT
toDate(client_timestamp) as date,
variant_id,
count(DISTINCT user_id) as users,
countIf(event_type = 'purchase') / users as conv_rate
FROM events_raw
WHERE experiment_id = 'exp_123'
AND event_type IN ('experiment_exposure', 'purchase')
GROUP BY date, variant_id
ORDER BY date;
Краткий ответ: На странице эксперимента аналитик видит:
- Сводку ключевых метрик по вариантам (users, conversion rate, revenue, ARPU)
- Воронку конверсии в разрезе вариантов с сравнением по каждому шагу
- Статистическую значимость (p-value, confidence intervals, uplift)
- Сегментацию по платформам, странам, типу пользователей
- Временную динамику (графики по дням)
- Retention анализ (когорты, возврат пользователей)
Вопрос 38. Почему были выбраны конкретные технологии, а не их альтернативы?
Таймкод: 01:21:36
Ответ собеседова: Неполный. PostgreSQL выбрана как простая и надёжная для небольшого объёма данных. Kafka — для буферизации и сохранения порядка событий. ClickHouse — для аналитических запросов. Cassandra — для key-value хранения сегментов. Не сравнивались альтернативы вроде RabbitMQ или других очередей.
Правильный ответ:
Сравнение выбранных технологий с альтернативами
┌─────────────────────────────────────────────────────────────────────────────┐
│ СРАВНЕНИЕ ТЕХНОЛОГИЙ С АЛЬТЕРНАТИВАМИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ БРОКЕРЫ СООБЩЕНИЙ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Kafka │ │ RabbitMQ │ │ Pulsar │ │ NATS │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ АНАЛИТИЧЕСКИЕ БД │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ ClickHouse │ │ Greenplum │ │ Druid │ │ BigQuery │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ KEY-VALUE ХРАНИЛИЩА │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Cassandra │ │ ScyllaDB │ │ DynamoDB │ │ HBase │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Kafka vs RabbitMQ vs Pulsar
// Сравнение характеристик брокеров
type BrokerComparison struct {
Name string
Throughput string // Пропускная способность
Latency string // Задержка
Ordering bool // Гарантия порядка
Replay bool // Возможность переиграть сообщения
MultiDC bool // Междата-центровая репликация
UseCase string // Основной сценарий
}
var brokers = []BrokerComparison{
{
Name: "Kafka",
Throughput: "1M+ msg/sec",
Latency: "5-50ms",
Ordering: true, // В пределах партиции
Replay: true, // Consumer groups с offset
MultiDC: true, // MirrorMaker 2
UseCase: "Event streaming, log aggregation",
},
{
Name: "RabbitMQ",
Throughput: "50K msg/sec",
Latency: "<1ms",
Ordering: false, // Нет гарантии при множественных consumer
Replay: false, // Сообщения удаляются после доставки
MultiDC: false, // Federation/shovel сложны
UseCase: "Task queues, RPC",
},
{
Name: "Pulsar",
Throughput: "1M+ msg/sec",
Latency: "<5ms",
Ordering: true,
Replay: true,
MultiDC: true, // Built-in geo-replication
UseCase: "Streaming + queuing",
},
{
Name: "NATS",
Throughput: "10M+ msg/sec",
Latency: "<1ms",
Ordering: false,
Replay: true, // Только с JetStream
MultiDC: false,
UseCase: "Service mesh, real-time messaging",
},
}
Почему Kafka, а не RabbitMQ:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Kafka vs RabbitMQ для аналитики │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Kafka RabbitMQ │
│ ──────── ───── ──────── │
│ │
│ Пропускная 1M+ msg/sec 50K msg/sec │
│ способность (горизонтальное (вертикальное │
│ масштабирование) масштабирование) │
│ │
│ Хранение Дни/недели/месяцы До доставки │
│ сообщений (настраиваемый TTL) (нет хранения) │
│ │
│ Переplay Да (consumer groups) Нет │
│ событий (можно переиграть (сообщения │
│ с любого offset) удаляются) │
│ │
│ Порядок событий Да (в партиции) Нет гарантии │
│ (по user_id) (при множественных │
│ consumer) │
│ │
│ Модель Pull-based Push-based │
│ потребления (consumer сам (брокер отправляет │
│ забирает) сообщения) │
│ │
│ Идемпотентность Да (exactly-once Нет │
│ semantics) (at-most-once │
│ или at-least-once) │
│ │
│ Сложность Высокая Средняя │
│ эксплуатации (ZooKeeper/KRaft, (проще в │
│ настройка настройке) │
│ партиций) │
│ │
│ Пример для События пользователей: Обработка платежей: │
│ аналитики - 100K events/sec - 10K payments/sec │
│ - Нужен replay - Нужна низкая │
│ - Порядок по user_id латентность │
│ - Хранение 30 дней - Нет нужды в │
│ replay │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Практический пример — почему Kafka лучше для аналитики:
// Сценарий: нужно пересчитать сегменты за последние 7 дней
// С Kafka это просто:
func (s *SegmentService) RecalculateSegments(ctx context.Context) error {
// Создаём нового consumer с нужного offset
consumer := kafka.NewConsumer(kafka.Config{
Brokers: []string{"kafka:9092"},
Topic: "events.raw",
GroupID: "segment-recalc-2024-01", // Новая группа для пересчёта
})
// Ищем offset 7 дней назад
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
offsets, err := consumer.GetOffsetsByTime(sevenDaysAgo)
if err != nil {
return err
}
// Читаем события с этого момента
for partition, offset := range offsets {
consumer.Seek(partition, offset)
}
// Обрабатываем события и пересчитываем сегменты
for msg := range consumer.Messages() {
event := parseEvent(msg)
s.processSegmentUpdate(event)
}
return nil
}
// С RabbitMQ это НЕВОЗМОЖНО без дополнительных решений:
// - Сообщения уже удалены после обработки
// - Нет механизма "переиграть" историю
// - Нужно писать все сообщения в отдельное хранилище
2. ClickHouse vs Greenplum vs Druid
-- Сравнение производительности аналитических запросов
-- Тестовый запрос: подсчёт уникальных пользователей по сегментам за месяц
-- ClickHouse: ~2 секунды на 1 млрд строк
SELECT
segment_id,
uniq(user_id) as unique_users,
count() as total_events
FROM events
WHERE client_timestamp >= '2024-01-01'
AND client_timestamp < '2024-02-01'
GROUP BY segment_id
ORDER BY unique_users DESC;
-- Greenplum: ~15 секунд на 1 млрд строк
-- (распределённая БД, но не оптимизирована для аналитики)
-- Druid: ~5 секунд на 1 млрд строк
-- (но сложнее в настройке, меньше возможностей SQL)
Почему ClickHouse, а не Greenplum:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ClickHouse vs Greenplum для аналитики │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий ClickHouse Greenplum │
│ ──────── ────────── ───────── │
│ │
│ Архитектура Колоночная Реляционная │
│ (оптимизирована (PostgreSQL-based) │
│ для аналитики) │
│ │
│ Сжатие 10-100x 3-5x │
│ (кодирование, (TOAST, но менее │
│ delta encoding) эффективно) │
│ │
│ Агрегации Векторизованный Строка за строкой │
│ выполнение (или batch, но │
│ (SIMD-инструкции) медленнее) │
│ │
│ Вставка Batch insert Row-by-row │
│ (оптимально (медленнее │
│ для потоков) при bulk) │
│ │
│ UPDATE/DELETE Асинхронный ACID-совместимый │
│ (mutations) (но медленный) │
│ │
│ SQL-возможности Расширенный Полный SQL │
│ (но не все JOIN) (все JOIN, │
│ подзапросы) │
│ │
│ Масштабирование Шардирование Сегменты │
│ (ReplicatedMergeTree) (MPP-архитектура) │
│ │
│ Сложность Средняя Высокая │
│ эксплуатации (проще, чем (сложнее │
│ Greenplum) в настройке) │
│ │
│ Пример для 1 млрд событий/мес 100 млн событий/мес │
│ аналитики 10-50 ТБ данных 1-5 ТБ данных │
│ Стоимость: $X Стоимость: $3X │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Практический пример — схема данных в ClickHouse:
-- Оптимизированная схема для аналитики событий
CREATE TABLE events ON CLUSTER '{cluster}'
(
-- Идентификаторы
event_id UUID DEFAULT generateUUIDv4(),
user_id UInt64,
session_id String,
-- Тип события (LowCardinality для экономии памяти)
event_type LowCardinality(String),
event_name LowCardinality(String),
-- Платформа (LowCardinality для повторяющихся значений)
platform LowCardinality(String),
app_version LowCardinality(String),
-- Время (DateTime64 для миллисекунд)
client_timestamp DateTime64(3, 'UTC'),
server_timestamp DateTime64(3, 'UTC'),
-- Геолокация (LowCardinality для стран)
country LowCardinality(String),
city LowCardinality(String),
-- Устройство
device String,
os LowCardinality(String),
browser LowCardinality(String),
-- Пользовательские свойства (JSON)
properties String,
-- Сегменты (массив для быстрой фильтрации)
segments Array(UInt32),
-- Индексы для ускорения запросов
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4,
INDEX idx_segments segments TYPE set(100) GRANULARITY 4,
INDEX idx_properties properties TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(client_timestamp)
ORDER BY (user_id, event_type, client_timestamp)
TTL client_timestamp + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Материализованное представление для предварительной агрегации
CREATE MATERIALIZED VIEW events_daily_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, event_type, platform)
AS SELECT
toDate(client_timestamp) as date,
event_type,
platform,
count() as event_count,
uniq(user_id) as unique_users,
sum(JSONExtractInt(properties, 'revenue')) as total_revenue
FROM events
GROUP BY date, event_type, platform;
3. Cassandra vs ScyllaDB vs DynamoDB
// Сравнение Cassandra-совместимых хранилищ
type KVStoreComparison struct {
Name string
WriteThroughput string
ReadLatency string
Consistency string
MultiDC bool
Cost string
}
var kvStores = []KVStoreComparison{
{
Name: "Cassandra",
WriteThroughput: "100K writes/sec/node",
ReadLatency: "5-20ms",
Consistency: "Tunable (ONE, QUORUM, ALL)",
MultiDC: true,
Cost: "Free (open source)",
},
{
Name: "ScyllaDB",
WriteThroughput: "1M writes/sec/node",
ReadLatency: "1-5ms",
Consistency: "Tunable (как Cassandra)",
MultiDC: true,
Cost: "Free (open source) / Enterprise",
},
{
Name: "DynamoDB",
WriteThroughput: "Unlimited (платно)",
ReadLatency: "1-10ms",
Consistency: "Eventually or Strong",
MultiDC: true,
Cost: "Pay-per-request",
},
{
Name: "HBase",
WriteThroughput: "50K writes/sec/node",
ReadLatency: "10-50ms",
Consistency: "Strong (per row)",
MultiDC: false,
Cost: "Free (open source)",
},
}
Почему Cassandra, а не DynamoDB:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Cassandra vs DynamoDB для профилей │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Cassandra DynamoDB │
│ ──────── ────────── ──────── │
│ │
│ Стоимость Фиксированная Переменная │
│ (зависит от (зависит от │
│ количества нод) объёма запросов) │
│ │
│ Предсказуемость Да Нет │
│ стоимости (известная инфра) (может быть │
│ непредсказуемо) │
│ │
│ Модель данных Wide-column Document │
│ (гибкая схема) (ограниченная │
│ схема) │
│ │
│ Multi-DC Built-in Global Tables │
│ (настраиваемая) (менее гибкая) │
│ │
│ Зависимость Нет (on-premise) Да (AWS) │
│ от vendor или любой облако │
│ │
│ Сложность Высокая Низкая │
│ эксплуатации (нужен DBA) (managed service) │
│ │
│ Пример для 100M профилей 100M профилей │
│ профилей 10 нод × $500/мес $50K+/мес │
│ = $5K/мес (при 10K RPS) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Практический пример — модель данных в Cassandra:
-- Создание keyspace с репликацией
CREATE KEYSPACE IF NOT EXISTS analytics
WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3,
'dc2': 3
};
-- Таблица профилей пользователей
CREATE TABLE IF NOT EXISTS analytics.user_profiles (
user_id bigint PRIMARY KEY,
first_seen timestamp,
last_seen timestamp,
segment_ids frozen<list<int>>,
properties map<text, text>,
event_count bigint,
sessions_count int,
updated_at timestamp
) WITH bloom_filter_fp_chance = 0.01
AND caching = {'keys': 'ALL', 'rows_per_partition': '100'}
AND comment = 'User profiles for analytics platform'
AND compaction = {'class': 'LeveledCompactionStrategy'}
AND compression = {'sstable_compression': 'LZ4Compressor'}
AND dclocal_read_repair_chance = 0.1
AND default_time_to_live = 0
AND gc_grace_seconds = 864000
AND max_index_interval = 2048
AND memtable_flush_period_in_ms = 3600000
AND min_index_interval = 128
AND read_repair_chance = 0.0
AND speculative_retry = '99p';
-- Таблица для быстрого поиска по сегментам
CREATE TABLE IF NOT EXISTS analytics.users_by_segment (
segment_id int,
user_id bigint,
segment_score float,
updated_at timestamp,
PRIMARY KEY (segment_id, user_id)
) WITH CLUSTERING ORDER BY (user_id ASC)
AND compaction = {'class': 'LeveledCompactionStrategy'};
4. PostgreSQL — для метаданных
-- PostgreSQL для хранения метаданных системы
-- Таблица определений сегментов
CREATE TABLE segments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
definition JSONB NOT NULL, -- Определение сегмента в JSON
owner_id INTEGER REFERENCES users(id),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
estimated_count BIGINT, -- Примерное количество пользователей
last_calculated_at TIMESTAMPTZ
);
-- Индекс для поиска по определению сегмента
CREATE INDEX idx_segments_definition ON segments USING GIN (definition);
-- Таблица экспериментов
CREATE TABLE experiments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
hypothesis TEXT,
-- Параметры эксперимента
status VARCHAR(20) DEFAULT 'draft',
traffic_percentage DECIMAL(5,2),
targeting_rules JSONB,
-- Варианты
variants JSONB NOT NULL,
-- Метрики
primary_metric_id INTEGER REFERENCES metrics(id),
secondary_metrics INTEGER[],
guardrail_metrics INTEGER[],
-- Время
planned_start TIMESTAMPTZ,
planned_end TIMESTAMPTZ,
actual_start TIMESTAMPTZ,
actual_end TIMESTAMPTZ,
-- Результаты
results JSONB,
winner_variant VARCHAR(50),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Таблица дашбордов
CREATE TABLE dashboards (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id INTEGER REFERENCES users(id),
-- Конфигурация дашборда
config JSONB NOT NULL,
-- Права доступа
shared_with INTEGER[],
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Почему PostgreSQL для метаданных:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Почему PostgreSQL для метаданных │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Обоснование │
│ ──────── ────────── │
│ │
│ ACID-транзакции Важна целостность данных о сегментах, │
│ экспериментах, дашбордах │
│ │
│ JSONB Гибкая схема для определений сегментов, │
│ конфигураций экспериментов │
│ │
│ Сложные запросы JOIN для получения данных о сегментах │
│ с информацией об авторе │
│ │
│ Объём данных Метаданные — это MB-GB, не TB │
│ (не нужна горизонтальная масштабируемость) │
│ │
│ Зрелость Проверенная технология с большим сообществом │
│ │
│ Инструменты pgAdmin, DBeaver, миграции (golang-migrate) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Сводная таблица выбора технологий:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ИТОГОВОЕ ОБОСНОВАНИЕ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Компонент Выбрано Альтернативы Почему именно это │
│ ───────── ─────── ──────────── ───────────────── │
│ │
│ Event streaming Kafka RabbitMQ, Пропускная │
│ Pulsar способность, │
│ replay, │
│ ordering │
│ │
│ Аналитика ClickHouse Greenplum, Колоночное │
│ Druid, BigQuery хранение, │
│ скорость, │
│ стоимость │
│ │
│ Профили Cassandra ScyllaDB, Multi-DC, │
│ DynamoDB tunable │
│ consistency │
│ │
│ Кэш Redis Memcached, Скорость, │
│ KeyDB структуры │
│ данных │
│ │
│ Метаданные PostgreSQL MySQL, SQLite ACID, JSONB, │
│ зрелость │
│ │
│ Язык Go Java, Python, Производительность, │
│ Rust конкурентность, │
│ простота │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Выбор каждой технологии обоснован конкретными требованиями системы:
- Kafka — единственный брокер с replay и ordering для event streaming
- ClickHouse — оптимален для аналитики с колоночным хранением
- Cassandra — лучший выбор для key-value с Multi-DC
- Redis — стандарт для кэширования
- PostgreSQL — проверенное решение для метаданных с ACID
- Go — оптимален для высоконагруженных сервисов
Все технологии дополняют друг друга и образуют целостный стек для аналитической платформы.
Вопрос 26. Нужно ли подтягивать оффлайн-события для анализа экспериментов?
Таймкод: 00:49:41
Ответ собеседника: Правильный. Уточняется, что подтягивание оффлайн-событий (например, получил ли пользователь карту) выходит за рамки системы. Это интеграция с Data Warehouse, аналогичная подгрузке сегментов. Система собирает только онлайн-события.
Правильный ответ:
1. Разделение ответственности
┌─────────────────────────────────────────────────────────────────────────────┐
│ Offline Events Integration │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Experiment Platform Scope │ │
│ │ │ │
│ │ ✅ Онлайн-события (в рамках системы) │ │
│ │ • Клики, просмотры страниц │ │
│ │ • Взаимодействие с UI │ │
│ │ • Конверсии в приложении │ │
│ │ │ │
│ │ ❌ Оффлайн-события (за рамками системы) │ │
│ │ • Доставка товара │ │
│ │ • Получение карты курьером │ │
│ │ • Офлайн-покупки │ │
│ │ • Телефонные звонки в поддержку │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Data Warehouse Integration │ │
│ │ │ │
│ │ • Оффлайн-события хранятся в отдельных системах │ │
│ │ • Интеграция через ETL/ELT процессы │ │
│ │ • Обогащение данных эксперимента через batch-подключение │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ CRM System │ │ Logistics │ │ Customer Support │ │ │
│ │ │ │ │ System │ │ System │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────────┼───────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Data Warehouse │ │ │
│ │ │ (Snowflake, │ │ │
│ │ │ BigQuery) │ │ │
│ │ └────────┬────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Analytics │ │ │
│ │ │ Platform │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Примеры оффлайн-событий
┌─────────────────────────────────────────────────────────────────────────────┐
│ Offline Event Examples │
├──────────────────────────┬──────────────────────────────────────────────────┤
│ │ │
│ Delivery Events │ • Заказ доставлен │
│ │ • Заказ получен курьером │
│ │ • Возврат товара │
│ │ • Проблема с доставкой │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Physical Store Events │ • Покупка в магазине │
│ │ • Возврат в магазине │
│ │ • Использование промокода │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Support Events │ • Обращение в поддержку │
│ │ • Решение проблемы │
│ │ • Оценка качества обслуживания │
│ │ │
├──────────────────────────┼──────────────────────────────────────────────────┤
│ │ │
│ Payment Events │ • Возврат средств │
│ │ • Оплата наличными │
│ │ • Изменение способа оплаты │
│ │ │
└──────────────────────────┴──────────────────────────────────────────────────┘
3. Интеграция через Data Warehouse
-- В Data Warehouse можно обогатить данные эксперимента оффлайн-событиями
-- Таблица оффлайн-событий
CREATE TABLE offline_events (
event_id String,
event_type LowCardinality(String), -- 'delivery_completed', 'return_initiated', 'support_call'
user_id String,
order_id Nullable(String),
-- Детали события
properties Map(String, String),
-- Время
event_timestamp DateTime64(3),
event_date Date DEFAULT toDate(event_timestamp),
-- Источник данных
source_system LowCardinality(String) -- 'crm', 'logistics', 'support'
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_timestamp);
-- Обогащение данных эксперимента
SELECT
e.experiment_id,
e.variant_id,
e.user_id,
-- Онлайн-метрики
countIf(e.event_type = 'purchase') as online_purchases,
sumIf(toFloat64OrNull(e.properties['revenue']), e.event_type = 'purchase') as online_revenue,
-- Оффлайн-метрики
countIf(o.event_type = 'delivery_completed') as deliveries_completed,
countIf(o.event_type = 'return_initiated') as returns,
countIf(o.event_type = 'support_call') as support_calls,
-- Комбинированные метрики
deliveries_completed / online_purchases as delivery_rate,
returns / online_purchases as return_rate,
support_calls / online_purchases as support_rate
FROM events_raw e
LEFT JOIN offline_events o
ON e.user_id = o.user_id
AND o.event_timestamp BETWEEN e.client_timestamp AND e.client_timestamp + INTERVAL 30 DAY
WHERE e.experiment_id = 'exp_123'
GROUP BY e.experiment_id, e.variant_id, e.user_id;
4. Архитектура интеграции
┌─────────────────────────────────────────────────────────────────────────────┐
│ Offline Events Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Online Events (Real-time) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ Web App │────▶│ Event │────▶│ ClickHouse │ │ │
│ │ │ Mobile App│ │ Collector │ │ (Real-time) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ │ Latency: seconds to minutes │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Offline Events (Batch) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ CRM │ │ ETL/ELT │ │ Data Warehouse │ │ │
│ │ │ Logistics │────▶│ Pipeline │────▶│ (Batch) │ │ │
│ │ │ Support │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ │ Latency: hours to days │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Combined Analysis │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ Data Warehouse │────▶│ Analytics Platform │ │ │
│ │ │ (Joined Data) │ │ (Looker, Tableau, Metabase) │ │ │
│ │ └─────────────────────┘ └─────────────────────────────────┘ │ │
│ │ │ │
│ │ Latency: hours to days │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Когда нужны оффлайн-события
┌─────────────────────────────────────────────────────────────────────────────┐
│ When Offline Events Are Needed │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ Нужны для полного анализа: │
│ │
│ • Доставка как часть воронки │
│ Order → Payment → Delivery → Received │
│ Если эксперимент влияет на доставку, нужно отслеживать │
│ │
│ • Оффлайн-конверсии │
│ Пользователь увидел рекламу → пришёл в магазин → купил │
│ Нужно связать онлайн-экспозицию с оффлайн-покупкой │
│ │
│ • Долгосрочный эффект │
│ Эксперимент с онбордингом → Retention через 30/60/90 дней │
│ Нужно отслеживать возвраты и повторные покупки │
│ │
│ • Customer Journey │
│ Полный путь клиента включает офлайн-точки контакта │
│ │
│ ❌ Не нужны для базового анализа: │
│ │
│ • A/B тесты UI/UX │
│ Изменение цвета кнопки → конверсия в клик │
│ Онлайн-данных достаточно │
│ │
│ • Ценовые эксперименты │
│ Цена отображается онлайн → покупка онлайн │
│ Оплата происходит в том же потоке │
│ │
│ • Feature toggles │
│ Включение функции → использование функции │
│ Всё происходит в приложении │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Подход к интеграции
package integration
// OfflineEvent оффлайн-событие из внешней системы
type OfflineEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
UserID string `json:"user_id"`
OrderID *string `json:"order_id,omitempty"`
Properties map[string]string `json:"properties"`
SourceSystem string `json:"source_system"`
EventTimestamp time.Time `json:"event_timestamp"`
}
// OfflineEventStore хранилище оффлайн-событий
type OfflineEventStore interface {
// BatchImport импортирует оффлайн-события
BatchImport(ctx context.Context, events []OfflineEvent) error
// GetByUser возвращает оффлайн-события пользователя
GetByUser(ctx context.Context, userID string, from, to time.Time) ([]OfflineEvent, error)
// GetByExperiment возвращает оффлайн-события для пользователей эксперимента
GetByExperiment(ctx context.Context, experimentID string, from, to time.Time) ([]OfflineEvent, error)
}
// EnrichmentService сервис обогащения данных
type EnrichmentService struct {
onlineStore OnlineEventStore
offlineStore OfflineEventStore
}
// EnrichExperimentData обогащает данные эксперимента оффлайн-событиями
func (s *EnrichmentService) EnrichExperimentData(ctx context.Context, experimentID string) (*EnrichedExperimentData, error) {
// Получаем данные эксперимента
onlineData, err := s.onlineStore.GetExperimentData(ctx, experimentID)
if err != nil {
return nil, err
}
// Получаем оффлайн-события для пользователей эксперимента
offlineEvents, err := s.offlineStore.GetByExperiment(ctx, experimentID, onlineData.StartDate, time.Now())
if err != nil {
return nil, err
}
// Обогащаем данные
enriched := &EnrichedExperimentData{
ExperimentID: experimentID,
OnlineData: onlineData,
OfflineEvents: offlineEvents,
CombinedMetrics: calculateCombinedMetrics(onlineData, offlineEvents),
}
return enriched, nil
}
// CombinedMetrics комбинированные метрики
type CombinedMetrics struct {
OnlineConvRate float64 `json:"online_conv_rate"`
DeliveryRate float64 `json:"delivery_rate"` // Доставлено / Заказано
ReturnRate float64 `json:"return_rate"` // Возвраты / Заказано
SupportCallRate float64 `json:"support_call_rate"` // Звонки / Заказано
LTV float64 `json:"ltv"` // Lifetime Value
}
7. Итоговая рекомендация
┌─────────────────────────────────────────────────────────────────────────────┐
│ Recommendation │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Определите цель эксперимента │
│ • Если цель — онлайн-конверсия → онлайн-событий достаточно │
│ • Если цель — полный customer journey → нужны оффлайн-события │
│ │
│ 2. Оцените сложность интеграции │
│ • Есть ли доступ к Data Warehouse? │
│ • Есть ли общий ключ для связи (user_id, order_id)? │
│ • Какова задержка данных? │
│ │
│ 3. Выберите подход │
│ • Real-time: Kafka + Stream Processing (сложно) │
│ • Batch: ETL + Data Warehouse (проще) │
│ │
│ 4. Учитывайте время │
│ • Оффлайн-события приходят с задержкой │
│ • Анализ возможен только после получения всех данных │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Подтягивание оффлайн-событий выходит за рамки системы A/B-тестирования:
- Система собирает только онлайн-события — клики, просмотры, конверсии в приложении
- Оффлайн-события (доставка, офлайн-покупки, звонки в поддержку) хранятся в других системах
- Интеграция через Data Warehouse — аналогично подгрузке сегментов, через batch-процессы
- Нужны для полного анализа customer journey, но не для базовых A/B-тестов UI/UX
Вопрос 39. Что должно являться выходом системы? Достаточно ли просто сложить аналитику в ClickHouse?
Таймкод: 01:23:36
Ответ собеседова: Неполный. Аналитику сложили в ClickHouse, но не дорисовали сервис для запроса статистики аналитиками. Также не обсудили автоматический расчёт показателей экспериментов и принятие решений об окончании с отправкой нотификаций аналитикам. Время ограничено, поэтому не все части были проработаны.
Правильный ответ:
Выходы системы — полная архитектура
┌─────────────────────────────────────────────────────────────────────────────┐
│ ВЫХОДЫ СИСТЕМЫ АНАЛИТИКИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. ХРАНИЛИЩА ДАННЫХ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ ClickHouse │ │ Cassandra │ │ Redis │ │ │
│ │ │ (сырые │ │ (профили │ │ (кэш │ │ │
│ │ │ события) │ │ пользоват.)│ │ сегментов) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. API ДЛЯ ЗАПРОСОВ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Analytics │ │ Segment │ │ Experiment │ │ │
│ │ │ Query API │ │ Query API │ │ Results API │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. АВТОМАТИЧЕСКИЕ РАСЧЁТЫ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Segment │ │ Experiment │ │ Anomaly │ │ │
│ │ │ Calculator │ │ Calculator │ │ Detector │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. НОТИФИКАЦИИ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Email │ │ Slack │ │ Webhook │ │ │
│ │ │ Reports │ │ Alerts │ │ Callbacks │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 5. ДАШБОРДЫ И ВИЗУАЛИЗАЦИЯ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Grafana │ │ Custom │ │ Embedded │ │ │
│ │ │ Dashboards │ │ Dashboard │ │ Widgets │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Analytics Query API — сервис для запросов аналитиков
// Сервис для выполнения аналитических запросов
type AnalyticsQueryService struct {
clickhouse *sql.DB
redis *redis.Client
segmentSvc SegmentService
experimentSvc ExperimentService
}
// Запрос на получение статистики
type AnalyticsQuery struct {
// Фильтры
DateRange DateRange `json:"date_range"`
Segments []uint32 `json:"segments,omitempty"`
Platforms []string `json:"platforms,omitempty"`
Countries []string `json:"countries,omitempty"`
// Группировка
GroupBy []string `json:"group_by"` // date, segment, platform
// Метрики
Metrics []string `json:"metrics"` // events, users, revenue
// Сортировка и пагинация
OrderBy string `json:"order_by"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type DateRange struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}
// Результат запроса
type AnalyticsResult struct {
Data []map[string]interface{} `json:"data"`
TotalCount int64 `json:"total_count"`
QueryTime time.Duration `json:"query_time"`
CacheHit bool `json:"cache_hit"`
}
// Выполнение запроса с кэшированием
func (s *AnalyticsQueryService) ExecuteQuery(ctx context.Context, query AnalyticsQuery) (*AnalyticsResult, error) {
// Генерируем ключ кэша
cacheKey := s.generateCacheKey(query)
// Проверяем кэш
cached, err := s.redis.Get(ctx, cacheKey).Bytes()
if err == nil {
var result AnalyticsResult
if err := json.Unmarshal(cached, &result); err == nil {
result.CacheHit = true
return &result, nil
}
}
// Выполняем запрос к ClickHouse
start := time.Now()
result, err := s.executeClickHouseQuery(ctx, query)
if err != nil {
return nil, err
}
result.QueryTime = time.Since(start)
// Кэшируем результат на 5 минут
data, _ := json.Marshal(result)
s.redis.Set(ctx, cacheKey, data, 5*time.Minute)
return result, nil
}
func (s *AnalyticsQueryService) executeClickHouseQuery(ctx context.Context, query AnalyticsQuery) (*AnalyticsResult, error) {
// Строим SQL запрос
sql := s.buildSQL(query)
rows, err := s.clickhouse.QueryContext(ctx, sql)
if err != nil {
return nil, fmt.Errorf("clickhouse query failed: %w", err)
}
defer rows.Close()
columns, _ := rows.Columns()
var data []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
row := make(map[string]interface{})
for i, col := range columns {
row[col] = values[i]
}
data = append(data, row)
}
return &AnalyticsResult{
Data: data,
CacheHit: false,
}, nil
}
// Построение SQL запроса
func (s *AnalyticsQueryService) buildSQL(query AnalyticsQuery) string {
// SELECT
selectClause := "SELECT "
for i, group := range query.GroupBy {
if i > 0 {
selectClause += ", "
}
selectClause += group
}
for _, metric := range query.Metrics {
selectClause += ", " + s.getMetricSQL(metric)
}
// FROM
fromClause := "FROM events"
// WHERE
whereClause := fmt.Sprintf(
"WHERE client_timestamp >= '%s' AND client_timestamp < '%s'",
query.DateRange.From.Format("2006-01-02"),
query.DateRange.To.Format("2006-01-02"),
)
if len(query.Segments) > 0 {
whereClause += fmt.Sprintf(" AND has(segments, %d)", query.Segments[0])
}
if len(query.Platforms) > 0 {
whereClause += fmt.Sprintf(" AND platform IN (%s)", strings.Join(query.Platforms, ","))
}
// GROUP BY
groupByClause := "GROUP BY " + strings.Join(query.GroupBy, ", ")
// ORDER BY
orderByClause := "ORDER BY " + query.OrderBy
// LIMIT
limitClause := fmt.Sprintf("LIMIT %d OFFSET %d", query.Limit, query.Offset)
return fmt.Sprintf("%s %s %s %s %s %s",
selectClause, fromClause, whereClause, groupByClause, orderByClause, limitClause)
}
func (s *AnalyticsQueryService) getMetricSQL(metric string) string {
switch metric {
case "events":
return "count() as events"
case "users":
return "uniq(user_id) as users"
case "revenue":
return "sum(toFloat64OrNull(JSONExtractString(properties, 'revenue'))) as revenue"
default:
return "count() as " + metric
}
}
2. Segment Calculator — автоматический расчёт сегментов
// Сервис для автоматического пересчёта сегментов
type SegmentCalculator struct {
clickhouse *sql.DB
cassandra *gocql.Session
redis *redis.Client
kafka sarama.Producer
}
// Определение сегмента
type SegmentDefinition struct {
ID uint32
Name string
Conditions []Condition
SQL string // SQL запрос для определения пользователей
}
type Condition struct {
EventType string
Property string
Operator string // eq, gt, lt, gte, lte, in, not_in
Value interface{}
Timeframe string // 7d, 30d, 90d
}
// Пересчёт всех активных сегментов
func (sc *SegmentCalculator) RecalculateAllSegments(ctx context.Context) error {
// Получаем активные сегменты из PostgreSQL
segments, err := sc.getActiveSegments(ctx)
if err != nil {
return err
}
for _, segment := range segments {
if err := sc.recalculateSegment(ctx, segment); err != nil {
log.Error().Err(err).Uint32("segment_id", segment.ID).
Msg("failed to recalculate segment")
continue
}
}
return nil
}
// Пересчёт одного сегмента
func (sc *SegmentCalculator) recalculateSegment(ctx context.Context, segment SegmentDefinition) error {
log.Info().Uint32("segment_id", segment.ID).Str("name", segment.Name).
Msg("recalculating segment")
// Выполняем SQL запрос для получения пользователей сегмента
rows, err := sc.clickhouse.QueryContext(ctx, segment.SQL)
if err != nil {
return fmt.Errorf("clickhouse query failed: %w", err)
}
defer rows.Close()
// Обновляем сегменты в Cassandra
var userIDs []uint64
for rows.Next() {
var userID uint64
if err := rows.Scan(&userID); err != nil {
continue
}
userIDs = append(userIDs, userID)
// Обновляем пакетами по 1000
if len(userIDs) >= 1000 {
if err := sc.updateUserSegments(ctx, userIDs, segment.ID); err != nil {
log.Error().Err(err).Msg("failed to update user segments")
}
userIDs = userIDs[:0]
}
}
// Обновляем оставшихся
if len(userIDs) > 0 {
if err := sc.updateUserSegments(ctx, userIDs, segment.ID); err != nil {
log.Error().Err(err).Msg("failed to update user segments")
}
}
// Инвалидируем кэш Redis
sc.invalidateSegmentCache(ctx, userIDs)
// Публикуем событие об обновлении сегмента
sc.publishSegmentUpdateEvent(segment.ID, len(userIDs))
return nil
}
// Обновление сегментов пользователей в Cassandra
func (sc *SegmentCalculator) updateUserSegments(ctx context.Context, userIDs []uint64, segmentID uint32) error {
// Используем batch для эффективного обновления
batch := sc.cassandra.NewBatch(gocql.LoggedBatch)
for _, userID := range userIDs {
batch.Query(
`UPDATE user_profiles SET segment_ids = segment_ids + ?, updated_at = ? WHERE user_id = ?`,
[]int{int(segmentID)}, time.Now(), userID,
)
}
return sc.cassandra.ExecuteBatch(batch)
}
// Инвалидация кэша
func (sc *SegmentCalculator) invalidateSegmentCache(ctx context.Context, userIDs []uint64) {
pipe := sc.redis.Pipeline()
for _, userID := range userIDs {
key := fmt.Sprintf("user:%d:segments", userID)
pipe.Del(ctx, key)
}
pipe.Exec(ctx)
}
3. Experiment Calculator — расчёт результатов экспериментов
// Сервис для автоматического расчёта результатов A/B экспериментов
type ExperimentCalculator struct {
clickhouse *sql.DB
postgres *sql.DB
notifier NotificationService
}
// Определение эксперимента
type Experiment struct {
ID uint32
Name string
Status string // running, completed, stopped
StartTime time.Time
EndTime *time.Time
// Варианты
ControlVariant string
TestVariants []string
// Метрики
PrimaryMetric MetricDefinition
SecondaryMetrics []MetricDefinition
// Критерии остановки
MinSampleSize int64
MinEffectSize float64 // Минимальный значимый эффект (5% = 0.05)
SignificanceLevel float64 // Уровень значимости (0.05)
}
type MetricDefinition struct {
Name string
Type string // count, sum, avg, conversion
EventName string
Property string // Для sum/avg
}
// Результат эксперимента
type ExperimentResult struct {
ExperimentID uint32
Variant string
SampleSize int64
MetricValue float64
ConfidenceInterval [2]float64 // [lower, upper]
PValue float64
IsSignificant bool
}
// Расчёт результатов всех активных экспериментов
func (ec *ExperimentCalculator) CalculateAllExperiments(ctx context.Context) error {
experiments, err := ec.getRunningExperiments(ctx)
if err != nil {
return err
}
for _, exp := range experiments {
results, err := ec.calculateExperiment(ctx, exp)
if err != nil {
log.Error().Err(err).Uint32("experiment_id", exp.ID).
Msg("failed to calculate experiment")
continue
}
// Проверяем, можно ли остановить эксперимент
if ec.shouldStopExperiment(exp, results) {
if err := ec.stopExperiment(ctx, exp, results); err != nil {
log.Error().Err(err).Msg("failed to stop experiment")
}
}
}
return nil
}
// Расчёт результатов одного эксперимента
func (ec *ExperimentCalculator) calculateExperiment(ctx context.Context, exp Experiment) ([]ExperimentResult, error) {
// Получаем данные по вариантам
query := `
SELECT
variant,
user_id,
countIf(event_name = ?) as primary_events,
sumIf(toFloat64OrNull(JSONExtractString(properties, ?)), event_name = ?) as primary_value
FROM events
WHERE experiment_id = ?
AND client_timestamp >= ?
GROUP BY variant, user_id
`
rows, err := ec.clickhouse.QueryContext(ctx, query,
exp.PrimaryMetric.EventName,
exp.PrimaryMetric.Property,
exp.PrimaryMetric.EventName,
exp.ID,
exp.StartTime,
)
if err != nil {
return nil, err
}
defer rows.Close()
// Агрегируем результаты по вариантам
variantData := make(map[string]*VariantStats)
for rows.Next() {
var variant string
var userID uint64
var events int64
var value float64
if err := rows.Scan(&variant, &userID, &events, &value); err != nil {
continue
}
if _, ok := variantData[variant]; !ok {
variantData[variant] = &VariantStats{}
}
stats := variantData[variant]
stats.SampleSize++
stats.TotalEvents += events
stats.TotalValue += value
stats.Values = append(stats.Values, value)
}
// Рассчитываем статистическую значимость
controlStats := variantData[exp.ControlVariant]
var results []ExperimentResult
for variant, stats := range variantData {
result := ExperimentResult{
ExperimentID: exp.ID,
Variant: variant,
SampleSize: stats.SampleSize,
MetricValue: stats.TotalValue / float64(stats.SampleSize),
}
// T-test для сравнения с контролем
if variant != exp.ControlVariant && controlStats != nil {
tStat, pValue := welchTTest(controlStats.Values, stats.Values)
result.PValue = pValue
result.IsSignificant = pValue < exp.SignificanceLevel
// Доверительный интервал
meanDiff := result.MetricValue - (controlStats.TotalValue / float64(controlStats.SampleSize))
se := standardError(controlStats.Values, stats.Values)
result.ConfidenceInterval = [2]float64{
meanDiff - 1.96*se,
meanDiff + 1.96*se,
}
}
results = append(results, result)
}
return results, nil
}
// Welch's t-test для сравнения двух выборок
func welchTTest(sample1, sample2 []float64) (tStat, pValue float64) {
n1, n2 := float64(len(sample1)), float64(len(sample2))
mean1, mean2 := stat.Mean(sample1, nil), stat.Mean(sample2, nil)
var1, var2 := stat.Variance(sample1, nil), stat.Variance(sample2, nil)
se := math.Sqrt(var1/n1 + var2/n2)
tStat = (mean1 - mean2) / se
// Степени свободы (Welch-Satterthwaite)
df := math.Pow(var1/n1+var2/n2, 2) /
(math.Pow(var1/n1, 2)/(n1-1) + math.Pow(var2/n2, 2)/(n2-1))
// P-value из t-распределения
pValue = 2 * (1 - tDistCDF(math.Abs(tStat), df))
return tStat, pValue
}
// Проверка, нужно ли остановить эксперимент
func (ec *ExperimentCalculator) shouldStopExperiment(exp Experiment, results []ExperimentResult) bool {
// Проверяем минимальный размер выборки
for _, r := range results {
if r.SampleSize < exp.MinSampleSize {
return false
}
}
// Проверяем статистическую значимость
for _, r := range results {
if r.Variant != exp.ControlVariant && r.IsSignificant {
return true
}
}
// Проверяем время работы
if exp.EndTime != nil && time.Now().After(*exp.EndTime) {
return true
}
return false
}
// Остановка эксперимента с отправкой уведомления
func (ec *ExperimentCalculator) stopExperiment(ctx context.Context, exp Experiment, results []ExperimentResult) error {
// Определяем победителя
winner := ec.determineWinner(exp, results)
// Обновляем статус в PostgreSQL
_, err := ec.postgres.ExecContext(ctx,
`UPDATE experiments SET status = 'completed', winner_variant = ?, actual_end = ? WHERE id = ?`,
winner, time.Now(), exp.ID,
)
if err != nil {
return err
}
// Отправляем уведомление
notification := ExperimentCompletedNotification{
ExperimentID: exp.ID,
ExperimentName: exp.Name,
Winner: winner,
Results: results,
CompletedAt: time.Now(),
}
return ec.notifier.SendExperimentCompleted(ctx, notification)
}
func (ec *ExperimentCalculator) determineWinner(exp Experiment, results []ExperimentResult) string {
// Находим вариант с лучшим значением метрики
bestVariant := exp.ControlVariant
bestValue := 0.0
for _, r := range results {
if r.IsSignificant && r.MetricValue > bestValue {
bestValue = r.MetricValue
bestVariant = r.Variant
}
}
return bestVariant
}
4. Notification Service — отправка уведомлений
// Сервис для отправки уведомлений аналитикам
type NotificationService struct {
emailClient *ses.Client
slackClient *slack.Client
webhookClient *http.Client
}
// Типы уведомлений
type ExperimentCompletedNotification struct {
ExperimentID uint32
ExperimentName string
Winner string
Results []ExperimentResult
CompletedAt time.Time
}
type SegmentUpdatedNotification struct {
SegmentID uint32
SegmentName string
UserCount int64
UpdatedAt time.Time
}
type AnomalyDetectedNotification struct {
MetricName string
ExpectedValue float64
ActualValue float64
Deviation float64
DetectedAt time.Time
}
// Отправка уведомления о завершении эксперимента
func (ns *NotificationService) SendExperimentCompleted(ctx context.Context, notification ExperimentCompletedNotification) error {
// Формируем сообщение
message := ns.formatExperimentMessage(notification)
// Отправляем по всем каналам
var errs []error
// Email
if err := ns.sendEmail(ctx, "Experiment Completed", message); err != nil {
errs = append(errs, err)
}
// Slack
if err := ns.sendSlackMessage(ctx, message); err != nil {
errs = append(errs, err)
}
// Webhook
if err := ns.sendWebhook(ctx, notification); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("notification errors: %v", errs)
}
return nil
}
func (ns *NotificationService) formatExperimentMessage(notification ExperimentCompletedNotification) string {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("🧪 Experiment Completed: %s\n\n", notification.ExperimentName))
buf.WriteString(fmt.Sprintf("Winner: **%s**\n", notification.Winner))
buf.WriteString(fmt.Sprintf("Completed at: %s\n\n", notification.CompletedAt.Format(time.RFC3339)))
buf.WriteString("Results:\n")
for _, r := range notification.Results {
buf.WriteString(fmt.Sprintf("- %s: %.2f (n=%d, p=%.4f",
r.Variant, r.MetricValue, r.SampleSize, r.PValue))
if r.IsSignificant {
buf.WriteString(", significant")
}
buf.WriteString(")\n")
}
return buf.String()
}
// Отправка email
func (ns *NotificationService) sendEmail(ctx context.Context, subject, body string) error {
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: []*string{aws.String("analytics-team@company.com")},
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Data: aws.String(body),
},
},
Subject: &ses.Content{
Data: aws.String(subject),
},
},
Source: aws.String("noreply@company.com"),
}
_, err := ns.emailClient.SendEmailWithContext(ctx, input)
return err
}
// Отправка в Slack
func (ns *NotificationService) sendSlackMessage(ctx context.Context, message string) error {
_, _, err := ns.slackClient.PostMessage(
"analytics-alerts",
slack.MsgOptionText(message, false),
)
return err
}
// Отправка webhook
func (ns *NotificationService) sendWebhook(ctx context.Context, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST",
"https://hooks.company.io/analytics", bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := ns.webhookClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
5. Grafana Dashboards — визуализация
# Конфигурация Grafana datasource для ClickHouse
apiVersion: 1
datasources:
- name: ClickHouse
type: vertamedia-clickhouse-datasource
access: proxy
url: http://clickhouse:8123
basicAuth: true
basicAuthUser: grafana
secureJsonData:
basicAuthPassword: ${CLICKHOUSE_PASSWORD}
jsonData:
defaultDatabase: analytics
-- Dashboard: Активность пользователей по сегментам
-- Панель 1: DAU по сегментам
SELECT
toDate(client_timestamp) as date,
arrayJoin(segments) as segment_id,
uniq(user_id) as dau
FROM events
WHERE client_timestamp >= $__timeFrom() AND client_timestamp < $__timeTo()
GROUP BY date, segment_id
ORDER BY date;
-- Панель 2: Конверсия по вариантам эксперимента
SELECT
variant,
countIf(event_name = 'purchase') / countIf(event_name = 'page_view') as conversion_rate
FROM events
WHERE experiment_id = ${experiment_id}
AND client_timestamp >= $__timeFrom() AND client_timestamp < $__timeTo()
GROUP BY variant;
-- Панель 3: Retention по когортам
SELECT
cohort_date,
days_since_first,
uniq(user_id) as retained_users
FROM (
SELECT
user_id,
min(toDate(client_timestamp)) as cohort_date,
dateDiff('day', cohort_date, toDate(client_timestamp)) as days_since_first
FROM events
WHERE client_timestamp >= $__timeFrom() AND client_timestamp < $__timeTo()
GROUP BY user_id, days_since_first
)
GROUP BY cohort_date, days_since_first
ORDER BY cohort_date, days_since_first;
Итоговая архитектура выходов системы:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПОЛНАЯ АРХИТЕКТУРА ВЫХОДОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ДАННЫЕ │ │
│ │ │ │
│ │ ClickHouse ← Сырые события, агрегаты │ │
│ │ Cassandra ← Профили пользователей │ │
│ │ Redis ← Кэш сегментов │ │
│ │ PostgreSQL ← Метаданные (сегменты, эксперименты, дашборды) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ СЕРВИСЫ │ │
│ │ │ │
│ │ Analytics Query API ← Запросы аналитиков │ │
│ │ Segment Calculator ← Пересчёт сегментов │ │
│ │ Experiment Calculator ← Расчёт A/B тестов │ │
│ │ Anomaly Detector ← Обнаружение аномалий │ │
│ │ Report Generator ← Генерация отчётов │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ИНТЕРФЕЙСЫ │ │
│ │ │ │
│ │ REST API ← Для интеграций │ │
│ │ GraphQL ← Для сложных запросов │ │
│ │ Grafana ← Дашборды и мониторинг │ │
│ │ Custom Dashboard ← Веб-интерфейс для аналитиков │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ НОТИФИКАЦИИ │ │
│ │ │ │
│ │ Email Reports ← Ежедневные/еженедельные отчёты │ │
│ │ Slack Alerts ← Мгновенные уведомления │ │
│ │ Webhook Callbacks ← Интеграции с внешними системами │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
ClickHouse — это только часть системы. Полноценная аналитическая платформа должна включать:
- Хранилища данных — ClickHouse, Cassandra, Redis, PostgreSQL
- API для запросов — REST/GraphQL для аналитиков и интеграций
- Автоматические расчёты — сегменты, A/B тесты, аномалии
- Уведомления — email, Slack, webhooks
- Визуализация — Grafana, кастомные дашборды
Без этих компонентов система будет просто хранилищем данных без практической ценности для бизнеса.
Вопрос 27. Как сервис определяет, в какие эксперименты попадает пользователь?
Таймкод: 00:52:09
Ответ собеседника: Правильный. Предлагается создать Experiment Decision сервис, который определяет принадлежность пользователя к экспериментам. Для этого ему нужен список всех экспериментов и алгоритм определения на основе сегментов.
Правильный ответ:
1. Архитектура Experiment Decision Service
┌─────────────────────────────────────────────────────────────────────────────┐
│ Experiment Decision Flow │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────────────────┐ │
│ │ Client │────▶│ Experiment Decision Service │ │
│ │ (Web/App) │ │ │ │
│ └─────────────┘ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ 1. Get Active Experiments │ │ │
│ │ │ 2. Filter by Segments │ │ │
│ │ │ 3. Check Mutual Exclusions │ │ │
│ │ │ 4. Apply Traffic Allocation │ │ │
│ │ │ 5. Assign Variant (Hashing) │ │ │
│ │ │ 6. Return Assignment │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Response │ │
│ │ { │ │
│ │ "user_id": "user_123", │ │
│ │ "assignments": [ │ │
│ │ { │ │
│ │ "experiment_id": "exp_001", │ │
│ │ "variant_id": "control", │ │
│ │ "variant_name": "Baseline" │ │
│ │ }, │ │
│ │ { │ │
│ │ "experiment_id": "exp_005", │ │
│ │ "variant_id": "treatment_a", │ │
│ │ "variant_name": "New Checkout" │ │
│ │ } │ │
│ │ ] │ │
│ │ } │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Основные компоненты сервиса
┌─────────────────────────────────────────────────────────────────────────────┐
│ Decision Service Components │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ A. Experiment Cache │ │
│ │ │ │
│ │ • Хранит список активных экспериментов │ │
│ │ • Обновляется при изменении статуса эксперимента │ │
│ │ • TTL: 5 минут │ │
│ │ • Хранение: Redis / in-memory │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "active_experiments" │ │ │
│ │ │ Value: [ │ │ │
│ │ │ { │ │ │
│ │ │ "id": "exp_001", │ │ │
│ │ │ "status": "running", │ │ │
│ │ │ "segment": {"country": ["US", "UK"], "platform": "web"},│ │ │
│ │ │ "traffic_percentage": 50, │ │ │
│ │ │ "variants": [ │ │ │
│ │ │ {"id": "control", "percentage": 50}, │ │ │
│ │ │ {"id": "treatment", "percentage": 50} │ │ │
│ │ │ ], │ │ │
│ │ │ "exclusion_group": "group_a" │ │ │
│ │ │ } │ │ │
│ │ │ ] │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ B. Segment Service │ │
│ │ │ │
│ │ • Определяет сегменты пользователя │ │
│ │ • Источники: CRM, Data Warehouse, собственные данные │ │
│ │ • Кэширование сегментов пользователя │ │
│ │ │ │
│ │ User Segments: │ │
│ │ { │ │
│ │ "user_id": "user_123", │ │
│ │ "country": "US", │ │
│ │ "platform": "web", │ │
│ │ "user_type": "returning", │ │
│ │ "subscription": "premium", │ │
│ │ "registration_date": "2024-01-15" │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ C. Assignment Cache │ │
│ │ │ │
│ │ • Кэширует назначения пользователь → эксперимент │ │
│ │ • Обеспечивает консистентность (один и тот же результат) │ │
│ │ • TTL: длительность эксперимента + буфер │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "assignment:user_123" │ │ │
│ │ │ Value: { │ │ │
│ │ │ "exp_001": "control", │ │ │
│ │ │ "exp_005": "treatment_a" │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Алгоритм определения принадлежности
package decision
// ExperimentDecisionService сервис определения принадлежности к экспериментам
type ExperimentDecisionService struct {
experimentStore ExperimentStore
segmentService SegmentService
assignmentCache AssignmentCache
hasher ConsistentHasher
}
// Experiment эксперимент
type Experiment struct {
ID string `json:"id"`
Status ExperimentStatus `json:"status"`
SegmentCriteria SegmentCriteria `json:"segment_criteria"`
TrafficPercentage int `json:"traffic_percentage"`
Variants []Variant `json:"variants"`
ExclusionGroup *string `json:"exclusion_group,omitempty"`
StartDate time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date,omitempty"`
}
// SegmentCriteria критерии сегмента
type SegmentCriteria struct Country []string `json:"country,omitempty"`
Platform []string `json:"platform,omitempty"`
UserType []string `json:"user_type,omitempty"`
Subscription []string `json:"subscription,omitempty"`
CustomRules []Rule `json:"custom_rules,omitempty"`
}
// Variant вариант эксперимента
type Variant struct {
ID string `json:"id"`
Name string `json:"name"`
Percentage int `json:"percentage"`
IsControl bool `json:"is_control"`
}
// UserContext контекст пользователя
type UserContext struct {
UserID string `json:"user_id"`
Country string `json:"country"`
Platform string `json:"platform"`
UserType string `json:"user_type"`
Subscription string `json:"subscription"`
Properties map[string]string `json:"properties"`
}
// Assignment назначение пользователя в эксперимент
type Assignment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
}
// GetAssignments определяет назначения пользователя
func (s *ExperimentDecisionService) GetAssignments(ctx context.Context, userCtx UserContext) ([]Assignment, error) {
// 1. Проверяем кэш назначений
cachedAssignments, err := s.assignmentCache.Get(ctx, userCtx.UserID)
if err == nil && len(cachedAssignments) > 0 {
return cachedAssignments, nil
}
// 2. Получаем активные эксперименты
activeExperiments, err := s.experimentStore.GetActiveExperiments(ctx)
if err != nil {
return nil, err
}
// 3. Фильтруем эксперименты по сегментам
eligibleExperiments := s.filterBySegments(activeExperiments, userCtx)
// 4. Проверяем взаимные исключения
eligibleExperiments = s.applyExclusionGroups(eligibleExperiments)
// 5. Назначаем варианты
assignments := s.assignVariants(eligibleExperiments, userCtx.UserID)
// 6. Кэшируем результат
if err := s.assignmentCache.Set(ctx, userCtx.UserID, assignments); err != nil {
log.Error("failed to cache assignments", err)
}
return assignments, nil
}
// filterBySegments фильтрует эксперименты по сегментам пользователя
func (s *ExperimentDecisionService) filterBySegments(experiments []Experiment, userCtx UserContext) []Experiment {
var eligible []Experiment
for _, exp := range experiments {
if s.matchesSegment(exp.SegmentCriteria, userCtx) {
eligible = append(eligible, exp)
}
}
return eligible
}
// matchesSegment проверяет соответствие пользователя сегменту
func (s *ExperimentDecisionService) matchesSegment(criteria SegmentCriteria, userCtx UserContext) bool {
// Проверяем страну
if len(criteria.Country) > 0 && !contains(criteria.Country, userCtx.Country) {
return false
}
// Проверяем платформу
if len(criteria.Platform) > 0 && !contains(criteria.Platform, userCtx.Platform) {
return false
}
// Проверяем тип пользователя
if len(criteria.UserType) > 0 && !contains(criteria.UserType, userCtx.UserType) {
return false
}
// Проверяем подписку
if len(criteria.Subscription) > 0 && !contains(criteria.Subscription, userCtx.Subscription) {
return false
}
// Проверяем кастомные правила
for _, rule := range criteria.CustomRules {
if !s.evaluateRule(rule, userCtx) {
return false
}
}
return true
}
// applyExclusionGroups применяет взаимные исключения
func (s *ExperimentDecisionService) applyExclusionGroups(experiments []Experiment) []Experiment {
// Группируем эксперименты по exclusion_group
exclusionGroups := make(map[string][]Experiment)
var independent []Experiment
for _, exp := range experiments {
if exp.ExclusionGroup != nil {
exclusionGroups[*exp.ExclusionGroup] = append(exclusionGroups[*exp.ExclusionGroup], exp)
} else {
independent = append(independent, exp)
}
}
// Из каждой группы исключания выбираем только один эксперимент
// (с более высоким приоритетом или случайно)
var result []Experiment
result = append(result, independent...)
for _, group := range exclusionGroups {
// Выбираем эксперимент с наивысшим приоритетом
selected := selectByPriority(group)
result = append(result, selected)
}
return result
}
// assignVariants назначает варианты пользователю
func (s *ExperimentDecisionService) assignVariants(experiments []Experiment, userID string) []Assignment {
var assignments []Assignment
for _, exp := range experiments {
// Проверяем процент трафика
if !s.isInTrafficPercentage(exp, userID) {
continue
}
// Определяем вариант через хеширование
variantID := s.determineVariant(exp, userID)
// Находим имя варианта
variantName := ""
for _, v := range exp.Variants {
if v.ID == variantID {
variantName = v.Name
break
}
}
assignments = append(assignments, Assignment{
ExperimentID: exp.ID,
VariantID: variantID,
VariantName: variantName,
})
}
return assignments
}
// isInTrafficPercentage проверяет попадание в процент трафика
func (s *ExperimentDecisionService) isInTrafficPercentage(exp Experiment, userID string) bool {
// Используем хеш user_id + experiment_id для детерминированного результата
hash := s.hasher.Hash(userID, exp.ID)
return hash%100 < exp.TrafficPercentage
}
// determineVariant определяет вариант пользователя
func (s *ExperimentDecisionService) determineVariant(exp Experiment, userID string) string {
// Хешируем user_id + experiment_id
hash := s.hasher.Hash(userID, exp.ID)
// Распределяем по вариантам пропорционально их процентам
cumulative := 0
for _, variant := range exp.Variants {
cumulative += variant.Percentage
if hash%100 < cumulative {
return variant.ID
}
}
// Fallback на control
return exp.Variants[0].ID
}
4. Consistent Hashing для консистентности
package decision
import (
"crypto/md5"
"encoding/binary"
)
// ConsistentHasher консистентный хешер
type ConsistentHasher struct{}
// Hash возвращает детерминированный хеш
func (h *ConsistentHasher) Hash(userID, experimentID string) int {
// Комбинируем user_id и experiment_id
combined := userID + ":" + experimentID
// MD5 хеш
hash := md5.Sum([]byte(combined))
// Берем первые 4 байта и конвертируем в int
return int(binary.BigEndian.Uint32(hash[:4]))
}
// Альтернатива: FNV-1a hash (быстрее, хорошая дистрибуция)
func (h *ConsistentHasher) HashFNV(userID, experimentID string) int {
combined := userID + ":" + experimentID
var hash uint32 = 2166136261
for _, c := range combined {
hash ^= uint32(c)
hash *= 16777619
}
return int(hash)
}
5. Кэширование назначений
package decision
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisAssignmentCache кэш назначений в Redis
type RedisAssignmentCache struct {
client *redis.Client
ttl time.Duration
}
// NewRedisAssignmentCache создает новый кэш
func NewRedisAssignmentCache(client *redis.Client, ttl time.Duration) *RedisAssignmentCache {
return &RedisAssignmentCache{
client: client,
ttl: ttl,
}
}
// Get получает назначения из кэша
func (c *RedisAssignmentCache) Get(ctx context.Context, userID string) ([]Assignment, error) {
key := c.buildKey(userID)
data, err := c.client.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}
var assignments []Assignment
if err := json.Unmarshal(data, &assignments); err != nil {
return nil, err
}
return assignments, nil
}
// Set сохраняет назначения в кэш
func (c *RedisAssignmentCache) Set(ctx context.Context, userID string, assignments []Assignment) error {
key := c.buildKey(userID)
data, err := json.Marshal(assignments)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, c.ttl).Err()
}
// Invalidate инвалидирует кэш пользователя
func (c *RedisAssignmentCache) Invalidate(ctx context.Context, userID string) error {
key := c.buildKey(userID)
return c.client.Del(ctx, key).Err()
}
func (c *RedisAssignmentCache) buildKey(userID string) string {
return fmt.Sprintf("experiment:assignment:%s", userID)
}
6. Mutual Exclusion Groups
┌─────────────────────────────────────────────────────────────────────────────┐
│ Mutual Exclusion Groups │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Problem │ │
│ │ │ │
│ │ Пользователь может попасть в несколько экспериментов одновременно │ │
│ │ Эксперименты могут влиять друг на друга │ │
│ │ │ │
│ │ Пример: │ │
│ │ • exp_001: Изменение цвета кнопки (Checkout) │ │
│ │ • exp_002: Изменение текста кнопки (Checkout) │ │
│ │ │ │
│ │ Если пользователь в обоих экспериментах → непонятно, │ │
│ │ какой эксперимент вызвал изменение конверсии │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Solution: Exclusion Groups │ │
│ │ │ │
│ │ Group: "checkout_page" │ │
│ │ ├── exp_001: Button Color (priority: 1) │ │
│ │ └── exp_002: Button Text (priority: 2) │ │
│ │ │ │
│ │ Пользователь попадает только в exp_001 (высший приоритет) │ │
│ │ │ │
│ │ Group: "homepage" │ │
│ │ ├── exp_003: Hero Banner (priority: 1) │ │
│ │ └── exp_004: Navigation (priority: 2) │ │
│ │ │ │
│ │ Пользователь попадает только в exp_003 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Hierarchical Exclusion │ │
│ │ │ │
│ │ Layer 1: Page-level │ │
│ │ ├── checkout_page │ │
│ ├── homepage │ │
│ │ └── product_page │ │
│ │ │ │
│ │ Layer 2: Feature-level │ │
│ │ ├── payment_flow │ │
│ │ ├── search_functionality │ │
│ │ └── recommendation_engine │ │
│ │ │ │
│ │ Layer 3: Global │ │
│ │ └── onboarding_flow │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Traffic Allocation
┌─────────────────────────────────────────────────────────────────────────────┐
│ Traffic Allocation Strategies │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ A. User-level Allocation │ │
│ │ │ │
│ │ • Один и тот же пользователь всегда в одном варианте │ │
│ │ • Детерминированное хеширование │ │
│ │ • Консистентность при повторных визитах │ │
│ │ │ │
│ │ User ID: "user_123" │ │
│ │ Hash("user_123" + "exp_001") = 42 │ │
│ │ 42 % 100 = 42 < 50 → Control group │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ B. Session-level Allocation │ │
│ │ │ │
│ │ • Каждая сессия может быть в разном варианте │ │
│ │ • Используется для экспериментов с UI │ │
│ │ • Меньшая консистентность, но больше данных │ │
│ │ │ │
│ │ Session ID: "sess_abc" │ │
│ │ Hash("sess_abc" + "exp_001") = 73 │ │
│ │ 73 % 100 = 73 >= 50 → Treatment group │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ C. Percentage-based Rollout │ │
│ │ │ │
│ │ • Постепенное увеличение трафика │ │
│ │ • 1% → 5% → 10% → 25% → 50% → 100% │ │
│ │ • Мониторинг метрик на каждом этапе │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Day 1: ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1% │ │ │
│ │ │ Day 3: ▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% │ │ │
│ │ │ Day 5: ▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 10% │ │ │
│ │ │ Day 7: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░ 25% │ │ │
│ │ │ Day 10: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ 50% │ │ │
│ │ │ Day 14: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 100% │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
8. API Endpoints
package api
// GetAssignmentsRequest запрос на получение назначений
type GetAssignmentsRequest struct {
UserID string `json:"user_id" validate:"required"`
Country string `json:"country"`
Platform string `json:"platform"`
Properties map[string]string `json:"properties"`
}
// GetAssignmentsResponse ответ с назначениями
type GetAssignmentsResponse struct {
UserID string `json:"user_id"`
Assignments []Assignment `json:"assignments"`
}
// Assignment назначение в эксперимент
type Assignment struct {
ExperimentID string `json:"experiment_id"`
VariantID string `json:"variant_id"`
VariantName string `json:"variant_name"`
}
// ExperimentDecisionHandler обработчик запросов
type ExperimentDecisionHandler struct {
decisionService *decision.ExperimentDecisionService
}
// GetAssignments возвращает назначения пользователя
func (h *ExperimentDecisionHandler) GetAssignments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GetAssignmentsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userCtx := decision.UserContext{
UserID: req.UserID,
Country: req.Country,
Platform: req.Platform,
Properties: req.Properties,
}
assignments, err := h.decisionService.GetAssignments(ctx, userCtx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := GetAssignmentsResponse{
UserID: req.UserID,
Assignments: assignments,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Краткий ответ: Experiment Decision Service определяет принадлежность пользователя к экспериментам через:
- Получение активных экспериментов из кэша
- Фильтрацию по сегментам — проверка соответствия критериям (country, platform, user_type)
- Применение exclusion groups — взаимные исключения для экспериментов на одной странице
- Traffic allocation — проверка процента трафика через хеширование
- Определение варианта — консистентное хеширование user_id + experiment_id
- Кэширование результата — Redis для консистентности при повторных запросах
Вопрос 40. Почему были выбраны конкретные технологии, а не их альтернативы?
Таймкод: 01:21:36
Ответ собеседова: Неполный. PostgreSQL выбрана как простая и надёжная для небольшого объёма данных. Kafka — для буферизации и сохранения порядка событий. ClickHouse — для аналитических запросов. Cassandra — для key-value хранения сегментов. Не сравнивались альтернативы вроде RabbitMQ или других очередей.
Правильный ответ:
Сравнение выбранных технологий с альтернативами
┌─────────────────────────────────────────────────────────────────────────────┐
│ СРАВНЕНИЕ ТЕХНОЛОГИЙ С АЛЬТЕРНАТИВАМИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ БРОКЕРЫ СООБЩЕНИЙ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Kafka │ │ RabbitMQ │ │ Pulsar │ │ NATS │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ АНАЛИТИЧЕСКИЕ БД │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ ClickHouse │ │ Greenplum │ │ Druid │ │ BigQuery │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ KEY-VALUE ХРАНИЛИЩА │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Cassandra │ │ ScyllaDB │ │ DynamoDB │ │ HBase │ │ │
│ │ │ ✓ Выбран │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Kafka vs RabbitMQ vs Pulsar
// Сравнение характеристик брокеров
type BrokerComparison struct {
Name string
Throughput string
Latency string
Ordering bool
Replay bool
MultiDC bool
UseCase string
}
var brokers = []BrokerComparison{
{
Name: "Kafka",
Throughput: "1M+ msg/sec",
Latency: "5-50ms",
Ordering: true,
Replay: true,
MultiDC: true,
UseCase: "Event streaming, log aggregation",
},
{
Name: "RabbitMQ",
Throughput: "50K msg/sec",
Latency: "<1ms",
Ordering: false,
Replay: false,
MultiDC: false,
UseCase: "Task queues, RPC",
},
{
Name: "Pulsar",
Throughput: "1M+ msg/sec",
Latency: "<5ms",
Ordering: true,
Replay: true,
MultiDC: true,
UseCase: "Streaming + queuing",
},
{
Name: "NATS",
Throughput: "10M+ msg/sec",
Latency: "<1ms",
Ordering: false,
Replay: true,
MultiDC: false,
UseCase: "Service mesh, real-time messaging",
},
}
Почему Kafka, а не RabbitMQ:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Kafka vs RabbitMQ для аналитики │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Kafka RabbitMQ │
│ ──────── ───── ──────── │
│ │
│ Пропускная 1M+ msg/sec 50K msg/sec │
│ способность (горизонтальное (вертикальное │
│ масштабирование) масштабирование) │
│ │
│ Хранение Дни/недели/месяцы До доставки │
│ сообщений (настраиваемый TTL) (нет хранения) │
│ │
│ Переplay Да (consumer groups) Нет │
│ событий (можно переиграть (сообщения │
│ с любого offset) удаляются) │
│ │
│ Порядок событий Да (в партиции) Нет гарантии │
│ (по user_id) (при множественных │
│ consumer) │
│ │
│ Модель Pull-based Push-based │
│ потребления (consumer сам (брокер отправляет │
│ забирает) сообщения) │
│ │
│ Идемпотентность Да (exactly-once Нет │
│ semantics) (at-most-once │
│ или at-least-once) │
│ │
│ Сложность Высокая Средняя │
│ эксплуатации (ZooKeeper/KRaft, (проще в │
│ настройка настройке) │
│ партиций) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Практический пример — почему Kafka лучше для аналитики:
// Сценарий: нужно пересчитать сегменты за последние 7 дней
// С Kafka это просто:
func (s *SegmentService) RecalculateSegments(ctx context.Context) error {
consumer := kafka.NewConsumer(kafka.Config{
Brokers: []string{"kafka:9092"},
Topic: "events.raw",
GroupID: "segment-recalc-2024-01",
})
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
offsets, err := consumer.GetOffsetsByTime(sevenDaysAgo)
if err != nil {
return err
}
for partition, offset := range offsets {
consumer.Seek(partition, offset)
}
for msg := range consumer.Messages() {
event := parseEvent(msg)
s.processSegmentUpdate(event)
}
return nil
}
// С RabbitMQ это НЕВОЗМОЖНО без дополнительных решений:
// - Сообщения уже удалены после обработки
// - Нет механизма "переиграть" историю
// - Нужно писать все сообщения в отдельное хранилище
2. ClickHouse vs Greenplum vs Druid
-- Сравнение производительности аналитических запросов
-- ClickHouse: ~2 секунды на 1 млрд строк
SELECT
segment_id,
uniq(user_id) as unique_users,
count() as total_events
FROM events
WHERE client_timestamp >= '2024-01-01'
AND client_timestamp < '2024-02-01'
GROUP BY segment_id
ORDER BY unique_users DESC;
-- Greenplum: ~15 секунд на 1 млрд строк
-- Druid: ~5 секунд на 1 млрд строк
Почему ClickHouse, а не Greenplum:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ClickHouse vs Greenplum для аналитики │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий ClickHouse Greenplum │
│ ──────── ────────── ───────── │
│ │
│ Архитектура Колоночная Реляционная │
│ (оптимизирована (PostgreSQL-based) │
│ для аналитики) │
│ │
│ Сжатие 10-100x 3-5x │
│ (кодирование, (TOAST, но менее │
│ delta encoding) эффективно) │
│ │
│ Агрегации Векторизованный Строка за строкой │
│ выполнение (или batch, но │
│ (SIMD-инструкции) медленнее) │
│ │
│ Вставка Batch insert Row-by-row │
│ (оптимально (медленнее │
│ для потоков) при bulk) │
│ │
│ UPDATE/DELETE Асинхронный ACID-совместимый │
│ (mutations) (но медленный) │
│ │
│ SQL-возможности Расширенный Полный SQL │
│ (но не все JOIN) (все JOIN, │
│ подзапросы) │
│ │
│ Масштабирование Шардирование Сегменты │
│ (ReplicatedMergeTree) (MPP-архитектура) │
│ │
│ Сложность Средняя Высокая │
│ эксплуатации (проще, чем (сложнее │
│ Greenplum) в настройке) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Cassandra vs ScyllaDB vs DynamoDB
// Сравнение Cassandra-совместимых хранилищ
type KVStoreComparison struct {
Name string
WriteThroughput string
ReadLatency string
Consistency string
MultiDC bool
Cost string
}
var kvStores = []KVStoreComparison{
{
Name: "Cassandra",
WriteThroughput: "100K writes/sec/node",
ReadLatency: "5-20ms",
Consistency: "Tunable (ONE, QUORUM, ALL)",
MultiDC: true,
Cost: "Free (open source)",
},
{
Name: "ScyllaDB",
WriteThroughput: "1M writes/sec/node",
ReadLatency: "1-5ms",
Consistency: "Tunable (как Cassandra)",
MultiDC: true,
Cost: "Free (open source) / Enterprise",
},
{
Name: "DynamoDB",
WriteThroughput: "Unlimited (платно)",
ReadLatency: "1-10ms",
Consistency: "Eventually or Strong",
MultiDC: true,
Cost: "Pay-per-request",
},
{
Name: "HBase",
WriteThroughput: "50K writes/sec/node",
ReadLatency: "10-50ms",
Consistency: "Strong (per row)",
MultiDC: false,
Cost: "Free (open source)",
},
}
Почему Cassandra, а не DynamoDB:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Cassandra vs DynamoDB для профилей │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Cassandra DynamoDB │
│ ──────── ────────── ──────── │
│ │
│ Стоимость Фиксированная Переменная │
│ (зависит от (зависит от │
│ количества нод) объёма запросов) │
│ │
│ Предсказуемость Да Нет │
│ стоимости (известная инфра) (может быть │
│ непредсказуемо) │
│ │
│ Модель данных Wide-column Document │
│ (гибкая схема) (ограниченная │
│ схема) │
│ │
│ Multi-DC Built-in Global Tables │
│ (настраиваемая) (менее гибкая) │
│ │
│ Зависимость Нет (on-premise) Да (AWS) │
│ от vendor или любой облако │
│ │
│ Сложность Высокая Низкая │
│ эксплуатации (нужен DBA) (managed service) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. PostgreSQL — для метаданных
-- PostgreSQL для хранения метаданных системы
CREATE TABLE segments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
definition JSONB NOT NULL,
owner_id INTEGER REFERENCES users(id),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
estimated_count BIGINT,
last_calculated_at TIMESTAMPTZ
);
CREATE INDEX idx_segments_definition ON segments USING GIN (definition);
CREATE TABLE experiments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
hypothesis TEXT,
status VARCHAR(20) DEFAULT 'draft',
traffic_percentage DECIMAL(5,2),
targeting_rules JSONB,
variants JSONB NOT NULL,
primary_metric_id INTEGER REFERENCES metrics(id),
secondary_metrics INTEGER[],
guardrail_metrics INTEGER[],
planned_start TIMESTAMPTZ,
planned_end TIMESTAMPTZ,
actual_start TIMESTAMPTZ,
actual_end TIMESTAMPTZ,
results JSONB,
winner_variant VARCHAR(50),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Почему PostgreSQL для метаданных:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Почему PostgreSQL для метаданных │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Обоснование │
│ ──────── ────────── │
│ │
│ ACID-транзакции Важна целостность данных о сегментах, │
│ экспериментах, дашбордах │
│ │
│ JSONB Гибкая схема для определений сегментов, │
│ конфигураций экспериментов │
│ │
│ Сложные запросы JOIN для получения данных о сегментах │
│ с информацией об авторе │
│ │
│ Объём данных Метаданные — это MB-GB, не TB │
│ (не нужна горизонтальная масштабируемость) │
│ │
│ Зрелость Проверенная технология с большим сообществом │
│ │
│ Инструменты pgAdmin, DBeaver, миграции (golang-migrate) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Сводная таблица выбора технологий:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ИТОГОВОЕ ОБОСНОВАНИЕ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Компонент Выбрано Альтернативы Почему именно это │
│ ───────── ─────── ──────────── ───────────────── │
│ │
│ Event streaming Kafka RabbitMQ, Пропускная │
│ Pulsar способность, │
│ replay, │
│ ordering │
│ │
│ Аналитика ClickHouse Greenplum, Колоночное │
│ Druid, BigQuery хранение, │
│ скорость, │
│ стоимость │
│ │
│ Профили Cassandra ScyllaDB, Multi-DC, │
│ DynamoDB tunable │
│ consistency │
│ │
│ Кэш Redis Memcached, Скорость, │
│ KeyDB структуры │
│ данных │
│ │
│ Метаданные PostgreSQL MySQL, SQLite ACID, JSONB, │
│ зрелость │
│ │
│ Язык Go Java, Python, Производительность, │
│ Rust конкурентность, │
│ простота │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Выбор каждой технологии обоснован конкретными требованиями системы:
- Kafka — единственный брокер с replay и ordering для event streaming
- ClickHouse — оптимален для аналитики с колоночным хранением
- Cassandra — лучший выбор для key-value с Multi-DC
- Redis — стандарт для кэширования
- PostgreSQL — проверенное решение для метаданных с ACID
- Go — оптимален для высоконагруженных сервисов
Все технологии дополняют друг друга и образуют целостный стек для аналитической платформы.
Вопрос 28. Какие типы детерминации (правил распределения) существуют в экспериментах?
Таймкод: 00:54:24
Ответ собеседника: Правильный. Уточняется, что есть аудиторные сегменты (по полу, продуктам, источникам трафика) и технические (по геолокации, устройству, наличию NFC). Для текущего проектирования фокус на аудиторных сегментах.
Правильный ответ:
1. Классификация типов детерминации
┌─────────────────────────────────────────────────────────────────────────────┐
│ Types of Experiment Determination │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Аудиторные сегменты (Audience Segments) │ │
│ │ │ │
│ │ Основаны на характеристиках пользователя: │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Демографические: │ │ │
│ │ │ • Пол (gender) │ │ │
│ │ │ • Возраст (age, age_group) │ │ │
│ │ │ • Семейное положение (marital_status) │ │ │
│ │ │ • Доход (income_level) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Поведенческие: │ │ │
│ │ │ • Тип пользователя (new, returning, churned) │ │ │
│ │ │ • Частота покупок (frequency) │ │ │
│ │ │ • Средний чек (aov_segment) │ │ │
│ │ │ • LTV-сегмент (high_value, medium_value, low_value) │ │ │
│ │ │ • Retention (active, dormant, reactivated) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Продуктовые: │ │ │
│ │ │ • Подписка (subscription_type: free, premium, enterprise) │ │ │
│ │ │ • Категория интересов (category_affinity) │ │ │
│ │ │ • Используемые продукты (product_usage) │ │ │
│ │ │ • Статус онбординга (onboarding_completed) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Источники трафика: │ │ │
│ │ │ • Канал привлечения (utm_source, utm_medium) │ │ │
│ │ │ • Кампания (utm_campaign) │ │ │
│ │ │ • Реферер (referrer_domain) │ │ │
│ │ │ • Тип трафика (organic, paid, direct, referral) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Технические сегменты (Technical Segments) │ │
│ │ │ │
│ │ Основаны на технических характеристиках: │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Геолокация: │ │ │
│ │ │ • Страна (country) │ │ │
│ │ │ • Регион/штат (region, state) │ │ │
│ │ │ • Город (city) │ │ │
│ │ │ • Часовой пояс (timezone) │ │ │
│ │ │ • Язык (language, locale) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Устройство: │ │ │
│ │ │ • Тип устройства (device_type: mobile, desktop, tablet) │ │ │
│ │ │ • ОС (os: iOS, Android, Windows, macOS) │ │ │
│ │ │ • Версия ОС (os_version) │ │ │
│ │ │ • Модель устройства (device_model) │ │ │
│ │ │ • Браузер (browser: Chrome, Safari, Firefox) │ │ │
│ │ │ • Версия браузера (browser_version) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Технические возможности: │ │ │
│ │ │ • Наличие NFC (nfc_enabled: true/false) │ │ │
│ │ │ • Push-уведомления (push_enabled) │ │ │
│ │ │ • Биометрия (biometric_auth) │ │ │
│ │ │ • Размер экрана (screen_size) │ │ │
│ │ │ • Подключение (connection_type: wifi, cellular) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Контекстные сегменты (Contextual Segments) │ │
│ │ │ │
│ │ Основаны на контексте взаимодействия: │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Временные: │ │ │
│ │ │ • Время суток (time_of_day: morning, afternoon, evening) │ │ │
│ │ │ • День недели (day_of_week) │ │ │
│ │ │ • Сезонность (season) │ │ │
│ │ │ • Праздники (is_holiday) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Сессионные: │ │ │
│ │ │ • Номер сессии (session_number: 1st, 2nd, 3rd+) │ │ │
│ │ │ • Глубина просмотра (page_depth) │ │ │
│ │ │ • Время на сайте (time_on_site) │ │ │
│ │ │ • Источник входа (entry_point) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Поведенческие в сессии: │ │ │
│ │ │ • Корзина не пуста (has_cart_items) │ │ │
│ │ │ • Был поиск (performed_search) │ │ │
│ │ │ • Применял фильтры (used_filters) │ │ │
│ │ │ • Добавил в избранное (added_to_wishlist) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Реализация правил детерминации
package segment
// SegmentRule правило сегментации
type SegmentRule struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value interface{} `json:"value"`
}
// SegmentCriteria критерии сегмента
type SegmentCriteria struct {
Rules []SegmentRule `json:"rules"`
Logic string `json:"logic"` // "AND" или "OR"
Properties map[string]string `json:"properties,omitempty"`
}
// SegmentEvaluator оценщик сегментов
type SegmentEvaluator struct {
userService UserService
segmentService SegmentService
propertyService PropertyService
}
// Evaluate проверяет соответствие пользователя сегменту
func (e *SegmentEvaluator) Evaluate(ctx context.Context, userID string, criteria SegmentCriteria) (bool, error) {
user, err := e.userService.GetUser(ctx, userID)
if err != nil {
return false, err
}
results := make([]bool, len(criteria.Rules))
for i, rule := range criteria.Rules {
results[i], err = e.evaluateRule(ctx, user, rule)
if err != nil {
return false, err
}
}
// Применяем логику AND/OR
if criteria.Logic == "AND" {
for _, r := range results {
if !r {
return false, nil
}
}
return true, nil
}
// OR logic
for _, r := range results {
if r {
return true, nil
}
}
return false, nil
}
// evaluateRule оценивает одно правило
func (e *SegmentEvaluator) evaluateRule(ctx context.Context, user *User, rule SegmentRule) (bool, error) {
switch rule.Field {
// Аудиторные сегменты
case "gender":
return compareString(user.Gender, rule.Operator, rule.Value.(string)), nil
case "age":
return compareInt(user.Age, rule.Operator, rule.Value.(int)), nil
case "age_group":
ageGroup := calculateAgeGroup(user.Age)
return compareString(ageGroup, rule.Operator, rule.Value.(string)), nil
case "user_type":
return compareString(user.UserType, rule.Operator, rule.Value.(string)), nil
case "subscription":
return compareString(user.Subscription, rule.Operator, rule.Value.(string)), nil
// Технические сегменты
case "country":
return compareString(user.Country, rule.Operator, rule.Value.(string)), nil
case "platform":
return compareString(user.Platform, rule.Operator, rule.Value.(string)), nil
case "device_type":
return compareString(user.DeviceType, rule.Operator, rule.Value.(string)), nil
case "os":
return compareString(user.OS, rule.Operator, rule.Value.(string)), nil
case "nfc_enabled":
return compareBool(user.NFCEnabled, rule.Operator, rule.Value.(bool)), nil
// Кастомные свойства
default:
propValue, err := e.propertyService.GetProperty(ctx, user.ID, rule.Field)
if err != nil {
return false, err
}
return compareInterface(propValue, rule.Operator, rule.Value), nil
}
}
// Функции сравнения
func compareString(field, operator string, value string) bool {
switch operator {
case "equals":
return field == value
case "not_equals":
return field != value
case "in":
return containsStringSlice(value, field)
case "not_in":
return !containsStringSlice(value, field)
default:
return false
}
}
func compareInt(field int, operator string, value int) bool {
switch operator {
case "equals":
return field == value
case "not_equals":
return field != value
case "greater_than":
return field > value
case "less_than":
return field < value
case "between":
bounds := value.([]int)
return field >= bounds[0] && field <= bounds[1]
default:
return false
}
}
3. Примеры сегментов в формате JSON
// Пример 1: Премиум-пользователи из США на iOS
{
"segment_id": "premium_us_ios",
"name": "Premium US iOS Users",
"logic": "AND",
"rules": [
{
"field": "subscription",
"operator": "equals",
"value": "premium"
},
{
"field": "country",
"operator": "equals",
"value": "US"
},
{
"field": "os",
"operator": "equals",
"value": "iOS"
}
]
}
// Пример 2: Новые пользователи или вернувшиеся после 30 дней
{
"segment_id": "new_or_returning",
"name": "New or Returning Users",
"logic": "OR",
"rules": [
{
"field": "user_type",
"operator": "equals",
"value": "new"
},
{
"field": "days_since_last_visit",
"operator": "greater_than",
"value": 30
}
]
}
// Пример 3: Мобильные пользователи с NFC в определённых городах
{
"segment_id": "mobile_nfc_cities",
"name": "Mobile NFC in Target Cities",
"logic": "AND",
"rules": [
{
"field": "device_type",
"operator": "equals",
"value": "mobile"
},
{
"field": "nfc_enabled",
"operator": "equals",
"value": true
},
{
"field": "city",
"operator": "in",
"value": ["New York", "Los Angeles", "Chicago", "San Francisco"]
}
]
}
// Пример 4: Высокодоходные пользователи с частыми покупками
{
"segment_id": "high_value_frequent",
"name": "High Value Frequent Buyers",
"logic": "AND",
"rules": [
{
"field": "ltv_segment",
"operator": "in",
"value": ["high_value", "vip"]
},
{
"field": "purchase_frequency",
"operator": "greater_than",
"value": 5
},
{
"field": "days_since_registration",
"operator": "greater_than",
"value": 90
}
]
}
4. Источники данных для сегментации
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Sources for Segmentation │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ A. Real-time Data (Онлайн) │ │
│ │ │ │
│ │ • User Service — базовая информация о пользователе │ │
│ │ • Session Service — данные текущей сессии │ │
│ │ • Device Detection — информация об устройстве │ │
│ │ • GeoIP Service — геолокация по IP │ │
│ │ │ │
│ │ Latency: < 10ms │ │
│ │ Consistency: Strong │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ B. Near Real-time Data (Stream Processing) │ │
│ │ │ │
│ │ • Kafka + Flink — агрегация событий в реальном времени │ │
│ │ • Redis — кэш сегментов пользователя │ │
│ │ • ClickHouse — аналитические запросы │ │
│ │ │ │
│ │ Latency: seconds to minutes │ │
│ │ Consistency: Eventual │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ C. Batch Data (Оффлайн) │ │
│ │ │ │
│ │ • Data Warehouse — исторические данные, LTV, когорты │ │
│ │ • CRM System — профиль клиента, сегментация по ценности │ │
│ │ • ML Models — предиктивные сегменты (churn probability) │ │
│ │ │ │
│ │ Latency: hours to days │ │
│ │ Consistency: Eventual │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Приоритизация сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Segment Priority Levels │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Priority 1: Hard Requirements (Обязательные) │ │
│ │ │ │
│ │ • Технические ограничения │ │
│ │ • Регуляторные требования │ │
│ │ • Безопасность │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • "Только пользователи с NFC" — для эксперимента с NFC-оплатой │ │
│ │ • "Только 18+" — для эксперимента с алкоголем │ │
│ │ • "Только EU" — для GDPR-совместимых фичей │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Priority 2: Business Requirements (Бизнес-требования) │ │
│ │ │ │
│ │ • Целевая аудитория эксперимента │ │
│ │ • Продуктовые ограничения │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • "Только premium-пользователи" — для тестирования новой фичи │ │
│ │ • "Только активные покупатели" — для тестирования checkout │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Priority 3: Optimization (Оптимизация) │ │
│ │ │ │
│ │ • Улучшение статистической мощности │ │
│ │ • Снижение дисперсии │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • "Пользователи с похожим поведением" — для CUPED │ │
│ │ • "Пользователи из одной когорты" — для снижения шума │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. SQL-запросы для сегментации
-- Создание таблицы сегментов
CREATE TABLE user_segments (
user_id String,
segment_id String,
segment_name String,
-- Аудиторные атрибуты
gender LowCardinality(String),
age_group LowCardinality(String),
user_type LowCardinality(String),
subscription LowCardinality(String),
ltv_segment LowCardinality(String),
-- Технические атрибуты
country LowCardinality(String),
platform LowCardinality(String),
device_type LowCardinality(String),
os LowCardinality(String),
nfc_enabled UInt8,
-- Метаданные
updated_at DateTime,
expires_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, segment_id);
-- Запрос: пользователи для эксперимента
SELECT
user_id,
-- Проверяем все критерии сегмента
multiIf(
subscription = 'premium'
AND country = 'US'
AND os = 'iOS', 'premium_us_ios',
user_type = 'new'
OR days_since_last_visit > 30, 'new_or_returning',
device_type = 'mobile'
AND nfc_enabled = 1
AND city IN ('New York', 'Los Angeles'), 'mobile_nfc_cities',
'other'
) as matched_segment
FROM user_segments
WHERE updated_at >= now() - INTERVAL 1 DAY;
-- Запрос: подсчёт пользователей по сегментам
SELECT
segment_id,
count() as user_count,
uniqExact(user_id) as unique_users,
-- Разбивка по подсегментам
countIf(subscription = 'premium') as premium_users,
countIf(country = 'US') as us_users,
countIf(os = 'iOS') as ios_users
FROM user_segments
GROUP BY segment_id
ORDER BY user_count DESC;
7. Рекомендации по выбору сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Segment Selection Guidelines │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ✅ DO: │ │
│ │ │ │
│ │ • Используйте минимально необходимый набор критериев │ │
│ │ Чем больше критериев → меньше пользователей → дольше эксперимент │ │
│ │ │ │
│ │ • Начинайте с широких сегментов, затем сужайте │ │
│ │ Все пользователи → Активные → Целевой сегмент │ │
│ │ │ │
│ │ • Учитывайте технические ограничения │ │
│ │ Если фича требует NFC — фильтруйте по NFC в первую очередь │ │
│ │ │ │
│ │ • Документируйте критерии сегментации │ │
│ │ Для воспроизводимости и аудита результатов │ │
│ │ │ │
│ │ • Используйте кэширование сегментов │ │
│ │ Не пересчитывайте при каждом запросе │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ❌ DON'T: │ │
│ │ │ │
│ │ • Не используйте слишком узкие сегменты │ │
│ │ Риск: недостаточно данных для анализа │ │
│ │ │ │
│ │ • Не комбинируйте несовместимые критерии │ │
│ │ Пример: "Новые пользователи" + "LTV > $1000" │ │
│ │ │ │
│ │ • Не забывайте про кросс-платформенность │ │
│ │ Пользователь может быть на нескольких устройствах │ │
│ │ │ │
│ │ • Не игнорируйте временные факторы │ │
│ │ Сезонность, праздники, выходные │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Типы детерминации в экспериментах делятся на:
- Аудиторные сегменты — демография (пол, возраст), поведение (тип пользователя, LTV), продуктовые (подписка, категории), источники трафика
- Технические сегменты — геолокация (страна, город), устройство (тип, ОС, браузер), возможности (NFC, push)
- Контекстные сегменты — время (сутки, день недели), сессия (номер, глубина), поведение в сессии (корзина, поиск)
Для текущего проектирования фокус на аудиторных сегментах как наиболее релевантных для бизнес-экспериментов.
Вопрос 41. Можно ли применить паттерн BFF (Backend for Frontend) в этой системе?
Таймкод: 01:27:23
Ответ собеседова: Правильный. BFF применим, особенно для веба с server-driven UI. Можно создать сервис, который получает базовое отображение, применяет эксперименты и отдаёт модифицированный результат клиенту. Для мобильных приложений сейчас тоже движутся к такому подходу для некоторых экранов.
Правильный ответ:
BFF (Backend for Frontend) в аналитической системе
┌─────────────────────────────────────────────────────────────────────────────┐
│ АРХИТЕКТУРА С BFF │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web App │ │ Mobile iOS │ │ Mobile │ │ Admin │ │
│ │ (React) │ │ (Swift) │ │ Android │ │ Dashboard │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web BFF │ │ iOS BFF │ │ Android BFF │ │ Admin BFF │ │
│ │ │ │ │ │ │ │ │ │
│ │ • Server- │ │ • Native │ │ • Native │ │ • GraphQL │ │
│ │ driven UI │ │ UI │ │ UI │ │ • Complex │ │
│ │ • SSR │ │ • Config │ │ • Config │ │ queries │ │
│ │ • A/B │ │ API │ │ API │ │ • Real-time │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ └────────────────┴────────────────┴────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────┐ │
│ │ Core Services │ │
│ │ │ │
│ │ • Experiment Service │ │
│ │ • Segment Service │ │
│ │ • Analytics Service │ │
│ │ • User Profile Service │ │
│ └───────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Web BFF — Server-Driven UI с A/B экспериментами
// Web BFF сервис
type WebBFF struct {
experimentSvc ExperimentService
segmentSvc SegmentService
templateSvc TemplateService
configSvc RemoteConfigService
}
// Запрос на получение страницы
type PageRequest struct {
UserID uint64 `json:"user_id"`
PageID string `json:"page_id"`
Platform string `json:"platform"`
Locale string `json:"locale"`
DeviceInfo DeviceInfo `json:"device_info"`
Headers map[string]string `json:"headers"`
}
type DeviceInfo struct {
Type string `json:"type"` // desktop, tablet, mobile
ScreenSize string `json:"screen_size"`
OS string `json:"os"`
}
// Ответ — структура страницы с компонентами
type PageResponse struct {
PageID string `json:"page_id"`
Layout string `json:"layout"`
Components []Component `json:"components"`
Metadata map[string]interface{} `json:"metadata"`
Experiments []ExperimentAssignment `json:"experiments"`
}
// Компонент UI
type Component struct {
ID string `json:"id"`
Type string `json:"type"` // banner, card, list, form
Variant string `json:"variant,omitempty"`
Props map[string]interface{} `json:"props"`
Children []Component `json:"children,omitempty"`
Visibility *VisibilityRule `json:"visibility,omitempty"`
Events []EventConfig `json:"events,omitempty"`
}
// Правило видимости компонента
type VisibilityRule struct {
SegmentIDs []uint32 `json:"segment_ids,omitempty"`
Platforms []string `json:"platforms,omitempty"`
ScreenSizes []string `json:"screen_sizes,omitempty"`
Experiments []string `json:"experiments,omitempty"`
Condition string `json:"condition"` // all, any
}
// Получение страницы с учётом экспериментов
func (bff *WebBFF) GetPage(ctx context.Context, req PageRequest) (*PageResponse, error) {
// 1. Получаем базовый шаблон страницы
template, err := bff.templateSvc.GetTemplate(ctx, req.PageID, req.Locale)
if err != nil {
return nil, fmt.Errorf("failed to get template: %w", err)
}
// 2. Определяем сегменты пользователя
segments, err := bff.segmentSvc.GetUserSegments(ctx, req.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get user segments: %w", err)
}
// 3. Получаем активные эксперименты для страницы
experiments, err := bff.experimentSvc.GetPageExperiments(ctx, req.PageID, req.UserID, segments)
if err != nil {
return nil, fmt.Errorf("failed to get experiments: %w", err)
}
// 4. Применяем эксперименты к шаблону
response := bff.applyExperiments(template, experiments, req)
// 5. Фильтруем компоненты по сегментам и платформе
response.Components = bff.filterComponents(response.Components, segments, req)
// 6. Отправляем событие показа страницы
bff.trackPageView(ctx, req, experiments)
return response, nil
}
// Применение экспериментов к шаблону
func (bff *WebBFF) applyExperiments(template *Template, experiments []ExperimentAssignment, req PageRequest) *PageResponse {
response := &PageResponse{
PageID: template.PageID,
Layout: template.Layout,
Components: make([]Component, len(template.Components)),
Experiments: experiments,
}
copy(response.Components, template.Components)
for _, exp := range experiments {
switch exp.ExperimentID {
case "homepage_banner_test":
// Меняем баннер в зависимости от варианта
for i, comp := range response.Components {
if comp.ID == "main_banner" {
response.Components[i] = bff.applyBannerVariant(comp, exp.Variant)
}
}
case "pricing_page_redesign":
// Полностью заменяем layout
if exp.Variant == "test" {
response.Layout = "pricing_v2"
response.Components = bff.getPricingV2Components()
}
case "cta_button_color":
// Меняем цвет кнопки
for i, comp := range response.Components {
if comp.Type == "button" && comp.ID == "cta_primary" {
response.Components[i].Props["color"] = exp.Variant
}
}
}
}
return response
}
func (bff *WebBFF) applyBannerVariant(comp Component, variant string) Component {
variants := map[string]map[string]interface{}{
"control": {
"title": "Welcome to Our Platform",
"image": "/images/banner_default.jpg",
"cta": "Get Started",
},
"variant_a": {
"title": "Boost Your Productivity Today",
"image": "/images/banner_productivity.jpg",
"cta": "Try Free",
},
"variant_b": {
"title": "Join 10,000+ Happy Users",
"image": "/images/banner_social_proof.jpg",
"cta": "See Pricing",
},
}
if v, ok := variants[variant]; ok {
comp.Props["title"] = v["title"]
comp.Props["image"] = v["image"]
comp.Props["cta"] = v["cta"]
}
return comp
}
// Фильтрация компонентов по правилам видимости
func (bff *WebBFF) filterComponents(components []Component, segments []uint32, req PageRequest) []Component {
var filtered []Component
for _, comp := range components {
if comp.Visibility == nil {
filtered = append(filtered, comp)
continue
}
visible := bff.checkVisibility(*comp.Visibility, segments, req)
if visible {
filtered = append(filtered, comp)
}
}
return filtered
}
func (bff *WebBFF) checkVisibility(rule VisibilityRule, segments []uint32, req PageRequest) bool {
checks := []bool{}
// Проверка сегментов
if len(rule.SegmentIDs) > 0 {
hasSegment := false
for _, seg := range segments {
for _, ruleSeg := range rule.SegmentIDs {
if seg == ruleSeg {
hasSegment = true
break
}
}
}
checks = append(checks, hasSegment)
}
// Проверка платформы
if len(rule.Platforms) > 0 {
platformMatch := false
for _, p := range rule.Platforms {
if p == req.Platform {
platformMatch = true
break
}
}
checks = append(checks, platformMatch)
}
// Проверка размера экрана
if len(rule.ScreenSizes) > 0 {
screenMatch := false
for _, s := range rule.ScreenSizes {
if s == req.DeviceInfo.ScreenSize {
screenMatch = true
break
}
}
checks = append(checks, screenMatch)
}
// Применение условия
if len(checks) == 0 {
return true
}
switch rule.Condition {
case "all":
for _, c := range checks {
if !c {
return false
}
}
return true
case "any":
for _, c := range checks {
if c {
return true
}
}
return false
default:
return true
}
}
2. Mobile BFF — Remote Config API
// Mobile BFF для iOS/Android
type MobileBFF struct {
experimentSvc ExperimentService
configSvc RemoteConfigService
featureSvc FeatureFlagService
}
// Запрос конфигурации
type ConfigRequest struct {
UserID uint64 `json:"user_id"`
AppVersion string `json:"app_version"`
Platform string `json:"platform"` // ios, android
BuildNumber int `json:"build_number"`
Locale string `json:"locale"`
DeviceModel string `json:"device_model"`
OSVersion string `json:"os_version"`
}
// Ответ с конфигурацией
type ConfigResponse struct {
ConfigVersion string `json:"config_version"`
Features map[string]FeatureFlag `json:"features"`
Experiments []MobileExperiment `json:"experiments"`
RemoteConfig map[string]interface{} `json:"remote_config"`
UIConfig UIConfiguration `json:"ui_config"`
}
// Feature flag
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Variant string `json:"variant,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
Rollout float64 `json:"rollout"` // 0.0 - 1.0
}
// Эксперимент для мобильного
type MobileExperiment struct {
ID string `json:"id"`
Name string `json:"name"`
Variant string `json:"variant"`
Properties map[string]interface{} `json:"properties"`
}
// UI конфигурация
type UIConfiguration struct {
Theme string `json:"theme"`
PrimaryColor string `json:"primary_color"`
FontFamily string `json:"font_family"`
Layout string `json:"layout"`
Navigation NavigationConfig `json:"navigation"`
Screens []ScreenConfig `json:"screens"`
}
type NavigationConfig struct {
Type string `json:"type"` // tab, drawer, stack
Items []NavItem `json:"items"`
Order []string `json:"order"`
}
type NavItem struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon"`
ScreenID string `json:"screen_id"`
Badge *Badge `json:"badge,omitempty"`
Visible bool `json:"visible"`
}
type ScreenConfig struct {
ID string `json:"id"`
Title string `json:"title"`
Layout string `json:"layout"`
Components []MobileComponent `json:"components"`
Actions []Action `json:"actions"`
}
// Получение конфигурации для мобильного приложения
func (bff *MobileBFF) GetConfig(ctx context.Context, req ConfigRequest) (*ConfigResponse, error) {
// 1. Получаем базовую конфигурацию для версии приложения
baseConfig, err := bff.configSvc.GetBaseConfig(ctx, req.AppVersion, req.Platform)
if err != nil {
return nil, err
}
// 2. Определяем сегменты пользователя
segments, err := bff.experimentSvc.GetUserSegments(ctx, req.UserID)
if err != nil {
return nil, err
}
// 3. Получаем feature flags
features, err := bff.featureSvc.GetFlags(ctx, req.UserID, segments, req.Platform, req.AppVersion)
if err != nil {
return nil, err
}
// 4. Получаем эксперименты
experiments, err := bff.experimentSvc.GetMobileExperiments(ctx, req.UserID, segments, req.Platform)
if err != nil {
return nil, err
}
// 5. Собираем финальную конфигурацию
response := &ConfigResponse{
ConfigVersion: baseConfig.Version,
Features: features,
Experiments: experiments,
RemoteConfig: bff.mergeRemoteConfig(baseConfig.RemoteConfig, experiments),
UIConfig: bff.buildUIConfig(baseConfig.UIConfig, experiments, features),
}
return response, nil
}
// Сборка UI конфигурации с учётом экспериментов
func (bff *MobileBFF) buildUIConfig(base UIConfiguration, experiments []MobileExperiment, features map[string]FeatureFlag) UIConfiguration {
config := base
for _, exp := range experiments {
switch exp.ID {
case "navigation_redesign":
if exp.Variant == "test" {
config.Navigation.Type = "drawer"
config.Navigation.Items = []NavItem{
{ID: "home", Label: "Home", Icon: "home", ScreenID: "home_screen"},
{ID: "search", Label: "Search", Icon: "search", ScreenID: "search_screen"},
{ID: "profile", Label: "Profile", Icon: "person", ScreenID: "profile_screen"},
}
}
case "onboarding_flow":
if exp.Variant == "simplified" {
config.Screens = bff.filterScreens(config.Screens, []string{"welcome", "permissions", "main"})
}
case "dark_mode_default":
if exp.Variant == "enabled" {
config.Theme = "dark"
config.PrimaryColor = "#BB86FC"
}
}
}
// Применяем feature flags
if newFeatures, ok := features["new_checkout_flow"]; ok && newFeatures.Enabled {
config = bff.applyNewCheckoutFlow(config, newFeatures.Properties)
}
return config
}
3. Admin BFF — GraphQL API для дашборда
// Admin BFF с GraphQL
type AdminBFF struct {
analyticsSvc AnalyticsQueryService
experimentSvc ExperimentService
segmentSvc SegmentService
dashboardSvc DashboardService
}
// GraphQL запрос для получения данных дашборда
type DashboardQuery struct {
DashboardID string `json:"dashboard_id"`
Filters map[string]interface{} `json:"filters"`
DateRange DateRange `json:"date_range"`
Granularity string `json:"granularity"` // hour, day, week, month
}
// Пример GraphQL схемы
const schema = `
type Query {
dashboard(id: ID!, dateRange: DateRangeInput!): Dashboard
experiment(id: ID!): Experiment
segment(id: ID!): Segment
experiments(filter: ExperimentFilter, limit: Int, offset: Int): ExperimentConnection
segments(filter: SegmentFilter, limit: Int, offset: Int): SegmentConnection
}
type Dashboard {
id: ID!
name: String!
widgets: [Widget!]!
lastUpdated: DateTime!
}
type Widget {
id: ID!
type: WidgetType!
title: String!
data: WidgetData!
config: WidgetConfig
}
type WidgetData {
metrics: [MetricValue!]!
chart: ChartData
table: TableData
}
type MetricValue {
name: String!
value: Float!
previousValue: Float
change: Float
changePercent: Float
}
type ChartData {
type: ChartType!
series: [DataSeries!]!
xAxis: AxisConfig!
yAxis: AxisConfig!
}
type DataSeries {
name: String!
data: [DataPoint!]!
color: String
}
type DataPoint {
x: String!
y: Float!
}
type Experiment {
id: ID!
name: String!
status: ExperimentStatus!
variants: [Variant!]!
metrics: [ExperimentMetric!]!
startDate: DateTime!
endDate: DateTime
results: ExperimentResults
}
type ExperimentResults {
winner: Variant
confidence: Float!
uplift: Float
metrics: [MetricResult!]!
}
type MetricResult {
name: String!
controlValue: Float!
testValue: Float!
pValue: Float!
isSignificant: Boolean!
confidenceInterval: ConfidenceInterval!
}
`
// Обработчик GraphQL запросов
func (bff *AdminBFF) ResolveDashboard(ctx context.Context, query DashboardQuery) (*Dashboard, error) {
// Получаем конфигурацию дашборда
dashboard, err := bff.dashboardSvc.GetDashboard(ctx, query.DashboardID)
if err != nil {
return nil, err
}
// Параллельно загружаем данные для всех widgets
var wg sync.WaitGroup
errChan := make(chan error, len(dashboard.Widgets))
for i := range dashboard.Widgets {
wg.Add(1)
go func(idx int) {
defer wg.Done()
widget := &dashboard.Widgets[idx]
data, err := bff.resolveWidgetData(ctx, widget, query)
if err != nil {
errChan <- err
return
}
widget.Data = *data
}(i)
}
wg.Wait()
close(errChan)
// Проверяем ошибки
for err := range errChan {
if err != nil {
return nil, err
}
}
return dashboard, nil
}
func (bff *AdminBFF) resolveWidgetData(ctx context.Context, widget *Widget, query DashboardQuery) (*WidgetData, error) {
switch widget.Type {
case "metric_cards":
return bff.resolveMetricCards(ctx, widget, query)
case "line_chart":
return bff.resolveLineChart(ctx, widget, query)
case "table":
return bff.resolveTable(ctx, widget, query)
case "experiment_results":
return bff.resolveExperimentResults(ctx, widget, query)
case "funnel":
return bff.resolveFunnel(ctx, widget, query)
default:
return nil, fmt.Errorf("unknown widget type: %s", widget.Type)
}
}
4. Сравнение подходов с BFF и без
┌─────────────────────────────────────────────────────────────────────────────┐
│ СРАВНЕНИЕ ПОДХОДОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Критерий Без BFF С BFF │
│ ──────── ────── ───── │
│ │
│ Логика на клиенте Сложная Простая │
│ (эксперименты, (получает │
│ сегменты, конфиг) готовые данные) │
│ │
│ Размер API ответа Большой Оптимизированный │
│ (все варианты, (только нужный │
│ флаги) вариант) │
│ │
│ Зависимости клиента Много Минимум │
│ (SDK для экспериментов, (один API) │
│ конфига, сегментов) │
│ │
│ Скорость итерации Медленная Быстрая │
│ (нужен релиз (меняем на │
│ приложения) сервере) │
│ │
│ Сложность сервера Простая Средняя │
│ (один API) (несколько BFF) │
│ │
│ Тестирование Сложнее Проще │
│ (разные клиенты) (каждый BFF │
│ тестируется │
│ отдельно) │
│ │
│ Кэширование Сложное Простое │
│ (разные ключи (по платформе │
│ для разных клиентов) и пользователю) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Когда BFF полезен, а когда нет
┌─────────────────────────────────────────────────────────────────────────────┐
│ КОГДА ИСПОЛЬЗОВАТЬ BFF │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ИСПОЛЬЗОВАТЬ BFF: НЕ ИСПОЛЬЗОВАТЬ BFF: │
│ │
│ ✓ Несколько клиентов ✓ Один клиент │
│ (web, mobile, admin) (только API) │
│ │
│ ✓ Server-Driven UI ✓ Простые CRUD-операции │
│ (динамические экраны) │
│ │
│ ✓ A/B эксперименты на UI ✓ Микросервисы для │
│ (меняем без релиза) внутреннего использования │
│ │
│ ✓ Remote Config ✓ WebSocket/real-time │
│ (конфигурация с сервера) (прямое подключение) │
│ │
│ ✓ Сложная логика на клиенте ✓ Публичный API для │
│ (которую хотим вынести) внешних интеграций │
│ │
│ ✓ Нужна быстрая итерация ✓ Высоконагруженный │
│ (без релиза приложения) поток данных │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
BFF применим в аналитической системе для:
- Web BFF — Server-Driven UI с A/B экспериментами, когда хотим менять интерфейс без релиза
- Mobile BFF — Remote Config API для управления конфигурацией и feature flags
- Admin BFF — GraphQL API для сложных запросов к аналитическим данным
BFF особенно полезен когда:
- Несколько клиентов (web, mobile, admin)
- Нужна быстрая итерация UI без релиза приложений
- Сложная логика определения вариантов экспериментов
- Server-Driven UI для гибкого управления интерфейсом
Вопрос 29. Как организовано хранилище сегментов и в каком формате там данные?
Таймкод: 00:56:53
Ответ собеседника: Правильный. Хранилище сегментов содержит соответствие между User ID и списком номеров сегментов, к которым он относится. Это предрассчитанные данные (не вычисляемые на лету), чтобы быстро отвечать на запросы. Формат: User ID -> коллекция номеров сегментов.
Правильный ответ:
1. Архитектура хранилища сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Segment Storage Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Data Sources │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ User DB │ │ CRM System │ │ Analytics │ │ │
│ │ │ (PostgreSQL)│ │ (Salesforce)│ │ (ClickHouse)│ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │
│ │ │ │ │ │ │
│ └─────────┼─────────────────┼─────────────────┼───────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Batch Processing (Airflow / Spark) │ │
│ │ │ │
│ │ • Ежедневный пересчёт сегментов │ │
│ │ • Инкрементальные обновления │ │
│ │ • Валидация данных │ │
│ └─────────────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Pre-computed Segments Storage │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Redis (Hot Cache) │ │ │
│ │ │ • Key: "user_segments:{user_id}" │ │ │
│ │ │ • Value: Set of segment_ids │ │ │
│ │ │ • TTL: 24 hours │ │ │
│ │ │ • Latency: < 5ms │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ PostgreSQL (Source of Truth) │ │ │
│ │ │ • Таблица: user_segments │ │ │
│ │ │ • Поля: user_id, segment_id, updated_at │ │ │
│ │ │ • Индексы: (user_id), (segment_id) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ ClickHouse (Analytics) │ │ │
│ │ │ • Агрегированные данные по сегментам │ │ │
│ │ │ • Подсчёт пользователей, конверсии │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Experiment Decision Service │ │
│ │ │ │
│ │ • Чтение сегментов из Redis │ │
│ │ • Фильтрация экспериментов по сегментам │ │
│ │ • Определение принадлежности к эксперименту │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Формат данных в хранилище
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Format: User ID -> Segment IDs │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Redis Format (Primary Cache) │ │
│ │ │ │
│ │ Key: "user_segments:user_123" │ │
│ │ Type: Set │ │
│ │ Value: {1, 5, 12, 23, 45, 67, 89, 102} │ │
│ │ │ │
│ │ Где: │ │
│ │ 1 = "premium_subscribers" │ │
│ │ 5 = "us_users" │ │
│ │ 12 = "mobile_users" │ │
│ │ 23 = "high_ltv" │ │
│ │ 45 = "ios_users" │ │
│ │ 67 = "new_users" │ │
│ │ 89 = "nfc_enabled" │ │
│ │ 102 = "frequent_buyers" │ │
│ │ │ │
│ │ TTL: 86400 seconds (24 hours) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Alternative: Redis Hash (With Metadata) │ │
│ │ │ │
│ │ Key: "user_segments:user_123" │ │
│ │ Type: Hash │ │
│ │ Value: { │ │
│ │ "segment_ids": "1,5,12,23,45,67,89,102", │ │
│ │ "updated_at": "2024-12-20T10:30:00Z", │ │
│ │ "version": "3" │ │
│ │ } │ │
│ │ │ │
│ │ Преимущества: │ │
│ │ • Можно хранить метаданные │ │
│ │ • Легче отлаживать │ │
│ │ • Поддержка версионирования │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL Format (Source of Truth) │ │
│ │ │ │
│ │ Table: user_segments │ │
│ │ ┌────────────┬────────────┬─────────────────────┐ │ │
│ │ │ user_id │ segment_id │ updated_at │ │ │
│ │ ├────────────┼────────────┼─────────────────────┤ │ │
│ │ │ user_123 │ 1 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_123 │ 5 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_123 │ 12 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_123 │ 23 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_123 │ 45 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_456 │ 5 │ 2024-12-20 10:30:00 │ │ │
│ │ │ user_456 │ 67 │ 2024-12-20 10:30:00 │ │ │
│ │ └────────────┴────────────┴─────────────────────┘ │ │
│ │ │ │
│ │ Indexes: │ │
│ │ • PRIMARY KEY (user_id, segment_id) │ │
│ │ • INDEX idx_segment_id (segment_id) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реализация хранилища на Go
package segment
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// SegmentStorage интерфейс хранилища сегментов
type SegmentStorage interface {
GetUserSegments(ctx context.Context, userID string) ([]int64, error)
SetUserSegments(ctx context.Context, userID string, segments []int64) error
AddUserToSegment(ctx context.Context, userID string, segmentID int64) error
RemoveUserFromSegment(ctx context.Context, userID string, segmentID int64) error
IsUserInSegment(ctx context.Context, userID string, segmentID int64) (bool, error)
}
// RedisSegmentStorage реализация на Redis
type RedisSegmentStorage struct {
client *redis.Client
ttl time.Duration
}
// NewRedisSegmentStorage создаёт новое хранилище
func NewRedisSegmentStorage(client *redis.Client, ttl time.Duration) *RedisSegmentStorage {
return &RedisSegmentStorage{
client: client,
ttl: ttl,
}
}
// GetUserSegments возвращает сегменты пользователя
func (s *RedisSegmentStorage) GetUserSegments(ctx context.Context, userID string) ([]int64, error) {
key := s.buildKey(userID)
// Используем SMembers для получения всех элементов Set
members, err := s.client.SMembers(ctx, key).Result()
if err != nil {
return nil, fmt.Errorf("failed to get segments for user %s: %w", userID, err)
}
// Конвертируем строки в int64
segments := make([]int64, 0, len(members))
for _, m := range members {
var segID int64
if _, err := fmt.Sscanf(m, "%d", &segID); err != nil {
continue // Пропускаем невалидные значения
}
segments = append(segments, segID)
}
return segments, nil
}
// SetUserSegments устанавливает сегменты пользователя
func (s *RedisSegmentStorage) SetUserSegments(ctx context.Context, userID string, segments []int64) error {
key := s.buildKey(userID)
// Используем Pipeline для атомарности
pipe := s.client.Pipeline()
// Удаляем старый Set
pipe.Del(ctx, key)
// Добавляем новые сегменты
if len(segments) > 0 {
members := make([]interface{}, len(segments))
for i, seg := range segments {
members[i] = seg
}
pipe.SAdd(ctx, key, members...)
}
// Устанавливаем TTL
pipe.Expire(ctx, key, s.ttl)
_, err := pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to set segments for user %s: %w", userID, err)
}
return nil
}
// AddUserToSegment добавляет пользователя в сегмент
func (s *RedisSegmentStorage) AddUserToSegment(ctx context.Context, userID string, segmentID int64) error {
key := s.buildKey(userID)
// Используем SAdd - добавляет элемент в Set
added, err := s.client.SAdd(ctx, key, segmentID).Result()
if err != nil {
return fmt.Errorf("failed to add user %s to segment %d: %w", userID, segmentID, err)
}
if added > 0 {
// Обновляем TTL при изменении
s.client.Expire(ctx, key, s.ttl)
}
return nil
}
// RemoveUserFromSegment удаляет пользователя из сегмента
func (s *RedisSegmentStorage) RemoveUserFromSegment(ctx context.Context, userID string, segmentID int64) error {
key := s.buildKey(userID)
removed, err := s.client.SRem(ctx, key, segmentID).Result()
if err != nil {
return fmt.Errorf("failed to remove user %s from segment %d: %w", userID, segmentID, err)
}
if removed > 0 {
// Обновляем TTL при изменении
s.client.Expire(ctx, key, s.ttl)
}
return nil
}
// IsUserInSegment проверяет принадлежность пользователя к сегменту
func (s *RedisSegmentStorage) IsUserInSegment(ctx context.Context, userID string, segmentID int64) (bool, error) {
key := s.buildKey(userID)
// Используем SIsMember - O(1) операция
isMember, err := s.client.SIsMember(ctx, key, segmentID).Result()
if err != nil {
return false, fmt.Errorf("failed to check segment membership for user %s: %w", userID, err)
}
return isMember, nil
}
// IsUserInAnySegment проверяет принадлежность к любому из сегментов
func (s *RedisSegmentStorage) IsUserInAnySegment(ctx context.Context, userID string, segmentIDs []int64) (bool, error) {
key := s.buildKey(userID)
// Проверяем каждый сегмент
for _, segID := range segmentIDs {
isMember, err := s.client.SIsMember(ctx, key, segID).Result()
if err != nil {
return false, err
}
if isMember {
return true, nil
}
}
return false, nil
}
// IsUserInAllSegments проверяет принадлежность ко всем сегментам
func (s *RedisSegmentStorage) IsUserInAllSegments(ctx context.Context, userID string, segmentIDs []int64) (bool, error) {
key := s.buildKey(userID)
// Используем Pipeline для параллельной проверки
pipe := s.client.Pipeline()
cmds := make([]*redis.BoolCmd, len(segmentIDs))
for i, segID := range segmentIDs {
cmds[i] = pipe.SIsMember(ctx, key, segID)
}
_, err := pipe.Exec(ctx)
if err != nil {
return false, err
}
// Проверяем все результаты
for _, cmd := range cmds {
isMember, err := cmd.Result()
if err != nil || !isMember {
return false, err
}
}
return true, nil
}
func (s *RedisSegmentStorage) buildKey(userID string) string {
return fmt.Sprintf("user_segments:%s", userID)
}
4. PostgreSQL как Source of Truth
-- Таблица сегментов
CREATE TABLE segments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
segment_type VARCHAR(50) NOT NULL, -- 'audience', 'technical', 'contextual'
criteria JSONB NOT NULL, -- Критерии сегментации
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Таблица соответствия пользователь-сегмент
CREATE TABLE user_segments (
user_id VARCHAR(255) NOT NULL,
segment_id INTEGER NOT NULL REFERENCES segments(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, segment_id)
);
-- Индексы
CREATE INDEX idx_user_segments_user_id ON user_segments(user_id);
CREATE INDEX idx_user_segments_segment_id ON user_segments(segment_id);
CREATE INDEX idx_user_segments_updated_at ON user_segments(updated_at);
-- Таблица для отслеживания обновлений
CREATE TABLE segment_updates (
id SERIAL PRIMARY KEY,
segment_id INTEGER NOT NULL REFERENCES segments(id),
user_id VARCHAR(255) NOT NULL,
action VARCHAR(20) NOT NULL, -- 'added', 'removed'
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_segment_updates_segment_id ON segment_updates(segment_id);
CREATE INDEX idx_segment_updates_user_id ON segment_updates(user_id);
-- Запрос: получить все сегменты пользователя
SELECT s.id, s.name, s.segment_type
FROM user_segments us
JOIN segments s ON us.segment_id = s.id
WHERE us.user_id = 'user_123'
AND s.is_active = true;
-- Запрос: получить всех пользователей сегмента
SELECT us.user_id
FROM user_segments us
WHERE us.segment_id = 5;
-- Запрос: подсчёт пользователей по сегментам
SELECT
s.id,
s.name,
COUNT(us.user_id) as user_count
FROM segments s
LEFT JOIN user_segments us ON s.id = us.segment_id
WHERE s.is_active = true
GROUP BY s.id, s.name
ORDER BY user_count DESC;
-- Запрос: пользователи в пересечении сегментов
SELECT us1.user_id
FROM user_segments us1
JOIN user_segments us2 ON us1.user_id = us2.user_id
WHERE us1.segment_id = 5 -- US users
AND us2.segment_id = 23; -- High LTV
5. Сервис управления сегментами
package segment
import (
"context"
"fmt"
"time"
)
// SegmentService сервис управления сегментами
type SegmentService struct {
redisStorage *RedisSegmentStorage
postgresStorage *PostgresSegmentStorage
segmentCache *SegmentDefinitionCache
eventBus EventBus
}
// RefreshUserSegments обновляет сегменты пользователя
func (s *SegmentService) RefreshUserSegments(ctx context.Context, userID string) error {
// 1. Получаем все активные сегменты
segments, err := s.postgresStorage.GetActiveSegments(ctx)
if err != nil {
return fmt.Errorf("failed to get active segments: %w", err)
}
// 2. Вычисляем принадлежность пользователя к каждому сегменту
matchedSegments := make([]int64, 0)
for _, segment := range segments {
belongs, err := s.evaluateSegment(ctx, userID, segment)
if err != nil {
log.Error("failed to evaluate segment", err, "segment_id", segment.ID)
continue
}
if belongs {
matchedSegments = append(matchedSegments, segment.ID)
}
}
// 3. Получаем текущие сегменты для определения изменений
currentSegments, err := s.postgresStorage.GetUserSegments(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get current segments: %w", err)
}
// 4. Вычисляем разницу
added, removed := diffSegments(currentSegments, matchedSegments)
// 5. Обновляем PostgreSQL
if len(added) > 0 || len(removed) > 0 {
err = s.postgresStorage.UpdateUserSegments(ctx, userID, added, removed)
if err != nil {
return fmt.Errorf("failed to update segments: %w", err)
}
// 6. Обновляем Redis
err = s.redisStorage.SetUserSegments(ctx, userID, matchedSegments)
if err != nil {
log.Error("failed to update redis cache", err)
// Не возвращаем ошибку - Redis восстановится из PostgreSQL
}
// 7. Публикуем событие об изменении
s.eventBus.Publish(ctx, SegmentChangedEvent{
UserID: userID,
AddedSegments: added,
RemovedSegments: removed,
Timestamp: time.Now(),
})
}
return nil
}
// evaluateSegment проверяет принадлежность пользователя к сегменту
func (s *SegmentService) evaluateSegment(ctx context.Context, userID string, segment *Segment) (bool, error) {
switch segment.Type {
case "audience":
return s.evaluateAudienceSegment(ctx, userID, segment)
case "technical":
return s.evaluateTechnicalSegment(ctx, userID, segment)
case "contextual":
return s.evaluateContextualSegment(ctx, userID, segment)
default:
return false, fmt.Errorf("unknown segment type: %s", segment.Type)
}
}
// GetUserSegmentsWithCache получает сегменты с кэшированием
func (s *SegmentService) GetUserSegmentsWithCache(ctx context.Context, userID string) ([]int64, error) {
// 1. Пробуем получить из Redis
segments, err := s.redisStorage.GetUserSegments(ctx, userID)
if err == nil && len(segments) > 0 {
return segments, nil
}
// 2. Если в Redis нет - получаем из PostgreSQL
segments, err = s.postgresStorage.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
// 3. Обновляем Redis (fire-and-forget)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.redisStorage.SetUserSegments(ctx, userID, segments); err != nil {
log.Error("failed to update redis cache", err)
}
}()
return segments, nil
}
// diffSegments вычисляет разницу между старыми и новыми сегментами
func diffSegments(current, new []int64) (added, removed []int64) {
currentMap := make(map[int64]bool)
for _, s := range current {
currentMap[s] = true
}
newMap := make(map[int64]bool)
for _, s := range new {
newMap[s] = true
}
// Найденные сегменты
for _, s := range new {
if !currentMap[s] {
added = append(added, s)
}
}
// Удалённые сегменты
for _, s := range current {
if !newMap[s] {
removed = append(removed, s)
}
}
return added, removed
}
6. Мониторинг и метрики
package segment
import (
"context"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// Metrics метрики хранилища сегментов
type Metrics struct {
segmentLookupDuration prometheus.Histogram
segmentCacheHit prometheus.Counter
segmentCacheMiss prometheus.Counter
segmentUpdateDuration prometheus.Histogram
activeUsers prometheus.Gauge
activeSegments prometheus.Gauge
}
// NewMetrics создаёт метрики
func NewMetrics() *Metrics {
return &Metrics{
segmentLookupDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "segment_lookup_duration_seconds",
Help: "Duration of segment lookup operations",
Buckets: prometheus.DefBuckets,
}),
segmentCacheHit: prometheus.NewCounter(prometheus.CounterOpts{
Name: "segment_cache_hit_total",
Help: "Total number of segment cache hits",
}),
segmentCacheMiss: prometheus.NewCounter(prometheus.CounterOpts{
Name: "segment_cache_miss_total",
Help: "Total number of segment cache misses",
}),
segmentUpdateDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "segment_update_duration_seconds",
Help: "Duration of segment update operations",
Buckets: prometheus.DefBuckets,
}),
activeUsers: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "segment_active_users",
Help: "Number of active users with segments",
}),
activeSegments: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "segment_active_segments",
Help: "Number of active segments",
}),
}
}
// InstrumentedSegmentStorage обёртка с метриками
type InstrumentedSegmentStorage struct {
storage SegmentStorage
metrics *Metrics
}
// GetUserSegments с метриками
func (s *InstrumentedSegmentStorage) GetUserSegments(ctx context.Context, userID string) ([]int64, error) {
start := time.Now()
defer func() {
s.metrics.segmentLookupDuration.Observe(time.Since(start).Seconds())
}()
segments, err := s.storage.GetUserSegments(ctx, userID)
if err != nil {
return nil, err
}
if len(segments) > 0 {
s.metrics.segmentCacheHit.Inc()
} else {
s.metrics.segmentCacheMiss.Inc()
}
return segments, nil
}
Краткий ответ: Хранилище сегментов организовано как pre-computed mapping User ID → Set of Segment IDs:
- Redis (hot cache): Key
user_segments:{user_id}, Value — Set of segment IDs, TTL 24 часа, latency < 5ms - PostgreSQL (source of truth): Таблица
user_segments(user_id, segment_id, updated_at)с индексами - ClickHouse (analytics): Агрегированные данные для аналитики
Данные предрассчитываются batch-процессами (ежедневно), чтобы обеспечить быстрый ответ на запросы без вычислений на лету.
Вопрос 42. Всегда ли интервью проводится в таком формате и дополняется ли вопросами по теории?
Таймкод: 01:30:52
Ответ собеседова: Правильный. System Design проводится для инженеров уровня Senior и выше, тимлидов и архитекторов. Для архитекторов есть дополнительная секция про инженерные практики, архитектурные процессы и technical leadership. Также есть секция для технических руководителей с фокусом на менеджерские подходы.
Правильный ответ:
Форматы интервью в зависимости от уровня
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФОРМАТЫ ИНТЕРВЬЮ ПО УРОВНЯМ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Junior / Middle │ │
│ │ │ │
│ │ • Coding (алгоритмы, структуры данных) │ │
│ │ • Theory (язык, фреймворки, базы данных) │ │
│ │ • Basic System Design (простые системы) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Senior │ │
│ │ │ │
│ │ • Coding (сложные задачи, оптимизация) │ │
│ │ • System Design (проектирование распределённых систем) │ │
│ │ • Theory (глубокое понимание технологий) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Staff / Principal │ │
│ │ │ │
│ │ • System Design (сложные системы, trade-offs) │ │
│ │ • Engineering Practices (CI/CD, мониторинг, тестирование) │ │
│ │ • Technical Leadership (принятие решений, менторинг) │ │
│ │ • Cross-team Architecture (взаимодействие команд) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Engineering Manager │ │
│ │ │ │
│ │ • System Design (высокоуровневое проектирование) │ │
│ │ • People Management (управление командами) │ │
│ │ • Project Management (планирование, приоритизация) │ │
│ │ • Technical Strategy (техническая стратегия) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Типичный процесс найма для Senior Go Developer
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПРОЦЕСС НАЙМА ДЛЯ SENIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Этап 1: Скрининг (30-60 мин) │
│ ───────────────────────────── │
│ • Обсуждение опыта и проектов │
│ • Базовые вопросы по Go и технологиям │
│ • Проверка соответствия требованиям позиции │
│ │
│ Этап 2: Coding Interview (60-90 мин) │
│ ──────────────────────────────────── │
│ • Алгоритмические задачи │
│ • Задачи на проектирование структур данных │
│ • Code review существующего кода │
│ │
│ Этап 3: System Design (60-90 мин) │
│ ────────────────────────────────── │
│ • Проектирование распределённой системы │
│ • Обсуждение trade-offs │
│ • Выбор технологий и обоснование │
│ │
│ Этап 4: Theory Deep Dive (45-60 мин) │
│ ──────────────────────────────────── │
│ • Глубокие вопросы по Go (runtime, GC, concurrency) │
│ • Вопросы по базам данных, очередям, кэшам │
│ • Вопросы по сетям и протоколам │
│ │
│ Этап 5: Behavioral / Culture Fit (30-45 мин) │
│ ────────────────────────────────────────── │
│ • Обсуждение опыта работы в команде │
│ • Конфликты и их разрешение │
│ • Менторинг и обучение │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Теоретические вопросы по Go для Senior
// Примеры теоретических вопросов по Go
// Вопрос 1: Как работает Go scheduler?
/*
Go scheduler использует модель M:N scheduling:
- M горутин выполняются на N OS-потоках
- G (goroutine) — структура данных горутины
- M (machine) — OS-поток
- P (processor) — логический процессор
Work stealing: когда P не имеет работы,
он "ворует" горутины у других P.
*/
// Вопрос 2: Как устроен слайс в Go?
type SliceHeader struct {
Data uintptr // указатель на underlying array
Len int // длина слайса
Cap int // ёмкость слайса
}
// Вопрос 3: Что такое escape analysis?
/*
Escape analysis определяет, где выделяется память:
- На стеке (быстро, автоматически освобождается)
- В куче (медленнее, требует GC)
Переменная "убегает" в кучу, если:
- Возвращается по указателю из функции
- Сохраняется в замыкании
- Отправляется в канал
- Размер неизвестен на этапе компиляции
*/
// Вопрос 4: Как работает GC в Go?
/*
Go использует concurrent mark-and-sweep GC:
1. Mark phase — помечаем живые объекты (concurrent)
2. Mark termination — завершаем пометку (stop-the-world)
3. Sweep phase — собираем мёртвые объекты (concurrent)
Настройки:
- GOGC=100 — GC запускается когда куча выросла на 100%
- GOGC=off — отключить GC
- debug.SetGCPercent() — настройка из кода
*/
// Вопрос 5: Как работают channels?
type Channel struct {
qcount uint // количество элементов в очереди
dataqsiz uint // размер буфера
buf unsafe.Pointer // буфер для элементов
recvq waitq // очередь ожидающих получателей
sendq waitq // очередь ожидающих отправителей
lock mutex // мьютекс для синхронизации
}
3. Теоретические вопросы по распределённым системам
┌─────────────────────────────────────────────────────────────────────────────┐
│ ТЕОРЕТИЧЕСКИЕ ВОПРОСЫ ПО ТЕХНОЛОГИЯМ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CAP ТЕОРЕМА: │
│ ──────────── │
│ В распределённой системе можно гарантировать только 2 из 3 свойств: │
│ • Consistency (согласованность) │
│ • Availability (доступность) │
│ • Partition tolerance (устойчивость к разделению) │
│ │
│ Примеры: │
│ • CP: PostgreSQL, MongoDB (с настройкой) │
│ • AP: Cassandra, DynamoDB │
│ • CA: теоретически невозможно в реальных системах │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ CONSISTENCY MODELS: │
│ ──────────────────── │
│ • Strong consistency — все узлы видят одинаковые данные │
│ • Eventual consistency — данные сойдутся со временем │
│ • Causal consistency — причинно-следственные связи сохраняются │
│ • Read-your-writes — пользователь видит свои изменения │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ CONSENSUS ALGORITHMS: │
│ ───────────────────── │
│ • Paxos — классический алгоритм консенсуса │
│ • Raft — более понятная альтернатива Paxos │
│ • ZAB — используется в ZooKeeper │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ DISTRIBUTED TRANSACTIONS: │
│ ───────────────────────── │
│ • 2PC (Two-Phase Commit) — координированный commit │
│ • Saga — последовательность локальных транзакций с компенсацией │
│ • TCC (Try-Confirm-Cancel) — трёхфазный подход │
│ • Outbox Pattern — гарантированная доставка событий │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Вопросы по инженерным практикам для Staff/Principal
┌─────────────────────────────────────────────────────────────────────────────┐
│ ИНЖЕНЕРНЫЕ ПРАКТИКИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CI/CD: │
│ ────── │
│ • Blue-Green Deployment │
│ • Canary Releases │
│ • Feature Flags │
│ • GitOps │
│ │
│ MONITORING: │
│ ─────────── │
│ • RED Metrics (Rate, Errors, Duration) │
│ • USE Metrics (Utilization, Saturation, Errors) │
│ • Distributed Tracing (OpenTelemetry) │
│ • Alerting strategies │
│ │
│ TESTING: │
│ ──────── │
│ • Unit tests │
│ • Integration tests │
│ • Contract tests │
│ • Chaos engineering │
│ │
│ ARCHITECTURE: │
│ ───────────── │
│ • Microservices vs Monolith │
│ • Event-Driven Architecture │
│ • CQRS │
│ • Domain-Driven Design │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Вопросы по Technical Leadership
┌─────────────────────────────────────────────────────────────────────────────┐
│ TECHNICAL LEADERSHIP │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Принятие решений: │
│ ───────────────── │
│ • Как выбирали технологии для проекта? │
│ • Как оценивали технический долг? │
│ • Как приоритизировали задачи? │
│ │
│ Менторинг: │
│ ────────── │
│ • Как помогали расти junior-разработчикам? │
│ • Как проводили code review? │
│ • Как документировали архитектурные решения? │
│ │
│ Коммуникация: │
│ ───────────── │
│ • Как объясняли сложные технические концепции бизнесу? │
│ • Как работали с другими командами? │
│ • Как управляли техническими рисками? │
│ │
│ Стратегия: │
│ ────────── │
│ • Как планировали техническое развитие продукта? │
│ • Как оценивали необходимость рефакторинга? │
│ • Как балансировали между скоростью и качеством? │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Примеры теоретических вопросов по базам данных
-- Вопрос: Как работают индексы в PostgreSQL?
-- B-tree индекс (по умолчанию)
CREATE INDEX idx_users_email ON users(email);
-- Hash индекс (только для =)
CREATE INDEX idx_users_id ON users USING HASH(id);
-- GIN индекс для JSONB
CREATE INDEX idx_events_data ON events USING GIN(data);
-- GiST индекс для полнотекстового поиска
CREATE INDEX idx_articles_search ON articles USING GIST(search_vector);
-- Partial индекс
CREATE INDEX idx_active_users ON users(email) WHERE active = true;
-- Covering индекс (INCLUDE)
CREATE INDEX idx_users_email ON users(email) INCLUDE (name, created_at);
// Вопрос: Как работает транзакция в PostgreSQL?
// Read Committed (по умолчанию)
// - Каждый запрос видит только завершённые транзакции
// Repeatable Read
// - Транзакция видит только данные, зафиксированные до её начала
// - Предотвращает non-repeatable reads
// Serializable
// - Самый строгий уровень
// - Предотвращает все аномалии, но может вызывать ошибки сериализации
// Пример в Go:
func (r *Repo) TransferMoney(ctx context.Context, fromID, toID int64, amount decimal.Decimal) error {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return err
}
defer tx.Rollback()
// Проверяем баланс
var balance decimal.Decimal
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
fromID,
).Scan(&balance)
if err != nil {
return err
}
if balance.LessThan(amount) {
return ErrInsufficientFunds
}
// Списываем
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, fromID,
)
if err != nil {
return err
}
// Зачисляем
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, toID,
)
if err != nil {
return err
}
return tx.Commit()
}
7. Вопросы по сетям и протоколам
┌─────────────────────────────────────────────────────────────────────────────┐
│ ВОПРОСЫ ПО СЕТЯМ И ПРОТОКОЛАМ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ TCP vs UDP: │
│ ──────────── │
│ • TCP: надёжный, с установлением соединения, медленнее │
│ • UDP: ненадёжный, без соединения, быстрее │
│ │
│ HTTP/1.1 vs HTTP/2 vs HTTP/3: │
│ ────────────────────────────── │
│ • HTTP/1.1: текстовый, один запрос на соединение (без pipelining) │
│ • HTTP/2: бинарный, мультиплексирование, server push │
│ • HTTP/3: на базе QUIC (UDP), быстрее при потере пакетов │
│ │
│ gRPC vs REST: │
│ ────────────── │
│ • gRPC: protobuf, HTTP/2, двунаправленный поток, быстрее │
│ • REST: JSON, HTTP/1.1, проще в отладке, универсальнее │
│ │
│ WebSocket vs SSE vs Long Polling: │
│ ────────────────────────────────── │
│ • WebSocket: двунаправленный, полный дуплекс │
│ • SSE: сервер -> клиент, автоматическое переподключение │
│ • Long Polling: эмуляция real-time, проще реализация │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Формат интервью зависит от уровня позиции:
- Junior/Middle — больше внимания на coding и базовую теорию
- Senior — System Design + глубокая теория по технологиям
- Staff/Principal — System Design + инженерные практики + лидерство
- Engineering Manager — высокоуровневое проектирование + управление
Теоретические вопросы обычно дополняют System Design и проверяют:
- Глубину понимания используемых технологий
- Способность объяснить выбор и trade-offs
- Понимание фундаментальных конкурентов (CAP, консенсус, транзакции)
- Практический опыт работы с технологиями
Вопрос 30. Как выглядит полный сценарий работы мобильного приложения с системой экспериментов от старта до отправки аналитики?
Таймкод: 00:59:44
Ответ собеседника: Правильный. При старте приложение получает список экспериментов через Experiment Decision сервис, который обращается к хранилищу сегментов для определения принадлежности пользователя к экспериментам. Далее приложение отправляет аналитические события через коллектор в Kafka, откуда они попадают в аналитическую БД.
Правильный ответ:
1. Полный flow мобильного приложения
┌─────────────────────────────────────────────────────────────────────────────┐
│ Mobile App Experiment Flow │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 1: App Initialization (Запуск приложения) │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Mobile App │ │ │
│ │ │ Start │ │ │
│ │ └──────┬──────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 1.1 Загрузка локального кэша экспериментов │ │ │
│ │ │ • Проверяем сохранённые эксперименты в SharedPreferences │ │ │
│ │ │ • Если кэш валиден (< 24 часов) - используем его │ │ │
│ │ │ • Иначе - запрашиваем с сервера │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 1.2 Инициализация SDK экспериментов │ │ │
│ │ │ • Создание анонимного user_id (если новый пользователь) │ │ │
│ │ │ • Загрузка конфигурации SDK │ │ │
│ │ │ • Подготовка контекста устройства │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 2: Experiment Fetching (Получение экспериментов) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ Mobile App │ ──────► │ Experiment │ │ │
│ │ │ │ HTTPS │ Decision Service │ │ │
│ │ │ │ ◄────── │ │ │ │
│ │ └─────────────┘ └────────┬─────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Segment Storage │ │ │
│ │ │ (Redis) │ │ │
│ │ │ │ │ │
│ │ │ user_segments: │ │ │
│ │ │ {1, 5, 12, 23} │ │ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ │ Request: │ │
│ │ POST /api/v1/experiments/fetch │ │
│ │ { │ │
│ │ "user_id": "user_123", │ │
│ │ "device_id": "device_abc", │ │
│ │ "app_version": "2.5.1", │ │
│ │ "platform": "ios", │ │
│ │ "os_version": "17.0", │ │
│ │ "country": "US", │ │
│ │ "locale": "en_US" │ │
│ │ } │ │
│ │ │ │
│ │ Response: │ │
│ │ { │ │
│ │ "experiments": [ │ │
│ │ { │ │
│ │ "experiment_id": "exp_001", │ │
│ │ "name": "New Checkout Flow", │ │
│ │ "variant": "treatment", │ │
│ │ "variant_id": 2, │ │
│ │ "config": { │ │
│ │ "button_color": "#FF5722", │ │
│ │ "show_progress_bar": true │ │
│ │ } │ │
│ │ }, │ │
│ │ { │ │
│ │ "experiment_id": "exp_015", │ │
│ │ "name": "Onboarding V2", │ │
│ │ "variant": "control", │ │
│ │ "variant_id": 1, │ │
│ │ "config": {} │ │
│ │ } │ │
│ │ ], │ │
│ │ "timestamp": "2024-12-20T10:30:00Z", │ │
│ │ "request_id": "req_xyz789" │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 3: Local Caching (Локальное кэширование) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 3.1 Сохранение экспериментов в локальный кэш │ │ │
│ │ │ • SharedPreferences (Android) / UserDefaults (iOS) │ │ │
│ │ │ • TTL: 24 часа │ │ │
│ │ │ • Шифрование данных │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 3.2 Применение экспериментальных конфигураций │ │ │
│ │ │ • Обновление UI согласно variant │ │ │
│ │ │ • Переключение feature flags │ │ │
│ │ │ • Настройка бизнес-логики │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 4: User Interaction (Взаимодействие пользователя) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 4.1 Пользователь взаимодействует с приложением │ │ │
│ │ │ • Просмотр экранов │ │ │
│ │ │ • Тапы, свайпы, действия │ │ │
│ │ │ • Покупки, конверсии │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 4.2 SDK автоматически отслеживает события │ │ │
│ │ │ • Каждое событие обогащается данными эксперимента │ │ │
│ │ │ • Формируется аналитический событие │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 5: Event Collection (Сбор событий) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 5.1 Формирование аналитического события │ │ │
│ │ │ │ │ │
│ │ │ { │ │ │
│ │ │ "event_id": "evt_abc123", │ │ │
│ │ │ "event_name": "button_click", │ │ │
│ │ │ "user_id": "user_123", │ │ │
│ │ │ "device_id": "device_abc", │ │ │
│ │ │ "timestamp": "2024-12-20T10:35:00Z", │ │ │
│ │ │ "session_id": "sess_xyz", │ │ │
│ │ │ "properties": { │ │ │
│ │ │ "button_id": "checkout_submit", │ │ │
│ │ │ "screen": "checkout" │ │ │
│ │ │ }, │ │ │
│ │ │ "experiments": [ │ │ │
│ │ │ { │ │ │
│ │ │ "experiment_id": "exp_001", │ │ │
│ │ │ "variant_id": 2, │ │ │
│ │ │ "variant_name": "treatment" │ │ │
│ │ │ } │ │ │
│ │ │ ], │ │ │
│ │ │ "app_version": "2.5.1", │ │ │
│ │ │ "platform": "ios" │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 5.2 Буферизация событий │ │ │
│ │ │ • События накапливаются в локальном буфере │ │ │
│ │ │ • Отправка при достижении порога (50 событий) │ │ │
│ │ │ • Или по таймеру (каждые 30 секунд) │ │ │
│ │ │ • Или при переходе в background │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 6: Event Sending (Отправка событий) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ Mobile App │ ──────► │ Event Collector │ │ │
│ │ │ │ HTTPS │ Service │ │ │
│ │ │ │ │ │ │ │
│ │ └─────────────┘ └────────┬─────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Kafka │ │ │
│ │ │ │ │ │
│ │ │ Topic: │ │ │
│ │ │ app.events.raw │ │ │
│ │ └────────┬─────────┘ │ │
│ │ │ │ │
│ └───────────────────────────────────┼──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 7: Event Processing (Обработка событий) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 7.1 Stream Processing (Flink / Kafka Streams) │ │ │
│ │ │ • Валидация событий │ │ │
│ │ │ • Дедупликация │ │ │
│ │ │ • Обогащение данными │ │ │
│ │ │ • Фильтрация ботов │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 7.2 Запись в аналитическое хранилище │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ │
│ │ │ │ ClickHouse │ │ PostgreSQL │ │ S3 (Parquet) │ │ │ │
│ │ │ │ (Analytics) │ │ (Aggregates) │ │ (Raw Data) │ │ │ │
│ │ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Phase 8: Experiment Analysis (Анализ экспериментов) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 8.1 Расчёт метрик │ │ │
│ │ │ • Конверсия по variant │ │ │
│ │ │ • Статистическая значимость │ │ │
│ │ │ • Доверительные интервалы │ │ │
│ │ │ • CUPED-коррекция │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 8.2 Визуализация и отчёты │ │ │
│ │ │ • Dashboard с результатами │ │ │
│ │ │ • Алерты при достижении значимости │ │ │
│ │ │ • Рекомендации по завершению эксперимента │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Реализация SDK для мобильного приложения
package experiment
import (
"context"
"sync"
"time"
)
// ExperimentSDK клиент SDK для работы с экспериментами
type ExperimentSDK struct {
config *SDKConfig
httpClient *http.Client
storage LocalStorage
eventBuffer *EventBuffer
userProvider UserProvider
deviceProvider DeviceProvider
experiments map[string]*Experiment
mu sync.RWMutex
eventBus EventBus
logger Logger
}
// SDKConfig конфигурация SDK
type SDKConfig struct {
APIKey string
APIEndpoint string
FetchInterval time.Duration
EventBatchSize int
EventFlushInterval time.Duration
MaxRetries int
Timeout time.Duration
}
// NewExperimentSDK создаёт новый SDK клиент
func NewExperimentSDK(config *SDKConfig) *ExperimentSDK {
sdk := &ExperimentSDK{
config: config,
httpClient: &http.Client{
Timeout: config.Timeout,
},
experiments: make(map[string]*Experiment),
}
// Запускаем фоновые процессы
go sdk.periodicFetch()
go sdk.periodicFlush()
return sdk
}
// Initialize инициализация SDK
func (sdk *ExperimentSDK) Initialize(ctx context.Context) error {
// 1. Загружаем сохранённые эксперименты
cached, err := sdk.storage.LoadExperiments()
if err == nil && len(cached) > 0 {
sdk.mu.Lock()
for _, exp := range cached {
sdk.experiments[exp.ID] = exp
}
sdk.mu.Unlock()
}
// 2. Запрашиваем свежие эксперименты
return sdk.FetchExperiments(ctx)
}
// FetchExperiments получает эксперименты с сервера
func (sdk *ExperimentSDK) FetchExperiments(ctx context.Context) error {
// 1. Формируем контекст запроса
req := &FetchExperimentsRequest{
UserID: sdk.userProvider.GetUserID(),
DeviceID: sdk.deviceProvider.GetDeviceID(),
AppVersion: sdk.deviceProvider.GetAppVersion(),
Platform: sdk.deviceProvider.GetPlatform(),
OSVersion: sdk.deviceProvider.GetOSVersion(),
Country: sdk.deviceProvider.GetCountry(),
Locale: sdk.deviceProvider.GetLocale(),
}
// 2. Отправляем запрос
resp, err := sdk.httpClient.Do(sdk.buildRequest(ctx, req))
if err != nil {
return fmt.Errorf("failed to fetch experiments: %w", err)
}
defer resp.Body.Close()
// 3. Парсим ответ
var result FetchExperimentsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
// 4. Обновляем локальный кэш
sdk.mu.Lock()
for _, exp := range result.Experiments {
sdk.experiments[exp.ID] = exp
}
sdk.mu.Unlock()
// 5. Сохраняем в постоянное хранилище
if err := sdk.storage.SaveExperiments(result.Experiments); err != nil {
sdk.logger.Error("failed to save experiments", err)
}
// 6. Логируем событие
sdk.eventBuffer.Add(&Event{
Name: "experiments_fetched",
Timestamp: time.Now(),
Properties: map[string]interface{}{
"count": len(result.Experiments),
"request_id": result.RequestID,
},
})
return nil
}
// GetExperimentVariant возвращает variant эксперимента
func (sdk *ExperimentSDK) GetExperimentVariant(experimentID string) (string, error) {
sdk.mu.RLock()
defer sdk.mu.RUnlock()
exp, ok := sdk.experiments[experimentID]
if !ok {
return "", fmt.Errorf("experiment %s not found", experimentID)
}
return exp.Variant, nil
}
// GetExperimentConfig возвращает конфигурацию эксперимента
func (sdk *ExperimentSDK) GetExperimentConfig(experimentID string) (map[string]interface{}, error) {
sdk.mu.RLock()
defer sdk.mu.RUnlock()
exp, ok := sdk.experiments[experimentID]
if !ok {
return nil, fmt.Errorf("experiment %s not found", experimentID)
}
return exp.Config, nil
}
// TrackEvent отправляет аналитическое событие
func (sdk *ExperimentSDK) TrackEvent(ctx context.Context, eventName string, properties map[string]interface{}) error {
// 1. Обогащаем событие данными экспериментов
sdk.mu.RLock()
experiments := make([]ExperimentInfo, 0)
for _, exp := range sdk.experiments {
experiments = append(experiments, ExperimentInfo{
ExperimentID: exp.ID,
VariantID: exp.VariantID,
VariantName: exp.Variant,
})
}
sdk.mu.RUnlock()
// 2. Формируем событие
event := &Event{
ID: generateEventID(),
Name: eventName,
UserID: sdk.userProvider.GetUserID(),
DeviceID: sdk.deviceProvider.GetDeviceID(),
SessionID: sdk.userProvider.GetSessionID(),
Timestamp: time.Now(),
Properties: properties,
Experiments: experiments,
AppVersion: sdk.deviceProvider.GetAppVersion(),
Platform: sdk.deviceProvider.GetPlatform(),
}
// 3. Добавляем в буфер
sdk.eventBuffer.Add(event)
// 4. Проверяем, нужно ли отправить
if sdk.eventBuffer.Size() >= sdk.config.EventBatchSize {
return sdk.FlushEvents(ctx)
}
return nil
}
// FlushEvents отправляет накопленные события
func (sdk *ExperimentSDK) FlushEvents(ctx context.Context) error {
events := sdk.eventBuffer.GetAndClear()
if len(events) == 0 {
return nil
}
// Формируем батч
batch := &EventBatch{
Events: events,
SentAt: time.Now(),
BatchID: generateBatchID(),
}
// Отправляем с retry
var lastErr error
for i := 0; i <= sdk.config.MaxRetries; i++ {
if i > 0 {
time.Sleep(time.Duration(i) * time.Second) // Exponential backoff
}
err := sdk.sendEventsBatch(ctx, batch)
if err == nil {
return nil
}
lastErr = err
sdk.logger.Warn("failed to send events, retrying", "attempt", i+1, "error", err)
}
// Если все попытки исчерпаны - сохраняем для следующей отправки
sdk.eventBuffer.AddBatch(events)
return fmt.Errorf("failed to send events after %d retries: %w", sdk.config.MaxRetries, lastErr)
}
// sendEventsBatch отправляет батч событий
func (sdk *ExperimentSDK) sendEventsBatch(ctx context.Context, batch *EventBatch) error {
body, err := json.Marshal(batch)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST",
sdk.config.APIEndpoint+"/events/batch", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+sdk.config.APIKey)
resp, err := sdk.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
// periodicFetch периодическое обновление экспериментов
func (sdk *ExperimentSDK) periodicFetch() {
ticker := time.NewTicker(sdk.config.FetchInterval)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), sdk.config.Timeout)
if err := sdk.FetchExperiments(ctx); err != nil {
sdk.logger.Error("periodic fetch failed", err)
}
cancel()
}
}
// periodicFlush периодическая отправка событий
func (sdk *ExperimentSDK) periodicFlush() {
ticker := time.NewTicker(sdk.config.EventFlushInterval)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), sdk.config.Timeout)
if err := sdk.FlushEvents(ctx); err != nil {
sdk.logger.Error("periodic flush failed", err)
}
cancel()
}
}
3. Event Collector Service
package collector
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
)
// EventCollector сервис сбора событий
type EventCollector struct {
kafkaWriter *kafka.Writer
validator *EventValidator
metrics *Metrics
}
// EventBatch батч событий от клиента
type EventBatch struct {
BatchID string `json:"batch_id"`
Events []*Event `json:"events"`
SentAt time.Time `json:"sent_at"`
}
// Event аналитическое событие
type Event struct {
ID string `json:"event_id"`
Name string `json:"event_name"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id"`
Timestamp time.Time `json:"timestamp"`
Properties map[string]interface{} `json:"properties"`
Experiments []ExperimentInfo `json:"experiments"`
AppVersion string `json:"app_version"`
Platform string `json:"platform"`
}
// ExperimentInfo информация об эксперименте
type ExperimentInfo struct {
ExperimentID string `json:"experiment_id"`
VariantID int `json:"variant_id"`
VariantName string `json:"variant_name"`
}
// HandleBatchRequest обработка батча событий
func (c *EventCollector) HandleBatchRequest(ctx context.Context, batch *EventBatch) error {
startTime := time.Now()
defer func() {
c.metrics.RequestDuration.Observe(time.Since(startTime).Seconds())
}()
// 1. Валидация батча
if err := c.validator.ValidateBatch(batch); err != nil {
c.metrics.InvalidBatches.Inc()
return fmt.Errorf("invalid batch: %w", err)
}
// 2. Обработка каждого события
for _, event := range batch.Events {
if err := c.processEvent(ctx, event); err != nil {
c.metrics.ProcessedEvents.WithLabelValues("error").Inc()
c.logger.Error("failed to process event", err, "event_id", event.ID)
continue
}
c.metrics.ProcessedEvents.WithLabelValues("success").Inc()
}
c.metrics.BatchesProcessed.Inc()
return nil
}
// processEvent обработка одного события
func (c *EventCollector) processEvent(ctx context.Context, event *Event) error {
// 1. Валидация события
if err := c.validator.ValidateEvent(event); err != nil {
return fmt.Errorf("invalid event: %w", err)
}
// 2. Дедупликация (проверяем по event_id)
if c.isDuplicate(ctx, event.ID) {
c.metrics.DuplicateEvents.Inc()
return nil
}
// 3. Обогащение события
enriched := c.enrichEvent(event)
// 4. Отправка в Kafka
return c.sendToKafka(ctx, enriched)
}
// sendToKafka отправка события в Kafka
func (c *EventCollector) sendToKafka(ctx context.Context, event *Event) error {
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
// Определяем партицию по user_id для сохранения порядка
partitionKey := []byte(event.UserID)
msg := kafka.Message{
Key: partitionKey,
Value: data,
Time: event.Timestamp,
Headers: []kafka.Header{
{Key: "event_name", Value: []byte(event.Name)},
{Key: "platform", Value: []byte(event.Platform)},
},
}
if err := c.kafkaWriter.WriteMessages(ctx, msg); err != nil {
c.metrics.KafkaErrors.Inc()
return fmt.Errorf("failed to write to kafka: %w", err)
}
c.metrics.KafkaMessagesSent.Inc()
return nil
}
// enrichEvent обогащение события
func (c *EventCollector) enrichEvent(event *Event) *Event {
// Добавляем серверное время
event.Properties["server_timestamp"] = time.Now().UTC()
// Добавляем информацию о геолокации (если есть IP)
if ip, ok := event.Properties["ip"]; ok {
if geoInfo := c.geoLookup.Lookup(ip.(string)); geoInfo != nil {
event.Properties["country"] = geoInfo.Country
event.Properties["city"] = geoInfo.City
}
}
return event
}
4. Схема данных в ClickHouse
-- Таблица сырых событий
CREATE TABLE events_raw (
event_id String,
event_name LowCardinality(String),
user_id String,
device_id String,
session_id String,
timestamp DateTime64(3),
-- Свойства события
properties String, -- JSON
-- Эксперименты (массив структур)
experiment_ids Array(String),
variant_ids Array(UInt32),
variant_names Array(String),
-- Информация о приложении
app_version LowCardinality(String),
platform LowCardinality(String),
os_version LowCardinality(String),
-- Геолокация
country LowCardinality(String),
city LowCardinality(String),
-- Метаданные
server_timestamp DateTime64(3),
kafka_partition UInt32,
kafka_offset UInt64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Материализованное представление для экспериментальных событий
CREATE TABLE experiment_events (
event_date Date,
experiment_id String,
variant_id UInt32,
variant_name String,
event_name LowCardinality(String),
user_id String,
timestamp DateTime64(3),
properties String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_date, user_id);
-- Materialized View для автоматического заполнения
CREATE MATERIALIZED VIEW experiment_events_mv TO experiment_events AS
SELECT
toDate(timestamp) as event_date,
experiment_ids[1] as experiment_id,
variant_ids[1] as variant_id,
variant_names[1] as variant_name,
event_name,
user_id,
timestamp,
properties
FROM events_raw
WHERE length(experiment_ids) > 0;
-- Запрос: метрики эксперимента
SELECT
experiment_id,
variant_name,
countDistinct(user_id) as users,
count() as events,
countIf(event_name = 'purchase') as purchases,
countIf(event_name = 'purchase') / countDistinct(user_id) as conversion_rate
FROM experiment_events
WHERE experiment_id = 'exp_001'
AND event_date BETWEEN '2024-12-01' AND '2024-12-20'
GROUP BY experiment_id, variant_name
ORDER BY variant_name;
Краткий ответ: Полный сценарий работы мобильного приложения:
- Инициализация — загрузка кэша экспериментов, создание user_id
- Fetch экспериментов — POST запрос к Experiment Decision Service с контекстом устройства
- Проверка сегментов — сервис обращается к Redis для получения сегментов пользователя
- Определение экспериментов — фильтрация активных экспериментов по сегментам
- Локальное кэширование — сохранение в SharedPreferences/UserDefaults
- Трекинг событий — SDK автоматически обогащает события данными экспериментов
- Буферизация и отправка — накопление событий и пакетная отправка в Event Collector
- Kafka — события публикуются в топик
app.events.raw - Stream Processing — Flink/Streams обрабатывает и обогащает события
- Аналитика — запись в ClickHouse для анализа результатов экспериментов
Вопрос 43. Входит ли в оценку анализ трафика и железа исходя из нефункциональных требований?
Таймкод: 01:33:54
Ответ собеседова: Правильный. Анализ трафика и железа является частью оценки. Нужно считать RPS, размер нагрузки по сети, хранимые данные. На уровне коробочных диаграмм можно оценить что влетает в систему, а детальный расчёт делается когда есть конкретные компоненты.
Правильный ответ:
Оценка трафика и ресурсов в System Design
┌─────────────────────────────────────────────────────────────────────────────┐
│ ПОРЯДОК ОЦЕНКИ НАГРУЗКИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Шаг 1: Сбор нефункциональных требований │
│ ───────────────────────────────────────── │
│ • DAU (Daily Active Users) │
│ • MAU (Monthly Active Users) │
│ • Pиковая нагрузка (обычно 2-10x от средней) │
│ • SLA (availability, latency) │
│ • Data retention period │
│ │
│ Шаг 2: Расчёт трафика (Traffic Estimation) │
│ ────────────────────────────────────────── │
│ • RPS (Requests Per Second) │
│ • Bandwidth (bytes/second) │
│ • Write vs Read ratio │
│ │
│ Шаг 3: Расчёт хранилища (Storage Estimation) │
│ ──────────────────────────────────────────── │
│ • Data per entity │
│ • Growth rate │
│ • Replication factor │
│ │
│ Шаг 4: Расчёт ресурсов (Resource Estimation) │
│ ──────────────────────────────────────────── │
│ • CPU cores │
│ • RAM │
│ • Disk I/O │
│ • Network I/O │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Пример расчёта для аналитической системы
┌─────────────────────────────────────────────────────────────────────────────┐
│ РАСЧЁТ ДЛЯ АНАЛИТИЧЕСКОЙ СИСТЕМЫ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Дано: │
│ ───── │
│ • 100M DAU (Daily Active Users) │
│ • Каждый пользователь генерирует 100 событий в день │
│ • Средний размер события: 1 KB │
│ • Read/Write ratio: 1:100 (много записи, мало чтения) │
│ • Data retention: 90 дней │
│ • Peak factor: 3x │
│ │
│ Расчёт трафика: │
│ ──────────────── │
│ │
│ Событий в день: │
│ 100M users × 100 events = 10B events/day │
│ │
│ Средний RPS: │
│ 10B / 86400 = ~116K events/sec │
│ │
│ Пиковый RPS: │
│ 116K × 3 = ~350K events/sec │
│ │
│ Ingress bandwidth: │
│ 116K × 1 KB = ~116 MB/s (average) │
│ 350K × 1 KB = ~350 MB/s (peak) │
│ │
│ Egress bandwidth (чтение): │
│ 116K / 100 × 10 KB = ~11.6 MB/s (average) │
│ │
│ Расчёт хранилища: │
│ ────────────────── │
│ │
│ Данные в день: │
│ 10B events × 1 KB = ~10 TB/day │
│ │
│ С репликацией (3x): │
│ 10 TB × 3 = ~30 TB/day │
│ │
│ За 90 дней: │
│ 30 TB × 90 = ~2.7 PB │
│ │
│ Расчёт ресурсов: │
│ ───────────────── │
│ │
│ Kafka brokers: │
│ ~350K msg/s ÷ 50K msg/s per broker = 7 brokers │
│ │
│ ClickHouse nodes: │
│ ~10 TB/day ÷ 2 TB/day per node = 5 nodes (with replication) │
│ │
│ API servers: │
│ ~350K RPS ÷ 10K RPS per instance = 35 instances │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Back-of-the-envelope расчёты
┌─────────────────────────────────────────────────────────────────────────────┐
│ BACK-OF-THE-ENVELOPE КОНСТАНТЫ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Память: │
│ ──────── │
│ • int64: 8 bytes │
│ • string (avg): 50-100 bytes │
│ • UUID: 16 bytes │
│ • timestamp: 8 bytes │
│ │
│ Диск: │
│ ───── │
│ • HDD: ~100 IOPS, ~100 MB/s │
│ • SSD: ~10K IOPS, ~500 MB/s │
│ • NVMe: ~100K IOPS, ~3 GB/s │
│ │
│ Сеть: │
│ ───── │
│ • 1 Gbps = 125 MB/s │
│ • 10 Gbps = 1.25 GB/s │
│ │
│ CPU: │
│ ──── │
│ • 1 core ≈ 10K-100K simple ops/sec │
│ • 1 core ≈ 1K-10K complex queries/sec │
│ │
│ Статистика интернета: │
│ ───────────────────── │
│ • YouTube: ~1B hours watched/day │
│ • Twitter: ~500M tweets/day │
│ • Google: ~8.5B searches/day │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Шаблон для расчёта
// Структура для расчёта нагрузки
type TrafficEstimate struct {
DAU uint64
EventsPerUser uint64
AvgEventSize uint64 // bytes
PeakFactor float64
ReadWriteRatio float64
RetentionDays uint64
ReplicationFactor uint64
}
type ResourceEstimate struct {
AvgRPS uint64
PeakRPS uint64
AvgIngressMBps float64
PeakIngressMBps float64
AvgEgressMBps float64
DailyStorageTB float64
TotalStorageTB float64
}
func (t *TrafficEstimate) Calculate() ResourceEstimate {
eventsPerDay := t.DAU * t.EventsPerUser
avgRPS := uint64(float64(eventsPerDay) / 86400)
peakRPS := uint64(float64(avgRPS) * t.PeakFactor)
avgIngressMBps := float64(avgRPS*t.AvgEventSize) / (1024 * 1024)
peakIngressMBps := float64(peakRPS*t.AvgEventSize) / (1024 * 1024)
avgEgressMBps := avgIngressMBps / t.ReadWriteRatio
dailyStorageTB := float64(eventsPerDay*t.AvgEventSize) / (1024 * 1024 * 1024 * 1024)
totalStorageTB := dailyStorageTB * float64(t.RetentionDays) * float64(t.ReplicationFactor)
return ResourceEstimate{
AvgRPS: avgRPS,
PeakRPS: peakRPS,
AvgIngressMBps: avgIngressMBps,
PeakIngressMBps: peakIngressMBps,
AvgEgressMBps: avgEgressMBps,
DailyStorageTB: dailyStorageTB,
TotalStorageTB: totalStorageTB,
}
}
// Пример использования
func main() {
estimate := TrafficEstimate{
DAU: 100_000_000,
EventsPerUser: 100,
AvgEventSize: 1024, // 1 KB
PeakFactor: 3,
ReadWriteRatio: 100,
RetentionDays: 90,
ReplicationFactor: 3,
}
resources := estimate.Calculate()
fmt.Printf("Average RPS: %d\n", resources.AvgRPS)
fmt.Printf("Peak RPS: %d\n", resources.PeakRPS)
fmt.Printf("Average Ingress: %.2f MB/s\n", resources.AvgIngressMBps)
fmt.Printf("Peak Ingress: %.2f MB/s\n", resources.PeakIngressMBps)
fmt.Printf("Daily Storage: %.2f TB\n", resources.DailyStorageTB)
fmt.Printf("Total Storage: %.2f TB\n", resources.TotalStorageTB)
}
4. Оценка количества серверов
┌─────────────────────────────────────────────────────────────────────────────┐
│ ОЦЕНКА КОЛИЧЕСТВА СЕРВЕРОВ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Типичные характеристики сервера: │
│ ────────────────────────────────── │
│ • CPU: 16-64 cores │
│ • RAM: 64-256 GB │
│ • Disk: 1-10 TB NVMe │
│ • Network: 1-10 Gbps │
│ │
│ Правила для расчёта: │
│ ───────────────────── │
│ │
│ API серверы: │
│ Peak RPS / RPS per instance │
│ 350K / 10K = 35 instances │
│ │
│ Применяем headroom 30-50%: │
│ 35 × 1.5 = ~53 instances │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Kafka brokers: │
│ Peak throughput / throughput per broker │
│ 350 MB/s / 100 MB/s = 4 brokers │
│ │
│ Или по партициям: │
│ 100 partitions / 20 per broker = 5 brokers │
│ │
│ Берём максимум + replication: │
│ max(4, 5) × 1.5 = ~8 brokers │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ ClickHouse nodes: │
│ Daily data / data per node per day │
│ 10 TB / 2 TB = 5 nodes │
│ │
│ Или по общему хранилищу: │
│ 2.7 PB / 100 TB per node = 27 nodes │
│ │
│ Берём максимум + replication: │
│ max(5, 27) × 1.5 = ~41 nodes │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Redis Cluster: │
│ Working set size / RAM per node │
│ 500 GB / 128 GB = 4 nodes │
│ │
│ + replication factor 2: │
│ 4 × 2 = 8 nodes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Влияние на архитектурные решения
┌─────────────────────────────────────────────────────────────────────────────┐
│ ВЛИЯНИЕ НАГРУЗКИ НА АРХИТЕКТУРУ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Высокий write throughput (>100K writes/sec): │
│ ──────────────────────────────────────────── │
│ • Используем Kafka для ingestion │
│ • Рассматриваем Cassandra/ScyllaDB │
│ • Batch writes вместо single writes │
│ • Write-behind caching │
│ │
│ Высокий read throughput (>1M reads/sec): │
│ ────────────────────────────────────────── │
│ • Множество read replicas │
│ • CDN для статического контента │
│ • Redis/Memcached для hot data │
│ • Materialized views │
│ │
│ Большой объём данных (>1PB): │
│ ────────────────────────────── │
│ • Columnar storage (ClickHouse, BigQuery) │
│ • Data partitioning by time │
│ • Tiered storage (hot/warm/cold) │
│ • Compression │
│ │
│ Низкая latency (<10ms p99): │
│ ──────────────────────────── │
│ • In-memory storage │
│ • Local caching │
│ • Avoid network hops │
│ • Pre-computation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Чек-лист для оценки нагрузки
┌─────────────────────────────────────────────────────────────────────────────┐
│ ЧЕК-ЛИСТ ОЦЕНКИ НАГРУЗКИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ □ Определил DAU/MAU │
│ □ Расчитал средний и пиковый RPS │
│ □ Определил соотношение Read/Write │
│ □ Расчитал bandwidth (ingress/egress) │
│ □ Определил размер события/запроса │
│ □ Расчитал ежедневный рост данных │
│ □ Учёл retention period │
│ □ Учёл replication factor │
│ □ Расчитал общее хранилище │
│ □ Определил количество серверов для каждого компонента │
│ □ Добавил headroom (30-50%) │
│ □ Проверил, что сеть не является bottleneck │
│ □ Проверил, что диск не является bottleneck │
│ □ Учёл географическое распределение (если нужно) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Анализ трафика и ресурсов — обязательная часть System Design интервью. Это показывает:
- Практическое мышление — кандидат понимает, что архитектура должна решать реальные задачи
- Опыт эксплуатации — понимание реальных ограничений железа
- Способность к планированию — умение оценить стоимость и ресурсы
- Коммуникация с бизнесом — возможность объяснить требования в числах
На интервью достаточно back-of-the-envelope расчётов на уровне порядков величин. Детальные расчёты делаются уже при реальной реализации.
Вопрос 31. Какие технологии предлагается использовать для каждого компонента системы?
Таймкод: 01:00:53
Ответ собеседника: Правильный. Experiment Service — PostgreSQL (простая таблица, мало данных), сбор аналитики — Kafka (для буферизации и сохранения порядка событий), аналитическая БД — ClickHouse (аналитические запросы, срезы, материализованные представления), хранилище сегментов — Cassandra или MongoDB (key-value, горизонтальное масштабирование).
Правильный ответ:
1. Полная технологическая карта системы
┌─────────────────────────────────────────────────────────────────────────────┐
│ Technology Stack Overview │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ MOBILE CLIENTS │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ iOS App │ │ Android App │ │ Web App │ │ │
│ │ │ (Swift) │ │ (Kotlin) │ │ (React) │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────┼────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────▼─────┐ │ │
│ │ │ SDK │ │ │
│ │ │ (Go/ │ │ │
│ │ │ Swift/ │ │ │
│ │ │ Kotlin) │ │ │
│ │ └─────┬─────┘ │ │
│ └──────────────────────────┼────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ API GATEWAY / LOAD BALANCER │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Kong / Nginx / AWS ALB │ │ │
│ │ │ • Rate limiting │ │ │
│ │ │ • Authentication │ │ │
│ │ │ • SSL termination │ │ │
│ │ │ • Request routing │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Experiment │ │ Event │ │ Segment │ │
│ │ Decision │ │ Collector │ │ Service │ │
│ │ Service │ │ Service │ │ │ │
│ │ (Go) │ │ (Go) │ │ (Go) │ │
│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Kafka │ │ │
│ │ │ Cluster │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DATA STORAGE LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ PostgreSQL │ │ Redis │ │ ClickHouse │ │ │
│ │ │ (Metadata) │ │ (Cache) │ │ (Analytics) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Cassandra │ │ S3 │ │ Airflow │ │ │
│ │ │ (Segments) │ │ (Parquet) │ │ (Batch) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Детальное обоснование выбора технологий
┌─────────────────────────────────────────────────────────────────────────────┐
│ Technology Justification │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Experiment Decision Service → Go + PostgreSQL │ │
│ │ │ │
│ │ Почему Go: │ │
│ │ • Высокая производительность для HTTP-сервисов │ │
│ │ • Низкое потребление памяти │ │
│ │ • Отличная поддержка конкурентности (goroutines) │ │
│ │ • Быстрая компиляция и деплой │ │
│ │ │ │
│ │ Почему PostgreSQL: │ │
│ │ • Данные экспериментов — небольшие объёмы (MB, не GB) │ │
│ │ • Сложные запросы с JOIN для конфигураций │ │
│ │ • ACID-транзакции для целостности данных │ │
│ │ • JSONB для гибких конфигураций экспериментов │ │
│ │ • Зрелая экосистема и инструменты │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Event Collector Service → Go + Kafka │ │
│ │ │ │
│ │ Почему Kafka: │ │
│ │ • Сохранение порядка событий внутри партиции │ │
│ │ • Буферизация при пиковых нагрузках │ │
│ │ • Множество потребителей (analytics, monitoring, ML) │ │
│ │ • Репликация и отказоустойчивость │ │
│ │ • Retention policy для повторной обработки │ │
│ │ │ │
│ │ Архитектура Kafka: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Topics: │ │ │
│ │ │ • app.events.raw (партиции: 12, RF: 3) │ │ │
│ │ │ • app.events.validated (партиции: 12, RF: 3) │ │ │
│ │ │ • app.events.enriched (партиции: 12, RF: 3) │ │ │
│ │ │ • experiment.results (партиции: 6, RF: 3) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Analytics Database → ClickHouse │ │
│ │ │ │
│ │ Почему ClickHouse: │ │
│ │ • Колоночное хранение — быстрая агрегация │ │
│ │ • Материализованные представления для предрасчёта │ │
│ │ • Сжатие данных в 10-100 раз │ │
│ │ • Распределённые запросы для масштабирования │ │
│ │ • SQL-интерфейс для аналитиков │ │
│ │ │ │
│ │ Альтернативы: │ │
│ │ • Apache Druid — для real-time analytics │ │
│ │ • TimescaleDB — если нужен PostgreSQL-совместимый │ │
│ │ • BigQuery — для облачных решений │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. Segment Storage → Redis + PostgreSQL │ │
│ │ │ │
│ │ Почему Redis (primary): │ │
│ │ • Sub-millisecond latency для горячих данных │ │
│ │ • Структура Set для хранения сегментов пользователя │ │
│ │ • TTL для автоматического обновления │ │
│ │ • Кластер mode для горизонтального масштабирования │ │
│ │ │ │
│ │ Почему PostgreSQL (source of truth): │ │
│ │ • Надёжное хранение данных │ │
│ │ • Сложные запросы для аналитики сегментов │ │
│ │ • Резервное копирование и восстановление │ │
│ │ │ │
│ │ Альтернативы для очень больших объёмов: │ │
│ │ • Cassandra — для >100M пользователей, write-heavy │ │
│ │ • ScyllaDB — Cassandra-совместимая, выше производительность │ │
│ │ • MongoDB — если нужны документ-ориентированные запросы │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Конфигурация PostgreSQL для Experiment Service
-- Таблица экспериментов
CREATE TABLE experiments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- draft, running, paused, completed, archived
hypothesis TEXT,
-- Целевые сегменты
target_segments INTEGER[] NOT NULL DEFAULT '{}',
-- Варианты (control, treatment, etc.)
variants JSONB NOT NULL DEFAULT '[]',
/*
[
{
"id": 1,
"name": "control",
"traffic_percentage": 50,
"config": {}
},
{
"id": 2,
"name": "treatment",
"traffic_percentage": 50,
"config": {
"button_color": "#FF5722",
"show_progress_bar": true
}
}
]
*/
-- Метрики для отслеживания
primary_metric VARCHAR(100),
secondary_metrics VARCHAR(100)[] DEFAULT '{}',
-- Время проведения
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
-- Минимальный размер выборки
min_sample_size INTEGER DEFAULT 1000,
-- Метаданные
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Индексы
CONSTRAINT valid_status CHECK (status IN ('draft', 'running', 'paused', 'completed', 'archived'))
);
-- Индексы
CREATE INDEX idx_experiments_status ON experiments(status);
CREATE INDEX idx_experiments_target_segments ON experiments USING GIN(target_segments);
CREATE INDEX idx_experiments_time_range ON experiments(start_time, end_time);
-- Таблица пользовательских назначений (для sticky assignment)
CREATE TABLE user_assignments (
user_id VARCHAR(255) NOT NULL,
experiment_id UUID NOT NULL REFERENCES experiments(id),
variant_id INTEGER NOT NULL,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, experiment_id)
);
CREATE INDEX idx_user_assignments_experiment ON user_assignments(experiment_id);
-- Таблица сегментов
CREATE TABLE segments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
segment_type VARCHAR(50) NOT NULL,
-- audience, technical, contextual
criteria JSONB NOT NULL,
/*
{
"conditions": [
{
"field": "country",
"operator": "in",
"value": ["US", "CA", "GB"]
},
{
"field": "ltv",
"operator": "greater_than",
"value": 100
}
],
"logic": "AND"
}
*/
is_active BOOLEAN DEFAULT true,
user_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Таблица соответствия пользователь-сегмент (batch-computed)
CREATE TABLE user_segments (
user_id VARCHAR(255) NOT NULL,
segment_id INTEGER NOT NULL REFERENCES segments(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, segment_id)
);
CREATE INDEX idx_user_segments_segment ON user_segments(segment_id);
CREATE INDEX idx_user_segments_updated ON user_segments(updated_at);
-- Функция обновления updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_experiments_updated_at
BEFORE UPDATE ON experiments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
4. Конфигурация Redis для хранилища сегментов
package config
// RedisConfig конфигурация Redis
type RedisConfig struct {
// Основные настройки
Addrs []string `yaml:"addrs"`
Password string `yaml:"password"`
DB int `yaml:"db"`
// Пул соединений
PoolSize int `yaml:"pool_size"`
MinIdleConns int `yaml:"min_idle_conns"`
MaxRetries int `yaml:"max_retries"`
// Таймауты
DialTimeout time.Duration `yaml:"dial_timeout"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
// TTL для данных
SegmentCacheTTL time.Duration `yaml:"segment_cache_ttl"`
}
// DefaultRedisConfig возвращает конфигурацию по умолчанию
func DefaultRedisConfig() *RedisConfig {
return &RedisConfig{
Addrs: []string{"localhost:6379"},
PoolSize: 100,
MinIdleConns: 10,
MaxRetries: 3,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
SegmentCacheTTL: 24 * time.Hour,
}
}
// ClusterRedisConfig конфигурация для кластера
func ClusterRedisConfig() *RedisConfig {
return &RedisConfig{
Addrs: []string{
"redis-node-1:6379",
"redis-node-2:6379",
"redis-node-3:6379",
},
PoolSize: 200,
MinIdleConns: 20,
MaxRetries: 3,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
SegmentCacheTTL: 24 * time.Hour,
}
}
5. Конфигурация ClickHouse для аналитики
-- Кластерная конфигурация
CREATE TABLE events_raw ON CLUSTER '{cluster}' (
event_id String,
event_name LowCardinality(String),
user_id String,
device_id String,
session_id String,
timestamp DateTime64(3),
-- Свойства
properties String, -- JSON
-- Эксперименты
experiment_ids Array(String),
variant_ids Array(UInt32),
variant_names Array(String),
-- Устройство
app_version LowCardinality(String),
platform LowCardinality(String),
os_version LowCardinality(String),
device_model LowCardinality(String),
-- Геолокация
country LowCardinality(String),
city LowCardinality(String),
region LowCardinality(String),
-- Метаданные
server_timestamp DateTime64(3),
kafka_partition UInt32,
kafka_offset UInt64
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_raw', '{replica}')
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, timestamp)
TTL timestamp + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
-- Распределённая таблица
CREATE TABLE events_raw_distributed ON CLUSTER '{cluster}' AS events_raw
ENGINE = Distributed('{cluster}', default, events_raw, rand());
-- Предрассчитанные метрики экспериментов
CREATE TABLE experiment_daily_metrics ON CLUSTER '{cluster}' (
event_date Date,
experiment_id String,
variant_id UInt32,
variant_name String,
-- Основные метрики
total_users UInt64,
total_events UInt64,
-- Конверсии
purchases UInt64,
revenue Decimal64(2),
-- Поведенческие метрики
session_duration AggregateFunction(avg, UInt32),
page_views UInt64,
-- Статистика
unique_users AggregateFunction(uniq, String)
) ENGINE = ReplicatedAggregatingMergeTree('/clickhouse/tables/{shard}/experiment_daily_metrics', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (experiment_id, variant_id, event_date);
-- Materialized View для автоматического заполнения
CREATE MATERIALIZED VIEW experiment_daily_metrics_mv ON CLUSTER '{cluster}'
TO experiment_daily_metrics AS
SELECT
toDate(timestamp) as event_date,
arrayJoin(experiment_ids) as experiment_id,
arrayJoin(variant_ids) as variant_id,
arrayJoin(variant_names) as variant_name,
count() as total_events,
uniqHLL12(user_id) as unique_users,
countIf(event_name = 'purchase') as purchases,
sumIf(
toFloat64(JSONExtractFloat(properties, 'revenue')),
event_name = 'purchase'
) as revenue,
countIf(event_name = 'page_view') as page_views
FROM events_raw
WHERE length(experiment_ids) > 0
GROUP BY event_date, experiment_id, variant_id, variant_name;
6. Конфигурация Kafka
# kafka-config.yaml
topics:
app.events.raw:
partitions: 12
replication_factor: 3:
config:
retention.ms: 604800000 # 7 дней
compression.type: lz4
min.insync.replicas: 2
app.events.validated:
partitions: 12
replication_factor: 3
config:
retention.ms: 604800000
compression.type: lz4
app.events.enriched:
partitions: 12
replication_factor: 3
config:
retention.ms: 604800000
compression.type: lz4
experiment.results:
partitions: 6
replication_factor: 3
config:
retention.ms: 2592000000 # 30 дней
compression.type: snappy
consumer_groups:
analytics-processor:
topics:
- app.events.raw
config:
auto.offset.reset: earliest
enable.auto.commit: false
max.poll.records: 500
experiment-aggregator:
topics:
- app.events.enriched
config:
auto.offset.reset: earliest
enable.auto.commit: false
Краткий ответ: Технологический стек системы:
| Компонент | Технология | Обоснование |
|---|---|---|
| Experiment Service | Go + PostgreSQL | Небольшие объёмы данных, сложные запросы с JOIN, ACID |
| Event Collector | Go + Kafka | Сохранение порядка событий, буферизация, множество потребителей |
| Analytics DB | ClickHouse | Колоночное хранение, быстрая агрегация, материализованные представления |
| Segment Storage | Redis (cache) + PostgreSQL (source of truth) | Sub-ms latency для горячих данных, надёжное хранение |
| Batch Processing | Airflow + Spark | Планирование и выполнение batch-задач |
| Stream Processing | Flink / Kafka Streams | Real-time обработка событий |
| Object Storage | S3 (Parquet) | Долгосрочное хранение сырых данных |
Вопрос 44. Сколько времени даётся на решение задачи и как распределять время?
Таймкод: 01:36:33
Ответ собеседова: Правильный. Даётся один час. Рекомендуется сначала формализовать задачу, понять сценарии и API, потом переходить к компонентам. Интервьюер подталкивает если кандидат двигается слишком медленно. Иногда время могут продлить если кандидат интересный и обсуждение продуктивное.
Правильный ответ:
Распределение времени на System Design интервью
┌─────────────────────────────────────────────────────────────────────────────┐
│ СТАНДАРТНЫЙ ФОРМАТ: 60 МИНУТ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 0-5 мин │ Уточнение требований │ │
│ │ │ • Функциональные требования │ │
│ │ │ • Нефункциональные требования │ │
│ │ │ • Ограничения и допущения │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 5-10 мин │ Оценка масштаба │ │
│ │ │ • DAU/MAU, RPS │ │
│ │ │ • Storage estimation │ │
│ │ │ • Bandwidth │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 10-15 мин │ Высокоуровневая архитектура │ │
│ │ │ • Основные компоненты │ │
│ │ │ • API design │ │
│ │ │ • Data model (высокоуровневая) │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 15-40 мин │ Детальное проектирование │ │
│ │ │ • Глубокое погружение в каждый компонент │ │
│ │ │ • Trade-offs и обоснование выбора │ │
│ │ │ • Проблемы и решения │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 40-50 мин │ Бутылочные горлышки и проблемы │ │
│ │ │ • Single points of failure │ │
│ │ │ • Scalability issues │ │
│ │ │ • Failure scenarios │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 50-60 мин │ Итоги и вопросы │ │
│ │ │ • Резюме архитектуры │ │
│ │ │ • Потенциальные улучшения │ │
│ │ │ • Вопросы интервьюеру │ │
│ └──────────────┴───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1. Детальное распределение по фазам
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 1: УТОЧНЕНИЕ (5 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Понять задачу ПОЛНОСТЬЮ перед началом проектирования │
│ │
│ Задавать вопросы: │
│ ───────────────── │
│ • Какие основные функции нужны? MVP или полная версия? │
│ • Сколько пользователей? DAU vs MAU? │
│ • Какие регионы? Географическое распределение? │
│ • Какие требования к latency? Availability? │
│ • Есть ли ограничения по бюджету? │
│ │
│ Примеры вопросов: │
│ ────────────────── │
│ "Это глобальный сервис или локальный?" │
│ "Какой ожидаемый трафик при запуске и через год?" │
│ "Есть ли особые compliance требования (GDPR, HIPAA)?" │
│ "Какие самые важные сценарии использования?" │
│ │
│ ⚠️ Не начинайте проектирование до уточнения требований! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 2: ОЦЕНКА МАСШТАБА (5 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Понять порядок величин для правильного выбора технологий │
│ │
│ Рассчитать: │
│ ──────────── │
│ • RPS (средний и пиковый) │
│ • Storage (дневной рост и общий объём) │
│ • Bandwidth (ingress/egress) │
│ • Примерное количество серверов │
│ │
│ Пример: │
│ ──────── │
│ "Допустим, 10M DAU, каждый делает 10 запросов в день" │
│ "Средний RPS = 10M × 10 / 86400 ≈ 1,200 RPS" │
│ "Пиковый RPS (3x) ≈ 3,600 RPS" │
│ "Каждый запрос ~1KB, значит bandwidth ≈ 12 MB/s" │
│ │
│ ⚠️ Говорите вслух, показывая ход мысли! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 3: API И ДАТА МОДЕЛЬ (5 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Определить интерфейс системы и структуру данных │
│ │
│ API Design: │
│ ──────────── │
│ • RESTful endpoints или gRPC? │
│ • Request/Response формат │
│ • Authentication │
│ │
│ Data Model: │
│ ──────────── │
│ • Основные сущности │
│ • Связи между ними │
│ • Типы БД (SQL vs NoSQL) │
│ │
│ Пример: │
│ ──────── │
│ POST /api/v1/orders │
│ GET /api/v1/orders/{id} │
│ GET /api/v1/users/{id}/orders │
│ │
│ Tables: Users, Orders, OrderItems, Products │
│ │
│ ⚠️ Не углубляйтесь в детали — это высокоуровневый дизайн! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 4: HIGH-LEVEL DESIGN (5 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Нарисовать "коробочную диаграмму" системы │
│ │
│ Нарисовать: │
│ ──────────── │
│ • Clients (Web, Mobile, Third-party) │
│ • Load Balancer │
│ • API Gateway │
│ • Microservices / Components │
│ • Databases │
│ • Cache │
│ • Message Queue │
│ • CDN │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Client │────▶│ LB │────▶│ API GW │────▶│ Service │ │
│ └─────────┘ └─────────┘ └─────────┘ └────┬────┘ │
│ │ │
│ ┌─────────────────────────┼──────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌─────▼───┐ ┌───▼───┐ │
│ │ Cache │ │ DB │ │ Queue │ │
│ └─────────┘ └─────────┘ └───────┘ │
│ │
│ ⚠️ Это скелет, на который будем наращивать детали! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 5: DEEP DIVE (25 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Детально проработать каждый компонент │
│ │
│ Для каждого компонента: │
│ ──────────────────────── │
│ • Как это работает? │
│ • Почему этот выбор? │
│ • Какие есть альтернативы? │
│ • Какие проблемы могут возникнуть? │
│ │
│ Пример для базы данных: │
│ ─────────────────────── │
│ "Выбираем PostgreSQL потому что: │
│ - Нужны ACID транзакции │
│ - Сложные запросы с JOIN │
│ - Размер данных помещается на один сервер │
│ - Если данных станет много — добавим шардирование по user_id" │
│ │
│ Потенциальные проблемы: │
│ • Что если master упадёт? → Автоматический failover с репликами │
│ • Что если данных станет много? → Шардирование │
│ • Что если нагрузка вырастет? → Read replicas │
│ │
│ ⚠️ Это самая важная часть — здесь оценивается глубина знаний! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 6: BOTTLENECKS (10 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Показать понимание реальных проблем эксплуатации │
│ │
│ Обсудить: │
│ ────────── │
│ • Single points of failure │
│ • Как мониторить систему? │
│ • Что делать при инцидентах? │
│ • Как масштабировать при росте нагрузки? │
│ │
│ Примеры: │
│ ──────── │
│ "SPOF: API Gateway. Решение: active-active в нескольких AZ" │
│ "SPOF: Database master. Решение: automatic failover" │
│ "Мониторинг: Prometheus + Grafana + PagerDuty" │
│ "Rate limiting на уровне API Gateway" │
│ │
│ ⚠️ Показывает опыт продакшн-эксплуатации! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФАЗА 7: WRAP-UP (10 минут) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Цель: Подвести итоги и обсудить следующие шаги │
│ │
│ Сделать: │
│ ──────── │
│ • Резюмировать архитектуру (2-3 минуты) │
│ • Обсудить, что бы улучшили при наличии больше времени │
│ • Обсудить evolution архитектуры при росте │
│ • Задать вопросы интервьюеру │
│ │
│ Пример резюме: │
│ ────────────── │
│ "Итак, мы спроектировали систему на основе: │
│ - API Gateway для routing и rate limiting │
│ - Микросервисную архитектуру с 3 основными сервисами │
│ - PostgreSQL как основное хранилище │
│ - Redis для кэширования горячих данных │
│ - Kafka для async обработки │
│ - CDN для статики │
│ Система рассчитана на X RPS и Y TB данных" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Типичные ошибки в управлении временем
┌─────────────────────────────────────────────────────────────────────────────┐
│ ТИПИЧНЫЕ ОШИБКИ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ Ошибка 1: Слишком много времени на уточнения (>10 мин) │
│ ✅ Решение: Задавайте только критичные вопросы, остальное — допущения │
│ │
│ ❌ Ошибка 2: Слишком мало времени на уточнения (<2 мин) │
│ ✅ Решение: Минимум 3-5 минут на понимание задачи │
│ │
│ ❌ Ошибка 3: Погружение в детали одного компонента │
│ ✅ Решение: Сначала показать общую картину, потом детали │
│ │
│ ❌ Ошибка 4: Забыли про бутылочные горлышки │
│ ✅ Решение: Выделить 10-15 минут в конце на обсуждение проблем │
│ │
│ ❌ Ошибка 5: Молчаливое проектирование │
│ ✅ Решение: Говорить вслух, объяснять каждый выбор │
│ │
│ ❌ Ошибка 6: Отсутствие структуры │
│ ✅ Решение: Следовать плану: Requirements → Scale → API → Design → Deep │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Советы по тайм-менеджменту
┌─────────────────────────────────────────────────────────────────────────────┐
│ СОВЕТЫ ПО УПРАВЛЕНИЮ ВРЕМЕНЕМ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. ИСПОЛЬЗУЙТЕ ТАЙМЕР │
│ ───────────────────── │
│ Периодически спрашивайте интервьюера: │
│ "Сколько у нас осталось времени?" │
│ │
│ 2. ДЕЛАЙТЕ ДОПУЩЕНИЯ ЯВНО │
│ ────────────────────────── │
│ "Предположим, что у нас 10M DAU" │
│ "Для простоты считаем, что данные помещаются на один сервер" │
│ │
│ 3. ПРИОРИТИЗИРУЙТЕ КОМПОНЕНТЫ │
│ ────────────────────────────── │
│ Сначала спроектируйте критичные пути (happy path), │
│ потом — edge cases и оптимизации │
│ │
│ 4. НЕ БОЙТЕСЬ ПРОПУСТИТЬ ДЕТАЛИ │
│ ────────────────────────────────── │
│ "Этот компонент требует отдельного обсуждения, давайте вернёмся к нему │
│ позже" — лучше, чем закопаться на 20 минут │
│ │
│ 5. СЛУШАЙТЕ ХИНТЫ ИНТЕРВЬЮЕРА │
│ ──────────────────────────────── │
│ Если интервьюер говорит "давайте перейдём к..." — значит, пора двигаться │
│ │
│ 6. ПРАКТИКУЙТЕСЬ С ТАЙМЕРОМ │
│ ────────────────────────────── │
│ Решая задачи дома, ставьте таймер на 60 минут │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Форматы интервью в разных компаниях
┌─────────────────────────────────────────────────────────────────────────────┐
│ ФОРМАТЫ В РАЗНЫХ КОМПАНИЯХ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Google / Meta / Amazon: │
│ ──────────────────────── │
│ • 45-60 минут │
│ • Строгий тайминг │
│ • Ожидается полный дизайн за это время │
│ │
│ Startups: │
│ ────────── │
│ • 60-90 минут │
│ • Более гибкий формат │
│ • Фокус на практичности, а не на идеальности │
│ │
│ Enterprise: │
│ ──────────── │
│ • 60-120 минут │
│ • Больше внимания на compliance, security │
│ • Меньше фокуса на scale │
│ │
│ Расширенные форматы (для Staff+): │
│ ────────────────────────────────── │
│ • 90-120 минут │
│ • Multiple rounds │
│ • Ожидается более глубокое погружение │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Что делать, если не укладываетесь во время
┌─────────────────────────────────────────────────────────────────────────────┐
│ ЕСЛИ НЕ УКЛАДЫВАЕТЕСЬ ВО ВРЕМЯ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Сценарий 1: Затянули с уточнениями │
│ ────────────────────────────────── │
│ "Я понимаю, что мы потратили много времени на уточнения. │
│ Давайте я сделаю допущения и перейду к дизайну" │
│ │
│ Сценарий 2: Застряли на одном компоненте │
│ ──────────────────────────────────────── │
│ "Этот компонент важен, но давайте я опишу его на высоком уровне │
│ и мы перейдём к следующему" │
│ │
│ Сценарий 3: Не успеваете обсудить все компоненты │
│ ──────────────────────────────────────────────── │
│ "Мы обсудили основные компоненты. Давайте я кратко опишу │
│ оставшиеся и мы перейдём к бутылочным горлышкам" │
│ │
│ Сценарий 4: Интервьюер торопит │
│ ────────────────────────────── │
│ Примите это спокойно — значит, пора переходить к следующему этапу │
│ │
│ Главное правило: │
│ ──────────────── │
│ Лучше показать общую картину, чем детально проработать один компонент │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Вывод:
Ключевые принципы тайм-менеджмента на System Design:
- Структура — следуйте плану по фазам
- Коммуникация — говорите вслух, объясняйте выбор
- Приоритизация — сначала happy path, потом edge cases
- Гибкость — адаптируйтесь под подсказки интервьюера
- Практика — тренируйтесь с таймером перед реальным интервью
Помните: интервьюер оценивает не только результат, но и процесс мышления. Управление временем — часть этого процесса.
Вопрос 32. Как обеспечить прилипание пользователя к эксперименту (sticky assignment)?
Таймкод: 01:03:47
Ответ собеседника: Неполный. Предлагается два подхода: 1) Хранение пары User ID + ID эксперимента в отдельном хранилище. 2) Создание сегмента пользователей, попавших в тестовый вариант эксперимента. Второй вариант предпочтительнее, так как не требует отдельного компонента.
Правильный ответ:
1. Проблема "прилипания" (Sticky Assignment)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Проблема Sticky Assignment │
│ │
│ Без sticky assignment: │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Запрос 1 │ │ Запрос 2 │ │ Запрос 3 │ │
│ │ 10:00:00 │ │ 10:05:00 │ │ 10:10:00 │ │
│ │ │ │ │ │ │ │
│ │ Variant: A │ │ Variant: B │ │ Variant: A │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Пользователь видит РАЗНЫЕ варианты → сломанный UX, некорректная аналитика │
│ │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ С sticky assignment: │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Запрос 1 │ │ Запрос 2 │ │ Запрос 3 │ │
│ │ 10:00:00 │ │ 10:05:00 │ │ 10:10:00 │ │
│ │ │ │ │ │ │ │
│ │ Variant: A │ │ Variant: A │ │ Variant: A │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Пользователь видит ОДИН вариант → консистентный UX, корректная аналитика │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Подход 1: Хранение назначений (Assignment Storage)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Assignment Storage Approach │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ При первом запросе пользователя: │ │
│ │ │ │
│ │ 1. Проверяем Assignment Storage │ │
│ │ ├─ Есть запись? → Возвращаем сохранённый variant │ │
│ │ └─ Нет записи? → Генерируем новый assignment │ │
│ │ │ │
│ │ 2. Генерация assignment: │ │
│ │ ├─ Определяем сегменты пользователя │ │
│ │ ├─ Находим активные эксперименты для этих сегментов │ │
│ │ ├─ Применяем рандомизацию (hash-based или percentage-based) │ │
│ │ └─ Сохраняем результат в Assignment Storage │ │
│ │ │ │
│ │ 3. Возвращаем variant пользователю │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Хранение данных: │ │
│ │ │ │
│ │ PostgreSQL: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ user_assignments │ │ │
│ │ │ ┌────────────┬──────────────┬───────────┬───────────────┐ │ │ │
│ │ │ │ user_id │ experiment_id│ variant_id│ assigned_at │ │ │ │
│ │ │ ├────────────┼──────────────┼───────────┼───────────────┤ │ │ │
│ │ │ │ user_123 │ exp_001 │ 2 │ 2024-12-20... │ │ │ │
│ │ │ │ user_123 │ exp_015 │ 1 │ 2024-12-20... │ │ │ │
│ │ │ │ user_456 │ exp_001 │ 1 │ 2024-12-19... │ │ │ │
│ │ │ └────────────┴──────────────┴───────────┴───────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Redis (кэш): │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: assignment:{user_id} │ │ │
│ │ │ Value: Hash { │ │ │
│ │ │ "exp_001": "2", │ │ │
│ │ │ "exp_015": "1" │ │ │
│ │ │ } │ │ │
│ │ │ TTL: 24h │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Подход 2: Сегментный подход (Segment-based)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Segment-based Approach │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ При назначении пользователя в эксперимент: │ │
│ │ │ │
│ │ 1. Определяем variant пользователя │ │
│ │ └─ На основе user_id, эксперимента и traffic percentage │ │
│ │ │ │
│ │ 2. Если попал в treatment: │ │
│ │ └─ Добавляем в сегмент "exp_001_treatment" │ │
│ │ │ │
│ │ 3. Если попал в control: │ │
│ │ └─ Добавляем в сегмент "exp_001_control" │ │
│ │ │ │
│ │ 4. При последующих запросах: │ │
│ │ └─ Проверяем принадлежность к сегменту → знаем variant │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Структура сегментов: │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ experiment_segments │ │ │
│ │ │ ┌─────────────────────┬──────────────────────────────────┐ │ │ │
│ │ │ │ segment_name │ Описание │ │ │ │
│ │ │ ├─────────────────────┼──────────────────────────────────┤ │ │ │
│ │ │ │ exp_001_treatment │ Пользователи в treatment │ │ │ │
│ │ │ │ exp_001_control │ Пользователи в control │ │ │ │
│ │ │ │ exp_015_treatment │ Пользователи в treatment │ │ │ │
│ │ │ │ exp_015_control │ Пользователи в control │ │ │ │
│ │ │ └─────────────────────┴──────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Redis: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: segment:exp_001_treatment │ │ │
│ │ │ Type: Set │ │ │
│ │ │ Members: {user_123, user_456, user_789, ...} │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Реализация Assignment Storage
package assignment
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
)
// AssignmentService сервис назначения пользователей в эксперименты
type AssignmentService struct {
db *sql.DB
redis *redis.Client
segmentSvc *segment.Service
experimentSvc *experiment.Service
logger *zap.Logger
}
// UserAssignment назначение пользователя в эксперимент
type UserAssignment struct {
UserID string `db:"user_id"`
ExperimentID string `db:"experiment_id"`
VariantID int `db:"variant_id"`
VariantName string `db:"variant_name"`
AssignedAt time.Time `db:"assigned_at"`
}
// GetOrCreateAssignment получает существующее или создаёт новое назначение
func (s *AssignmentService) GetOrCreateAssignment(
ctx context.Context,
userID string,
experimentID string,
) (*UserAssignment, error) {
// 1. Проверяем кэш Redis
assignment, err := s.getFromCache(ctx, userID, experimentID)
if err == nil && assignment != nil {
s.logger.Debug("assignment found in cache",
zap.String("user_id", userID),
zap.String("experiment_id", experimentID))
return assignment, nil
}
// 2. Проверяем PostgreSQL
assignment, err = s.getFromDB(ctx, userID, experimentID)
if err == nil && assignment != nil {
// Обновляем кэш
s.setCache(ctx, assignment)
return assignment, nil
}
// 3. Создаём новое назначение
assignment, err = s.createAssignment(ctx, userID, experimentID)
if err != nil {
return nil, fmt.Errorf("failed to create assignment: %w", err)
}
return assignment, nil
}
// createAssignment создаёт новое назначение пользователя
func (s *AssignmentService) createAssignment(
ctx context.Context,
userID string,
experimentID string,
) (*UserAssignment, error) {
// 1. Получаем эксперимент
exp, err := s.experimentSvc.GetExperiment(ctx, experimentID)
if err != nil {
return nil, fmt.Errorf("experiment not found: %w", err)
}
// 2. Проверяем, что эксперимент активен
if exp.Status != "running" {
return nil, fmt.Errorf("experiment is not running: %s", exp.Status)
}
// 3. Определяем сегменты пользователя
userSegments, err := s.segmentSvc.GetUserSegments(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get user segments: %w", err)
}
// 4. Проверяем, что пользователь в целевых сегментах
if !s.isUserInTargetSegments(userSegments, exp.TargetSegments) {
return nil, fmt.Errorf("user not in target segments")
}
// 5. Определяем variant на основе хеша
variantID, variantName := s.determineVariant(userID, exp)
// 6. Сохраняем назначение
assignment := &UserAssignment{
UserID: userID,
ExperimentID: experimentID,
VariantID: variantID,
VariantName: variantName,
AssignedAt: time.Now(),
}
// 7. Транзакция: сохраняем в DB и кэш
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `
INSERT INTO user_assignments (user_id, experiment_id, variant_id, assigned_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, experiment_id) DO NOTHING
`, assignment.UserID, assignment.ExperimentID, assignment.VariantID, assignment.AssignedAt)
if err != nil {
return nil, fmt.Errorf("failed to insert assignment: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
// 8. Обновляем кэш
s.setCache(ctx, assignment)
// 9. Добавляем в сегмент эксперимента (для аналитики)
segmentName := fmt.Sprintf("%s_%s", experimentID, variantName)
if err := s.segmentSvc.AddUserToSegment(ctx, userID, segmentName); err != nil {
s.logger.Error("failed to add user to experiment segment",
zap.Error(err),
zap.String("user_id", userID),
zap.String("segment", segmentName))
}
s.logger.Info("assignment created",
zap.String("user_id", userID),
zap.String("experiment_id", experimentID),
zap.Int("variant_id", variantID))
return assignment, nil
}
// determineVariant определяет variant на основе детерминированного хеша
func (s *AssignmentService) determineVariant(
userID string,
exp *experiment.Experiment,
) (int, string) {
// Используем хеш user_id + experiment_id для детерминированного результата
hash := sha256.Sum256([]byte(userID + ":" + exp.ID))
// Берём первые 4 байта хеша и конвертируем в число 0-9999
hashValue := binary.BigEndian.Uint32(hash[:4]) % 10000
// Распределяем по variants на основе traffic percentage
cumulativePercentage := 0
for _, variant := range exp.Variants {
cumulativePercentage += variant.TrafficPercentage
if int(hashValue) < cumulativePercentage*100 {
return variant.ID, variant.Name
}
}
// Fallback на последний variant
lastVariant := exp.Variants[len(exp.Variants)-1]
return lastVariant.ID, lastVariant.Name
}
// getFromCache получает назначение из Redis
func (s *AssignmentService) getFromCache(
ctx context.Context,
userID string,
experimentID string,
) (*UserAssignment, error) {
key := fmt.Sprintf("assignment:%s", userID)
result, err := s.redis.HGet(ctx, key, experimentID).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
// Парсим результат
var assignment UserAssignment
if err := json.Unmarshal([]byte(result), &assignment); err != nil {
return nil, err
}
return &assignment, nil
}
// setCache сохраняет назначение в Redis
func (s *AssignmentService) setCache(
ctx context.Context,
assignment *UserAssignment,
) error {
key := fmt.Sprintf("assignment:%s", assignment.UserID)
data, err := json.Marshal(assignment)
if err != nil {
return err
}
pipe := s.redis.Pipeline()
pipe.HSet(ctx, key, assignment.ExperimentID, data)
pipe.Expire(ctx, key, 24*time.Hour)
_, err = pipe.Exec(ctx)
return err
}
// isUserInTargetSegments проверяет принадлежность пользователя к целевым сегментам
func (s *AssignmentService) isUserInTargetSegments(
userSegments []int,
targetSegments []int,
) bool {
if len(targetSegments) == 0 {
return true // Нет ограничений по сегментам
}
userSegmentSet := make(map[int]bool)
for _, seg := range userSegments {
userSegmentSet[seg] = true
}
for _, target := range targetSegments {
if userSegmentSet[target] {
return true
}
}
return false
}
5. Реализация Segment-based подхода
package segment
import (
"context"
"fmt"
"time"
)
// ExperimentSegmentService сервис управления экспериментальными сегментами
type ExperimentSegmentService struct {
redis *redis.Client
db *sql.DB
logger *zap.Logger
}
// AssignUserToExperiment назначает пользователя в эксперимент через сегменты
func (s *ExperimentSegmentService) AssignUserToExperiment(
ctx context.Context,
userID string,
experimentID string,
variantName string,
) error {
segmentName := s.buildSegmentName(experimentID, variantName)
// 1. Добавляем пользователя в сегмент в Redis
key := fmt.Sprintf("segment:%s", segmentName)
if err := s.redis.SAdd(ctx, key, userID).Err(); err != nil {
return fmt.Errorf("failed to add user to segment: %w", err)
}
// 2. Сохраняем в PostgreSQL для долгосрочного хранения
_, err := s.db.ExecContext(ctx, `
INSERT INTO user_segments (user_id, segment_id, created_at)
SELECT $1, s.id, $3
FROM segments s
WHERE s.name = $2
ON CONFLICT (user_id, segment_id) DO NOTHING
`, userID, segmentName, time.Now())
if err != nil {
s.logger.Error("failed to save user segment to DB",
zap.Error(err),
zap.String("user_id", userID),
zap.String("segment", segmentName))
}
return nil
}
// GetUserExperimentVariant получает variant пользователя через проверку сегментов
func (s *ExperimentSegmentService) GetUserExperimentVariant(
ctx context.Context,
userID string,
experimentID string,
variants []string,
) (string, error) {
// Проверяем каждый variant
for _, variant := range variants {
segmentName := s.buildSegmentName(experimentID, variant)
key := fmt.Sprintf("segment:%s", segmentName)
isMember, err := s.redis.SIsMember(ctx, key, userID).Result()
if err != nil {
return "", fmt.Errorf("failed to check segment membership: %w", err)
}
if isMember {
return variant, nil
}
}
return "", fmt.Errorf("user not assigned to any variant")
}
// IsUserInExperiment проверяет, участвует ли пользователь в эксперименте
func (s *ExperimentSegmentService) IsUserInExperiment(
ctx context.Context,
userID string,
experimentID string,
) (bool, error) {
// Проверяем все возможные сегменты эксперимента
pattern := fmt.Sprintf("segment:%s_*", experimentID)
iter := s.redis.Scan(ctx, 0, pattern, 0).Iterator()
for iter.Next(ctx) {
isMember, err := s.redis.SIsMember(ctx, iter.Val(), userID).Result()
if err != nil {
return false, err
}
if isMember {
return true, nil
}
}
return false, nil
}
// buildSegmentName формирует имя сегмента
func (s *ExperimentSegmentService) buildSegmentName(
experimentID string,
variantName string,
) string {
return fmt.Sprintf("%s_%s", experimentID, variantName)
}
// SyncSegmentsToRedis синхронизирует сегменты из PostgreSQL в Redis
func (s *ExperimentSegmentService) SyncSegmentsToRedis(ctx context.Context) error {
// Получаем все экспериментальные сегменты
rows, err := s.db.QueryContext(ctx, `
SELECT s.name, us.user_id
FROM segments s
JOIN user_segments us ON s.id = us.segment_id
WHERE s.name LIKE 'exp_%'
ORDER BY s.name
`)
if err != nil {
return fmt.Errorf("failed to query segments: %w", err)
}
defer rows.Close()
pipe := s.redis.Pipeline()
count := 0
for rows.Next() {
var segmentName, userID string
if err := rows.Scan(&segmentName, &userID); err != nil {
continue
}
key := fmt.Sprintf("segment:%s", segmentName)
pipe.SAdd(ctx, key, userID)
count++
// Выполняем батчами по 1000
if count%1000 == 0 {
if _, err := pipe.Exec(ctx); err != nil {
s.logger.Error("failed to execute pipeline", zap.Error(err))
}
}
}
// Выполняем оставшиеся команды
if count%1000 != 0 {
if _, err := pipe.Exec(ctx); err != nil {
s.logger.Error("failed to execute pipeline", zap.Error(err))
}
}
s.logger.Info("segments synced to redis", zap.Int("count", count))
return nil
}
6. Сравнение подходов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение подходов к Sticky Assignment │
│ │
│ ┌────────────────────┬──────────────────────┬──────────────────────┐ │
│ │ Критерий │ Assignment Storage │ Segment-based │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Сложность │ Средняя │ Низкая │ │
│ │ реализации │ (отдельная таблица) │ (используем │ │
│ │ │ │ существующие │ │
│ │ │ │ сегменты) │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Производительность│ Высокая │ Высокая │ │
│ │ чтения │ (индексы по user_id)│ (SISMEMBER O(1)) │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Масштабируемость │ Средняя │ Высокая │ │
│ │ │ (нужен шардинг │ (Redis Cluster │ │
│ │ │ по user_id) │ масштабируется) │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Аналитика │ Нужен JOIN для │ Встроена в систему │ │
│ │ │ получения данных │ сегментов │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Удаление данных │ Простое DELETE │ Нужно удалять из │ │
│ │ │ │ всех сегментов │ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Гибкость │ Можно хранить │ Ограничена │ │
│ │ │ доп. метаданные │ структурой сегментов│ │
│ ├────────────────────┼──────────────────────┼──────────────────────┤ │
│ │ Рекомендация │ Для сложных │ Для простых систем │ │
│ │ │ сценариев │ и быстрого старта │ │
│ └────────────────────┴──────────────────────┴──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Гибридный подход (рекомендуемый)
package assignment
// HybridAssignmentService комбинирует оба подхода
type HybridAssignmentService struct {
assignmentStore *AssignmentService
segmentService *segment.ExperimentSegmentService
redis *redis.Client
logger *zap.Logger
}
// GetAssignment получает назначение пользователя
func (s *HybridAssignmentService) GetAssignment(
ctx context.Context,
userID string,
experimentID string,
) (*UserAssignment, error) {
// 1. Пробуем получить из кэша назначений
assignment, err := s.assignmentStore.getFromCache(ctx, userID, experimentID)
if err == nil && assignment != nil {
return assignment, nil
}
// 2. Пробуем получить из сегментов (fallback)
variants := []string{"control", "treatment"} // или из конфига эксперимента
variantName, err := s.segmentService.GetUserExperimentVariant(
ctx, userID, experimentID, variants)
if err == nil {
// Восстанавливаем назначение из сегмента
assignment = &UserAssignment{
UserID: userID,
ExperimentID: experimentID,
VariantName: variantName,
AssignedAt: time.Now(), // Приблизительно
}
// Сохраняем в кэш для будущих запросов
s.assignmentStore.setCache(ctx, assignment)
return assignment, nil
}
// 3. Создаём новое назначение
return s.assignmentStore.createAssignment(ctx, userID, experimentID)
}
Краткий ответ: Для обеспечения прилипания пользователя к эксперименту используются два основных подхода:
Assignment Storage — хранение пары (user_id, experiment_id) → variant_id в PostgreSQL + Redis кэш. При первом запросе генерируется детерминированный хеш, результат сохраняется. При последующих запросах возвращается сохранённый variant.
Segment-based — при назначении пользователь добавляется в сегмент exp_001_treatment. При запросе проверяется принадлежность к сегменту через SISMEMBER в Redis (O(1)).
Рекомендация: Использовать гибридный подход — Assignment Storage как основной источник, сегменты для аналитики и fallback-механизма.
Вопрос 33. Какие онлайн-сегменты можно было бы добавить и как они работают?
Таймкод: 01:06:36
Ответ собеседника: Правильный. Уточняется, что при загрузке списка экспериментов на каждое действие (как в вебе) потребовались бы онлайн-сегменты на основе поведения в реальном времени. Для этого нужен обработчик потока событий из Kafka, который считает онлайн-сегменты и складывает их в Cassandra. Это позволяет запускать цепочки экспериментов и учитывать поведение.
Правильный ответ:
1. Типы онлайн-сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Типы онлайн-сегментов │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Поведенческие сегменты (Behavioral) │ │
│ │ │ │
│ │ Описание: Сегменты на основе действий пользователя в реальном времени│ │
│ │ │ │
│ │ Примеры: │ │
│ │ • viewed_product_last_hour — просмотрел товар за последний час │ │
│ │ • added_to_cart_last_30min — добавил в корзину за 30 минут │ │
│ │ • searched_last_5min — искал что-то за последние 5 минут │ │
│ │ • completed_onboarding — завершил онбординг │ │
│ │ • abandoned_cart — бросил корзину │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Контекстуальные сегменты (Contextual) │ │
│ │ │ │
│ │ Описание: Сегменты на основе текущего контекста использования │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • current_page_checkout — сейчас на странице оплаты │ │
│ │ • current_page_product — сейчас на странице товара │ │
│ │ • session_duration_5min — сессия длится более 5 минут │ │
│ │ • is_new_session — новая сессия (первый визит) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Кумулятивные сегменты (Cumulative) │ │
│ │ │ │
│ │ Описание: Сегменты на основе накопленных данных за период │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • views_count_10_last_day — 10+ просмотров за день │ │
│ │ • purchases_count_3_last_week — 3+ покупки за неделю │ │
│ │ • total_spend_100_last_month — потратил 100+ за месяц │ │
│ │ • sessions_count_5_last_week — 5+ сессий за неделю │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. Последовательные сегменты (Sequential) │ │
│ │ │ │
│ │ Описание: Сегменты на основе последовательности действий │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • viewed_then_added_to_cart — просмотрел, затем добавил │ │
│ │ • searched_then_viewed — искал, затем просмотрел │ │
│ │ • onboarding_incomplete — не завершил онбординг │ │
│ │ • payment_attempt_failed — попытка оплаты не удалась │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Архитектура обработки онлайн-сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Архитектура онлайн-сегментов │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EVENT STREAM │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Event 1 │ │ Event 2 │ │ Event 3 │ │ Event N │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────┴────────────┴────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Kafka │ │ │
│ │ │ Topic: │ │ │
│ │ │ app.events │ │ │
│ │ └──────┬──────┘ │ │
│ └──────────────────────────┼────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SEGMENT PROCESSOR │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Stream Processor (Kafka Streams / Flink) │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Windowed │ │ State │ │ Rules │ │ │ │
│ │ │ │ Aggregator │ │ Store │ │ Engine │ │ │ │
│ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ └───────────────┼───────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ Segment Membership │ │ │ │
│ │ │ │ Calculator │ │ │ │
│ │ │ └──────────┬──────────┘ │ │ │
│ │ └─────────────────────────┼────────────────────────────────┘ │ │
│ └────────────────────────────┼─────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Redis │ │ Cassandra │ │ PostgreSQL │ │
│ │ (Hot Cache) │ │ (Warm Storage) │ │ (Cold Storage) │ │
│ │ │ │ │ │ │ │
│ │ TTL: 1 hour │ │ TTL: 24 hours │ │ Permanent │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реализация процессора онлайн-сегментов
package segment
import (
"context"
"encoding/json"
"fmt"
"time"
)
// OnlineSegmentProcessor процессор онлайн-сегментов
type OnlineSegmentProcessor struct {
kafkaReader *kafka.Reader
redis *redis.Client
cassandra *gocql.Session
rules []SegmentRule
logger *zap.Logger
}
// SegmentRule правило для вычисления сегмента
type SegmentRule struct {
Name string `json:"name"`
Description string `json:"description"`
EventType string `json:"event_type"`
Conditions []Condition `json:"conditions"`
Window time.Duration `json:"window"`
Threshold int `json:"threshold"`
TTL time.Duration `json:"ttl"`
}
// Condition условие для фильтрации событий
type Condition struct {
Field string `json:"field"`
Operator string `json:"operator"` // eq, neq, gt, lt, in, contains
Value interface{} `json:"value"`
}
// Event событие из Kafka
type Event struct {
EventID string `json:"event_id"`
EventName string `json:"event_name"`
UserID string `json:"user_id"`
Timestamp time.Time `json:"timestamp"`
Properties map[string]interface{} `json:"properties"`
}
// ProcessEvents обрабатывает поток событий
func (p *OnlineSegmentProcessor) ProcessEvents(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Читаем сообщение из Kafka
msg, err := p.kafkaReader.ReadMessage(ctx)
if err != nil {
p.logger.Error("failed to read kafka message", zap.Error(err))
continue
}
// Парсим событие
var event Event
if err := json.Unmarshal(msg.Value, &event); err != nil {
p.logger.Error("failed to unmarshal event", zap.Error(err))
continue
}
// Обрабатываем событие для каждого правила
for _, rule := range p.rules {
if rule.EventType != event.EventName {
continue
}
// Проверяем условия
if !p.checkConditions(event, rule.Conditions) {
continue
}
// Обновляем счётчик/состояние
if err := p.updateSegmentState(ctx, event.UserID, &rule); err != nil {
p.logger.Error("failed to update segment state",
zap.Error(err),
zap.String("rule", rule.Name),
zap.String("user_id", event.UserID))
}
}
}
}
// checkConditions проверяет условия правила
func (p *OnlineSegmentProcessor) checkConditions(event Event, conditions []Condition) bool {
for _, cond := range conditions {
value := p.getFieldValue(event, cond.Field)
if value == nil {
return false
}
if !p.evaluateCondition(value, cond.Operator, cond.Value) {
return false
}
}
return true
}
// evaluateCondition оценивает одно условие
func (p *OnlineSegmentProcessor) evaluateCondition(
value interface{},
operator string,
expected interface{},
) bool {
switch operator {
case "eq":
return fmt.Sprintf("%v", value) == fmt.Sprintf("%v", expected)
case "neq":
return fmt.Sprintf("%v", value) != fmt.Sprintf("%v", expected)
case "gt":
return p.compareValues(value, expected) > 0
case "lt":
return p.compareValues(value, expected) < 0
case "in":
return p.valueInList(value, expected)
case "contains":
return p.stringContains(value, expected)
default:
return false
}
}
// updateSegmentState обновляет состояние сегмента для пользователя
func (p *OnlineSegmentProcessor) updateSegmentState(
ctx context.Context,
userID string,
rule *SegmentRule,
) error {
key := fmt.Sprintf("segment:%s:%s", rule.Name, userID)
// Для счётчиков (cumulative segments)
if rule.Threshold > 0 {
// Увеличиваем счётчик в Redis
count, err := p.redis.Incr(ctx, key).Result()
if err != nil {
return fmt.Errorf("failed to increment counter: %w", err)
}
// Устанавливаем TTL при первом инкременте
if count == 1 {
p.redis.Expire(ctx, key, rule.Window)
}
// Проверяем порог
if int(count) >= rule.Threshold {
// Добавляем пользователя в сегмент
p.addUserToSegment(ctx, userID, rule.Name)
}
} else {
// Для бинарных сегментов (behavioral/contextual)
p.redis.Set(ctx, key, "1", rule.Window)
p.addUserToSegment(ctx, userID, rule.Name)
}
// Сохраняем в Cassandra для долгосрочного хранения
return p.saveToCassandra(ctx, userID, rule.Name)
}
// addUserToSegment добавляет пользователя в сегмент
func (p *OnlineSegmentProcessor) addUserToSegment(
ctx context.Context,
userID string,
segmentName string,
) error {
key := fmt.Sprintf("online_segment:%s", segmentName)
// Добавляем в Redis Set
if err := p.redis.SAdd(ctx, key, userID).Err(); err != nil {
return fmt.Errorf("failed to add user to segment: %w", err)
}
// Устанавливаем TTL для сегмента
p.redis.Expire(ctx, key, 24*time.Hour)
return nil
}
// saveToCassandra сохраняет данные в Cassandra
func (p *OnlineSegmentProcessor) saveToCassandra(
ctx context.Context,
userID string,
segmentName string,
) error {
query := `INSERT INTO user_online_segments (user_id, segment_name, created_at)
VALUES (?, ?, ?) USING TTL ?`
return p.cassandra.Query(query,
userID,
segmentName,
time.Now(),
int(24*time.Hour.Seconds()),
).Exec()
}
// IsUserInOnlineSegment проверяет принадлежность пользователя к онлайн-сегменту
func (p *OnlineSegmentProcessor) IsUserInOnlineSegment(
ctx context.Context,
userID string,
segmentName string,
) (bool, error) {
key := fmt.Sprintf("online_segment:%s", segmentName)
// Проверяем в Redis
isMember, err := p.redis.SIsMember(ctx, key, userID).Result()
if err != nil {
return false, err
}
if isMember {
return true, nil
}
// Fallback: проверяем в Cassandra
return p.checkCassandra(ctx, userID, segmentName)
}
// IsUserInOnlineSegment проверяет принадлежность пользователя к онлайн-сегменту
func (p *OnlineSegmentProcessor) checkCassandra(
ctx context.Context,
userID string,
segmentName string,
) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM user_online_segments
WHERE user_id = ? AND segment_name = ?`
if err := p.cassandra.Query(query, userID, segmentName).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
4. Конфигурация правил сегментов
# online-segments-config.yaml
segments:
# Поведенческие сегменты
- name: "viewed_product_last_hour"
description: "Пользователи, просмотревшие товар за последний час"
event_type: "product_view"
conditions: []
window: 1h
threshold: 0 # Бинарный сегмент (есть/нет)
ttl: 1h
- name: "added_to_cart_last_30min"
description: "Пользователи, добавившие товар в корзину за 30 минут"
event_type: "add_to_cart"
conditions: []
window: 30m
threshold: 0
ttl: 30m
- name: "high_engagement_last_day"
description: "Пользователи с 10+ действиями за день"
event_type: "user_action"
conditions: []
window: 24h
threshold: 10
ttl: 24h
# Контекстуальные сегменты
- name: "on_checkout_page"
description: "Пользователи на странице оплаты"
event_type: "page_view"
conditions:
- field: "page_name"
operator: "eq"
value: "checkout"
window: 5m
threshold: 0
ttl: 5m
- name: "on_product_page"
description: "Пользователи на странице товара"
event_type: "page_view"
conditions:
- field: "page_name"
operator: "eq"
value: "product"
window: 5m
threshold: 0
ttl: 5m
# Кумулятивные сегменты
- name: "frequent_buyer_last_week"
description: "Пользователи с 3+ покупками за неделю"
event_type: "purchase"
conditions: []
window: 168h # 7 дней
threshold: 3
ttl: 168h
- name: "big_spender_last_month"
description: "Пользователи, потратившие 1000+ за месяц"
event_type: "purchase"
conditions: []
window: 720h # 30 дней
threshold: 1000
ttl: 720h
# Последовательные сегменты (требуют более сложной логики)
- name: "cart_abandoner"
description: "Пользователи, добавившие в корзину, но не купившие"
event_type: "add_to_cart"
conditions: []
window: 24h
threshold: 0
ttl: 24h
requires_sequence: true
sequence:
- event: "add_to_cart"
must_occur: true
- event: "purchase"
must_occur: false
within: 24h
5. Схема Cassandra для онлайн-сегментов
-- Создание keyspace
CREATE KEYSPACE IF NOT EXISTS experiment_platform
WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3
};
USE experiment_platform;
-- Таблица онлайн-сегментов пользователей
CREATE TABLE IF NOT EXISTS user_online_segments (
user_id text,
segment_name text,
created_at timestamp,
updated_at timestamp,
ttl_seconds int,
PRIMARY KEY ((user_id), segment_name)
) WITH default_time_to_live = 86400 -- 24 часа
AND gc_grace_seconds = 86400;
-- Таблица обратного индекса (сегмент -> пользователи)
CREATE TABLE IF NOT EXISTS segment_members (
segment_name text,
user_id text,
joined_at timestamp,
PRIMARY KEY ((segment_name), user_id)
) WITH default_time_to_live = 86400;
-- Таблица для хранения состояния последовательных сегментов
CREATE TABLE IF NOT EXISTS user_event_sequences (
user_id text,
sequence_name text,
event_name text,
event_timestamp timestamp,
properties map<text, text>,
PRIMARY KEY ((user_id, sequence_name), event_timestamp)
) WITH CLUSTERING ORDER BY (event_timestamp DESC)
AND default_time_to_live = 86400;
-- Материализованное представление для быстрого поиска по сегментам
CREATE MATERIALIZED VIEW IF NOT EXISTS segment_members_by_user AS
SELECT user_id, segment_name, joined_at
FROM segment_members
PRIMARY KEY ((user_id), segment_name, joined_at);
6. Использование онлайн-сегментов в экспериментах
package experiment
// OnlineSegmentExperimentService сервис экспериментов с онлайн-сегментами
type OnlineSegmentExperimentService struct {
assignmentSvc *assignment.AssignmentService
segmentSvc *segment.OnlineSegmentProcessor
logger *zap.Logger
}
// GetExperimentVariant получает variant эксперимента с учётом онлайн-сегментов
func (s *OnlineSegmentExperimentService) GetExperimentVariant(
ctx context.Context,
userID string,
experimentID string,
) (string, error) {
// 1. Получаем эксперимент
exp, err := s.getExperiment(ctx, experimentID)
if err != nil {
return "", fmt.Errorf("experiment not found: %w", err)
}
// 2. Проверяем онлайн-сегменты
for _, segmentReq := range exp.OnlineSegmentRequirements {
isInSegment, err := s.segmentSvc.IsUserInOnlineSegment(
ctx, userID, segmentReq.SegmentName)
if err != nil {
s.logger.Error("failed to check online segment",
zap.Error(err),
zap.String("user_id", userID),
zap.String("segment", segmentReq.SegmentName))
continue
}
if segmentReq.Required && !isInSegment {
return "", fmt.Errorf("user does not meet online segment requirement: %s",
segmentReq.SegmentName)
}
if !segmentReq.Required && isInSegment {
return "", fmt.Errorf("user excluded by online segment: %s",
segmentReq.SegmentName)
}
}
// 3. Получаем или создаём назначение
assignment, err := s.assignmentSvc.GetOrCreateAssignment(ctx, userID, experimentID)
if err != nil {
return "", fmt.Errorf("failed to get assignment: %w", err)
}
return assignment.VariantName, nil
}
// Пример использования: цепочка экспериментов
func (s *OnlineSegmentExperimentService) ProcessUserAction(
ctx context.Context,
userID string,
action string,
) error {
switch action {
case "viewed_product":
// После просмотра товара проверяем, нужно ли запустить эксперимент
isInSegment, _ := s.segmentSvc.IsUserInOnlineSegment(
ctx, userID, "viewed_product_last_hour")
if isInSegment {
// Запускаем эксперимент с персонализацией
variant, err := s.GetExperimentVariant(ctx, userID, "exp_personalization")
if err == nil {
s.logger.Info("user eligible for personalization experiment",
zap.String("user_id", userID),
zap.String("variant", variant))
}
}
case "added_to_cart":
// После добавления в корзину запускаем эксперимент с рекомендациями
isInSegment, _ := s.segmentSvc.IsUserInOnlineSegment(
ctx, userID, "added_to_cart_last_30min")
if isInSegment {
variant, err := s.GetExperimentVariant(ctx, userID, "exp_recommendations")
if err == nil {
s.logger.Info("user eligible for recommendations experiment",
zap.String("user_id", userID),
zap.String("variant", variant))
}
}
}
return nil
}
Краткий ответ: Онлайн-сегменты — это динамически вычисляемые группы пользователей на основе их поведения в реальном времени:
| Тип сегмента | Пример | Использование |
|---|---|---|
| Behavioral | viewed_product_last_hour | Таргетинг на активных пользователей |
| Contextual | on_checkout_page | Показ эксперимента в определённом контексте |
| Cumulative | purchases_3_last_week | Сегментация по накопленным метрикам |
| Sequential | cart_abandoner | Сложные сценарии поведения |
Архитектура: Kafka → Stream Processor (Flink/Kafka Streams) → Redis (горячие данные) + Cassandra (долгосрочное хранение). Это позволяет запускать цепочки экспериментов, где результат одного триггерит участие в следующем.
Вопрос 34. Какие альтернативы Cassandra рассматриваются для хранения профилей пользователей (сегментов)?
Таймкод: 01:12:47
Ответ собеседника: Правильный. Уточняется, что рассматривались Cassandra, Foundation DB и собственный stateful сервис с шардингом. Также упоминается Aerospike как коммерческая альтернатива. Учитывались два разреза данных: по User ID получать сегменты, и по сегментам находить все User ID для маркетинговых рассылок.
Правильный ответ:
Дополню более детальным сравнением альтернатив и критериями выбора.
1. Критерии выбора хранилища для сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Критерии выбора хранилища сегментов │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Производительность чтения │ │
│ │ • Время ответа < 10ms для онлайн-запросов │ │
│ │ • Поддержка миллионов запросов в секунду │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Масштабируемость │ │
│ │ • Горизонтальное масштабирование │ │
│ │ • Линейный рост производительности при добавлении нод │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Два паттерна доступа │ │
│ │ • По User ID → получить список сегментов (user-centric) │ │
│ │ • По Segment ID → получить список пользователей (segment-centric)│ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. Согласованность │ │
│ │ • Eventual consistency допустима для аналитики │ │
│ │ • Strong consistency нужна для экспериментов │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Сравнение альтернатив
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение хранилищ для сегментов │
│ │
│ ┌──────────────┬────────────┬────────────┬────────────┬────────────┐ │
│ │ Критерий │ Cassandra │ Aerospike │FoundationDB│ Custom + │ │
│ │ │ │ │ │ PostgreSQL │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Чтение │ 10-50ms │ 1-5ms │ 5-20ms │ 5-15ms │ │
│ │ (p99) │ │ │ │ │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Запись │ 5-20ms │ 1-5ms │ 10-50ms │ 10-30ms │ │
│ │ (p99) │ │ │ │ │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ User→Seg │ ✓ │ ✓ │ ✓ │ ✓ │ │
│ │ (быстрый) │ Быстрый │ Быстрый │ Средний │ Средний │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Seg→User │ ✓ │ ✓ │ ✓ │ ✓ │ │
│ │ (обратный) │ Медленный │ Средний │ Быстрый │ Средний │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Strong │ ○ │ ✓ │ ✓ │ ✓ │ │
│ │ Consistency │ Настройки │ Да │ Да │ Да │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Сложность │ Высокая │ Средняя │ Высокая │ Высокая │ │
│ │ эксплуатации│ │ │ │ │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Стоимость │ Бесплатно │ Платно │ Бесплатно │ Бесплатно │ │
│ │ │ (OSS) │ (Enterprise)│ (OSS) │ (OSS) │ │
│ ├──────────────┼────────────┼────────────┼────────────┼────────────┤ │
│ │ Зрелость │ 2008 │ 2012 │ 2015 │ - │ │
│ │ │ 15+ лет │ 12+ лет │ 9+ лет │ │ │
│ └──────────────┴────────────┴────────────┴────────────┴────────────┘ │
│ │
│ Легенда: ✓ — хорошо, ○ — настраивается, ✗ — плохо │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Cassandra — детальный анализ
-- Схема данных для Cassandra
-- Прямой индекс: User ID → сегменты
CREATE TABLE user_segments (
user_id bigint,
segment_id int,
segment_name text,
joined_at timestamp,
ttl_seconds int,
PRIMARY KEY ((user_id), segment_id)
) WITH CLUSTERING ORDER BY (segment_id ASC)
AND compaction = {'class': 'LeveledCompactionStrategy'}
AND compression = {'sstable_compression': 'LZ4Compressor'};
-- Обратный индекс: Segment ID → пользователи
CREATE TABLE segment_members (
segment_id int,
user_id bigint,
joined_at timestamp,
PRIMARY KEY ((segment_id), user_id)
) WITH compaction = {'class': 'LeveledCompactionStrategy'};
-- Для быстрого подсчёта размера сегмента
CREATE TABLE segment_stats (
segment_id int PRIMARY KEY,
member_count counter,
last_updated timestamp
);
// Реализация для Cassandra
type CassandraSegmentStore struct {
session *gocql.Session
}
// GetUserSegments получает сегменты пользователя
func (s *CassandraSegmentStore) GetUserSegments(ctx context.Context, userID int64) ([]Segment, error) {
query := `SELECT segment_id, segment_name, joined_at
FROM user_segments WHERE user_id = ?`
iter := s.session.Query(query, userID).WithContext(ctx).Iter()
var segments []Segment
var seg Segment
for iter.Scan(&seg.ID, &seg.Name, &seg.JoinedAt) {
segments = append(segments, seg)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get user segments: %w", err)
}
return segments, nil
}
// GetSegmentMembers получает пользователей сегмента (медленная операция!)
func (s *CassandraSegmentStore) GetSegmentMembers(ctx context.Context, segmentID int) ([]int64, error) {
query := `SELECT user_id FROM segment_members WHERE segment_id = ?`
iter := s.session.Query(query, segmentID).WithContext(ctx).Iter()
var userIDs []int64
var userID int64
for iter.Scan(&userID) {
userIDs = append(userIDs, userID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get segment members: %w", err)
}
return userIDs, nil
}
// AddUserToSegment добавляет пользователя в сегмент
func (s *CassandraSegmentStore) AddUserToSegment(ctx context.Context, userID int64, segmentID int) error {
batch := s.session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
// Прямой индекс
batch.Query(`INSERT INTO user_segments (user_id, segment_id, joined_at)
VALUES (?, ?, ?)`, userID, segmentID, time.Now())
// Обратный индекс
batch.Query(`INSERT INTO segment_members (segment_id, user_id, joined_at)
VALUES (?, ?, ?)`, segmentID, userID, time.Now())
// Обновляем счётчик
batch.Query(`UPDATE segment_stats SET member_count = member_count + 1
WHERE segment_id = ?`, segmentID)
return s.session.ExecuteBatch(batch)
}
4. Aerospike — детальный анализ
┌─────────────────────────────────────────────────────────────────────────────┐
│ Aerospike Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Преимущества Aerospike для сегментов: │ │
│ │ │ │
│ │ 1. Гибридная память (Hybrid Memory) │ │
│ │ • Индексы всегда в RAM │ │
│ │ • Данные на SSD │ │
│ │ • Предсказуемое время отклика │ │
│ │ │ │
│ │ 2. Strong Consistency │ │
│ │ • Поддержка ACID-транзакций │ │
│ │ • Важно для экспериментов │ │
│ │ │ │
│ │ 3. Автоматическое шардирование │ │
│ │ • 4096 партиций │ │
│ │ • Равномерное распределение │ │
│ │ │ │
│ │ 4. Встроенные операции над списками/мапами │ │
│ │ • List operations для сегментов │ │
│ │ • Map operations для свойств │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Модель данных: │ │
│ │ │ │
│ │ Set: "user_segments" │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "user_123" │ │ │
│ │ │ Bins: │ │ │
│ │ │ - segments: [1, 5, 12, 34] (List of segment IDs) │ │ │
│ │ │ - updated_at: 1703020800 │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Set: "segment_members" │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "segment_1" │ │ │
│ │ │ Bins: │ │ │
│ │ │ - members: [123, 456, 789, ...] (List of user IDs) │ │ │
│ │ │ - count: 150000 │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// Реализация для Aerospike
type AerospikeSegmentStore struct {
client *aerospike.Client
}
// GetUserSegments получает сегменты пользователя
func (s *AerospikeSegmentStore) GetUserSegments(ctx context.Context, userID int64) ([]int, error) {
key, err := aerospike.NewKey("experiment_platform", "user_segments", fmt.Sprintf("user_%d", userID))
if err != nil {
return nil, err
}
record, err := s.client.Get(nil, key)
if err != nil {
return nil, fmt.Errorf("failed to get user segments: %w", err)
}
// Извлекаем список сегментов
segments, ok := record.Bins["segments"].([]interface{})
if !ok {
return nil, nil
}
result := make([]int, len(segments))
for i, seg := range segments {
result[i] = int(seg.(int64))
}
return result, nil
}
// AddUserToSegment добавляет пользователя в сегмент
func (s *AerospikeSegmentStore) AddUserToSegment(ctx context.Context, userID int64, segmentID int) error {
userKey, _ := aerospike.NewKey("experiment_platform", "user_segments", fmt.Sprintf("user_%d", userID))
segmentKey, _ := aerospike.NewKey("experiment_platform", "segment_members", fmt.Sprintf("segment_%d", segmentID))
// Добавляем segment ID в список пользователя
_, err := s.client.Operate(nil, userKey,
aerospike.ListAppendOp("segments", segmentID),
aerospike.PutOp(aerospike.NewBin("updated_at", time.Now().Unix())),
)
if err != nil {
return fmt.Errorf("failed to add segment to user: %w", err)
}
// Добавляем user ID в список сегмента
_, err = s.client.Operate(nil, segmentKey,
aerospike.ListAppendOp("members", userID),
aerospike.AddOp(aerospike.NewBin("count", 1)),
)
if err != nil {
return fmt.Errorf("failed to add user to segment: %w", err)
}
return nil
}
5. FoundationDB — детальный анализ
┌─────────────────────────────────────────────────────────────────────────────┐
│ FoundationDB Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Преимущества FoundationDB для сегментов: │ │
│ │ │ │
│ │ 1. ACID-транзакции │ │
│ │ • Строгая согласованность │ │
│ │ • Атомарные обновления обоих индексов │ │
│ │ │ │
│ │ 2. Масштабируемость │ │
│ │ • Автоматическое шардирование │ │
│ │ • Линейное масштабирование │ │
│ │ │ │
│ │ 3. Подпространства ключей (Subspaces) │ │
│ │ • Логическая группировка данных │ │
│ │ • Эффективное чтение по префиксу │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Модель данных: │ │
│ │ │ │
│ │ Subspace: ["user_segments", user_id] │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: ["user_segments", 123, 1] → Value: timestamp │ │ │
│ │ │ Key: ["user_segments", 123, 5] → Value: timestamp │ │ │
│ │ │ Key: ["user_segments", 123, 12] → Value: timestamp │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Subspace: ["segment_members", segment_id] │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: ["segment_members", 1, 123] → Value: timestamp │ │ │
│ │ │ Key: ["segment_members", 1, 456] → Value: timestamp │ │ │
│ │ │ Key: ["segment_members", 1, 789] → Value: timestamp │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Гибридный подход: Redis + PostgreSQL
┌─────────────────────────────────────────────────────────────────────────────┐
│ Гибридная архитектура: Redis + PostgreSQL │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Для онлайн-запросов (горячий путь): │ │
│ │ │ │
│ │ Redis: │ │
│ │ • User → Segments: SET user:{id}:segments │ │
│ │ • Segment → Users: SET segment:{id}:members │ │
│ │ • TTL: 1-24 часа │ │
│ │ • Latency: < 1ms │ │
│ │ │ │
│ │ PostgreSQL: │ │
│ │ • Основное хранилище │ │
│ │ • Индексы для обоих паттернов доступа │ │
│ │ • Партиционирование по user_id │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Схема PostgreSQL: │ │
│ │ │ │
│ │ CREATE TABLE user_segments ( │ │
│ │ user_id BIGINT NOT NULL, │ │
│ │ segment_id INTEGER NOT NULL, │ │
│ │ created_at TIMESTAMPTZ DEFAULT NOW(), │ │
│ │ PRIMARY KEY (user_id, segment_id) │ │
│ │ ) PARTITION BY HASH (user_id); │ │
│ │ │ │
│ │ CREATE INDEX idx_segment_members │ │
│ │ ON user_segments (segment_id, user_id); │ │
│ │ │ │
│ │ -- Партиции для горячих данных │ │
│ │ CREATE TABLE user_segments_p0 PARTITION OF user_segments │ │
│ │ FOR VALUES WITH (MODULUS 16, REMAINDER 0); │ │
│ │ -- ... ещё 15 партиций │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Рекомендации по выбору
┌─────────────────────────────────────────────────────────────────────────────┐
│ Рекомендации по выбору хранилища │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Выбирайте Cassandra, если: │ │
│ │ • Нужна высокая пропускная способность записи │ │
│ │ • Eventual consistency приемлема │ │
│ │ • Есть опыт эксплуатации Cassandra │ │
│ │ • Бюджет ограничен (OSS) │ │
│ │ • Объём данных > 1TB │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Выбирайте Aerospike, если: │ │
│ │ • Критична низкая латентность (< 5ms) │ │
│ │ • Нужна strong consistency │ │
│ │ • Готовы платить за Enterprise │ │
│ │ • Важна предсказуемая производительность │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Выбирайте FoundationDB, если: │ │
│ │ • Нужны ACID-транзакции │ │
│ │ • Важна строгая согласованность │ │
│ │ • Готовы к экспериментам (меньше community) │ │
│ │ • Планируется сложная бизнес-логика │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Выбирайте Redis + PostgreSQL, если: │ │
│ │ • Нужна простота разработки │ │
│ │ • Команда знакома с PostgreSQL │ │
│ │ • Объём данных < 100GB │ │
│ │ • Нужны сложные запросы (JOIN, агрегации) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Для хранения профилей пользователей (сегментов) рассматриваются:
| Хранилище | Латентность | Consistency | Сложность | Стоимость |
|---|---|---|---|---|
| Cassandra | 10-50ms | Eventual | Высокая | OSS |
| Aerospike | 1-5ms | Strong | Средняя | Платно |
| FoundationDB | 5-20ms | ACID | Высокая | OSS |
| Redis + PostgreSQL | < 1ms (cache) | Strong | Средняя | OSS |
Ключевые критерии выбора:
- Два паттерна доступа: User → Segments и Segment → Users
- Требования к согласованности (eventual vs strong)
- Объём данных и скорость роста
- Опыт команды
- Бюджет
Вопрос 35. Можно ли оптимизировать аналитику, добавляя весь контекст экспериментов в сессию пользователя, а не в каждое событие?
Таймкод: 01:15:40
Ответ собеседника: Правильный. Уточняется, что это хороший вариант оптимизации. При получении списка экспериментов на старте можно запомнить прилипшие эксперименты для сессии и не прикреплять их к каждому событию, что сэкономит объём данных при хранении.
Правильный ответ:
Да, это отличная оптимизация. Подробнее о том, как это работает и какие есть нюансы.
1. Проблема: дублирование контекста экспериментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Проблема дублирования данных │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Текущий подход (без оптимизации): │ │
│ │ │ │
│ │ Событие 1: { │ │
│ │ "event": "page_view", │ │
│ │ "user_id": 123, │ │
│ │ "experiments": { ← Дублируется в КАЖДОМ событии │ │
│ │ "exp_button_color": "variant_a", │ │
│ │ "exp_homepage": "variant_b", │ │
│ │ "exp_checkout": "control" │ │
│ │ }, │ │
│ │ "timestamp": "2024-01-15T10:00:00Z" │ │
│ │ } │ │
│ │ │ │
│ │ Событие 2: { ← Те же эксперименты повторяются │ │
│ │ "event": "click", │ │
│ │ "user_id": 123, │ │
│ │ "experiments": { │ │
│ │ "exp_button_color": "variant_a", │ │
│ │ "exp_homepage": "variant_b", │ │
│ │ "exp_checkout": "control" │ │
│ │ }, │ │
│ │ "timestamp": "2024-01-15T10:00:01Z" │ │
│ │ } │ │
│ │ │ │
│ │ Событие 3: { ← И так далее... │ │
│ │ "event": "purchase", │ │
│ │ "user_id": 123, │ │
│ │ "experiments": { │ │
│ │ "exp_button_color": "variant_a", │ │
│ │ "exp_homepage": "variant_b", │ │
│ │ "exp_checkout": "control" │ │
│ │ }, │ │
│ │ "timestamp": "2024-01-15T10:00:05Z" │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Экономия при оптимизации: │ │
│ │ │ │
│ │ Среднее количество событий за сессию: 50 │ │
│ │ Размер контекста экспериментов: ~500 байт │ │
│ │ │ │
│ │ Без оптимизации: 50 × 500 = 25,000 байт на сессию │ │
│ │ С оптимизацией: 1 × 500 = 500 байт на сессию │ │
│ │ │ │
│ │ Экономия: ~98% по объёму данных │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Решение: привязка экспериментов к сессии
┌─────────────────────────────────────────────────────────────────────────────┐
│ Оптимизированная архитектура │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Событие начала сессии: │ │
│ │ │ │
│ │ { │ │
│ │ "event": "session_start", │ │
│ │ "user_id": 123, │ │
│ │ "session_id": "sess_abc123", │ │
│ │ "experiments": { ← Контекст только здесь │ │
│ │ "exp_button_color": "variant_a", │ │
│ │ "exp_homepage": "variant_b", │ │
│ │ "exp_checkout": "control" │ │
│ │ }, │ │
│ │ "timestamp": "2024-01-15T10:00:00Z" │ │
│ │ } │ │
│ │ │ │
│ │ Последующие события: │ │
│ │ │ │
│ │ { │ │
│ │ "event": "page_view", │ │
│ │ "user_id": 123, │ │
│ │ "session_id": "sess_abc123", ← Ссылка на сессию │ │
│ │ "timestamp": "2024-01-15T10:00:01Z" │ │
│ │ } │ │
│ │ │ │
│ │ { │ │
│ │ "event": "click", │ │
│ │ "user_id": 123, │ │
│ │ "session_id": "sess_abc123", │ │
│ │ "timestamp": "2024-01-15T10:00:02Z" │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реализация на стороне клиента (SDK)
package experiment
import (
"context"
"sync"
"time"
)
// ExperimentContext контекст экспериментов для сессии
type ExperimentContext struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Experiments map[string]string `json:"experiments"` // experiment_id -> variant
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// SessionExperimentManager управляет экспериментами на уровне сессии
type SessionExperimentManager struct {
client ExperimentClient
currentCtx *ExperimentContext
mu sync.RWMutex
sessionTTL time.Duration
}
// NewSessionExperimentManager создаёт новый менеджер
func NewSessionExperimentManager(client ExperimentClient, sessionTTL time.Duration) *SessionExperimentManager {
return &SessionExperimentManager{
client: client,
sessionTTL: sessionTTL,
}
}
// StartSession начинает новую сессию и загружает эксперименты
func (m *SessionExperimentManager) StartSession(ctx context.Context, userID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Генерируем ID сессии
sessionID := generateSessionID()
// Получаем активные эксперименты для пользователя
experiments, err := m.client.GetActiveExperiments(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get experiments: %w", err)
}
// Формируем контекст сессии
m.currentCtx = &ExperimentContext{
SessionID: sessionID,
UserID: userID,
Experiments: experiments,
StartedAt: time.Now(),
ExpiresAt: time.Now().Add(m.sessionTTL),
}
// Отправляем событие начала сессии с полным контекстом
m.emitSessionStart()
return nil
}
// GetVariant получает variant эксперимента из кеша сессии
func (m *SessionExperimentManager) GetVariant(experimentID string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.currentCtx == nil {
return "", false
}
// Проверяем не истекла ли сессия
if time.Now().After(m.currentCtx.ExpiresAt) {
return "", false
}
variant, ok := m.currentCtx.Experiments[experimentID]
return variant, ok
}
// TrackEvent отправляет событие с минимальным контекстом
func (m *SessionExperimentManager) TrackEvent(ctx context.Context, eventName string, properties map[string]string) error {
m.mu.RLock()
defer m.mu.RUnlock()
if m.currentCtx == nil {
return fmt.Errorf("no active session")
}
event := Event{
Name: eventName,
UserID: m.currentCtx.UserID,
SessionID: m.currentCtx.SessionID, // Только session_id, без experiments!
Properties: properties,
Timestamp: time.Now(),
}
return m.client.TrackEvent(ctx, event)
}
// emitSessionStart отправляет событие начала сессии
func (m *SessionExperimentManager) emitSessionStart() {
event := Event{
Name: "session_start",
UserID: m.currentCtx.UserID,
SessionID: m.currentCtx.SessionID,
Properties: map[string]string{
"experiments": marshalExperiments(m.currentCtx.Experiments),
},
Timestamp: m.currentCtx.StartedAt,
}
// Отправляем асинхронно
go m.client.TrackEvent(context.Background(), event)
}
// RefreshSession обновляет сессию (если эксперименты изменились)
func (m *SessionExperimentManager) RefreshSession(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.currentCtx == nil {
return fmt.Errorf("no active session")
}
// Получаем актуальные эксперименты
experiments, err := m.client.GetActiveExperiments(ctx, m.currentCtx.UserID)
if err != nil {
return err
}
// Проверяем изменились ли эксперименты
if !experimentsChanged(m.currentCtx.Experiments, experiments) {
// Продлеваем сессию
m.currentCtx.ExpiresAt = time.Now().Add(m.sessionTTL)
return nil
}
// Эксперименты изменились — завершаем текущую сессию
m.emitSessionEnd()
// Начинаем новую сессию
m.currentCtx = &ExperimentContext{
SessionID: generateSessionID(),
UserID: m.currentCtx.UserID,
Experiments: experiments,
StartedAt: time.Now(),
ExpiresAt: time.Now().Add(m.sessionTTL),
}
m.emitSessionStart()
return nil
}
4. Реализация на стороне сервера (аналитика)
package analytics
import (
"context"
"encoding/json"
"fmt"
"time"
)
// SessionExperimentStore хранит контекст экспериментов по сессиям
type SessionExperimentStore struct {
redis *redis.Client
postgres *sql.DB
ttl time.Duration
}
// SessionContext контекст сессии с экспериментами
type SessionContext struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Experiments map[string]string `json:"experiments"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
// SaveSessionContext сохраняет контекст сессии
func (s *SessionExperimentStore) SaveSessionContext(ctx context.Context, sessionCtx *SessionContext) error {
key := fmt.Sprintf("session:%s", sessionCtx.SessionID)
data, err := json.Marshal(sessionCtx)
if err != nil {
return err
}
return s.redis.Set(ctx, key, data, s.ttl).Err()
}
// GetSessionContext получает контекст сессии
func (s *SessionExperimentStore) GetSessionContext(ctx context.Context, sessionID string) (*SessionContext, error) {
key := fmt.Sprintf("session:%s", sessionID)
data, err := s.redis.Get(ctx, key).Bytes()
if err == redis.Nil {
// Fallback на PostgreSQL
return s.getFromPostgres(ctx, sessionID)
}
if err != nil {
return nil, err
}
var sessionCtx SessionContext
if err := json.Unmarshal(data, &sessionCtx); err != nil {
return nil, err
}
return &sessionCtx, nil
}
// EnrichEvent обогащает событие контекстом экспериментов
func (s *SessionExperimentStore) EnrichEvent(ctx context.Context, event *Event) error {
if event.SessionID == "" {
return nil
}
sessionCtx, err := s.GetSessionContext(ctx, event.SessionID)
if err != nil {
// Логируем, но не блокируем обработку события
log.Warn("failed to get session context",
zap.String("session_id", event.SessionID),
zap.Error(err))
return nil
}
// Добавляем эксперименты к событию
event.Experiments = sessionCtx.Experiments
return nil
}
// ProcessEventBatch обрабатывает батч событий с обогащением
func (s *SessionExperimentStore) ProcessEventBatch(ctx context.Context, events []*Event) error {
// Группируем события по сессиям
sessionEvents := make(map[string][]*Event)
for _, event := range events {
if event.SessionID != "" {
sessionEvents[event.SessionID] = append(sessionEvents[event.SessionID], event)
}
}
// Загружаем контексты сессий
sessionCtxs := make(map[string]*SessionContext)
for sessionID := range sessionEvents {
ctx, err := s.GetSessionContext(ctx, sessionID)
if err == nil {
sessionCtxs[sessionID] = ctx
}
}
// Обогащаем события
for _, event := range events {
if ctx, ok := sessionCtxs[event.SessionID]; ok {
event.Experiments = ctx.Experiments
}
}
return nil
}
5. SQL-схема для хранения сессий и событий
-- Таблица сессий с контекстом экспериментов
CREATE TABLE sessions (
session_id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
experiments JSONB NOT NULL DEFAULT '{}',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_user_id ON sessions (user_id);
CREATE INDEX idx_sessions_started_at ON sessions (started_at);
-- Таблица событий (без дублирования контекста)
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
session_id UUID REFERENCES sessions(session_id),
user_id BIGINT NOT NULL,
event_name TEXT NOT NULL,
properties JSONB NOT NULL DEFAULT '{}',
-- НЕТ поля experiments! Контекст берётся из сессии
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_events_session_id ON events (session_id);
CREATE INDEX idx_events_user_id ON events (user_id);
CREATE INDEX idx_events_event_name ON events (event_name);
CREATE INDEX idx_events_created_at ON events (created_at);
-- Материализованное представление для аналитики
-- (JOIN событий с сессиями для получения полного контекста)
CREATE MATERIALIZED VIEW events_with_experiments AS
SELECT
e.event_id,
e.session_id,
e.user_id,
e.event_name,
e.properties,
s.experiments,
e.created_at
FROM events e
JOIN sessions s ON e.session_id = s.session_id;
CREATE INDEX idx_events_experiments_experiment_id
ON events_with_experiments USING GIN (experiments);
-- Функция для аналитики по экспериментам
CREATE OR REPLACE FUNCTION get_experiment_metrics(
p_experiment_id TEXT,
p_start_date TIMESTAMPTZ,
p_end_date TIMESTAMPTZ
)
RETURNS TABLE (
variant TEXT,
total_events BIGINT,
unique_users BIGINT,
conversion_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
s.experiments->>p_experiment_id AS variant,
COUNT(*) AS total_events,
COUNT(DISTINCT e.user_id) AS unique_users,
ROUND(
COUNT(DISTINCT CASE WHEN e.event_name = 'purchase' THEN e.user_id END)::NUMERIC /
NULLIF(COUNT(DISTINCT e.user_id), 0) * 100,
2
) AS conversion_rate
FROM events e
JOIN sessions s ON e.session_id = s.session_id
WHERE e.created_at BETWEEN p_start_date AND p_end_date
AND s.experiments ? p_experiment_id
GROUP BY s.experiments->>p_experiment_id;
END;
$$ LANGUAGE plpgsql;
6. Сравнение подходов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение подходов к хранению контекста │
│ │
│ ┌──────────────────┬──────────────────┬──────────────────┬──────────────┐ │
│ │ Критерий │ В каждое событие │ В сессию │ Гибридный │ │
│ ├──────────────────┼──────────────────┼──────────────────┼──────────────┤ │
│ │ Объём данных │ Высокий │ Низкий │ Средний │ │
│ ├──────────────────┼──────────────────┼──────────────────┼──────────────┤ │
│ │ Простота │ Простой │ Сложнее │ Сложный │ │
│ │ реализации │ │ │ │ │
│ ├──────────────────┼──────────────────┼──────────────────┼──────────────┤ │
│ │ Надёжность │ Высокая │ Средняя* │ Высокая │ │
│ │ данных │ │ │ │ │
│ ├──────────────────┼──────────────────┼──────────────────┼──────────────┤ │
│ │ Гибкость │ Высокая │ Средняя │ Высокая │ │
│ │ аналитики │ │ │ │ │
│ ├──────────────────┼──────────────────┼──────────────────┼──────────────┤ │
│ │ Зависимость │ Нет │ Да (от │ Частичная │ │
│ │ от session_id │ │ session_id) │ │ │
│ └──────────────────┴──────────────────┴──────────────────┴──────────────┘ │
│ │
│ * При потере session_id теряется контекст экспериментов │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Гибридный подход (рекомендуемый)
package analytics
// HybridEventEnricher комбинирует оба подхода
type HybridEventEnricher struct {
sessionStore *SessionExperimentStore
// Порог для определения "тяжёлых" событий
heavyEventThreshold int
}
// События, которые всегда должны иметь полный контекст
var criticalEvents = map[string]bool{
"purchase": true,
"subscription": true,
"signup": true,
"session_start": true,
}
// EnrichEvent обогащает событие контекстом
func (e *HybridEventEnricher) EnrichEvent(ctx context.Context, event *Event) error {
// Для критических событий всегда добавляем полный контекст
if criticalEvents[event.Name] {
if event.Experiments == nil && event.SessionID != "" {
sessionCtx, err := e.sessionStore.GetSessionContext(ctx, event.SessionID)
if err == nil {
event.Experiments = sessionCtx.Experiments
}
}
return nil
}
// Для обычных событий используем session_id
// Контекст будет добавлен при аналитике через JOIN
return nil
}
// ShouldEmbedContext определяет, нужно ли встраивать контекст в событие
func (e *HybridEventEnricher) ShouldEmbedContext(event *Event) bool {
// Встраиваем контекст если:
// 1. Событие критическое
// 2. SessionID отсутствует
// 3. Размер контекста небольшой
if criticalEvents[event.Name] {
return true
}
if event.SessionID == "" {
return true
}
if event.Experiments != nil && len(event.Experiments) <= 3 {
return true
}
return false
}
Краткий ответ: Да, оптимизация через привязку контекста экспериментов к сессии — это отличный подход:
| Метод | Объём данных | Надёжность | Сложность |
|---|---|---|---|
| В каждое событие | Высокий | Высокая | Простой |
| В сессию | Низкий | Средняя | Сложнее |
| Гибридный | Средний | Высокая | Сложный |
Рекомендация: Используйте гибридный подход:
- Контекст экспериментов хранится в сессии (Redis + PostgreSQL)
- Критические события (purchase, signup) получают полный контекст
- Обычные события содержат только session_id
- При аналитике контекст добавляется через JOIN с таблицей сессий
Это даёт экономию ~90-95% объёма данных при сохранении надёжности аналитики.
Вопрос 36. Как соотносится получившаяся архитектура с реальной системой компании?
Таймкод: 01:16:52
Ответ собеседника: Правильный. Уточняется, что концептуально похоже на реальную систему. В реальности кубиков больше и она сложнее. API Gateway скрывает отдельные сервисы. Рабочее место аналитика скрыто и имеет аутентификацию/авторизацию для внутренних пользователей. Статистик коллектор достаточно простой.
Правильный ответ:
Концептуально архитектура совпадает с реальной системой, но в реальности она значительно сложнее. Подробнее о различиях и дополнительных компонентах.
1. Сравнение упрощённой и реальной архитектуры
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение архитектур │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Упрощённая архитектура (из интервью): │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ SDK │───▶│ Event │───▶│ Analytics │ │ │
│ │ │ │ │ Collector │ │ Storage │ │ │
│ │ └──────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │Experiment│ │ Session │ │ Analyst │ │ │
│ │ │Service │ │ Store │ │ Dashboard │ │ │
│ │ └──────────┘ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Реальная архитектура: │ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ │ │ API Gateway │ │ │
│ │ │ (Kong/AWS) │ │ │
│ │ └──────┬───────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────┼─────────────────────┐ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Experiment │ │ Event │ │ Segment │ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │
│ │ │ │Launch │ │ │ │Collector │ │ │ │Evaluator │ │ │ │
│ │ │ │Manager │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │ │
│ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │
│ │ │ │Variant │ │ │ │Enricher │ │ │ │Sync │ │ │ │
│ │ │ │Assigner │ │ │ │ │ │ │ │Engine │ │ │ │
│ │ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────────────┼─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Message │ │ │
│ │ │ Queue │ │ │
│ │ │ (Kafka) │ │ │
│ │ └──────┬───────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────┼─────────────────────┐ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Stream │ │ Batch │ │ Real-time │ │ │
│ │ │ Processor │ │ Processor │ │ Aggregator │ │ │
│ │ │ (Flink) │ │ (Spark) │ │ (ClickHouse)│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────────────┼─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ Data Storage Layer │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ Cassandra│ │ClickHouse│ │ Redis │ │ S3 │ │ │ │
│ │ │ │(profiles)│ │(analytics│ │ (cache) │ │ (archive)│ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ Internal Portal │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ Auth │ │Dashboard │ │ Admin │ │ Audit │ │ │ │
│ │ │ │ (SSO) │ │ │ │ Panel │ │ Logs │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Ключевые различия
┌─────────────────────────────────────────────────────────────────────────────┐
│ Ключевые различия архитектур │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. API Gateway │ │
│ │ │ │
│ │ Упрощённо: клиенты напрямую обращаются к сервисам │ │
│ │ Реальность: API Gateway (Kong, AWS API Gateway) маршрутизирует │ │
│ │ запросы к нужным сервисам, обеспечивает rate limiting, │ │
│ │ authentication, мониторинг │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Внутренний портал для аналитиков │ │
│ │ │ │
│ │ Упрощённо: нет или простой dashboard │ │
│ │ Реальность: полноценный internal portal с: │ │
│ │ • SSO аутентификация (корпоративная) │ │
│ │ • RBAC авторизация (разные роли: аналитик, админ, менеджер) │ │
│ │ • Визуализация результатов экспериментов │ │
│ │ • Аудит действий пользователей │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Statistic Collector │ │
│ │ │ │
│ │ Упрощённо: сложная система с множеством функций │ │
│ │ Реальность: достаточно простой сервис, который: │ │
│ │ • Принимает события │ │
│ │ • Валидирует их │ │
│ │ • Отправляет в Kafka │ │
│ │ • Вся сложная логика — в обработчиках событий │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. Дополнительные сервисы в реальности │ │
│ │ │ │
│ │ • Notification Service — уведомления о завершении экспериментов │ │
│ │ • Audit Service — логирование всех действий │ │
│ │ • Scheduler — запуск/остановка экспериментов по расписанию │ │
│ │ • Feature Flag Service — управление фича-флагами │ │
│ │ • User Service — данные пользователей │ │
│ │ • Integration Service — интеграция с внешними системами │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реальная архитектура: детализация компонентов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Реальная архитектура: детали │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ External Clients │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Web App │ │Mobile App│ │ Backend │ │ Partner │ │ │
│ │ │ │ │ │ │ Services │ │ API │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ API Gateway │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Rate │ │ Auth │ │ Route │ │ Load │ │ │
│ │ │ Limit │ │ Verify │ │ Match │ │ Balance │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Experiment │ │ Event │ │ Segment │ │
│ │ Service │ │ Service │ │ Service │ │
│ │ │ │ │ │ │ │
│ │ • Launch │ │ • Collect │ │ • Evaluate │ │
│ │ Manager │ │ • Validate │ │ • Sync to │ │
│ │ • Variant │ │ • Enrich │ │ storage │ │
│ │ Assigner │ │ • Route to │ │ • Query │ │
│ │ • Targeting │ │ Kafka │ │ interface │ │
│ │ • Scheduling │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └──────────────────────────┼──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Message Queue (Kafka) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │experiment│ │ events │ │ segment │ │ audit │ │ │
│ │ │.updates │ │ .raw │ │ .changes │ │ .log │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Stream │ │ Batch │ │ Real-time │ │
│ │ Processing │ │ Processing │ │ Aggregation │ │
│ │ (Flink) │ │ (Spark) │ │ (ClickHouse) │ │
│ │ │ │ │ │ │ │
│ │ • Real-time │ │ • Daily │ │ • Metrics │ │
│ │ enrichment │ │ reports │ │ • Dashboards │ │
│ │ • Anomaly │ │ • ML models │ │ • Alerts │ │
│ │ detection │ │ • Historical │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └──────────────────────────┼──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Data Storage │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Cassandra│ │ClickHouse│ │ Redis │ │ S3 │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ User │ │ Events │ │ Sessions │ │ Archive │ │ │
│ │ │ profiles │ │ metrics │ │ cache │ │ backups │ │ │
│ │ │ segments │ │ aggreg. │ │ rate │ │ logs │ │ │
│ │ │ │ │ │ │ limiting │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Internal Portal (для сотрудников) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ SSO │ │Dashboard │ │ Admin │ │ Audit │ │ │
│ │ │ Auth │ │ │ │ Panel │ │ Logs │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ Corporate│ │ A/B test │ │ Create │ │ Who │ │ │
│ │ │ login │ │ results │ │ edit │ │ changed │ │ │
│ │ │ │ │ metrics │ │ delete │ │ what │ │ │
│ │ │ │ │ charts │ │ launch │ │ when │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Аутентификация и авторизация во внутреннем портале
package auth
import (
"context"
"fmt"
)
// Role роль пользователя во внутреннем портале
type Role string
const (
RoleAdmin Role = "admin" // Полный доступ
RoleAnalyst Role = "analyst" // Просмотр и создание отчётов
RoleManager Role = "manager" // Управление экспериментами
RoleViewer Role = "viewer" // Только просмотр
)
// Permission разрешение на действие
type Permission string
const (
PermissionExperimentCreate Permission = "experiment:create"
PermissionExperimentUpdate Permission = "experiment:update"
PermissionExperimentDelete Permission = "experiment:delete"
PermissionExperimentView Permission = "experiment:view"
PermissionDashboardView Permission = "dashboard:view"
PermissionReportCreate Permission = "report:create"
PermissionAdminPanel Permission = "admin:panel"
)
// RBACConfig конфигурация ролей и разрешений
var RBACConfig = map[Role][]Permission{
RoleAdmin: {
PermissionExperimentCreate,
PermissionExperimentUpdate,
PermissionExperimentDelete,
PermissionExperimentView,
PermissionDashboardView,
PermissionReportCreate,
PermissionAdminPanel,
},
RoleManager: {
PermissionExperimentCreate,
PermissionExperimentUpdate,
PermissionExperimentView,
PermissionDashboardView,
PermissionReportCreate,
},
RoleAnalyst: {
PermissionExperimentView,
PermissionDashboardView,
PermissionReportCreate,
},
RoleViewer: {
PermissionExperimentView,
PermissionDashboardView,
},
}
// SSOService сервис корпоративной аутентификации
type SSOService struct {
ssoClient SSOClient
userStore UserStore
}
// Authenticate аутентифицирует пользователя через SSO
func (s *SSOService) Authenticate(ctx context.Context, token string) (*User, error) {
// Валидируем токен через корпоративный SSO
ssoUser, err := s.ssoClient.ValidateToken(ctx, token)
if err != nil {
return nil, fmt.Errorf("SSO validation failed: %w", err)
}
// Получаем или создаём локального пользователя
user, err := s.userStore.GetByEmail(ctx, ssoUser.Email)
if err != nil {
// Создаём нового пользователя
user = &User{
Email: ssoUser.Email,
Name: ssoUser.Name,
Role: RoleViewer, // По умолчанию — viewer
CreatedAt: time.Now(),
}
if err := s.userStore.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
}
return user, nil
}
// Authorize проверяет разрешение пользователя
func (s *SSOService) Authorize(user *User, permission Permission) error {
permissions, ok := RBACConfig[user.Role]
if !ok {
return fmt.Errorf("unknown role: %s", user.Role)
}
for _, p := range permissions {
if p == permission {
return nil
}
}
return fmt.Errorf("user %s with role %s does not have permission %s",
user.Email, user.Role, permission)
}
// Middleware для проверки авторизации
func AuthMiddleware(requiredPermission Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := Authorize(user, requiredPermission); err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
5. Audit логирование
package audit
import (
"context"
"time"
)
// AuditEvent событие аудита
type AuditEvent struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
OldValue string `json:"old_value,omitempty"`
NewValue string `json:"new_value,omitempty"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Timestamp time.Time `json:"timestamp"`
}
// AuditService сервис аудита
type AuditService struct {
store AuditStore
}
// Log логирует действие пользователя
func (s *AuditService) Log(ctx context.Context, event *AuditEvent) error {
event.ID = generateID()
event.Timestamp = time.Now()
// Получаем информацию о пользователе из контекста
if user := GetUserFromContext(ctx); user != nil {
event.UserID = user.ID
event.UserEmail = user.Email
}
// Получаем IP и User-Agent из запроса
if r := GetRequestFromContext(ctx); r != nil {
event.IP = getClientIP(r)
event.UserAgent = r.UserAgent()
}
return s.store.Save(ctx, event)
}
// Пример использования в сервисе экспериментов
type ExperimentService struct {
experimentStore ExperimentStore
auditService *AuditService
}
func (s *ExperimentService) UpdateExperiment(ctx context.Context, experimentID string, updates *ExperimentUpdate) (*Experiment, error) {
// Получаем старое значение
oldExperiment, err := s.experimentStore.Get(ctx, experimentID)
if err != nil {
return nil, err
}
// Обновляем эксперимент
newExperiment, err := s.experimentStore.Update(ctx, experimentID, updates)
if err != nil {
return nil, err
}
// Логируем изменение
s.auditService.Log(ctx, &AuditEvent{
Action: "experiment.update",
Resource: "experiment",
ResourceID: experimentID,
OldValue: marshal(oldExperiment),
NewValue: marshal(newExperiment),
})
return newExperiment, nil
}
6. Схема хранения данных для аудита
-- Таблица аудита
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
user_id BIGINT,
user_email TEXT,
action TEXT NOT NULL,
resource TEXT NOT NULL,
resource_id TEXT,
old_value JSONB,
new_value JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_user_id ON audit_log (user_id);
CREATE INDEX idx_audit_action ON audit_log (action);
CREATE INDEX idx_audit_resource ON audit_log (resource, resource_id);
CREATE INDEX idx_audit_created_at ON audit_log (created_at);
-- Партиционирование по времени (месяц)
CREATE TABLE audit_log_2024_01 PARTITION OF audit_log
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE audit_log_2024_02 PARTITION OF audit_log
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- ... и так далее
-- Таблица пользователей внутреннего портала
CREATE TABLE portal_users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
is_active BOOLEAN NOT NULL DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Таблица сессий пользователей
CREATE TABLE portal_sessions (
id UUID PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_users(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_token ON portal_sessions (token);
CREATE INDEX idx_sessions_expires ON portal_sessions (expires_at);
Краткий ответ: Концептуально архитектура совпадает, но реальная система значительно сложнее:
| Компонент | Упрощённо | Реальность |
|---|---|---|
| Доступ | Напрямую к сервисам | Через API Gateway |
| Аналитика | Простой dashboard | Internal portal с SSO, RBAC, аудитом |
| Statistic Collector | Сложный | Простой (только приём и маршрутизация) |
| Обработка | Один сервис | Stream + Batch + Real-time |
| Хранение | Одна БД | Cassandra + ClickHouse + Redis + S3 |
| Аудит | Нет | Полное логирование действий |
Ключевые дополнения в реальности:
- API Gateway — единая точка входа с rate limiting, auth, маршрутизацией
- Internal Portal — с SSO аутентификацией, RBAC авторизацией, аудитом
- Упрощённый Statistic Collector — вся сложность вынесена в обработчики
- Kafka — как шина данных между сервисами
- Множество хранилищ — каждое под свою задачу
Вопрос 37. Какой технологический стек используется и почему именно он?
Таймкод: 01:19:25
Ответ собеседника: Правильный. Уточняется, что целевое решение включает Kubernetes, сервисы в контейнерах, Kafka, ClickHouse, Cassandra, Redis. Компания придерживается подхода использования стандартных инструментов платформы. Выбранные технологии соответствуют экспертизе компании и предоставляются в рамках платформы.
Правильный ответ:
Технологический стек выбран на основе принципа «стандартные инструменты платформы» и соответствует экспертизе компании. Подробнее о каждом компоненте и причинах выбора.
1. Обзор технологического стека
┌─────────────────────────────────────────────────────────────────────────────┐
│ Технологический стек системы │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Инфраструктура │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Kubernetes│ │ Docker │ │ Helm │ │ Istio │ │ │
│ │ │ │ │ │ │ Charts │ │ Service │ │ │
│ │ │Оркестра- │ │Контейне- │ │Деплой │ │ Mesh │ │ │
│ │ │ция │ │ризация │ │ │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Языки программирования │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Go │ │ Python │ │ Java │ │ React │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │Backend │ │ML/Анали- │ │Legacy │ │Frontend │ │ │
│ │ │сервисы │ │тика │ │сервисы │ │Dashboard │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Брокеры сообщений │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Kafka │ │ RabbitMQ │ │ │
│ │ │ │ │ │ │ │
│ │ │Основная │ │Для │ │ │
│ │ │шина │ │RPC │ │ │
│ │ │событий │ │ │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Базы данных и хранилища │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ClickHouse│ │Cassandra │ │ Redis │ │PostgreSQL│ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │Аналитика │ │Профили │ │Кеш │ │Метаданные│ │ │
│ │ │событий │ │сегменты │ │сессии │ │конфиги │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Мониторинг и наблюдательность │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Prometheus│ │ Grafana │ │ Jaeger │ │ ELK │ │ │
│ │ │ │ │ │ │ │ │ Stack │ │ │
│ │ │Метрики │ │Дашборды │ │Трейсинг │ │Логи │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Обоснование выбора каждой технологии
┌─────────────────────────────────────────────────────────────────────────────┐
│ Обоснование выбора технологий │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Kubernetes + Docker │ │
│ │ │ │
│ │ Почему: │ │
│ │ • Стандарт для оркестрации контейнеров в компании │ │
│ │ • Автоматическое масштабирование подов │ │
│ │ • Self-healing (перезапуск упавших контейнеров) │ │
│ │ • Декларативное управление через YAML/Helm │ │
│ │ • Поддержка rolling updates и canary deployments │ │
│ │ │ │
│ │ Альтернативы: Docker Swarm (проще, но меньше возможностей), │ │
│ │ Nomad (от HashiCorp, менее распространён) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Kafka │ │
│ │ │ │
│ │ Почему: │ │
│ │ • Высокая пропускная способность (млн сообщений/сек) │ │
│ │ • Гарантированная доставка (at-least-once, exactly-once) │ │
│ │ • Партиционирование для параллельной обработки │ │
│ │ • Retention сообщений (возможность переиграть события) │ │
│ │ • Разделение producers и consumers │ │
│ │ │ │
│ │ Альтернативы: RabbitMQ (лучше для RPC, хуже для streaming), │ │
│ │ Pulsar (новее, но меньше экспертизы в компании) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ClickHouse │ │
│ │ │ │
│ │ Почему: │ │
│ │ • Колоночное хранение — быстрая агрегация больших объёмов │ │
│ │ • Вставка миллионов строк в секунду │ │
│ │ • Эффективные аналитические запросы (SUM, COUNT, GROUP BY) │ │
│ │ • Сжатие данных (до 10x) │ │
│ │ • Поддержка SQL (знакомый синтаксис) │ │
│ │ │ │
│ │ Для каких задач: │ │
│ │ • Хранение событий экспериментов │ │
│ │ • Расчёт метрик (conversion rate, средние значения) │ │
│ │ • Построение отчётов для аналитиков │ │
│ │ │ │
│ │ Альтернативы: Druid (сложнее в администрировании), │ │
│ │ TimescaleDB (на PostgreSQL, но медленнее для аналитики) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Cassandra │ │
│ │ │ │
│ │ Почему: │ │
│ │ • Высокая доступность (no single point of failure) │ │
│ │ • Линейное масштабирование записи │ │
│ │ • Тюнинг консистентности (ONE, QUORUM, ALL) │ │
│ │ • Хорошо подходит для профилей пользователей │ │
│ │ │ │
│ │ Для каких задач: │ │
│ │ • Профили пользователей (user_id → атрибуты) │ │
│ │ • Сегменты пользователей │ │
│ │ • Настройки экспериментов │ │
│ │ │ │
│ │ Альтернативы: ScyllaDB (совместима, быстрее), │ │
│ │ DynamoDB (AWS-only, vendor lock-in) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Redis │ │
│ │ │ │
│ │ Почему: │ │
│ │ • In-memory — минимальная латентность │ │
│ │ • Поддержка TTL (автоистечение сессий) │ │
│ │ • Атомарные операции (INCR, HINCRBY) │ │
│ │ • Pub/Sub для real-time уведомлений │ │
│ │ │ │
│ │ Для каких задач: │ │
│ │ • Кеш сессий пользователей │ │
│ │ • Кеш контекста экспериментов │ │
│ │ • Rate limiting │ │
│ │ • Счётчики в реальном времени │ │
│ │ │ │
│ │ Альтернативы: Memcached (проще, меньше типов данных) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ │ │
│ │ Почему: │ │
│ │ • ACID-транзакции для метаданных │ │
│ │ • Знакомый SQL-синтаксис │ │
│ │ • Надёжность и зрелость │ │
│ │ • Поддержка JSONB для гибких схем │ │
│ │ │ │
│ │ Для каких задач: │ │
│ │ • Конфигурации экспериментов │ │
│ │ • Данные пользователей внутреннего портала │ │
│ │ • Аудит логирование │ │
│ │ │ │
│ │ Альтернативы: MySQL (менее гибкий с JSON), │ │
│ │ CockroachDB (распределённый, но сложнее) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Принцип «стандартные инструменты платформы»
┌─────────────────────────────────────────────────────────────────────────────┐
│ Принцип выбора технологий │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Проблема с кастомными решениями: │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Команда │ │ Команда │ │ Команда │ │ │
│ │ │ A │ │ B │ │ C │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Custom │ │ Custom │ │ Custom │ │ │
│ │ │ Solution │ │ Solution │ │ Solution │ │ │
│ │ │ X │ │ Y │ │ Z │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Проблемы: │ │ │
│ │ │ • Каждая команда изобретает велосипед │ │ │
│ │ │ • Невозможно переиспользовать решения │ │ │
│ │ │ • Сложно нанимать (уникальный стек) │ │ │
│ │ │ • Высокая стоимость поддержки │ │ │
│ │ │ • Bus factor = 1 (знает только автор) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Решение — стандартная платформа: │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Команда │ │ Команда │ │ Команда │ │ │
│ │ │ A │ │ B │ │ C │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────┼────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Platform Team │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ K8s │ │ Kafka │ │ClickHouse│ │ Cassandra│ │ │ │
│ │ │ │ Cluster │ │ Cluster │ │ Cluster │ │ Cluster │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ Redis │ │PostgreSQL│ │Prometheus│ │ Grafana │ │ │ │
│ │ │ │ Cluster │ │ Cluster │ │ │ │ │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Преимущества: │ │
│ │ • Единый стек для всех команд │ │
│ │ • Platform Team поддерживает инфраструктуру │ │
│ │ • Легче нанимать (стандартные технологии) │ │
│ │ • Общая экспертиза и лучшие практики │ │
│ │ • Экономия на масштабе │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Пример конфигурации деплоя (Helm + Kubernetes)
# helm/values.yaml
global:
environment: production
imageRegistry: registry.company.com
experiment-service:
replicaCount: 3
image:
repository: experiment-service
tag: "1.2.3"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
env:
KAFKA_BROKERS: "kafka-cluster:9092"
REDIS_URL: "redis-cluster:6379"
CASSANDRA_HOSTS: "cassandra-1,cassandra-2,cassandra-3"
CLICKHOUSE_HOST: "clickhouse-cluster:8123"
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
event-service:
replicaCount: 5
image:
repository: event-service
tag: "2.0.1"
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 20
targetCPUUtilizationPercentage: 60
5. Пример Go-кода для работы с выбранным стеком
package main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/segmentio/kafka-go"
"github.com/go-redis/redis/v8"
"github.com/gocql/gocql"
"github.com/ClickHouse/clickhouse-go/v2"
)
// EventService сервис событий, использующий весь стек
type EventService struct {
kafkaWriter *kafka.Writer
redisClient *redis.Client
cassandraSession *gocql.Session
clickhouseConn clickhouse.Conn
}
// NewEventService создаёт сервис с подключениями ко всем хранилищам
func NewEventService(cfg *Config) (*EventService, error) {
// Kafka producer
kafkaWriter := kafka.NewWriter(kafka.WriterConfig{
Brokers: cfg.Kafka.Brokers,
Topic: cfg.Kafka.Topic,
Balancer: &kafka.LeastBytes{},
BatchSize: 100,
BatchTimeout: 10 * time.Millisecond,
Async: false, // Для гарантии доставки
})
// Redis client
redisClient := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
ReadTimeout: 200 * time.Millisecond,
WriteTimeout: 200 * time.Millisecond,
})
// Cassandra session
cassandraCluster := gocql.NewCluster(cfg.Cassandra.Hosts...)
cassandraCluster.Keyspace = cfg.Cassandra.Keyspace
cassandraCluster.Consistency = gocql.Quorum
cassandraCluster.Timeout = 2 * time.Second
cassandraSession, err := cassandraCluster.CreateSession()
if err != nil {
return nil, fmt.Errorf("cassandra connection failed: %w", err)
}
// ClickHouse connection
clickhouseConn, err := clickhouse.Open(&clickhouse.Options{
Addr: cfg.ClickHouse.Addrs,
Auth: clickhouse.Auth{
Database: cfg.ClickHouse.Database,
Username: cfg.ClickHouse.Username,
Password: cfg.ClickHouse.Password,
},
DialTimeout: 5 * time.Second,
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
})
if err != nil {
return nil, fmt.Errorf("clickhouse connection failed: %w", err)
}
return &EventService{
kafkaWriter: kafkaWriter,
redisClient: redisClient,
cassandraSession: cassandraSession,
clickhouseConn: clickhouseConn,
}, nil
}
// ProcessEvent обрабатывает событие через весь стек
func (s *EventService) ProcessEvent(ctx context.Context, event *Event) error {
// 1. Проверяем кеш сессии в Redis
sessionKey := fmt.Sprintf("session:%s", event.SessionID)
sessionData, err := s.redisClient.Get(ctx, sessionKey).Bytes()
if err == redis.Nil {
// Сессия не найдена в кеше — загружаем из Cassandra
sessionData, err = s.loadSessionFromCassandra(ctx, event.SessionID)
if err != nil {
return fmt.Errorf("session not found: %w", err)
}
// Сохраняем в кеш
s.redisClient.Set(ctx, sessionKey, sessionData, 30*time.Minute)
} else if err != nil {
return fmt.Errorf("redis error: %w", err)
}
// 2. Обогащаем событие контекстом сессии
var sessionCtx SessionContext
json.Unmarshal(sessionData, &sessionCtx)
event.Experiments = sessionCtx.Experiments
// 3. Отправляем в Kafka для дальнейшей обработки
eventData, _ := json.Marshal(event)
err = s.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(event.UserID),
Value: eventData,
Topic: "events.enriched",
Headers: []kafka.Header{
{Key: "experiment_id", Value: []byte(event.ExperimentID)},
},
})
if err != nil {
return fmt.Errorf("kafka write failed: %w", err)
}
// 4. Обратная связь — обновляем счётчик в Redis
counterKey := fmt.Sprintf("counter:%s:%s", event.ExperimentID, event.Variant)
s.redisClient.Incr(ctx, counterKey)
return nil
}
// loadSessionFromCassandra загружает сессию из Cassandra
func (s *EventService) loadSessionFromCassandra(ctx context.Context, sessionID string) ([]byte, error) {
var data []byte
query := "SELECT data FROM sessions WHERE session_id = ?"
err := s.cassandraSession.Query(query, sessionID).WithContext(ctx).Scan(&data)
return data, err
}
// SaveEventToClickHouse сохраняет событие в ClickHouse
func (s *EventService) SaveEventToClickHouse(ctx context.Context, event *Event) error {
query := `
INSERT INTO events (event_id, user_id, session_id, experiment_id, variant, event_name, properties, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
return s.clickhouseConn.Exec(ctx, query,
event.ID,
event.UserID,
event.SessionID,
event.ExperimentID,
event.Variant,
event.Name,
event.Properties,
event.Timestamp,
)
}
// GetExperimentMetrics получает метрики из ClickHouse
func (s *EventService) GetExperimentMetrics(ctx context.Context, experimentID string, startDate, endDate time.Time) ([]Metric, error) {
query := `
SELECT
variant,
count() as total_events,
uniq(user_id) as unique_users,
countIf(event_name = 'purchase') as purchases,
purchases / unique_users * 100 as conversion_rate
FROM events
WHERE experiment_id = ?
AND created_at BETWEEN ? AND ?
GROUP BY variant
ORDER BY variant
`
rows, err := s.clickhouseConn.Query(ctx, query, experimentID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var metrics []Metric
for rows.Next() {
var m Metric
if err := rows.Scan(&m.Variant, &m.TotalEvents, &m.UniqueUsers, &m.Purchases, &m.ConversionRate); err != nil {
return nil, err
}
metrics = append(metrics, m)
}
return metrics, nil
}
6. SQL-схема для ClickHouse
-- Таблица событий в ClickHouse
CREATE TABLE events (
event_id UUID,
user_id UInt64,
session_id UUID,
experiment_id LowCardinality(String),
variant LowCardinality(String),
event_name LowCardinality(String),
properties String, -- JSON
created_at DateTime64(3)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (experiment_id, variant, created_at)
TTL created_at + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
-- Материализованное представление для агрегации
CREATE MATERIALIZED VIEW experiment_metrics_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant, date)
AS SELECT
experiment_id,
variant,
toDate(created_at) as date,
count() as event_count,
uniq(user_id) as unique_users,
sumIf(1, event_name = 'purchase') as purchases
FROM events
GROUP BY experiment_id, variant, date;
-- Быстрый запрос метрик
SELECT
experiment_id,
variant,
sum(event_count) as total_events,
uniqMerge(unique_users) as unique_users,
sum(purchases) as total_purchases,
total_purchases / unique_users * 100 as conversion_rate
FROM experiment_metrics_mv
WHERE experiment_id = 'exp_123'
AND date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY experiment_id, variant;
Краткий ответ: Технологический стек выбран на основе принципа «стандартные инструменты платформы»:
| Технология | Зачем | Альтернативы |
|---|---|---|
| Kubernetes | Оркестрация контейнеров | Docker Swarm, Nomad |
| Kafka | Шина событий | RabbitMQ, Pulsar |
| ClickHouse | Аналитика событий | Druid, TimescaleDB |
| Cassandra | Профили пользователей | ScyllaDB, DynamoDB |
| Redis | Кеш, сессии, rate limiting | Memcached |
| PostgreSQL | Метаданные, конфигурации | MySQL, CockroachDB |
Ключевой принцип: Все технологии — стандартные для компании, поддерживаются Platform Team, что снижает стоимость владения и упрощает найм.
Вопрос 38. Почему были выбраны конкретные технологии, а не их альтернативы?
Таймкод: 01:21:36
Ответ собеседника: Неполный. PostgreSQL выбрана как простая и надёжная для небольшого объёма данных. Kafka — для буферизации и сохранения порядка событий. ClickHouse — для аналитических запросов. Cassandra — для key-value хранения сегментов. Не сравнивались альтернативы вроде RabbitMQ или других очередей.
Правильный ответ:
Выбор каждой технологии обусловлен конкретными требованиями системы и сравнением с альтернативами. Подробный анализ для каждой технологии.
1. PostgreSQL для метаданных и конфигураций
┌─────────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL: обоснование выбора │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Требования к хранилищу метаданных: │ │
│ │ │ │
│ │ • ACID-транзакции (консистентность конфигураций) │ │
│ │ • Небольшой объём данных (тысячи экспериментов, не миллионы) │ │
│ │ • Сложные запросы с JOIN (эксперименты ↔ сегменты ↔ пользователи) │ │
│ │ • Строгая схема данных │ │
│ │ • Знакомый SQL-синтаксис для команды │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сравнение с альтернативами: │ │
│ │ │ │
│ │ ┌──────────────┬──────────┬──────────┬──────────┬──────────┐ │ │
│ │ │ │PostgreSQL│ MySQL │ CockroachDB│ MongoDB │ │ │
│ │ ├──────────────┼──────────┼──────────┼──────────┼──────────┤ │ │
│ │ │ ACID │ ✅ │ ✅ │ ✅ │ ⚠️ │ │ │
│ │ │ JOIN │ ✅ │ ✅ │ ✅ │ ❌ │ │ │
│ │ │ JSONB │ ✅ │ ⚠️ │ ❌ │ ✅ │ │ │
│ │ │ Зрелость │ ✅ │ ✅ │ ⚠️ │ ✅ │ │ │
│ │ │ Горизонт. │ ⚠️ │ ⚠️ │ ✅ │ ✅ │ │ │
│ │ │ масштаб. │ │ │ │ │ │ │
│ │ │ Сложность │ Низкая │ Низкая │ Высокая│ Средняя│ │ │
│ │ │ администр. │ │ │ │ │ │ │
│ │ └──────────────┴──────────┴──────────┴──────────┴──────────┘ │ │
│ │ │ │
│ │ Вывод: PostgreSQL — оптимальный выбор для метаданных, │ │
│ │ т.к. не требует горизонтального масштабирования, │ │
│ │ но нужны ACID и сложные запросы │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Kafka как шина событий
┌─────────────────────────────────────────────────────────────────────────────┐
│ Kafka: обоснование выбора │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Требования к брокеру событий: │ │
│ │ │ │
│ │ • Высокая пропускная способность (>100K событий/сек) │ │
│ │ • Сохранение порядка событий в рамках партиции │ │
│ │ • Возможность переиграть события (retention) │ │
│ │ • Множество consumers с независимым прогрессом │ │
│ │ • Буферизация при пиковых нагрузках │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сравнение с альтернативами: │ │
│ │ │ │
│ │ ┌──────────────┬──────────┬──────────┬──────────┬──────────┐ │ │
│ │ │ │ Kafka │ RabbitMQ │ Pulsar │ NATS │ │ │
│ │ ├──────────────┼──────────┼──────────┼──────────┼──────────┤ │ │
│ │ │ Пропускная │ 100K+ │ 20-50K │ 100K+ │ 1M+ │ │ │
│ │ │ способность │ │ │ │ │ │ │
│ │ │ (сообщ/сек) │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Порядок │ ✅ │ ✅ │ ✅ │ ❌ │ │ │
│ │ │ сообщений │(в партиции)│(в очереди)│(в топике)│ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Retention │ ✅ │ ❌ │ ✅ │ ❌ │ │ │
│ │ │ (переиграть) │(дни/недели)│ │(дни/недели)│ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Consumer │ ✅ │ ❌ │ ✅ │ ❌ │ │ │
│ │ │ Groups │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Exactly-once │ ✅ │ ❌ │ ✅ │ ❌ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Сложность │ Средняя │ Низкая │ Высокая │ Низкая │ │ │
│ │ │ эксплуатации │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Экосистема │ Большая │ Большая │ Растущая │ Малая │ │ │
│ │ └──────────────┴──────────┴──────────┴──────────┴──────────┘ │ │
│ │ │ │
│ │ Почему не RabbitMQ: │ │
│ │ • Нет retention — нельзя переиграть события │ │
│ │ • Ограниченная пропускная способность │ │
│ │ • Сообщения удаляются после потребления │ │
│ │ │ │
│ │ Почему не Pulsar: │ │
│ │ • Сложнее в эксплуатации │ │
│ │ • Меньше экспертизы в компании │ │
│ │ • Kafka уже развёрнут на платформе │ │
│ │ │ │
│ │ Почему не NATS: │ │
│ │ • Нет persistence по умолчанию │ │
│ │ • Нет гарантии порядка │ │
│ │ • Нет retention │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. ClickHouse для аналитики
┌─────────────────────────────────────────────────────────────────────────────┐
│ ClickHouse: обоснование выбора │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Требования к аналитическому хранилищу: │ │
│ │ │ │
│ │ • Быстрая вставка миллионов событий в секунду │ │
│ │ • Быстрая агрегация (SUM, COUNT, AVG) по большим объёмам │ │
│ │ • Эффективное сжатие данных │ │
│ │ • SQL-интерфейс для аналитиков │ │
│ │ • Поддержка материализованных представлений │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сравнение с альтернативами: │ │
│ │ │ │
│ │ ┌──────────────┬──────────┬──────────┬──────────┬──────────┐ │ │
│ │ │ │ClickHouse│ Druid │TimescaleDB│ BigQuery │ │ │
│ │ ├──────────────┼──────────┼──────────┼──────────┼──────────┤ │ │
│ │ │ Скорость │ Очень │ Высокая │ Средняя │ Высокая │ │ │
│ │ │ вставки │ высокая │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Скорость │ Очень │ Высокая │ Средняя │ Высокая │ │ │
│ │ │ агрегации │ высокая │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Сжатие │ 10x+ │ 5-10x │ 3-5x │ N/A │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Self-hosted │ ✅ │ ✅ │ ✅ │ ❌ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Сложность │ Средняя │ Высокая │ Низкая │ Низкая │ │ │
│ │ │ эксплуатации │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Стоимость │ Низкая │ Средняя │ Низкая │ Высокая │ │ │
│ │ │ (TCO) │ │ │ │ │ │ │
│ │ └──────────────┴──────────┴──────────┴──────────┴──────────┘ │ │
│ │ │ │
│ │ Почему не Druid: │ │
│ │ • Сложнее в настройке и эксплуатации │ │
│ │ • Требует больше ресурсов │ │
│ │ • Меньше экспертизы в компании │ │
│ │ │ │
│ │ Почему не TimescaleDB: │ │
│ │ • Основан на PostgreSQL — строковое хранение │ │
│ │ • Медленнее для аналитических запросов │ │
│ │ • Хуже сжатие │ │
│ │ │ │
│ │ Почему не BigQuery: │ │
│ │ • Vendor lock-in (Google Cloud) │ │
│ │ • Высокая стоимость при большом объёме │ │
│ │ • Задержка запросов (не real-time) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Cassandra для профилей и сегментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Cassandra: обоснование выбора │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Требования к хранилищу профилей: │ │
│ │ │ │
│ │ • Высокая скорость записи (много обновлений профилей) │ │
│ │ • Линейное масштабирование при росте пользователей │ │
│ │ • Высокая доступность (no single point of failure) │ │
│ │ • Паттерн доступа: чтение/запись по ключу (user_id) │ │
│ │ • Географическая распределённость (опционально) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Сравнение с альтернативами: │ │
│ │ │ │
│ │ ┌──────────────┬──────────┬──────────┬──────────┬──────────┐ │ │
│ │ │ │Cassandra │ ScyllaDB │ DynamoDB │ MongoDB │ │ │
│ │ ├──────────────┼──────────┼──────────┼──────────┼──────────┤ │ │
│ │ │ Скорость │ Высокая │ Очень │ Высокая │ Средняя │ │ │
│ │ │ записи │ │ высокая │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Скорость │ Высокая │ Очень │ Высокая │ Высокая │ │ │
│ │ │ чтения (key) │ │ высокая │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Масштабир. │ Линейное│ Линейное│ Автомат.│ Линейное│ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Доступность │ Высокая │ Высокая │ Высокая │ Средняя │ │ │
│ │ │ │(tunable) │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Self-hosted │ ✅ │ ✅ │ ❌ │ ✅ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Vendor │ Нет │ Нет │ AWS │ Нет │ │ │
│ │ │ lock-in │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Сложность │ Высокая │ Высокая │ Низкая │ Средняя │ │ │
│ │ │ эксплуатации │ │ │ │ │ │ │
│ │ └──────────────┴──────────┴──────────┴──────────┴──────────┘ │ │
│ │ │ │
│ │ Почему не ScyllaDB: │ │
│ │ • Совместима с Cassandra, но меньше экспертизы в компании │ │
│ │ • Cassandra уже развёрнут на платформе │ │
│ │ │ │
│ │ Почему не DynamoDB: │ │
│ │ • Vendor lock-in (AWS) │ │
│ │ • Высокая стоимость при большом объёме │ │
│ │ • Ограниченная гибкость в тюнинге │ │
│ │ │ │
│ │ Почему не MongoDB: │ │
│ │ • Хуже масштабируется для записи │ │
│ │ • Нет tunable consistency │ │
│ │ • Хуже подходит для time-series данных │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Сводная таблица выбора технологий
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сводная таблица выбора технологий │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────┬───────────────────┬───────────────────────────┐ │ │
│ │ │ Компонент │ Выбрано │ Почему не альтернативы │ │ │
│ │ ├──────────────┼───────────────────┼───────────────────────────┤ │ │
│ │ │ Метаданные │ PostgreSQL │ MySQL — хуже JSONB │ │ │
│ │ │ │ │ CockroachDB — избыточен │ │ │
│ │ │ │ │ MongoDB — нет ACID │ │ │
│ │ ├──────────────┼───────────────────┼───────────────────────────┤ │ │
│ │ │ Шина событий │ Kafka │ RabbitMQ — нет retention │ │ │
│ │ │ │ │ Pulsar — сложнее │ │ │
│ │ │ │ │ NATS — нет persistence │ │ │
│ │ ├──────────────┼───────────────────┼───────────────────────────┤ │ │
│ │ │ Аналитика │ ClickHouse │ Druid — сложнее │ │ │
│ │ │ │ │ TimescaleDB — медленнее │ │ │
│ │ │ │ │ BigQuery — vendor lock-in │ │ │
│ │ ├──────────────┼───────────────────┼───────────────────────────┤ │ │
│ │ │ Профили │ Cassandra │ ScyllaDB — меньше эксп. │ │ │
│ │ │ │ │ DynamoDB — vendor lock-in │ │ │
│ │ │ │ │ MongoDB — хуже запись │ │ │
│ │ ├──────────────┼───────────────────┼───────────────────────────┤ │ │
│ │ │ Кеш │ Redis │ Memcached — меньше типов │ │ │
│ │ └──────────────┴───────────────────┴───────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Принципы выбора технологий
┌─────────────────────────────────────────────────────────────────────────────┐
│ Принципы выбора технологий │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Экспертиза компании │ │
│ │ │ │
│ │ • Выбираем технологии, которые уже знает команда │ │
│ │ • Легче нанимать специалистов │ │
│ │ • Есть готовые практики и инструменты │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. Стандарты платформы │ │
│ │ │ │
│ │ • Platform Team поддерживает ограниченный набор технологий │ │
│ │ • Единый стек снижает стоимость владения (TCO) │ │
│ │ • Проще мониторинг и операционная поддержка │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Соответствие требованиям │ │
│ │ │ │
│ │ • Каждая технология решает свою задачу │ │
│ │ • Нет «золотой пули» — каждое хранилище под свою нагрузку │ │
│ │ • Polyglot persistence — нормальная практика │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. Зрелость и экосистема │ │
│ │ │ │
│ │ • Предпочитаем зрелые технологии с большим сообществом │ │
│ │ • Есть библиотеки, документация, best practices │ │
│ │ • Меньше рисков при эксплуатации │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 5. Стоимость владения (TCO) │ │
│ │ │ │
│ │ • Self-hosted > SaaS (при большом масштабе) │ │
│ │ • Open-source > Commercial │ │
│ │ • Учитываем не только лицензии, но и операционные расходы │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Выбор технологий обусловлен тремя факторами:
| Фактор | Описание |
|---|---|
| Экспертиза | Технологии уже знакомы команде, легче нанимать |
| Платформа | Platform Team поддерживает стандартный стек |
| Требования | Каждая технология оптимальна для своей задачи |
Ключевые сравнения:
| Задача | Выбрано | Не выбрано | Почему |
|---|---|---|---|
| Метаданные | PostgreSQL | MongoDB | Нужны ACID и JOIN |
| Шина событий | Kafka | RabbitMQ | Нужен retention и порядок |
| Аналитика | ClickHouse | BigQuery | Self-hosted, нет vendor lock-in |
| Профили | Cassandra | DynamoDB | Нет vendor lock-in, tunable consistency |
Вопрос 39. Что должно являться выходом системы? Достаточно ли просто сложить аналитику в ClickHouse?
Таймкод: 01:23:36
Ответ собеседника: Неполный. Аналитику сложили в ClickHouse, но не дорисовали сервис для запроса статистики аналитиками. Также не обсудили автоматический расчёт показателей экспериментов и принятие решений об окончании с отправкой нотификаций. Время ограничено, поэтому не все части были проработаны.
Правильный ответ:
Выход системы — это не просто хранилище данных, а полноценный цикл от сбора событий до принятия решений. Разберём, что должно быть на выходе системы.
1. Полная архитектура выхода системы
┌─────────────────────────────────────────────────────────────────────────────┐
│ Выход системы: полный цикл │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Уровень 1: Хранение данных (ClickHouse) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ ClickHouse │ │ │
│ │ │ • Сырые события │ │ │
│ │ │ • Агрегированные метрики (MV) │ │ │
│ │ │ • Предрасчитанные отчёты │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐│ │
│ │ │ Уровень 2: Сервис аналитики (Analytics Service) ││ │
│ │ │ ││ │
│ │ │ • API для запроса метрик ││ │
│ │ │ • Кеширование результатов ││ │
│ │ │ • Фильтрация и группировка ││ │
│ │ └─────────────────────────────────────────────────────────────────┘│ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐│ │
│ │ │ Уровень 3: Статистический движок (Stats Engine) ││ │
│ │ │ ││ │
│ │ │ • Расчёт статистической значимости ││ │
│ │ │ • Доверительные интервалы ││ │
│ │ │ • Байесовский анализ ││ │
│ │ │ • Коррекция на множественные сравнения ││ │
│ │ └─────────────────────────────────────────────────────────────────┘│ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐│ │
│ │ │ Уровень 4: Движок решений (Decision Engine) ││ │
│ │ │ ││ │
│ │ │ • Автоматическая остановка экспериментов ││ │
│ │ │ • Определение победителя ││ │
│ │ │ • Проверка условий завершения ││ │
│ │ └─────────────────────────────────────────────────────────────────┘│ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐│ │
│ │ │ Уровень 5: Уведомления и интеграции (Notifications) ││ │
│ │ │ ││ │
│ │ │ • Email/Slack уведомления аналитикам ││ │
│ │ │ • Webhook для внешних систем ││ │
│ │ │ • Обновление статуса эксперимента ││ │
│ │ │ • Триггеры для автоскалирования ││ │
│ │ └─────────────────────────────────────────────────────────────────┘│ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Почему ClickHouse — это только начало
┌─────────────────────────────────────────────────────────────────────────────┐
│ Проблемы при использовании только ClickHouse │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Проблема 1: Нет статистического анализа │ │
│ │ │ │
│ │ ClickHouse умеет: │ │
│ │ ✅ COUNT, SUM, AVG, PERCENTILE │ │
│ │ │ │
│ │ ClickHouse НЕ умеет: │ │
│ │ ❌ T-test, chi-square test │ │
│ │ ❌ Доверительные интервалы │ │
│ │ ❌ Байесовский вывод │ │
│ │ ❌ Коррекция Бонферрони │ │
│ │ │ │
│ │ Нужен отдельный сервис для статистики! │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Проблема 2: Нет автоматического принятия решений │ │
│ │ │ │
│ │ Аналитик должен: │ │
│ │ ❌ Вручную проверять каждый эксперимент │ │
│ │ ❌ Смотреть на метрики и принимать решение │ │
│ │ ❌ Забыть остановить эксперимент │ │
│ │ │ │
│ │ Нужен автоматический Decision Engine! │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Проблема 3: Нет уведомлений │ │
│ │ │ │
│ │ Без уведомлений: │ │
│ │ ❌ Аналитики не знают о завершении эксперимента │ │
│ │ ❌ Нет автоматической остановки при деградации │ │
│ │ ❌ Нет интеграции с внешними системами │ │
│ │ │ │
│ │ Нужна система нотификаций! │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Сервис аналитики (Analytics Service)
package analytics
import (
"context"
"fmt"
"time"
"experiment-system/internal/models"
"experiment-system/internal/stats"
)
// AnalyticsService предоставляет API для запроса метрик
type AnalyticsService struct {
clickhouse ClickHouseClient
redis RedisClient
statsEngine *stats.Engine
}
// GetExperimentStats возвращает полную статистику эксперимента
func (s *AnalyticsService) GetExperimentStats(
ctx context.Context,
experimentID string,
opts StatsOptions,
) (*ExperimentStats, error) {
// 1. Проверяем кеш
cacheKey := fmt.Sprintf("stats:%s:%s:%s",
experimentID, opts.StartDate, opts.EndDate)
if cached, err := s.redis.Get(ctx, cacheKey).Result(); err == nil {
var stats ExperimentStats
if err := json.Unmarshal([]byte(cached), &stats); err == nil {
return &stats, nil
}
}
// 2. Загружаем сырые метрики из ClickHouse
rawMetrics, err := s.loadRawMetrics(ctx, experimentID, opts)
if err != nil {
return nil, fmt.Errorf("failed to load metrics: %w", err)
}
// 3. Рассчитываем статистику
result := &ExperimentStats{
ExperimentID: experimentID,
StartDate: opts.StartDate,
EndDate: opts.EndDate,
Variants: make(map[string]VariantStats),
}
control := rawMetrics["control"]
for variantName, metrics := range rawMetrics {
vs := VariantStats{
TotalUsers: metrics.UniqueUsers,
Conversions: metrics.Conversions,
ConversionRate: float64(metrics.Conversions) / float64(metrics.UniqueUsers) * 100,
Revenue: metrics.Revenue,
}
// Статистическая значимость (только для treatment)
if variantName != "control" {
pValue := s.statsEngine.TTest(
control.Conversions, control.UniqueUsers,
metrics.Conversions, metrics.UniqueUsers,
)
vs.PValue = pValue
vs.IsSignificant = pValue < opts.SignificanceLevel // обычно 0.05
// Доверительный интервал
ci := s.statsEngine.ConfidenceInterval(
metrics.Conversions, metrics.UniqueUsers,
opts.ConfidenceLevel, // обычно 0.95
)
vs.ConfidenceInterval = ci
// Lift (относительное изменение)
controlRate := float64(control.Conversions) / float64(control.UniqueUsers)
treatmentRate := float64(metrics.Conversions) / float64(metrics.UniqueUsers)
vs.Lift = (treatmentRate - controlRate) / controlRate * 100
}
result.Variants[variantName] = vs
}
// 4. Сохраняем в кеш
resultJSON, _ := json.Marshal(result)
s.redis.Set(ctx, cacheKey, resultJSON, 5*time.Minute)
return result, nil
}
// loadRawMetrics загружает агрегированные метрики из ClickHouse
func (s *AnalyticsService) loadRawMetrics(
ctx context.Context,
experimentID string,
opts StatsOptions,
) (map[string]RawMetrics, error) {
query := `
SELECT
variant,
uniq(user_id) as unique_users,
countIf(event_name = 'purchase') as conversions,
sumIf(toFloat64OrNull(JSONExtractString(properties, 'revenue')),
event_name = 'purchase') as revenue
FROM events
WHERE experiment_id = ?
AND created_at BETWEEN ? AND ?
GROUP BY variant
`
rows, err := s.clickhouse.Query(ctx, query,
experimentID, opts.StartDate, opts.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]RawMetrics)
for rows.Next() {
var m RawMetrics
var variant string
if err := rows.Scan(&variant, &m.UniqueUsers, &m.Conversions, &m.Revenue); err != nil {
return nil, err
}
result[variant] = m
}
return result, nil
}
// ExperimentStats полная статистика эксперимента
type ExperimentStats struct {
ExperimentID string `json:"experiment_id"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Variants map[string]VariantStats `json:"variants"`
}
// VariantStats статистика по варианту
type VariantStats struct {
TotalUsers uint64 `json:"total_users"`
Conversions uint64 `json:"conversions"`
ConversionRate float64 `json:"conversion_rate"`
Revenue float64 `json:"revenue"`
PValue float64 `json:"p_value,omitempty"`
IsSignificant bool `json:"is_significant"`
ConfidenceInterval [2]float64 `json:"confidence_interval,omitempty"`
Lift float64 `json:"lift,omitempty"`
}
4. Статистический движок (Stats Engine)
package stats
import (
"math"
"gonum.org/v1/gonum/stat"
"gonum.org/v1/gonum/stat/distuv"
)
// Engine предоставляет статистические методы для A/B тестов
type Engine struct{}
// TTest выполняет двухвыборочный z-test для пропорций
// Возвращает p-value
func (e *Engine) TTest(
successes1 uint64, total1 uint64, // control
successes2 uint64, total2 uint64, // treatment
) float64 {
// Конверсии
p1 := float64(successes1) / float64(total1)
p2 := float64(successes2) / float64(total2)
// Pooled proportion
p := float64(successes1+successes2) / float64(total1+total2)
// Standard error
se := math.Sqrt(p * (1 - p) * (1/float64(total1) + 1/float64(total2)))
// Z-statistic
z := (p2 - p1) / se
// Two-tailed p-value
normal := distuv.Normal{Mu: 0, Sigma: 1}
pValue := 2 * (1 - normal.CDF(math.Abs(z)))
return pValue
}
// ConfidenceInterval рассчитывает доверительный интервал для пропорции
func (e *Engine) ConfidenceInterval(
successes uint64,
total uint64,
confidenceLevel float64,
) [2]float64 {
p := float64(successes) / float64(total)
z := stat.StdErr(p, float64(total))
alpha := 1 - confidenceLevel
zCritical := distuv.Normal{Mu: 0, Sigma: 1}.Quantile(1 - alpha/2)
margin := zCritical * z
return [2]float64{
math.Max(0, p - margin),
math.Min(1, p + margin),
}
}
// BayesianAnalysis выполняет байесовский анализ
func (e *Engine) BayesianAnalysis(
successesControl uint64, totalControl uint64,
successesTreatment uint64, totalTreatment uint64,
priorAlpha float64, priorBeta float64,
) BayesianResult {
// Posterior для control (Beta distribution)
postAlphaControl := priorAlpha + float64(successesControl)
postBetaControl := priorBeta + float64(totalControl-successesControl)
// Posterior для treatment
postAlphaTreatment := priorAlpha + float64(successesTreatment)
postBetaTreatment := priorBeta + float64(totalTreatment-successesTreatment)
// Monte Carlo simulation для P(treatment > control)
nSamples := 100000
wins := 0
controlDist := distuv.Beta{Alpha: postAlphaControl, Beta: postBetaControl}
treatmentDist := distuv.Beta{Alpha: postAlphaTreatment, Beta: postBetaTreatment}
for i := 0; i < nSamples; i++ {
controlSample := controlDist.Rand()
treatmentSample := treatmentDist.Rand()
if treatmentSample > controlSample {
wins++
}
}
probTreatmentBetter := float64(wins) / float64(nSamples)
// Expected loss
var expectedLoss float64
for i := 0; i < nSamples; i++ {
controlSample := controlDist.Rand()
treatmentSample := treatmentDist.Rand()
loss := controlSample - treatmentSample
if loss > 0 {
expectedLoss += loss
}
}
expectedLoss /= float64(nSamples)
return BayesianResult{
ProbTreatmentBetter: probTreatmentBetter,
ExpectedLoss: expectedLoss,
}
}
// BonferroniCorrection применяет коррекцию Бонферрони для множественных сравнений
func (e *Engine) BonferroniCorrection(pValues []float64, alpha float64) []bool {
n := float64(len(pValues))
correctedAlpha := alpha / n
results := make([]bool, len(pValues))
for i, p := range pValues {
results[i] = p < correctedAlpha
}
return results
}
// BayesianResult результат байесовского анализа
type BayesianResult struct {
ProbTreatmentBetter float64 `json:"prob_treatment_better"`
ExpectedLoss float64 `json:"expected_loss"`
}
5. Движок решений (Decision Engine)
package decision
import (
"context"
"fmt"
"time"
"experiment-system/internal/models"
"experiment-system/internal/analytics"
)
// Engine автоматически принимает решения по экспериментам
type Engine struct {
analytics *analytics.AnalyticsService
experiment ExperimentStore
notifier Notifier
}
// CheckExperiment проверяет условия завершения эксперимента
func (e *Engine) CheckExperiment(ctx context.Context, experimentID string) (*Decision, error) {
exp, err := e.experiment.Get(ctx, experimentID)
if err != nil {
return nil, err
}
// Проверяем, не завершён ли уже
if exp.Status != models.StatusRunning {
return nil, fmt.Errorf("experiment is not running: %s", exp.Status)
}
// Проверяем условия остановки
decision := &Decision{
ExperimentID: experimentID,
Timestamp: time.Now(),
}
// 1. Проверяем минимальную длительность
minDuration := time.Duration(exp.Config.MinDurationHours) * time.Hour
if time.Since(exp.StartedAt) < minDuration {
decision.Action = ActionContinue
decision.Reason = fmt.Sprintf(
"Minimum duration not reached: %v < %v",
time.Since(exp.StartedAt), minDuration,
)
return decision, nil
}
// 2. Загружаем статистику
stats, err := e.analytics.GetExperimentStats(ctx, experimentID, analytics.StatsOptions{
StartDate: exp.StartedAt.Format("2006-01-02"),
EndDate: time.Now().Format("2006-01-02"),
SignificanceLevel: exp.Config.SignificanceLevel, // обычно 0.05
ConfidenceLevel: 0.95,
})
if err != nil {
return nil, err
}
// 3. Проверяем минимальный размер выборки
for variantName, vs := range stats.Variants {
if vs.TotalUsers < exp.Config.MinSampleSize {
decision.Action = ActionContinue
decision.Reason = fmt.Sprintf(
"Insufficient sample size for %s: %d < %d",
variantName, vs.TotalUsers, exp.Config.MinSampleSize,
)
return decision, nil
}
}
// 4. Проверяем деградацию (guardrail метрики)
control := stats.Variants["control"]
for variantName, vs := range stats.Variants {
if variantName == "control" {
continue
}
// Если деградируем контрольную метрику — останавливаем
if vs.ConversionRate < control.ConversionRate*0.9 { // >10% деградация
decision.Action = ActionStop
decision.Reason = fmt.Sprintf(
"Degradation detected: %s conversion rate %.2f%% vs control %.2f%%",
variantName, vs.ConversionRate, control.ConversionRate,
)
decision.Winner = "control"
// Автоматически останавливаем
if err := e.StopExperiment(ctx, experimentID, decision); err != nil {
return nil, err
}
return decision, nil
}
}
// 5. Проверяем статистическую значимость
for variantName, vs := range stats.Variants {
if variantName == "control" {
continue
}
if vs.IsSignificant && vs.Lift > 0 {
// Есть значимый положительный эффект
decision.Action = ActionStop
decision.Reason = fmt.Sprintf(
"Significant positive effect detected: %s lift %.2f%%, p-value %.4f",
variantName, vs.Lift, vs.PValue,
)
decision.Winner = variantName
if err := e.StopExperiment(ctx, experimentID, decision); err != nil {
return nil, err
}
return decision, nil
}
}
// 6. Проверяем максимальную длительность
maxDuration := time.Duration(exp.Config.MaxDurationHours) * time.Hour
if time.Since(exp.StartedAt) >= maxDuration {
decision.Action = ActionStop
decision.Reason = fmt.Sprintf(
"Maximum duration reached: %v >= %v",
time.Since(exp.StartedAt), maxDuration,
)
// Определяем победителя по точке оценки
bestVariant := "control"
bestRate := control.ConversionRate
for variantName, vs := range stats.Variants {
if vs.ConversionRate > bestRate {
bestRate = vs.ConversionRate
bestVariant = variantName
}
}
decision.Winner = bestVariant
if err := e.StopExperiment(ctx, experimentID, decision); err != nil {
return nil, err
}
return decision, nil
}
// Продолжаем эксперимент
decision.Action = ActionContinue
decision.Reason = "No stopping conditions met"
return decision, nil
}
// StopExperiment останавливает эксперимент
func (e *Engine) StopExperiment(ctx context.Context, experimentID string, decision *Decision) error {
// 1. Обновляем статус эксперимента
if err := e.experiment.UpdateStatus(ctx, experimentID, models.StatusCompleted); err != nil {
return err
}
// 2. Сохраняем результат
result := &models.ExperimentResult{
ExperimentID: experimentID,
Winner: decision.Winner,
Reason: decision.Reason,
StoppedAt: time.Now(),
}
if err := e.experiment.SaveResult(ctx, result); err != nil {
return err
}
// 3. Отправляем уведомление
if err := e.notifier.NotifyExperimentCompleted(ctx, result); err != nil {
// Логируем, но не возвращаем ошибку
log.Printf("Failed to send notification: %v", err)
}
return nil
}
// Decision решение по эксперименту
type Decision struct {
ExperimentID string `json:"experiment_id"`
Action Action `json:"action"`
Reason string `json:"reason"`
Winner string `json:"winner,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type Action string
const (
ActionContinue Action = "continue"
ActionStop Action = "stop"
)
6. Система уведомлений
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"text/template"
"time"
"experiment-system/internal/models"
)
// Notifier отправляет уведомления о событиях экспериментов
type Notifier struct {
slackWebhook string
emailService EmailService
experimentStore ExperimentStore
}
// NotifyExperimentCompleted отправляет уведомление о завершении эксперимента
func (n *Notifier) NotifyExperimentCompleted(ctx context.Context, result *models.ExperimentResult) error {
exp, err := n.experimentStore.Get(ctx, result.ExperimentID)
if err != nil {
return err
}
// Отправляем в Slack
if err := n.sendSlackNotification(ctx, exp, result); err != nil {
log.Printf("Failed to send Slack notification: %v", err)
}
// Отправляем email аналитикам
analysts := exp.Config.NotifyEmails
if len(analysts) > 0 {
if err := n.sendEmailNotification(ctx, analysts, exp, result); err != nil {
log.Printf("Failed to send email notification: %v", err)
}
}
return nil
}
// sendSlackNotification отправляет уведомление в Slack
func (n *Notifier) sendSlackNotification(ctx context.Context, exp *models.Experiment, result *models.ExperimentResult) error {
payload := map[string]interface{}{
"blocks": []map[string]interface{}{
{
"type": "header",
"text": map[string]string{
"type": "plain_text",
"text": fmt.Sprintf("🧪 Experiment Completed: %s", exp.Name),
},
},
{
"type": "section",
"fields": []map[string]string{
{"type": "mrkdwn", "text": fmt.Sprintf("*Winner:*\n%s", result.Winner)},
{"type": "mrkdwn", "text": fmt.Sprintf("*Reason:*\n%s", result.Reason)},
{"type": "mrkdwn", "text": fmt.Sprintf("*Duration:*\n%v", result.StoppedAt.Sub(exp.StartedAt))},
{"type": "mrkdwn", "text": fmt.Sprintf("*Status:*\nCompleted")},
},
},
{
"type": "actions",
"elements": []map[string]interface{}{
{
"type": "button",
"text": map[string]string{
"type": "plain_text",
"text": "View Details",
},
"url": fmt.Sprintf("https://experiments.company.com/%s", exp.ID),
"style": "primary",
},
},
},
},
}
payloadJSON, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", n.slackWebhook, bytes.NewBuffer(payloadJSON))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
7. SQL для ClickHouse: материализованные представления
-- Материализованное представление для быстрой агрегации
CREATE MATERIALIZED VIEW experiment_daily_stats_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (experiment_id, variant, date)
AS SELECT
experiment_id,
variant,
toDate(created_at) as date,
count() as event_count,
uniq(user_id) as unique_users,
countIf(event_name = 'purchase') as purchases,
sumIf(toFloat64OrNull(JSONExtractString(properties, 'revenue')),
event_name = 'purchase') as revenue
FROM events
GROUP BY experiment_id, variant, date;
-- Запрос для получения статистики эксперимента
SELECT
variant,
sum(unique_users) as total_users,
sum(purchases) as total_purchases,
sum(purchases) / sum(unique_users) * 100 as conversion_rate,
sum(revenue) as total_revenue
FROM experiment_daily_stats_mv
WHERE experiment_id = 'exp_123'
AND date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY variant
ORDER BY variant;
8. Итоговая архитектура выхода системы
┌─────────────────────────────────────────────────────────────────────────────┐
│ Итоговая архитектура выхода системы │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ События ──► Kafka ──► ClickHouse ──► Analytics Service │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Stats Engine │ │
│ │ (t-test, Bayes) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Decision Engine │ │
│ │ (auto-stop, winner) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Notifier │ │
│ │ (Slack, Email, Webhook) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Выход системы (что получает пользователь): │ │
│ │ │ │
│ │ ✅ Dashboard с метриками и графиками │ │
│ │ ✅ Статистическая значимость (p-value) │ │
│ │ ✅ Доверительные интервалы │ │
│ │ ✅ Автоматическое определение победителя │ │
│ │ ✅ Уведомления о завершении эксперимента │ │
│ │ ✅ API для интеграции с внешними системами │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: ClickHouse — это только хранилище данных. Полноценный выход системы включает:
| Компонент | Зачем |
|---|---|
| Analytics Service | API для запроса метрик, кеширование |
| Stats Engine | Расчёт p-value, доверительных интервалов, байесовский анализ |
| Decision Engine | Автоматическая остановка экспериментов, определение победителя |
| Notifier | Уведомления аналитикам (Slack, Email, Webhook) |
Нет, просто сложить аналитику в ClickHouse недостаточно! Нужен полный цикл от данных до решений.
Вопрос 40. Упустили ли мы вариант сравнения эффективности нескольких подходов одновременно (многорукий бандит)?
Таймкод: 01:25:19
Ответ собеседника: Правильный. Вопрос валидный, но относится к деталям A/B тестирования и статистики. В задаче намеренно меньше фокуса на статистике, чтобы обсудить компоненты, сценарии и потоки выполнения. Рекомендуется книга "Trustworthy Online Controlled Experiments" для углубления в тему.
Правильный ответ:
Многорукий бандит (Multi-Armed Bandit, MAB) — это альтернативный подход к A/B тестированию, который заслуживает обсуждения. Разберём, что это, когда применять и как интегрировать в архитектуру.
1. Сравнение A/B тестов и Multi-Armed Bandit
┌─────────────────────────────────────────────────────────────────────────────┐
│ A/B тесты vs Multi-Armed Bandit │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ A/B тестирование (Frequentist) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Фиксированный трафик ──► Ожидание ──► Анализ ──► Решение │ │ │
│ │ │ │ │ │
│ │ │ 50/50 трафик 2 недели p-value Выбор победителя │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Плюсы: │ │
│ │ ✅ Статистическая строгость │ │
│ │ ✅ Понятная интерпретация результатов │ │
│ │ ✅ Стандарт в индустрии │ │
│ │ │ │
│ │ Минусы: │ │
│ │ ❌ Потери конверсии во время эксперимента │ │
│ │ ❌ Долгое время до результата │ │
│ │ ❌ Нет адаптации к данным в процессе │ │
│ │ │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Multi-Armed Bandit (Bayesian) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Адаптивный трафик ──► Непрерывная оптимизация ──► Результат│ │ │
│ │ │ │ │ │
│ │ │ 50/50 ──► 60/40 ──► 80/20 ──► 95/5 ──► 100/0 │ │ │
│ │ │ (постепенный сдвиг трафика к победителю) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Плюсы: │ │
│ │ ✅ Минимизация потерь (exploitation) │ │
│ │ ✅ Быстрая адаптация к данным │ │
│ │ ✅ Непрерывная оптимизация │ │
│ │ │ │
│ │ Минусы: │ │
│ │ ❌ Сложнее в реализации │ │
│ │ ❌ Труднее интерпретировать результаты │ │
│ │ ❌ Может застрять в локальном оптимуме │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Алгоритмы Multi-Armed Bandit
┌─────────────────────────────────────────────────────────────────────────────┐
│ Основные алгоритмы MAB │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. Epsilon-Greedy │ │
│ │ │ │
│ │ • С вероятностью ε — исследуем (выбираем случайный вариант) │ │
│ │ • С вероятностью 1-ε — эксплуатируем (выбираем лучший) │ │
│ │ │ │
│ │ Пример: ε = 0.1 │ │
│ │ • 90% времени — показываем лучший вариант │ │
│ │ • 10% времени — тестируем другие варианты │ │
│ │ │ │
│ │ Простой, но не оптимальный │ │
│ │ │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 2. Upper Confidence Bound (UCB1) │ │
│ │ │ │
│ │ • Выбираем вариант с максимальным upper confidence bound │ │
│ │ • Учитывает неопределённость: редко тестируемые варианты │ │
│ │ получают "бонус" за исследование │ │
│ │ │ │
│ │ UCB = μ + √(2 * ln(N) / n) │ │
│ │ │ │
│ │ где: │ │
│ │ • μ — средняя конверсия варианта │ │
│ │ • N — общее количество показов │ │
│ │ • n — количество показов данного варианта │ │
│ │ │ │
│ │ Хороший баланс exploration/exploitation │ │
│ │ │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 3. Thompson Sampling │ │
│ │ │ │
│ │ • Байесовский подход │ │
│ │ • Для каждого варианта поддерживаем распределение вероятности │ │
│ │ • На каждом шаге сэмплируем из распределений и выбираем лучший │ │
│ │ │ │
│ │ Для бинарных метрик (конверсия): │ │
│ │ • Prior: Beta(α=1, β=1) — равномерное распределение │ │
│ │ • Posterior: Beta(α + successes, β + failures) │ │
│ │ │ │
│ │ Считается оптимальным для A/B тестирования │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реализация Thompson Sampling на Go
package bandit
import (
"math"
"math/rand"
"sync"
"gonum.org/v1/gonum/stat/distuv"
)
// Variant представляет один "рычаг" бандита
type Variant struct {
Name string
Alpha float64 // successes + 1
Beta float64 // failures + 1
Impressions uint64
Conversions uint64
}
// ThompsonSampling реализует алгоритм Thompson Sampling
type ThompsonSampling struct {
mu sync.RWMutex
variants map[string]*Variant
priorAlpha float64
priorBeta float64
}
// NewThompsonSampling создаёт новый экземпляр
func NewThompsonSampling(priorAlpha, priorBeta float64) *ThompsonSampling {
return &ThompsonSampling{
variants: make(map[string]*Variant),
priorAlpha: priorAlpha,
priorBeta: priorBeta,
}
}
// AddVariant добавляет новый вариант
func (ts *ThompsonSampling) AddVariant(name string) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.variants[name] = &Variant{
Name: name,
Alpha: ts.priorAlpha,
Beta: ts.priorBeta,
}
}
// SelectVariant выбирает вариант для показа (Thompson Sampling)
func (ts *ThompsonSampling) SelectVariant() string {
ts.mu.RLock()
defer ts.mu.RUnlock()
var bestVariant string
bestSample := -1.0
for name, v := range ts.variants {
// Сэмплируем из Beta-распределения
beta := distuv.Beta{
Alpha: v.Alpha,
Beta: v.Beta,
Src: rand.NewSource(rand.Int63()),
}
sample := beta.Rand()
if sample > bestSample {
bestSample = sample
bestVariant = name
}
}
return bestVariant
}
// Update обновляет статистику варианта после показа
func (ts *ThompsonSampling) Update(variantName string, converted bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
v := ts.variants[variantName]
v.Impressions++
if converted {
v.Conversions++
v.Alpha++
} else {
v.Beta++
}
}
// GetStats возвращает текущую статистику по вариантам
func (ts *ThompsonSampling) GetStats() map[string]VariantStats {
ts.mu.RLock()
defer ts.mu.RUnlock()
stats := make(map[string]VariantStats)
for name, v := range ts.variants {
// Математическое ожидание Beta-распределения: α / (α + β)
expectedRate := v.Alpha / (v.Alpha + v.Beta)
// 95% credible interval
beta := distuv.Beta{Alpha: v.Alpha, Beta: v.Beta}
lower := beta.Quantile(0.025)
upper := beta.Quantile(0.975)
stats[name] = VariantStats{
Impressions: v.Impressions,
Conversions: v.Conversions,
ExpectedRate: expectedRate,
CredibleInterval: [2]float64{lower, upper},
}
}
return stats
}
// GetProbBest возвращает вероятность того, что каждый вариант лучший
func (ts *ThompsonSampling) GetProbBest(nSamples int) map[string]float64 {
ts.mu.RLock()
defer ts.mu.RUnlock()
wins := make(map[string]int)
for name := range ts.variants {
wins[name] = 0
}
for i := 0; i < nSamples; i++ {
var bestVariant string
bestSample := -1.0
for name, v := range ts.variants {
beta := distuv.Beta{Alpha: v.Alpha, Beta: v.Beta}
sample := beta.Rand()
if sample > bestSample {
bestSample = sample
bestVariant = name
}
}
wins[bestVariant]++
}
probs := make(map[string]float64)
for name, winCount := range wins {
probs[name] = float64(winCount) / float64(nSamples)
}
return probs
}
// VariantStats статистика варианта
type VariantStats struct {
Impressions uint64 `json:"impressions"`
Conversions uint64 `json:"conversions"`
ExpectedRate float64 `json:"expected_rate"`
CredibleInterval [2]float64 `json:"credible_interval"`
}
4. Интеграция MAB в сервис экспериментов
package experiment
import (
"context"
"fmt"
"experiment-system/internal/bandit"
"experiment-system/internal/models"
)
// ExperimentService расширенный сервис экспериментов
type ExperimentService struct {
store ExperimentStore
bandits map[string]*bandit.ThompsonSampling // experimentID -> bandit
allocator TrafficAllocator
}
// GetVariant выбирает вариант для пользователя
func (s *ExperimentService) GetVariant(
ctx context.Context,
experimentID string,
userID string,
) (string, error) {
exp, err := s.store.Get(ctx, experimentID)
if err != nil {
return "", err
}
switch exp.Config.Type {
case models.ExperimentTypeAB:
// Классический A/B тест — фиксированное распределение
return s.allocator.AllocateFixed(exp, userID)
case models.ExperimentTypeMAB:
// Multi-Armed Bandit — адаптивное распределение
return s.allocateBandit(exp, userID)
default:
return "", fmt.Errorf("unknown experiment type: %s", exp.Config.Type)
}
}
// allocateBandit выбирает вариант через Thompson Sampling
func (s *ExperimentService) allocateBandit(
exp *models.Experiment,
userID string,
) (string, error) {
// Получаем или создаём бандит для эксперимента
b, ok := s.bandits[exp.ID]
if !ok {
// Инициализируем бандит из текущей статистики
b = bandit.NewThompsonSampling(1, 1) // uniform prior
for _, variant := range exp.Variants {
b.AddVariant(variant.Name)
// Загружаем текущую статистику
stats := s.getVariantStats(exp.ID, variant.Name)
for i := uint64(0); i < stats.Conversions; i++ {
b.Update(variant.Name, true)
}
for i := uint64(0); i < stats.Impressions-stats.Conversions; i++ {
b.Update(variant.Name, false)
}
}
s.bandits[exp.ID] = b
}
// Thompson Sampling
selectedVariant := b.SelectVariant()
return selectedVariant, nil
}
// TrackConversion отслеживает конверсию и обновляет бандит
func (s *ExperimentService) TrackConversion(
ctx context.Context,
experimentID string,
variantName string,
userID string,
) error {
// Сохраняем событие
if err := s.store.SaveEvent(ctx, &models.ExperimentEvent{
ExperimentID: experimentID,
Variant: variantName,
UserID: userID,
EventType: "conversion",
Timestamp: time.Now(),
}); err != nil {
return err
}
// Обновляем бандит (если это MAB эксперимент)
if b, ok := s.bandits[experimentID]; ok {
b.Update(variantName, true)
}
return nil
}
5. Когда использовать A/B тесты, а когда MAB
┌─────────────────────────────────────────────────────────────────────────────┐
│ Когда что использовать │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Используйте A/B тесты, когда: │ │
│ │ │ │
│ │ ✅ Нужна статистическая строгость (p-value, доверительные интервалы)│ │
│ │ ✅ Эксперимент запускается один раз и анализируется после │ │
│ │ ✅ Важна интерпретируемость результатов для стейкхолдеров │ │
│ │ ✅ Нужно сравнить 2-3 варианта │ │
│ │ ✅ Эксперимент длится долго (недели) │ │
│ │ ✅ Нет ограничений по времени │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • Тестирование нового дизайна страницы │ │
│ │ • Сравнение двух алгоритмов рекомендаций │ │
│ │ • Тестирование ценовой стратегии │ │
│ │ │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Используйте MAB, когда: │ │
│ │ │ │
│ │ ✅ Важна скорость оптимизации │ │
│ │ ✅ Нужна непрерывная оптимизация │ │
│ │ ✅ Потери конверсии критичны │ │
│ │ ✅ Много вариантов для тестирования (>3) │ │
│ │ ✅ Эксперимент работает постоянно │ │
│ │ ✅ Нет необходимости в строгой статистике │ │
│ │ │ │
│ │ Примеры: │ │
│ │ • Оптимизация CTR баннеров │ │
│ │ • Персонализация контента │ │
│ │ • Оптимизация ставок в рекламе │ │
│ │ • A/B/n тесты с большим числом вариантов │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Гибридный подход: A/B + MAB
package experiment
// HybridExperiment комбинирует A/B тест и MAB
type HybridExperiment struct {
// Фаза 1: A/B тест (первые N пользователей)
// Собираем базовую статистику с фиксированным распределением
// Фаза 2: MAB (после N пользователей)
// Переключаемся на Thompson Sampling для оптимизации
}
// GetVariant гибридная логика
func (s *ExperimentService) GetVariantHybrid(
ctx context.Context,
experimentID string,
userID string,
) (string, error) {
exp, err := s.store.Get(ctx, experimentID)
if err != nil {
return "", err
}
// Считаем общее количество участников
totalParticipants := s.getParticipantCount(experimentID)
if totalParticipants < exp.Config.ABPhaseMinSamples {
// Фаза 1: Классический A/B тест
return s.allocator.AllocateFixed(exp, userID)
}
// Фаза 2: Multi-Armed Bandit
return s.allocateBandit(exp, userID)
}
7. Сравнительная таблица
┌─────────────────────────────────────────────────────────────────────────────┐
│ Сравнение подходов │
│ │
│ ┌──────────────────┬──────────────────┬──────────────────┐ │
│ │ │ A/B тест │ MAB │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Подход │ Frequentist │ Bayesian │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Распределение │ Фиксированное │ Адаптивное │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Время до результата │ 2-4 недели │ Непрерывно │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Потери конверсии │ Высокие │ Минимальные │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Стат. строгость │ Высокая │ Средняя │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Сложность │ Низкая │ Высокая │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Множественные │ Сложно │ Легко │ │
│ │ варианты │ │ │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Интерпретируемость │ Высокая │ Низкая │ │
│ └──────────────────┴──────────────────┴──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
8. Рекомендации по литературе
┌─────────────────────────────────────────────────────────────────────────────┐
│ Рекомендуемая литература │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📚 "Trustworthy Online Controlled Experiments" │ │
│ │ Авторы: Ron Kohavi, Diane Tang, Ya Xu │ │
│ │ Описание: Библия A/B тестирования от Microsoft/Google │ │
│ │ │ │
│ │ 📚 "Bandit Algorithms for Website Optimization" │ │
│ │ Автор: John Myles White │ │
│ │ Описание: Практическое руководство по MAB │ │
│ │ │ │
│ │ 📚 "Statistical Methods in Online A/B Testing" │ │
│ │ Автор: Georgi Georgiev │ │
│ │ Описание: Глубокое погружение в статистику A/B тестов │ │
│ │ │ │
│ │ 📄 "A Tutorial on Thompson Sampling" │ │
│ │ Авторы: Russo, Van Roy, Kazerouni, Osband, Wen │ │
│ │ Описание: Академический обзор Thompson Sampling │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Да, Multi-Armed Bandит — важный подход, который стоит рассмотреть как альтернативу или дополнение к A/B тестам.
| Критерий | A/B тест | MAB |
|---|---|---|
| Подход | Frequentist | Bayesian |
| Распределение | Фиксированное | Адаптивное |
| Потери | Высокие | Минимальные |
| Сложность | Низкая | Высокая |
| Статистика | p-value, CI | Credible intervals |
Рекомендация: Используйте гибридный подход — A/B тест для начальной фазы сбора данных, затем переключение на Thompson Sampling для непрерывной оптимизации.
Вопрос 41. Можно ли применить паттерн BFF (Backend for Frontend) в этой системе?
Таймкод: 01:27:23
Ответ собеседника: Правильный. BFF применим, особенно для веба с server-driven UI. Можно создать сервис, который получает базовое отображение, применяет эксперименты и отдаёт модифицированный результат клиенту. Для мобильных приложений сейчас тоже движутся к такому подходу для некоторых экранов.
Правильный ответ:
Да, паттерн BFF (Backend for Frontend) отлично применим к системе A/B тестирования и может значительно упростить управление экспериментами на стороне клиента. Разберём архитектуру и реализацию.
1. Что такое BFF в контексте A/B тестирования
┌─────────────────────────────────────────────────────────────────────────────┐
│ Паттерн BFF для A/B тестирования │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Без BFF: │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Web │──► Запрос к API ──► Получение данных │ │
│ │ │ Client │◄── Ответ с флагами ──► Логика экспериментов │ │
│ │ └─────────┘ (клиент сам решает, что показать) │ │
│ │ │ │
│ │ Проблемы: │ │
│ │ ❌ Сложная логика на клиенте │ │
│ │ ❌ Дублирование кода между платформами │ │
│ │ ❌ Медленные итерации (нужен релиз для изменения UI) │ │
│ │ ❌ Нет единой точки управления экспериментами │ │
│ │ │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ С BFF: │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Web │────►│ Web │────►│Experiment│────►│ Core │ │ │
│ │ │ Client │◄────│ BFF │◄────│ Service │◄────│ Services│ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────┐ │ │
│ │ │ MOBILE │ │ │
│ │ │ BFF │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Преимущества: │ │
│ │ ✅ Централизованная логика экспериментов │ │
│ │ ✅ Быстрые итерации без релиза клиента │ │
│ │ ✅ Оптимизация под каждую платформу │ │
│ │ ✅ Упрощённые клиенты │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Архитектура BFF для системы экспериментов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Архитектура BFF для A/B тестирования │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ API Gateway │ │ │
│ │ └────────┬────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┼────────────────────┐ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Web BFF │ │ Mobile BFF │ │ Partner BFF │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ • HTML/JSON │ │ • JSON │ │ • JSON │ │ │
│ │ │ • SSR │ │ • Protobuf │ │ • GraphQL │ │ │
│ │ │ • Server-driven │ │ • Compact │ │ • Custom schema │ │ │
│ │ │ UI │ │ responses │ │ │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────────┼────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Experiment Service │ │ │
│ │ │ │ │ │
│ │ │ • Определение варианта для пользователя │ │ │
│ │ │ • Получение конфигурации эксперимента │ │ │
│ │ │ • Применение изменений к ответу │ │ │
│ │ │ • Трекинг событий │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Core Services │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ │ │ Product │ │ User │ │ Payment │ │ Content │ │ │ │
│ │ │ │ Service │ │ Service │ │ Service │ │ Service │ │ │ │
│ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Реализация Web BFF на Go
package bff
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"experiment-system/internal/experiment"
"experiment-system/internal/models"
)
// WebBFF — Backend for Frontend для веб-клиентов
type WebBFF struct {
experimentClient *experiment.Client
productService ProductServiceClient
userService UserServiceClient
templateEngine *TemplateEngine
}
// PageResponse структура ответа с учётом экспериментов
type PageResponse struct {
Page string `json:"page"`
Variant string `json:"variant,omitempty"`
Components []Component `json:"components"`
Metadata map[string]interface{} `json:"metadata"`
}
// Component UI-компонент с вариациями
type Component struct {
ID string `json:"id"`
Type string `json:"type"`
Props map[string]interface{} `json:"props"`
Children []Component `json:"children,omitempty"`
}
// HandleProductPage обработчик страницы продукта
func (b *WebBFF) HandleProductPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Получаем данные о пользователе
userID := getUserIDFromRequest(r)
// 2. Получаем базовые данные о продукте
productID := r.URL.Query().Get("product_id")
product, err := b.productService.GetProduct(ctx, productID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 3. Определяем эксперименты для этого экрана
experiments := b.getExperimentsForPage("product_page")
// 4. Применяем эксперименты к ответу
response := &PageResponse{
Page: "product_page",
Metadata: make(map[string]interface{}),
}
// Базовая структура страницы
components := []Component{
{ID: "header", Type: "header", Props: map[string]interface{}{}},
{ID: "product_info", Type: "product_info", Props: map[string]interface{}{
"product": product,
}},
{ID: "add_to_cart", Type: "button", Props: map[string]interface{}{
"text": "Add to Cart",
"style": "primary",
}},
{ID: "recommendations", Type: "carousel", Props: map[string]interface{}{}},
}
// 5. Применяем вариации экспериментов
for _, exp := range experiments {
variant, err := b.experimentClient.GetVariant(ctx, exp.ID, userID)
if err != nil {
continue
}
response.Variant = variant
// Применяем изменения в зависимости от варианта
components = b.applyExperimentVariant(exp, variant, components)
// Добавляем метаданные для трекинга
response.Metadata["experiment_"+exp.ID] = variant
}
response.Components = components
// 6. Отправляем ответ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// applyExperimentVariant применяет вариацию эксперимента к компонентам
func (b *WebBFF) applyExperimentVariant(
exp models.Experiment,
variant string,
components []Component,
) []Component {
switch exp.ID {
case "exp_button_color":
// Эксперимент с цветом кнопки
for i, comp := range components {
if comp.ID == "add_to_cart" {
switch variant {
case "treatment_green":
components[i].Props["style"] = "success"
components[i].Props["color"] = "#28a745"
case "treatment_red":
components[i].Props["style"] = "danger"
components[i].Props["color"] = "#dc3545"
}
}
}
case "exp_button_text":
// Эксперимент с текстом кнопки
for i, comp := range components {
if comp.ID == "add_to_cart" {
switch variant {
case "treatment_urgency":
components[i].Props["text"] = "Buy Now - Limited Time!"
case "treatment_social_proof":
components[i].Props["text"] = "Join 10,000+ Buyers"
}
}
}
case "exp_recommendations_layout":
// Эксперимент с layout рекомендаций
for i, comp := range components {
if comp.ID == "recommendations" {
switch variant {
case "treatment_grid":
components[i].Props["layout"] = "grid"
components[i].Props["columns"] = 4
case "treatment_list":
components[i].Props["layout"] = "list"
components[i].Props["columns"] = 1
}
}
}
}
return components
}
// getExperimentsForPage возвращает эксперименты для страницы
func (b *WebBFF) getExperimentsForPage(page string) []models.Experiment {
// В реальности — запрос к Experiment Service
return []models.Experiment{
{ID: "exp_button_color", Page: page, Status: "active"},
{ID: "exp_button_text", Page: page, Status: "active"},
{ID: "exp_recommendations_layout", Page: page, Status: "active"},
}
}
4. Server-Driven UI с BFF
package bff
// ServerDrivenUI BFF для server-driven UI
type ServerDrivenUI struct {
experimentClient *experiment.Client
layoutService LayoutServiceClient
}
// GetScreen возвращает полную структуру экрана
func (s *ServerDrivenUI) GetScreen(
ctx context.Context,
screenID string,
userID string,
) (*ScreenResponse, error) {
// 1. Получаем базовый layout
layout, err := s.layoutService.GetLayout(ctx, screenID)
if err != nil {
return nil, err
}
// 2. Получаем активные эксперименты для экрана
experiments, err := s.experimentClient.GetActiveExperiments(ctx, screenID)
if err != nil {
return nil, err
}
// 3. Применяем эксперименты
response := &ScreenResponse{
ScreenID: screenID,
Layout: layout,
Tracks: make(map[string]string),
}
for _, exp := range experiments {
variant, err := s.experimentClient.GetVariant(ctx, exp.ID, userID)
if err != nil {
continue
}
// Применяем модификации к layout
response.Layout = s.applyVariant(response.Layout, exp, variant)
// Добавляем трекинг
response.Tracks[exp.ID] = variant
}
return response, nil
}
// ScreenResponse ответ с полной структурой экрана
type ScreenResponse struct {
ScreenID string `json:"screen_id"`
Layout *Layout `json:"layout"`
Tracks map[string]string `json:"tracks"` // experimentID -> variant
}
// Layout структура экрана
type Layout struct {
Version string `json:"version"`
Sections []Section `json:"sections"`
Actions []Action `json:"actions"`
}
// Section секция экрана
type Section struct {
ID string `json:"id"`
Type string `json:"type"`
Props map[string]interface{} `json:"props"`
Visible bool `json:"visible"`
Children []Section `json:"children,omitempty"`
}
// Action действие (кнопка, ссылка)
type Action struct {
ID string `json:"id"`
Type string `json:"type"`
Props map[string]interface{} `json:"props"`
Events []Event `json:"events"`
}
// Event событие для трекинга
type Event struct {
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
}
// applyVariant применяет вариацию к layout
func (s *ServerDrivenUI) applyVariant(
layout *Layout,
exp models.Experiment,
variant string,
) *Layout {
switch exp.ID {
case "exp_checkout_flow":
if variant == "treatment_simplified" {
// Упрощаем checkout — убираем лишние секции
layout.Sections = filterSections(layout.Sections, func(s Section) bool {
return s.ID != "promo_code" && s.ID != "gift_options"
})
}
case "exp_homepage_hero":
for i, section := range layout.Sections {
if section.ID == "hero_banner" {
layout.Sections[i].Props["image_url"] = getHeroImage(exp.ID, variant)
layout.Sections[i].Props["cta_text"] = getHeroCTA(exp.ID, variant)
}
}
}
return layout
}
5. Mobile BFF с оптимизированными ответами
package bff
// MobileBFF оптимизированный BFF для мобильных клиентов
type MobileBFF struct {
experimentClient *experiment.Client
contentService ContentServiceClient
}
// MobileResponse компактный ответ для мобильных
type MobileResponse struct {
V string `json:"v"` // version
D map[string]interface{} `json:"d"` // data
E map[string]string `json:"e"` // experiments
TTL int `json:"ttl"` // cache TTL
}
// GetHomeScreen возвращает данные для главного экрана
func (m *MobileBFF) GetHomeScreen(
ctx context.Context,
userID string,
appVersion string,
) (*MobileResponse, error) {
// 1. Получаем контент
content, err := m.contentService.GetHomeContent(ctx, userID)
if err != nil {
return nil, err
}
// 2. Определяем эксперименты
experiments := map[string]string{}
// Эксперимент с категориями (только для новых версий приложения)
if versionGTE(appVersion, "2.5.0") {
variant, _ := m.experimentClient.GetVariant(ctx, "exp_new_categories", userID)
experiments["exp_new_categories"] = variant
if variant == "treatment" {
content.Categories = getNewCategoriesLayout()
}
}
// Эксперимент с поиском
variant, _ := m.experimentClient.GetVariant(ctx, "exp_search_placement", userID)
experiments["exp_search_placement"] = variant
// 3. Формируем компактный ответ
response := &MobileResponse{
V: "2.5",
D: content.ToCompactMap(),
E: experiments,
TTL: 300, // 5 минут кеша
}
return response, nil
}
6. Преимущества BFF для A/B тестирования
┌─────────────────────────────────────────────────────────────────────────────┐
│ Преимущества BFF для A/B тестирования │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. Централизованное управление экспериментами │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Experiment Service (единственный источник правды) │ │ │
│ │ │ │ │ │ │
│ │ │ ┌────┼────┬────────┐ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ▼ ▼ ▼ ▼ │ │ │
│ │ │ Web Mobile Partner Internal │ │ │
│ │ │ BFF BFF BFF BFF │ │ │
│ │ │ │ │ │
│ │ │ Все BFF используют один Experiment Service │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 2. Быстрые итерации │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Без BFF: │ │ │
│ │ │ • Изменение UI → Релиз приложения → App Store Review │ │ │
│ │ │ • Время: 1-3 дня │ │ │
│ │ │ │ │ │
│ │ │ С BFF: │ │ │
│ │ │ • Изменение UI → Деплой BFF → Мгновенно │ │ │
│ │ │ • Время: 1-30 минут │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 3. Оптимизация под платформу │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Web BFF: │ │ │
│ │ │ • Полный HTML/JSON │ │ │
│ │ │ • Server-side rendering │ │ │
│ │ │ • SEO-оптимизация │ │ │
│ │ │ │ │ │
│ │ │ Mobile BFF: │ │ │
│ │ │ • Компактный JSON/Protobuf │ │ │
│ │ │ • Минимум данных │ │ │
│ │ │ • Оптимизация трафика │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 4. Упрощённые клиенты │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Клиент НЕ должен: │ │ │
│ │ │ • Знать о всех экспериментах │ │ │
│ │ │ • Реализовывать логику вариаций │ │ │
│ │ │ • Хранить feature flags │ │ │
│ │ │ │ │ │
│ │ │ Клиент просто: │ │ │
│ │ │ • Делает запрос к BFF │ │ │
│ │ │ • Получает готовую структуру │ │ │
│ │ │ • Рендерит то, что пришло │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. Трекинг событий через BFF
package bff
// TrackEvent обработчик трекинг-событий
func (b *WebBFF) TrackEvent(w http.ResponseWriter, r *http.Request) {
var event TrackEventRequest
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
userID := getUserIDFromRequest(r)
// Обогащаем событие информацией об экспериментах
enrichedEvent := &EnrichedEvent{
UserID: userID,
EventType: event.EventType,
Page: event.Page,
ElementID: event.ElementID,
Timestamp: time.Now(),
Experiments: b.getActiveExperimentsForUser(userID, event.Page),
Properties: event.Properties,
}
// Отправляем в аналитику
b.analyticsClient.Track(ctx, enrichedEvent)
w.WriteHeader(http.StatusOK)
}
// EnrichedEvent событие с информацией об экспериментах
type EnrichedEvent struct {
UserID string `json:"user_id"`
EventType string `json:"event_type"`
Page string `json:"page"`
ElementID string `json:"element_id"`
Timestamp time.Time `json:"timestamp"`
Experiments map[string]string `json:"experiments"` // experimentID -> variant
Properties map[string]interface{} `json:"properties"`
}
// getActiveExperimentsForUser возвращает активные эксперименты для пользователя
func (b *WebBFF) getActiveExperimentsForUser(userID string, page string) map[string]string {
experiments := make(map[string]string)
// Запрашиваем все активные эксперименты для страницы
activeExps := b.getExperimentsForPage(page)
for _, exp := range activeExps {
variant, err := b.experimentClient.GetVariant(context.Background(), exp.ID, userID)
if err == nil {
experiments[exp.ID] = variant
}
}
return experiments
}
8. Кеширование в BFF
package bff
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// CachedBFF BFF с кешированием
type CachedBFF struct {
bff *WebBFF
redis *redis.Client
ttl time.Duration
}
// GetPageWithCache получает страницу с кешированием
func (c *CachedBFF) GetPageWithCache(
ctx context.Context,
pageID string,
userID string,
) (*PageResponse, error) {
// Ключ кеша: страница + набор экспериментов (не userID!)
// Это позволяет кешировать для групп пользователей с одинаковыми вариантами
experimentHash := c.getExperimentHash(pageID, userID)
cacheKey := fmt.Sprintf("bff:%s:%s", pageID, experimentHash)
// Пробуем получить из кеша
cached, err := c.redis.Get(ctx, cacheKey).Result()
if err == nil {
var response PageResponse
if err := json.Unmarshal([]byte(cached), &response); err == nil {
return &response, nil
}
}
// Кеш пуст — генерируем ответ
response, err := c.bff.GeneratePage(ctx, pageID, userID)
if err != nil {
return nil, err
}
// Сохраняем в кеш
data, _ := json.Marshal(response)
c.redis.Set(ctx, cacheKey, data, c.ttl)
return response, nil
}
// getExperimentHash возвращает хеш вариантов экспериментов для пользователя
func (c *CachedBFF) getExperimentHash(pageID string, userID string) string {
experiments := c.bff.getExperimentsForPage(pageID)
// Собираем варианты в строку
var variants string
for _, exp := range experiments {
variant, _ := c.bff.experimentClient.GetVariant(context.Background(), exp.ID, userID)
variants += exp.ID + ":" + variant + ";"
}
// Хешируем
return fmt.Sprintf("%x", md5.Sum([]byte(variants)))
}
Краткий ответ: Да, BFF отлично применим к системе A/B тестирования.
| Аспект | Без BFF | С BFF |
|---|---|---|
| Управление экспериментами | Дублирование в клиентах | Централизованное |
| Скорость итераций | 1-3 дня (релиз) | 1-30 минут (деплой BFF) |
| Сложность клиентов | Высокая | Низкая |
| Оптимизация под платформу | Сложно | Легко (отдельный BFF) |
Ключевые преимущества BFF для A/B тестирования:
- Централизованное управление экспериментами
- Быстрые итерации без релиза клиентов
- Server-driven UI для максимальной гибкости
- Оптимизация ответа под каждую платформу
- Упрощённые клиенты, которые только рендерят то, что пришло с сервера
Вопрос 42. Всегда ли интервью проводится в таком формате и дополняется ли вопросами по теории?
Таймкод: 01:30:52
Ответ собеседника: Правильный. System Design проводится для инженеров уровня Senior и выше, тимлидов и архитекторов. Для архитекторов есть дополнительная секция про инженерные практики, архитектурные процессы и technical leadership. Также есть секция для технических руководителей с фокусом на менеджерские подходы.
Правильный ответ:
Формат интервью зависит от уровня позиции и компании. Разберём типичные варианты.
1. Уровни интервью и их фокус
┌─────────────────────────────────────────────────────────────────────────────┐
│ Уровни интервью и их фокус │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Junior / Middle │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Алгоритмы и структуры данных (LeetCode-style) │ │ │
│ │ │ • Теория языка (Go: goroutines, channels, interfaces) │ │ │
│ │ │ • Базы данных (SQL, индексы, нормализация) │ │ │
│ │ │ • Сети (HTTP, TCP/IP, REST) │ │ │
│ │ │ • Простые задачи на проектирование │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Senior │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • System Design (проектирование распределённых систем) │ │ │
│ │ │ • Углублённая теория (консенсус, шардирование, кеширование)│ │ │
│ │ │ • Code Review и рефакторинг │ │ │
│ │ │ • Производительность и оптимизация │ │ │
│ │ │ • Безопасность │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Staff / Principal │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Сложный System Design (масштабирование на уровне компании)│ │ │
│ │ │ • Архитектурные процессы и стандарты │ │ │
│ │ │ • Technical Leadership │ │ │
│ │ │ • Кросс-командное взаимодействие │ │ │
│ │ │ • Trade-offs и долгосрочные решения │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Engineering Manager / Tech Lead │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Менеджерские навыки (1-on-1, делегирование, найм) │ │ │
│ │ │ • Процессы (Agile, CI/CD, incident management) │ │ │
│ │ │ • Стратегия и roadmap │ │ │
│ │ │ • Коммуникация с бизнесом │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Типичный пайплайн интервью
┌─────────────────────────────────────────────────────────────────────────────┐
│ Типичный пайплайн интервью в крупных компаниях │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Этап 1: Phone Screen (30-45 мин) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Обсуждение опыта и резюме │ │ │
│ │ │ • Базовые вопросы по теории │ │ │
│ │ │ • Простая задача на код (для Senior — средней сложности) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Этап 2: Technical Screen (45-60 мин) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Углублённые вопросы по Go/языку │ │ │
│ │ │ • Задача на код (LeetCode Medium/Hard) │ │ │
│ │ │ • Вопросы по базам данных, сетям │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Этап 3: System Design (45-60 мин) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Проектирование системы (как на этом интервью) │ │ │
│ │ │ • Обсуждение trade-offs │ │ │
│ │ │ • Масштабирование и надёжность │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Этап 4: Behavioral / Leadership (30-45 мин) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Ситуационные вопросы (STAR-метод) │ │ │
│ │ │ • Конфликты и их разрешение │ │ │
│ │ │ • Принятие решений │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Этап 5: Hiring Manager (30-45 мин) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Культура и ценности │ │ │
│ │ │ • Карьерные цели │ │ │
│ │ │ • Ожидания от роли │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Теоретические вопросы, которые дополняют System Design
┌─────────────────────────────────────────────────────────────────────────────┐
│ Типичные теоретические вопросы для Senior Go │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Go Runtime и внутренности │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Как работает scheduler (GMP-модель)? │ │ │
│ │ │ • Как работает garbage collector? │ │ │
│ │ │ • Что такое escape analysis? │ │ │
│ │ │ • Как устроены каналы внутри? │ │ │
│ │ │ • Разница между slice и array? │ │ │
│ │ │ • Как работает interface{} внутри? │ │ │
│ │ │ • Что такое context и зачем он нужен? │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Базы данных │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Уровни изоляции транзакций │ │ │
│ │ │ • CAP-теорема и PACELC │ │ │
│ │ │ • Индексы (B-tree, LSM-tree, хеш-индексы) │ │ │
│ │ │ • Нормализация vs денормализация │ │ │
│ │ │ • Партиционирование и шардирование │ │ │
│ │ │ • Репликация (master-slave, master-master) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Распределённые системы │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Консенсус (Raft, Paxos) │ │ │
│ │ │ • Идемпотентность │ │ │
│ │ │ • Eventual consistency │ │ │
│ │ │ • Распределённые транзакции (2PC, Saga) │ │ │
│ │ │ • Service discovery │ │ │
│ │ │ • Circuit breaker, retry, backoff │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Сети │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • HTTP/1.1 vs HTTP/2 vs HTTP/3 │ │ │
│ │ │ • TCP vs UDP │ │ │
│ │ │ • TLS handshake │ │ │
│ │ │ • gRPC vs REST vs GraphQL │ │ │
│ │ │ • WebSocket │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Безопасность │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • OAuth 2.0 и OIDC │ │ │
│ │ │ • JWT vs сессии │ │ │
│ │ │ • SQL-инъекции, XSS, CSRF │ │ │
│ │ │ • Rate limiting │ │ │
│ │ │ • Шифрование (symmetric, asymmetric) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Как подготовиться к полному циклу интервью
┌─────────────────────────────────────────────────────────────────────────────┐
│ Чек-лист подготовки │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Для System Design: │ │
│ │ ✅ Практиковать проектирование 2-3 раза в неделю │ │
│ │ ✅ Изучить классические системы (URL shortener, chat, feed) │ │
│ │ ✅ Уметь считать capacity estimation │ │
│ │ ✅ Знать основные паттерны (CQRS, Event Sourcing, Saga) │ │
│ │ ✅ Уметь обсуждать trade-offs │ │
│ │ │ │
│ │ Для теоретических вопросов: │ │
│ │ ✅ Go: runtime, scheduler, GC, примитивы синхронизации │ │
│ │ ✅ БД: индексы, транзакции, репликация, шардирование │ │
│ │ ✅ Сети: HTTP, TCP, gRPC, REST │ │
│ │ ✅ Распределённые системы: CAP, консенсус, идемпотентность │ │
│ │ │ │
│ │ Для coding: │ │
│ │ ✅ LeetCode Medium/Hard (100-200 задач) │ │
│ │ ✅ Паттерны: two pointers, sliding window, BFS/DFS, DP │ │
│ │ ✅ Структуры данных: hash map, heap, trie, segment tree │ │
│ │ │ │
│ │ Для behavioral: │ │
│ │ ✅ Подготовить 5-7 историй по STAR-методу │ │
│ │ ✅ Конфликты, провалы, успехи, лидерство │ │
│ │ ✅ Вопросы для интервьюера │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Рекомендуемые ресурсы
┌─────────────────────────────────────────────────────────────────────────────┐
│ Рекомендуемые ресурсы для подготовки │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ System Design: │ │
│ │ 📚 "Designing Data-Intensive Applications" — Martin Kleppmann │ │
│ │ 📚 "System Design Interview" — Alex Xu │ │
│ │ 🎥 YouTube: ByteByteGo, Gaurav Sen │ │
│ │ 🌐 github.com/donnemartin/system-design-primer │ │
│ │ │ │
│ │ Go: │ │
│ │ 📚 "The Go Programming Language" — Donovan & Kernighan │ │
│ │ 📚 "Concurrency in Go" — Katherine Cox-Buday │ │
│ │ 🌐 go.dev/tour │ │
│ │ 🌐 github.com/golang/go/wiki/Performance │ │
│ │ │ │
│ │ Базы данных: │ │
│ │ 📚 "Database Internals" — Alex Petrov │ │
│ │ 📚 "SQL Performance Explained" — Markus Winand │ │
│ │ │ │
│ │ Распределённые системы: │ │
│ │ 📚 "Distributed Systems" — van Steen & Tanenbaum │ │
│ │ 📚 "Understanding Distributed Systems" — Roberto Vitillo │ │
│ │ │ │
│ │ Coding: │ │
│ │ 🌐 leetcode.com │ │
│ │ 🌐 neetcode.io │ │
│ │ 📚 "Cracking the Coding Interview" — Gayle McDowell │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Нет, формат интервью зависит от уровня позиции.
| Уровень | Фокус | Теория | System Design | Coding |
|---|---|---|---|---|
| Junior/Middle | Алгоритмы, базовая теория | Много | Минимум | Много |
| Senior | System Design + углублённая теория | Средний | Основной | Средний |
| Staff/Principal | Архитектура, leadership | Средний | Сложный | Минимум |
| EM/Tech Lead | Менеджерские навыки | Минимум | Средний | Минимум |
Рекомендация: Для Senior позиции типичный пайплайн включает:
- Phone Screen (теория + простой код)
- Technical Screen (углублённая теория + задача)
- System Design (как на этом интервью)
- Behavioral/Leadership
- Hiring Manager
Готовиться нужно ко всем аспектам, но пропорции зависят от уровня и компании.
Вопрос 43. Входит ли в оценку анализ трафика и железа исходя из нефункциональных требований?
Таймкод: 01:33:54
Ответ собеседника: Правильный. Есть критерий про проработку нагрузки. Некоторые задачи очень большие и не всегда успевают обсудить все пункты. Но в целом анализ трафика и железа является частью оценки.
Правильный ответ:
Да, анализ трафика и ресурсов (capacity estimation) — важная часть оценки на System Design интервью, особенно для позиций Senior и выше.
1. Что входит в анализ нагрузки
┌─────────────────────────────────────────────────────────────────────────────┐
│ Компоненты анализа нагрузки │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. Traffic Estimation (Оценка трафика) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • DAU/MAU (daily/monthly active users) │ │ │
│ │ │ • QPS (queries per second) │ │ │
│ │ │ • Read/Write ratio │ │ │
│ │ │ • Peak vs average traffic │ │ │
│ │ │ • Traffic growth rate │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 2. Storage Estimation (Оценка хранилища) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Data size per entity │ │ │
│ │ │ • Total data volume │ │ │
│ │ │ • Growth rate │ │ │
│ │ │ • Retention policy │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 3. Bandwidth Estimation (Оценка пропускной способности) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Inbound traffic │ │ │
│ │ │ • Outbound traffic │ │ │
│ │ │ • Internal service-to-service traffic │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 4. Hardware Estimation (Оценка ресурсов) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • CPU cores needed │ │ │
│ │ │ • RAM needed │ │ │
│ │ │ • Disk I/O │ │ │
│ │ │ • Network I/O │ │ │
│ │ │ • Number of instances │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Пример расчёта для системы A/B тестирования
┌─────────────────────────────────────────────────────────────────────────────┐
│ Пример: Capacity Estimation для A/B системы │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Дано: │ │
│ │ • 100M DAU (daily active users) │ │
│ │ • Каждый пользователь делает 10 запросов в день │ │
│ │ • Пиковый коэффициент: 3x от среднего │ │
│ │ • Read/Write ratio: 95/5 │ │
│ │ │ │
│ │ ───────────────────────────────────────────────────────────────── │ │
│ │ │ │
│ │ 1. QPS Calculation: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Total requests per day: 100M × 10 = 1B requests │ │ │
│ │ │ Average QPS: 1B / 86400 ≈ 11,600 req/s │ │ │
│ │ │ Peak QPS: 11,600 × 3 = 34,800 req/s │ │ │
│ │ │ │ │ │
│ │ │ Read QPS: 34,800 × 0.95 = 33,060 req/s │ │ │
│ │ │ Write QPS: 34,800 × 0.05 = 1,740 req/s │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 2. Storage Estimation: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Experiment config: ~1 KB × 1000 experiments = 1 MB │ │ │
│ │ │ User assignments: ~100 bytes × 100M users = 10 GB │ │ │
│ │ │ Events: ~500 bytes × 1B events/day = 500 GB/day │ │ │
│ │ │ │ │ │
│ │ │ With 30-day retention: 500 GB × 30 = 15 TB │ │ │
│ │ │ With compression (5:1): 15 TB / 5 = 3 TB │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 3. Bandwidth Estimation: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Response size: ~2 KB average │ │ │
│ │ │ Outbound: 33,060 × 2 KB = 66 MB/s ≈ 530 Mbps │ │ │
│ │ │ │ │ │
│ │ │ Internal (events): 1,740 × 500 bytes = 0.87 MB/s │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 4. Hardware Estimation: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ API Service (stateless): │ │ │
│ │ │ • ~500 req/s per instance │ │ │
│ │ │ • Instances: 34,800 / 500 ≈ 70 instances │ │ │
│ │ │ • With redundancy: 70 × 1.5 ≈ 105 instances │ │ │
│ │ │ │ │ │
│ │ │ Database (reads): │ │ │
│ │ │ • ~5,000 reads/s per node │ │ │
│ │ │ • Nodes: 33,060 / 5,000 ≈ 7 nodes │ │ │
│ │ │ │ │ │
│ │ │ Database (writes): │ │ │
│ │ │ • ~2,000 writes/s per node │ │ │
│ │ │ • Nodes: 1,740 / 2,000 ≈ 1 node │ │ │
│ │ │ │ │ │
│ │ │ Cache (Redis): │ │ │
│ │ │ • ~10 GB for hot data │ │ │
│ │ │ • Cluster: 3 nodes × 32 GB RAM │ │ │
│ │ │ │ │ │
│ │ │ Event Processing: │ │ │
│ │ │ • Kafka: 3 brokers │ │ │
│ │ │ • Consumers: 10-20 instances │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Как структурировать обсуждение нагрузки на интервью
┌─────────────────────────────────────────────────────────────────────────────┐
│ Структура обсуждения нагрузки на интервью │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. Уточнить требования │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • "Какой ожидаемый DAU?" │ │ │
│ │ │ • "Какой read/write ratio?" │ │ │
│ │ │ • "Есть ли пиковые периоды?" │ │ │
│ │ │ • "Какой retention period для данных?" │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 2. Сделать back-of-the-envelope расчёты │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • QPS (average и peak) │ │ │
│ │ │ • Storage (per day, total with retention) │ │ │
│ │ │ • Bandwidth (inbound, outbound) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 3. Определить узкие места │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Где будет bottleneck? │ │ │
│ │ │ • CPU-bound или I/O-bound? │ │ │
│ │ │ • Нужно ли шардирование? │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 4. Предложить решения │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Горизонтальное масштабирование │ │ │
│ │ │ • Кеширование │ │ │
│ │ │ • Шардирование │ │ │
│ │ │ • CDN для статики │ │ │
│ │ │ • Async processing для writes │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Полезные числа для быстрых расчётов
┌─────────────────────────────────────────────────────────────────────────────┐
│ Полезные числа для back-of-the-envelope расчётов │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Время │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 1 day = 86,400 seconds │ │ │
│ │ │ 1 month ≈ 2.6M seconds │ │ │
│ │ │ 1 year ≈ 31.5M seconds │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Степени двойки │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ 2^10 ≈ 10^3 (1 K) │ │ │
│ │ │ 2^20 ≈ 10^6 (1 M) │ │ │
│ │ │ 2^30 ≈ 10^9 (1 G) │ │ │
│ │ │ 2^40 ≈ 10^12 (1 T) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Производительность (примерные) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Memory read: ~100 ns │ │ │
│ │ │ SSD read: ~100 μs │ │ │
│ │ │ HDD seek: ~10 ms │ │ │
│ │ │ Network round-trip (same DC): ~0.5 ms │ │ │
│ │ │ Network round-trip (cross-region): ~100 ms │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Типичная нагрузка на сервер │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Stateless API: ~500-1000 req/s per instance │ │ │
│ │ │ Database reads: ~5,000-10,000 per node │ │ │
│ │ │ Database writes: ~1,000-2,000 per node │ │ │
│ │ │ Redis: ~100,000 ops/s per node │ │ │
│ │ │ Kafka: ~100 MB/s per broker │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Критерии оценки нагрузки на интервью
┌─────────────────────────────────────────────────────────────────────────────┐
│ Что оценивается в разделе "нагрузка" │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ✅ Понимает ли кандидат: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Как оценить QPS из DAU │ │ │
│ │ │ • Как учесть пиковые нагрузки │ │ │
│ │ │ • Как рассчитать storage requirements │ │ │
│ │ │ • Как определить количество инстансов │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ✅ Может ли кандидат: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Определить узкие места системы │ │ │
│ │ │ • Предложить решения для масштабирования │ │ │
│ │ │ • Обосновать выбор технологий на основе нагрузки │ │ │
│ │ │ • Учесть cost/performance trade-offs │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ✅ Знает ли кандидат: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ • Типичные числа производительности │ │ │
│ │ │ • Как работает горизонтальное масштабирование │ │ │
│ │ │ • Когда нужен кеш, шардирование, CDN │ │ │
│ │ │ • Как спроектировать систему под 10x рост │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Краткий ответ: Да, анализ трафика и ресурсов — важная часть оценки.
| Аспект | Что оценивается |
|---|---|
| Traffic estimation | QPS, read/write ratio, peak vs average |
| Storage estimation | Data volume, growth rate, retention |
| Bandwidth | Inbound, outbound, internal traffic |
| Hardware | CPU, RAM, disk, number of instances |
| Bottleneck analysis | Где узкое место, как масштабировать |
Советы для интервью:
- Начинайте с уточняющих вопросов о нагрузке
- Делайте расчёты вслух — показывайте ход мысли
- Учитывайте пиковые нагрузки (2-3x от среднего)
- Обсуждайте не только текущую нагрузку, но и рост (10x)
- Предлагайте решения для узких мест
Даже если не успеваете обсудить все аспекты, важно показать, что вы понимаете важность анализа нагрузки и умеете его проводить.
