Перейти к основному содержимому

Собеседование по System Design с Head of Engineering из американского FinTech

· 23 мин. чтения

Сегодня мы разберем процесс проектирования системы чат-поддержки в реальном времени, включая архитектурные решения, выбор технологий и обработку нефункциональных требований. В ходе собеседования кандидат продемонстрировал способность структурировать сложную задачу, выделять ключевые компоненты и обосновывать свои решения, хотя и столкнулся с трудностями в вопросах мультитенантности и изоляции данных. Мы также рассмотрим типичные ошибки и лучшие практики, которые помогут лучше подготовиться к подобным интервью.

Вопрос 1. Расскажите о своём опыте работы.

Таймкод: 00:00:00

Ответ собеседника: Правильный. Кандидат работает в Озоне 5 лет, начинал стажёром, сейчас занимает позицию синьора. Разрабатывает рекламную платформу — Performance кабинет рекламодателя, в частности продукт медийной рекламы.

Правильный ответ:

Отличный пример карьерного роста — путь от стажёра до синьора за 5 лет говорит о высокой вовлечённости, способности быстро обучаться и брать на себя всё более сложные задачи.

Ключевые аспекты, которые стоит раскрыть в таком ответе:

1. Структурированность рассказа

Рекомендуется рассказывать по схеме: компания → роль → период → ключевые достижения → стек технологий. Это позволяет интервьюеру быстро оценить глубину и релевантность опыта.

2. Конкретизация достижений

Вместо перечисления обязанностей лучше фокусироваться на результатах. Например:

  • «Спроектировал и внедрил систему ставок в реальном времени, что увеличило выручку от медийной рекламы на X%»
  • «Оптимизировал пайплайн обработки кликов, снизив latency с 200ms до 50ms»
  • «Мигрировал монолитный сервис на микросервисную архитектуру, что позволило командам деплоить независимо»

3. Технический стек

Для Golang-разработчика важно упомянуть:

  • Go-экосистема: используемые фреймворки (gin, echo, fiber), библиотеки (gRPC, protobuf)
  • Базы данных: PostgreSQL, ClickHouse, Redis — с конкретными примерами использования
  • Очереди сообщений: Kafka, NATS, RabbitMQ
  • Инфраструктура: Kubernetes, Docker, CI/CD пайплайны
  • Наблюдаемость: Prometheus, Grafana, Jaeger (distributed tracing)

4. Архитектурные решения

Особенно ценно упоминание:

  • Опыт проектирования высоконагруженных систем (рекламные платформы обычно обрабатывают тысячи RPS)
  • Работа с eventual consistency и распределёнными транзакциями
  • Паттерны: CQRS, Saga, Circuit Breaker, Rate Limiting

5. Soft skills

На позиции синьора важны:

  • Менторинг младших разработчиков
  • Проведение code review
  • Участие в принятии архитектурных решений (ADR)
  • Взаимодействие с продуктовой командой

Пример развёрнутого ответа:

> «Работаю в Ozon пять лет. Начинал стажёром в команде рекламной платформы, прошёл путь через мидла до синьора. Сейчас отвечаю за медийную рекламу в Performance-кабинете рекламодателя. > > Из ключевых достижений: спроектировал систему аукциона баннерных размещений, которая обрабатывает ~50k запросов в секунду. Для этого использовал Go с gRPC для inter-service communication, Redis для кэширования ставок и Kafka для event sourcing. > > Также инициировал переход на OpenTelemetry для трейсинга, что сократило время диагностики инцидентов с часов до минут. Менторю двух разработчиков в команде, провожу архитектурные ревью.»

Вопрос 2. Сколько раз роняли продакшн?

Таймкод: 00:01:11

Ответ собеседника: Правильный. Два раза ронял продакшн, будучи стажёром.

Правильный ответ:

Это хороший честный ответ, который показывает, что кандидат не боится признавать ошибки и учится на них. Для позиции синьора важно не количество инцидентов, а то, как человек из них извлёк уроки.

Как усилить этот ответ:

1. Контекст инцидентов

Стоит кратко описать, что произошло, без излишних деталей:

> «Да, дважды допустил падение продакшна, оба раза на стажёрской позиции. Первый раз — забыл добавить миграцию перед деплоем нового сервиса. Второй раз — не учёл граничный случай при обработке nil-указателя.»

2. Извлечённые уроки

Это самая важная часть — показать, что из инцидентов были сделаны выводы:

  • Внедрил/предложил добавить canary deployments
  • Добавил обязательные интеграционные тесты для критических путей
  • Предложил чек-лист перед деплоем (pre-deploy checklist)
  • Настроил алерты на ключевые метрики

3. Проактивные меры

Можно упомянуть, что делается для предотвращения инцидентов:

> «После тех случаев я стал активно участвовать в построении процессов: внедрил feature flags через unleash, настроил автоматические откаты при превышении error rate, добавил chaos engineering тесты в staging-окружение.»

4. Культура работы с инцидентами

