Собеседование по System Design с Head of Engineering из американского FinTech
Сегодня мы разберем процесс проектирования системы чат-поддержки в реальном времени, включая архитектурные решения, выбор технологий и обработку нефункциональных требований. В ходе собеседования кандидат продемонстрировал способность структурировать сложную задачу, выделять ключевые компоненты и обосновывать свои решения, хотя и столкнулся с трудностями в вопросах мультитенантности и изоляции данных. Мы также рассмотрим типичные ошибки и лучшие практики, которые помогут лучше подготовиться к подобным интервью.
Вопрос 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 connections | 75 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 Service | CRUD тикетов, workflow, назначения | PostgreSQL |
| Chat Service | Real-time сообщения, WebSocket | MongoDB/Cassandra |
| Notification Service | Email, 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 уровни Минусы: нужен отдельный брокер, менее распространён в вебе
Сравнительная таблица:
| Технология | Latency | Bidirectional | Сложность | Поддержка |
|---|---|---|---|---|
| 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. Различные паттерны нагрузки
| Метрика | B2C | B2B |
|---|---|---|
| Количество | Миллионы | Тысячи |
| RPS (чтение) | Высокий | Низкий |
| RPS (запись) | Низкий (регистрация) | Высокий (обновление статусов) |
| Кэширование | Long TTL | Short 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):
| Ресурс | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | 60% | Load avg > cores | - |
| Memory | 4GB / 8GB | OOM kills | OOM events |
| Network | 100 Mbps / 1 Gbps | Retransmits | Dropped 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