Важно показать зрелый подход:

  • Blameless postmortem — культура, где важно не найти виноватого, а понять корневую причину
  • Runbooks — документация по действиям при инцидентах
  • On-call ротация — участие в дежурствах
  • Error budget — понимание SLA/SLO и баланса между скоростью и надёжностью

Пример усиленного ответа:

> «Два раза, оба на стажёрской позиции. Первый раз — не добавил миграцию, второй — nil pointer dereference. Оба раза были болезненными, но именно они научили меня важным вещам. > > С тех пор я активно участвую в построении надёжных процессов: feature flags, canary deployments, автоматические откаты при error rate > 1%. Сейчас в команде у нас blameless postmortem культура — мы пишем детальные отчёты и превращаем каждый инцидент в улучшение системы.»

Вопрос 3. Какие функциональные требования к системе саппорта (тикетинг-система с мультитенантностью, чат для пользователей, дашборд с аналитикой, NPS)?

Таймкод: 00:02:06

Ответ собеседника: Правильный. Система поддерживает статусы тикетов (открыто/решено), назначение на одного саппортера с возможностью передачи, историю сообщений в тикете, фильтрацию по асайнменту и статусам, аналитику закрытых тикетов по саппортерам, NPS-оценку после закрытия тикета, обработку конфликтов при одновременном редактировании, статусы онлайн/печатает для саппортеров. Тикет создаётся сразу при первом сообщении в чат.

Правильный ответ:

Отличный структурированный ответ, охватывающий основные функциональные блоки. Для полноты можно расширить его следующими аспектами:

Тикетинг-система

Жизненный цикл тикета:

Открыт → В работе → Ожидает ответа клиента → Решен → Закрыт
↓ ↓
Переоткрыт ←←←←←←←←←←←←←←←←┘

Дополнительные статусы и фичи:

  • Приоритеты: низкий, средний, высокий, критический
  • Категории/теги: для классификации обращений (биллинг, техническая поддержка, возвраты)
  • SLA-таймеры: автоматические эскалации при превышении времени ответа
  • Шаблоны ответов: быстрые ответы на типичные вопросы
  • Макросы: последовательности действий (изменить статус + добавить комментарий + уведомить)

Мультитенантность:

  • Изоляция данных между тенантами
  • Кастомизация полей и workflow для каждого тенанта
  • Разграничение доступа на уровне ролей (RBAC)

Чат для пользователей

Функциональность:

  • WebSocket для real-time сообщений
  • Индикаторы: «печатает...», «онлайн/оффлайн», «прочитано»
  • Отправка файлов и изображений
  • Оценка диалога после закрытия
  • Переключение между чатами без потери контекста

Аналитический дашборд

Ключевые метрики:

МетрикаОписание
First Response TimeВремя первого ответа
Average Resolution TimeСреднее время решения
CSAT ScoreУдовлетворённость клиентов
Ticket VolumeКоличество тикетов по периодам
Agent UtilizationЗагрузка операторов

NPS (Net Promoter Score):

  • Сбор оценки через 1-10 после закрытия тикета
  • Сегментация: промоутеры (9-10), нейтралы (7-8), детракторы (0-6)
  • Follow-up опросы для детракторов

Конфликты при одновременном редактировании:

  • Optimistic Locking: версионирование записей
  • Operational Transform: для совместного редактирования
  • Last Write Wins: с уведомлением о перезаписи

Вопрос 4. Как рассчитывается DAU для системы саппорта с 50 000 компаний, 200 тикетов в день на компанию?

Таймкод: 00:06:59

Ответ собеседника: Правильный. DAU рассчитывается как 50 000 компаний × 200 тикетов × 2 пользователя (создатель и менеджер) = ~20 млн DAU. Учитывается, что один пользователь редко создаёт несколько тикетов, поэтому этим можно пренебречь.

Правильный ответ:

В целом подход верный, но расчёт требует уточнения. Давайте разберём более точно.

Уточнение формулы

DAU (Daily Active Users) — это количество уникальных пользователей, а не взаимодействий. Поэтому формула 50 000 × 200 × 2 даёт количество действий, а не пользователей.

Более точный расчёт:

Предположения:

  • В среднем в компании 5 сотрудников работают с саппортом
  • Из них 2 — тикет-создатели, 1 — менеджер, 2 — саппортеры
  • Коэффициент повторных обращений: 30% пользователей создают >1 тикет в день

Расчёт:

Уникальные пользователи в день:
- Клиенты: 50 000 × 2 × 0.7 (уникальные) = 70 000
- Саппортеры: 50 000 × 0.1 (доля активных) = 5 000
- Итого: ~75 000 DAU

Важные метрики для оценки нагрузки:

МетрикаРасчётЗначение
RPS (создание тикетов)10M / 86400~116 RPS
RPS (чтение тикетов)10M × 10 views / 86400~1,160 RPS
Peak RPS× 3-5~580 RPS
WebSocket connections75 000 × 0.3~22,500

Для архитектурных решений важнее считать:

  • RPS на запись — создание и обновление тикетов
  • RPS на чтение — просмотр списков, дашбордов
  • Количество WebSocket соединений — для real-time чата
  • Storage growth — 10M тикетов × средний размер сообщения

Вопрос 5. Как рассчитывается MAU и RPS для чата с учётом географического распределения пользователей?

Таймкод: 00:10:05

Ответ собеседника: Правильный. MAU рассчитывается как DAU × 30 дней × 0.9 (90% новых пользователей в месяц) ≈ 558 млн. Средний RPS для чата: 20 млн пользователей × 25 сообщений / 86400 секунд ≈ 5800. Пиковый RPS с учётом правила 80/20 для геораспределённой системы: ~23 000. RPS считается только для отправки сообщений, чтение и статусы онлайн не учитываются как значимые.

Правильный ответ:

Подход в целом верный, но есть неточности в расчётах MAU и стоит уточнить методику.

MAU (Monthly Active Users)

Формула DAU × 30 некорректна, так как один и тот же пользователь активен в разные дни. Правильный подход:

MAU = DAU × Среднее количество активных дней в месяц

Пример:

Если DAU = 75 000 и пользователь активен 22 дня в месяце:
MAU = 75 000 × (30/22) ≈ 102 000

Или если 90% пользователей уникальны каждый день:
MAU = 75 000 × 30 × 0.1 (повторяющиеся) ≈ 2 250 000

RPS для чата — детальный расчёт:

Отправка сообщений:

Средний RPS = (MAU × Среднее сообщений в день) / 86400
= (100 000 × 25) / 86400 ≈ 29 RPS

Пиковый RPS (×4 для пиковых часов):
= 29 × 4 ≈ 116 RPS

Геораспределение — правило 80/20:

При наличии нескольких регионов нагрузка распределяется неравномерно:

Регион 1 (Москва): 60% нагрузки → 70 RPS
Регион 2 (Европа): 25% → 29 RPS
Регион 3 (Азия): 15% → 17 RPS

Дополнительные RPS для чата:

Тип запросаДоля от отправкиRPS
Отправка сообщений100%29
Получение истории500%145
Статусы онлайн200%58
Индикатор «печатает»150%44
Прочитано/не прочитано100%29
Итого305 RPS

WebSocket соединения:

Активные соединения = DAU × 0.3 (30% онлайн одновременно)
= 75 000 × 0.3 = 22 500 соединений

С учётом регионов:
- Регион 1: 13 500
- Регион 2: 5 625
- Регион 3: 3 375

Важные уточнения:

  • RPS на чтение обычно в 5-10 раз выше RPS на запись
  • WebSocket соединения требуют памяти (~10-50 KB на соединение)
  • Нужно учитывать reconnection storms при проблемах с сетью

Вопрос 6. Хотим ли мы мгновенную доставку сообщений в чате и какой SLA доступности целимся?

Таймкод: 00:20:45

Ответ собеседника: Правильный. Да, сообщения должны доставляться моментально, и мы не хотим их терять. Целевой SLA доступности — 99.95% (три с половиной девятки).

Правильный ответ:

Отличный ответ, который демонстрирует понимание требований к real-time системам. Давайте раскроем детали.

Уровни SLA и допустимый downtime:

SLAДопустимый downtime в месяцДопустимый downtime в год
99.9%43 минуты8.7 часов
99.95%22 минуты4.4 часов
99.99%4.3 минуты52.6 минут

Для системы саппорта 99.95% — это разумный баланс между стоимостью и надёжностью.

Мгновенная доставка — что это значит:

Latency требования:

P50 (медиана): < 100ms
P95: < 300ms
P99: < 500ms

Гарантии доставки:

  • At-most-once — сообщение может быть потеряно (не подходит для саппорта)
  • At-least-once — сообщение гарантированно доставлено, возможны дубликаты
  • Exactly-once — идеальная доставка, но сложно реализуемо

Для чата саппорта выбираем at-least-once с дедупликацией на клиенте.

Архитектурные решения для 99.95%:

1. Multi-AZ deployment:

Region: eu-west-1
├── AZ-a: 50% трафика
├── AZ-b: 50% трафика
└── Failover: < 30 секунд

2. Graceful degradation:

При деградации:
- Отключаем «печатает...» индикатор
- Увеличиваем polling interval
- Отключаем некритичные фичи

3. Circuit Breaker для зависимостей:

// Пример реализации circuit breaker
type CircuitBreaker struct {
failures int
threshold int
timeout time.Duration
lastFailure time.Time
state State // Closed, Open, HalfOpen
}

4. Мониторинг SLA:

Error Budget = 1 - SLA = 0.05% = 43.2 минуты в месяц

Алерты:
- Warning: израсходовано 50% бюджета
- Critical: израсходовано 75% бюджета
- Page: израсходовано 100% бюджета

Компромиссы:

  • 99.99% стоит значительно дороже (×2-3)
  • Можно иметь разный SLA для разных компонентов:
    • Чат: 99.99% (критично)
    • Аналитика: 99.9% (допустима задержка)

Вопрос 7. Какие основные сервисы выделены в архитектуре и как устроена авторизация?

Таймкод: 00:23:28

Ответ собеседника: Правильный. Выделены сервисы: чат, тикеты, аналитика, авторизация, компании. Авторизация использует JWT-токены, поддерживает два сценария: внешние JWT от компаний-клиентов (через сертификаты) и собственную регистрацию. Для хранения данных пользователей выбран PostgreSQL из-за атомарности и встроенной репликации/шардирования.

Правильный ответ:

Хорошее разбиение на сервисы. Давайте дополним архитектуру и углубимся в авторизацию.

Основные сервисы — расширенный список:

Core Services:

СервисОтветственностьБаза данных
Auth ServiceАвторизация, аутентификация, выдача токеновPostgreSQL
User ServiceПрофили пользователей, роли, праваPostgreSQL
Company ServiceУправление компаниями, настройки тенантовPostgreSQL
Ticket ServiceCRUD тикетов, workflow, назначенияPostgreSQL
Chat ServiceReal-time сообщения, WebSocketMongoDB/Cassandra
Notification ServiceEmail, push, SMS уведомленияRedis + PostgreSQL
Analytics ServiceАгрегация метрик, отчётыClickHouse
File ServiceЗагрузка и хранение файловS3/MinIO

Авторизация — детальная архитектура:

JWT Flow:

┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────▶│ API Gateway │────▶│ Auth Service │
└──────────┘ └──────────────┘ └──────────────┘
│ │
│ ┌─────────────────────────────┘
│ ▼
│ ┌─────────┐ ┌──────────┐
│ │ Redis │ │PostgreSQL│
│ │ (cache) │ │ (users) │
│ └─────────┘ └──────────┘


┌─────────────────────────────────────────┐
│ Authorization: Bearer <JWT> │
│ │
│ Payload: { │
│ "sub": "user-uuid", │
│ "tenant_id": "company-uuid", │
│ "roles": ["support_agent"], │
│ "exp": 1699999999 │
│ } │
└─────────────────────────────────────────┘

Два сценария авторизации:

1. External JWT (SSO от клиентов):

type ExternalJWTValidator struct {
certPool *x509.CertPool
}

func (v *ExternalJWTValidator) Validate(tokenString string) (*Claims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) {
// Проверка подписи через сертификат компании
return v.getPublicKey(token)
})
// Маппинг external claims → internal claims
}

2. Internal Auth (собственная регистрация):

Email/Password → Auth Service → PostgreSQL (bcrypt hash)

JWT + Refresh Token

PostgreSQL для пользователей — обоснование:

  • ACID транзакции для критичных операций
  • Row-Level Security для мультитенантности
  • pgcrypto для шифрования чувствительных данных
  • Встроенная репликация (streaming replication)

Дополнительные компоненты:

  • API Gateway — валидация JWT, rate limiting, routing
  • Redis — кэширование сессий, blacklist отозванных токенов
  • Vault — хранение секретов, ключей подписи

Вопрос 8. Как устроен чат-сервис: WebSocket, Kafka, балансировка по chat_id, хранение истории сообщений?

Таймкод: 00:39:55

Ответ собеседника: Неполный. Чат-сервис использует WebSocket для мгновенной доставки сообщений. Сообщения перекладываются в Kafka для сохранения. Балансировка по chat_id через хэширование для распределения пользователей по инстансам. Хранение истории сообщений предлагается в S3 + PostgreSQL (PostgreSQL для маппинга chat_id на файл, S3 для хранения JSON-файлов с сообщениями). Для поиска по чатам предлагается Elasticsearch.

Правильный ответ:

Идея с S3 для хранения истории чатов спорная — для real-time чата лучше использовать специализированные решения. Давайте разберём архитектуру подробнее.

WebSocket Gateway:

type Hub struct {
rooms map[uuid.UUID]*Room // chat_id → Room
register chan *Client
unregister chan *Client
broadcast chan *Message
}

type Client struct {
conn *websocket.Conn
send chan []byte
userID uuid.UUID
chatIDs []uuid.UUID
}

func (h *Hub) Run() {
for {
select {
case client := <-h.register:
for _, chatID := range client.chatIDs {
h.rooms[chatID].addClient(client)
}
case msg := <-h.broadcast:
h.rooms[msg.ChatID].broadcast(msg)
}
}
}

Балансировка по chat_id:

Проблема: Пользователь может быть подключён к инстансу A, а собеседник — к инстансу B.

Решение — Consistent Hashing:

type ChatRouter struct {
ring *consistent.Consistent
}

func (r *ChatRouter) GetInstance(chatID uuid.UUID) string {
return r.ring.Get(chatID.String())
}

// При подключении:
// 1. Определяем инстанс для chat_id
// 2. Проксируем WebSocket на нужный инстанс
// 3. Или используем Redis Pub/Sub между инстансами

Redis Pub/Sub для межинстансного взаимодействия:

type MessageBroker struct {
redis *redis.Client
}

func (b *MessageBroker) Publish(ctx context.Context, chatID uuid.UUID, msg *Message) {
channel := fmt.Sprintf("chat:%s", chatID)
b.redis.Publish(ctx, channel, msg.Marshal())
}

func (b *MessageBroker) Subscribe(ctx context.Context, chatID uuid.UUID) <-chan *Message {
channel := fmt.Sprintf("chat:%s", chatID)
pubsub := b.redis.Subscribe(ctx, channel)
// ... обработка сообщений
}

Хранение истории сообщений:

Рекомендуемая архитектура:

┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Chat Service│────▶│ Kafka │────▶│ Consumer │
└─────────────┘ └─────────────┘ └──────────────┘

┌──────────────────────────┤
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Cassandra │ │Elasticsearch │
│ (hot storage)│ │ (search) │
└──────────────┘ └──────────────┘

▼ (TTL → cold)
┌──────────────┐
│ S3/Glacier │
│(cold storage)│
└──────────────┘

Cassandra для истории чатов:

-- Оптимальная схема для чатов
CREATE TABLE messages (
chat_id uuid,
bucket text, -- '2024-01' для партиционирования по времени
message_id timeuuid,
sender_id uuid,
content text,
attachments list<uuid>,
created_at timestamp,
PRIMARY KEY ((chat_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC)
AND default_time_to_live = 7776000; -- 90 дней

Почему не S3 + PostgreSQL:

  • S3 имеет высокую latency (100-200ms) — не подходит для real-time
  • PostgreSQL плохо масштабируется для write-heavy нагрузки чатов
  • Cassandra оптимизирована для append-only записей

Kafka для гарантии доставки:

type MessageProducer struct {
kafka *kafka.Producer
}

func (p *MessageProducer) Send(ctx context.Context, msg *Message) error {
return p.kafka.Produce(&kafka.Message{
Topic: "chat-messages",
Key: msg.ChatID.Bytes(), // Партиция по chat_id
Value: msg.Marshal(),
Headers: []kafka.Header{
{Key: "idempotency-key", Value: msg.ID.Bytes()},
},
})
}

Elasticsearch для поиска:

PUT /chat-messages
{
"mappings": {
"properties": {
"chat_id": { "type": "keyword" },
"content": { "type": "text", "analyzer": "russian" },
"sender_id": { "type": "keyword" },
"created_at": { "type": "date" }
}
}
}

Дополнительные фичи:

  • Дедупликация: idempotency key в Kafka headers
  • Офлайн сообщения: push уведомления через Notification Service
  • Прочитано/не прочитано: отдельная таблица в Redis
  • Редактирование сообщений: tombstone в Cassandra + новая версия

Вопрос 9. Какие альтернативы WebSocket существуют для реализации чата и почему выбраны именно WebSocket?

Таймкод: 00:46:23

Ответ собеседника: Правильный. Альтернатива — обычные HTTP-запросы (polling). Однако WebSocket выбраны для мгновенной доставки сообщений и снижения нагрузки на сервер по сравнению с polling. Ограничение WebSocket — один инстанс может держать ~50k соединений, но это решается горизонтальным масштабированием.

Правильный ответ:

Хороший ответ, давайте расширим список альтернатив и сравним их.

Альтернативы WebSocket:

1. Long Polling

Клиент: GET /messages?after=12345
Сервер: Ждёт 30 сек или пока не появится сообщение
Сервер: 200 OK [msg1, msg2]
Клиент: Сразу повторяет запрос

Плюсы: работает везде, простота реализации Минусы: высокий latency, нагрузка от постоянных переподключений

2. Server-Sent Events (SSE)

func (s *ChatHandler) SSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")

flusher := w.(http.Flusher)
for msg := range s.subscribe(chatID) {
fmt.Fprintf(w, "data: %s\n\n", msg.JSON())
flusher.Flush()
}
}

Плюсы: простота, работает поверх HTTP, автоматическое переподключение Минусы: однонаправленный (только сервер → клиент), нет бинарных данных

3. WebTransport / HTTP/3

Плюсы: мультиплексинг, низкий latency Минусы: новая технология, ограниченная поддержка

4. MQTT

Плюсы: лёгкий, pub/sub модель, QoS уровни Минусы: нужен отдельный брокер, менее распространён в вебе

Сравнительная таблица:

ТехнологияLatencyBidirectionalСложностьПоддержка
WebSocketНизкаяДаСредняяОтличная
SSEСредняяНетНизкаяХорошая
Long PollingВысокаяДаНизкаяОтличная
WebTransportНизкаяДаВысокаяОграниченная

Почему WebSocket для чата:

1. Полнодуплексная связь:

// Одновременно отправка и получение
conn, _ := websocket.Upgrade(w, r, nil)

// Чтение
go func() {
for {
_, msg, err := conn.ReadMessage()
handleMessage(msg)
}
}()

// Письмо
go func() {
for msg := range outgoing {
conn.WriteMessage(websocket.TextMessage, msg)
}
}()

2. Низкий overhead:

HTTP Request: ~800 bytes headers
WebSocket: ~2 bytes frame overhead

3. Масштабирование WebSocket:

Один инстанс: ~50k соединений (зависит от RAM)
С горизонтальным масштабированием:
- 10 инстансов: ~500k соединений
- 100 инстансов: ~5M соединений

Redis Pub/Sub для межинстансной коммуникации

Ограничения WebSocket и решения:

ОграничениеРешение
50k соединений на инстансГоризонтальное масштабирование
Нет автоматического reconnectРеализация на клиенте
Проблемы с прокси/балансировщикамиWebSocket-aware load balancer (nginx, envoy)
Stateful соединенияSticky sessions или stateless с Redis

Вопрос 10. Как устроен API для создания тикета и назначения саппортера?

Таймкод: 00:47:59

Ответ собеседника: Правильный. Метод create_ticket принимает user_id и бизнес-поля (например, название чата), возвращает ticket_id. Тикет сохраняется в БД, затем в фоне назначается саппортер (assignee) по ticket_id. Чат и тикет — одна сущность, так как весь лог сообщений привязан к тикету.

Правильный ответ:

Хороший подход с асинхронным назначением саппортера. Давайте детализируем API и процесс назначения.

API для создания тикета:

// Request
type CreateTicketRequest struct {
Title string `json:"title" validate:"required,max=255"`
Description string `json:"description" validate:"max=5000"`
Priority Priority `json:"priority" validate:"omitempty,oneof=low medium high critical"`
Category string `json:"category" validate:"omitempty,oneof=billing technical returns"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
CompanyID uuid.UUID `json:"company_id" validate:"required"`
Metadata map[string]string `json:"metadata"`
}

// Response
type CreateTicketResponse struct {
TicketID uuid.UUID `json:"ticket_id"`
Status TicketStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
Assignee *uuid.UUID `json:"assignee,omitempty"` // nil пока не назначен
}

Endpoint:

POST /api/v1/tickets
Authorization: Bearer <JWT>
X-Tenant-ID: <company-uuid>

Response: 202 Accepted
{
"ticket_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "open",
"created_at": "2024-01-15T10:30:00Z",
"assignee": null
}

Процесс назначения саппортера:

Вариант 1 — Очередь с приоритетом:

type AssignmentService struct {
queue *priorityqueue.Queue
agents map[uuid.UUID]*Agent
strategy AssignmentStrategy
}

func (s *AssignmentService) AssignTicket(ctx context.Context, ticket *Ticket) error {
agent := s.strategy.SelectAgent(ticket, s.getAvailableAgents())
if agent == nil {
// Очередь ожидания
s.queue.Push(ticket)
return nil
}

return s.doAssign(ctx, ticket, agent)
}

Стратегии назначения:

СтратегияОписаниеКогда использовать
Round RobinПо кругу между агентамиРавномерная нагрузка
Least BusyАгент с наименьшим количеством тикетовБалансировка
Skill-BasedПо навыкам и категории тикетаСпециализированная поддержка
Load-BasedС учётом текущей загрузки и SLAПриоритизация SLA

Схема БД для тикетов:

CREATE TABLE tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id),
customer_id UUID NOT NULL REFERENCES users(id),
assignee_id UUID REFERENCES users(id),

title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'open',
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
category VARCHAR(50),

first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
sla_deadline TIMESTAMPTZ,

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

-- Индексы для типичных запросов
CONSTRAINT valid_status CHECK (status IN ('open', 'in_progress', 'waiting', 'resolved', 'closed', 'reopened'))
);

CREATE INDEX idx_tickets_company_status ON tickets(company_id, status);
CREATE INDEX idx_tickets_assignee ON tickets(assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tickets_sla ON tickets(sla_deadline) WHERE status NOT IN ('closed', 'resolved');

События при создании тикета:

TicketCreated → Kafka
├── Assignment Service (назначение агента)
├── Notification Service (уведомление клиенту)
├── Analytics Service (метрики)
└── SLA Service (установка дедлайна)

Чат и тикет как одна сущность:

Это хорошее решение — упрощает архитектуру:

CREATE TABLE ticket_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES tickets(id),
sender_id UUID NOT NULL REFERENCES users(id),
sender_type VARCHAR(10) NOT NULL, -- 'customer' | 'agent' | 'system'

content TEXT NOT NULL,
attachments UUID[],

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

INDEX idx_ticket_messages (ticket_id, created_at DESC)
);

Вопрос 11. Как реализовано мультитенантность: разделение данных между компаниями, ролевая система?

Таймкод: 01:00:04

Ответ собеседника: Неправильный. Мультитенантность реализована через хранение company_id в таблицах чатов/тикетов и пользователей. Ролевая система разграничивает обычных пользователей и саппортеры. Саппортеры привязаны к компании через company_id. Данные компаний хранятся в PostgreSQL. Связь тикетов и компаний — один ко многим.

Правильный ответ:

Ответ неполный — кандидат не упомянул конкретные стратегии изоляции данных. Давайте разберём все варианты.

Стратегии мультитенантности:

1. Shared Database, Shared Schema (общая БД, общая схема)

-- Каждая таблица содержит tenant_id
CREATE TABLE tickets (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
-- ... остальные поленные
);

-- Все запросы фильтруются по tenant_id
SELECT * FROM tickets WHERE tenant_id = $1 AND status = 'open';

Плюсы: простота, низкая стоимость Минусы: риск утечки данных, сложность масштабирования

2. Shared Database, Separate Schemas (общая БД, отдельные схемы)

-- Каждый тенант получает свою схему
CREATE SCHEMA tenant_abc123;
CREATE TABLE tenant_abc123.tickets (...);
CREATE TABLE tenant_abc123.users (...);

-- Динамический выбор схемы
SET search_path TO tenant_abc123;
SELECT * FROM tickets WHERE status = 'open';

Плюсы: хорошая изоляция, простота бэкапов на тенанта Минусы: миграции сложнее, ограничение на количество схем

3. Separate Databases (отдельные БД)

tenant_abc123 → PostgreSQL cluster A
tenant_def456 → PostgreSQL cluster B
tenant_ghi789 → PostgreSQL cluster C

Плюсы: полная изоляция, индивидуальные настройки Минусы: высокая стоимость, сложность управления

Рекомендуемый подход — гибридный:

┌─────────────────────────────────────────────────────────────┐
│ Routing Layer │
│ │
│ Малые тенанты (< 10k тикетов/мес) → Shared Schema │
│ Средние тенанты (10k-100k) → Separate Schema │
│ Крупные тенанты (> 100k) → Separate Database │
└─────────────────────────────────────────────────────────────┘

Row-Level Security в PostgreSQL:

-- Включаем RLS
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;

-- Политика: пользователь видит только тикеты своей компании
CREATE POLICY tenant_isolation ON tickets
USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- Установка текущего тенанта при подключении
SET app.current_tenant = '550e8400-e29b-41d4-a716-446655440000';

Ролевая система (RBAC):

CREATE TABLE roles (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name VARCHAR(50) NOT NULL,
permissions JSONB NOT NULL,

UNIQUE(tenant_id, name)
);

-- Пример ролей
INSERT INTO roles (tenant_id, name, permissions) VALUES
('tenant-uuid', 'admin', '["tickets:read", "tickets:write", "tickets:delete", "users:manage", "settings:manage"]'),
('tenant-uuid', 'agent', '["tickets:read", "tickets:write", "tickets:assign"]'),
('tenant-uuid', 'customer', '["tickets:read", "tickets:create", "messages:write"]');

Middleware для проверки тенанта в Go:

func TenantMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := extractTenantFromJWT(c.GetHeader("Authorization"))

// Проверяем доступ пользователя к тенанту
if !userHasAccess(c.GetString("user_id"), tenantID) {
c.AbortWithStatus(403)
return
}

// Устанавливаем контекст
c.Set("tenant_id", tenantID)
c.Next()
}
}

Геораспределение и compliance:

GDPR (ЕС) → данные хранятся в eu-west-1
ФЗ-152 (РФ) → данные хранятся в ru-central-1
HIPAA (US Healthcare) → dedicated cluster в us-east-1

Миграции для мультитенантной БД:

-- Версионированные миграции с учётом тенантов
-- migration-001-add-priority.sql

DO $$
DECLARE
tenant_schema text;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
EXECUTE format('ALTER TABLE %I.tickets ADD COLUMN priority VARCHAR(10) DEFAULT ''medium''', tenant_schema);
END LOOP;
END $$;

Вопрос 12. Какие минусы хранения B2C и B2B пользователей в одной таблице?

Таймкод: 01:03:42

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

Правильный ответ:

Кандидат верно отметил проблему взаимного влияния. Давайте расширим список минусов и рассмотрим решения.

Минусы хранения B2C и B2B в одной таблице:

1. Различные модели данных

B2C пользователь:
- email, phone, name, avatar
- личные предпочтения
- история покупок

B2B пользователь (саппортер):
- employee_id, department
- рабочее расписание
- навыки (skills), рейтинг
- SLA метрики

Проблема: таблица будет содержать много NULL-полей, что усложняет схему и запросы.

2. Различные паттерны нагрузки

МетрикаB2CB2B
КоличествоМиллионыТысячи
RPS (чтение)ВысокийНизкий
RPS (запись)Низкий (регистрация)Высокий (обновление статусов)
КэшированиеLong TTLShort TTL

3. Безопасность и compliance

B2C: GDPR, персональные данные потребителей
B2B: корпоративные данные, NDA

Смешивание усложняет:
- Аудит доступа
- Шифрование полей
- Политики хранения
- Right to be forgotten

4. Различные индексы и оптимизации

-- B2C: поиск по email, phone
CREATE INDEX idx_users_email ON users(email) WHERE type = 'b2c';

-- B2B: поиск по department, skills
CREATE INDEX idx_users_department ON users(department) WHERE type = 'b2b';

5. Взаимное влияние (как отметил кандидат)

Массовая регистрация B2C:
- Раздувание таблицы
- Деградация запросов для B2B
- Lock contention при вставке

Рекомендуемое решение — разделение:

-- Общая таблица для аутентификации
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
type VARCHAR(10) NOT NULL, -- 'b2c' | 'b2b'
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- B2C профили
CREATE TABLE customer_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id),
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(20),
preferences JSONB
);

-- B2B профили (саппортеры)
CREATE TABLE agent_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id),
employee_id VARCHAR(50),
department VARCHAR(100),
skills TEXT[],
max_tickets INT DEFAULT 10,
is_available BOOLEAN DEFAULT true,
rating DECIMAL(3,2)
);

Когда можно хранить вместе:

  • MVP стадия продукта
  • Очень малый масштаб (< 10k пользователей)
  • Полностью идентичные модели данных
  • Нет compliance требований

Миграция при разделении:

-- Шаг 1: Создаём новые таблицы
CREATE TABLE customer_profiles AS
SELECT id, first_name, last_name, phone, NULL::JSONB as preferences
FROM users WHERE type = 'b2c';

-- Шаг 2: Двойная запись (dual write)
-- Шаг 3: Переключение чтения на новые таблицы
-- Шаг 4: Удаление старых колонок

Вопрос 13. Как мониторить чат-сервис: метрики, алерты, логи, трейсы?

Таймкод: 01:05:38

Ответ собеседния: Правильный. Кандидат предлагает логировать ошибки и создание сущностей чата, собирать метрики по количеству чатов в реальном времени, количеству сообщений, а также метрики по аномально активным пользователям. Также предлагает стандартные метрики по потреблению ресурсов (память и т.д.). Для алертов — на основе метрик. Если выбирать одно из логов/метрик/трейсов, выберет логи, так как из них можно вычислить всё остальное.

Правильный ответ:

Хороший ответ, но выбор только логов — спорное решение. Давайте разберём все три столпа наблюдаемости.

Три столпа наблюдаемости:

┌─────────────────────────────────────────────────────────────┐
│ Observability │
├─────────────────┬─────────────────┬─────────────────────────┤
│ Metrics │ Logs │ Traces │
│ │ │ │
│ - Aggregated │ - Discrete │ - Request flow │
│ - Numerical │ events │ - Latency breakdown │
│ - Efficient │ - Context-rich │ - Dependency mapping │
│ - Alerting │ - Debugging │ - Performance analysis │
└─────────────────┴─────────────────┴─────────────────────────┘

Метрики для чат-сервиса:

Business метрики:

var (
chatsCreated = promauto.NewCounter(prometheus.CounterOpts{
Name: "chat_chats_created_total",
Help: "Total number of chats created",
})

messagesSent = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "chat_messages_sent_total",
Help: "Total messages sent",
}, []string{"chat_type"})

messageLatency = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "chat_message_delivery_duration_seconds",
Help: "Message delivery latency",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 15),
})

activeConnections = promauto.NewGauge(prometheus.GaugeOpts{
Name: "chat_active_websocket_connections",
Help: "Current WebSocket connections",
})
)

RED метрики (Rate, Errors, Duration):

МетрикаОписаниеПример
RateЗапросов в секунду1000 RPS
ErrorsПроцент ошибок0.1%
DurationВремя обработкиP99 < 100ms

USE метрики (Utilization, Saturation, Errors):

РесурсUtilizationSaturationErrors
CPU60%Load avg > cores-
Memory4GB / 8GBOOM killsOOM events
Network100 Mbps / 1 GbpsRetransmitsDropped packets

Алерты:

# Prometheus alerting rules
groups:
- name: chat-service
rules:
- alert: HighErrorRate
expr: rate(chat_errors_total[5m]) / rate(chat_requests_total[5m]) > 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "Chat service error rate > 1%"

- alert: HighLatency
expr: histogram_quantile(0.99, rate(chat_message_duration_seconds_bucket[5m])) > 0.5
for: 10m
labels:
severity: warning

- alert: WebSocketConnectionsHigh
expr: chat_active_websocket_connections > 40000
for: 5m
labels:
severity: warning

Логи — структурированные:

import "go.uber.org/zap"

logger.Info("message_sent",
zap.String("chat_id", chatID.String()),
zap.String("message_id", msgID.String()),
zap.String("sender_id", senderID.String()),
zap.Duration("delivery_latency", latency),
zap.Int("message_size", len(content)),
)

Трейсы — OpenTelemetry:

import "go.opentelemetry.io/otel"

func (s *ChatService) SendMessage(ctx context.Context, msg *Message) error {
ctx, span := tracer.Start(ctx, "chat.SendMessage",
trace.WithAttributes(
attribute.String("chat.id", msg.ChatID.String()),
attribute.String("sender.id", msg.SenderID.String()),
),
)
defer span.End()

// Трейс автоматически распространяется через Kafka headers
err := s.kafkaProducer.Produce(ctx, msg)
if err != nil {
span.RecordError(err)
return err
}

return nil
}

Почему нужны все три:

СценарийЛучший инструмент
«Почему пользователь не получил сообщение?»Traces
«Сколько сообщений в секунду?»Metrics
«Что произошло в 15:42:33?»Logs
«Где bottleneck?»Traces + Metrics
«Кто виноват в инциденте?»Logs + Traces

Рекомендуемый стек:

Metrics: Prometheus + Grafana
Logs: ELK (Elasticsearch, Logstash, Kibana) или Loki
Traces: Jaeger или Tempo
Alerting: Alertmanager → PagerDuty/Telegram