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

Открытое System Design интервью на Senior Go-разработчика

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

Сегодня мы разберём системный дизайн мессенджера уровня WhatsApp/Telegram: от архитектуры хранения сообщений и доставки в реальном времени до отказоустойчивости и масштабирования на сотни миллионов пользователей. Кандидат и интервьюер детально проработали ключевые компоненты — WebSocket-соединения, сервис Discovery, шардирование баз данных, очереди сообщений для групповых чатов и стратегии обработки сбоев. В финале обсудили, как обеспечить гарантию доставки и консистентность данных при переподключении пользователей и падении серверов.

Вопрос 1. Как сервис сообщений взаимодействует с WebSocket-серверами — синхронно через RPC или асинхронно через очередь? Как решить проблему доставки на несколько устройств одного пользователя?

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

Ответ собеседника: Правильный. Рассмотрены оба варианта. Синхронный RPC проще, но при множественных устройствах возникает проблема: если одно из устройств недоступно, сообщение может быть потеряно. Асинхронный подход через Kafka решает эту проблему: сервис сообщений пишет одно сообщение в топик Kafka, а каждый WebSocket-сервер (AP) читает из своей партиции/топика и доставляет своим подключённым пользователям. При падении сервера сообщение не теряется — при переподключении пользователь запросит пропущенные сообщения. Также отмечена проблема неактуальности Discovery-данных при асинхронной доставке, но она решается тем, что при переподключении на другую ноду пользователь запрашивает недоставленные сообщения. Итог — гибридный подход: Discovery + Kafka с топиками по WebSocket-серверам.

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

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

Синхронный подход (RPC/gRPC)

При синхронном подходе сервис сообщений напрямую вызывает WebSocket-серверы через gRPC/HTTP. Плюсы: простота реализации, мгновенная обратная связь о доставке. Минусы: если устройство пользователя не подключено к конкретному WebSocket-серверу или сервер недоступен — сообщение теряется. Для нескольких устройств одного пользователя нужно поддерживать маппинг user_id → список подключённых серверов и делать fan-out вызовы, что создаёт каскадные задержки и проблемы с частичной доставкой.

Асинхронный подход (Kafka/очереди)

Более масштабируемый вариант. Сервис сообщений пишет сообщение в Kafka-топик, а WebSocket-серверы (или отдельные consumer-группы) читают и доставляют своим подключённым пользователям. Для доставки на несколько устройства используется паттерн:

  1. Топик с партиционировацией по user_id — все сообщения конкретного пользователя попадают в одну партицию.
  2. Каждый WebSocket-сервер подписывается на все партиции (или использует consumer group с одним потребителем на сервер).
  3. Сервер доставляет только тем пользователям, которые к нему подключены.

Альтернатива — отдельный топик на каждый WebSocket-сервер, куда роутер отправляет сообщения для пользователей, подключённых к этому серверу.

Гибридный подход (рекомендуемый)

На практике часто используется комбинация:

  • Discovery-сервис хранит актуальную информацию о том, на каком WebSocket-сервере подключён каждый пользователь.
  • Kafka используется как буфер и для гарантии доставки — сообщение не теряется при временной недоступности устройства.
  • При подключении пользователя к новому серверу он запрашивает пропущенные сообщения из персистентного хранилища (Kafka или отдельная БД сообщений).

Пример структуры топика Kafka для мульти-устройственной доставки:

type Message struct {
UserID string `json:"user_id"`
DeviceIDs []string `json:"device_ids"` // конкретные устройства или пустой массив = все устройства
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
MessageID string `json:"message_id"`
}

Партиционирование по user_id гарантирует порядок сообщений для одного пользователя, а пустой device_ids означает доставку на все устройства пользователя.

Вопрос 2. Какие базовые функции должен поддерживать мессенджер, аналогичный Telegram, ориентированный на пользователей?

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

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

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

Ответ собеседника покрывает основные функции хорошо. Расширим список для более полной картины и добавим технические нюансы реализации.

Основные функции мессенджера

1. Сообщения и чаты

  • Приватные чаты (1 на 1) — базовая единица коммуникации.
  • Групповые чаты — от небольших групп (50-200 человек) до крупных (до 200 000 участников как в Telegram).
  • Каналы (broadcast) — односторонняя рассылка от автора к неограниченной аудитории.
  • Супергруппы — группы с расширенными правами администрирования.

2. Типы контента

  • Текстовые сообщения с поддержкой markdown/форматирования.
  • Медиафайлы: изображения, видео, аудио, документы.
  • Стикеры и эмодзи.
  • Голосовые и видеосообщения.
  • Геолокация и контакты.

3. Хранение и синхронизация

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

4. Уведомления

  • Push-уведомления для мобильных устройств (FCM, APNS).
  • Настройка уведомлений по чатам (mute, расписание).
  • Счётчик непрочитанных сообщений.

5. Поиск

  • Полнотекстовый поиск по истории сообщений.
  • Фильтрация по дате, типу контента, автору.
  • Поиск по контактам и группам.

6. Дополнительные функции

  • Статусы пользователей (онлайн, был(а) недавно, офлайн).
  • Индикаторы набора текста и прочтения сообщений.
  • Удаление и редактирование сообщений.
  • Ответы на сообщения и форвардинг.
  • Энд-ту-энд шифрование (опционально, как в Secret Chats Telegram).

Технические аспекты реализации

Для хранения истории сообщений в PostgreSQL можно использовать следующую схему:

CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
content_type VARCHAR(20) NOT NULL, -- 'text', 'image', 'video', 'file'
content TEXT,
media_url VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
deleted_at TIMESTAMP,
reply_to_message_id BIGINT REFERENCES messages(id)
);

CREATE INDEX idx_messages_chat_created ON messages(chat_id, created_at DESC);
CREATE INDEX idx_messages_sender ON messages(sender_id);

Для полнотекстового поиска добавляем индекс:

ALTER TABLE messages ADD COLUMN content_tsv tsvector;

CREATE INDEX idx_messages_fts ON messages USING GIN(content_tsv);

-- Триггер для автоматического обновления вектора
CREATE TRIGGER messages_tsv_update BEFORE INSERT OR UPDATE
ON messages FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(content_tsv, 'pg_catalog.english', content);

В Go для работы с WebSocket-соединениями и доставкой сообщений:

type Hub struct {
clients map[int64]map[*Client]bool // userID -> множество устройств
broadcast chan *Message
register chan *Client
unregister chan *Client
}

type Client struct {
hub *Hub
conn *websocket.Conn
userID int64
deviceID string
send chan []byte
}

func (h *Hub) run() {
for {
select {
case client := <-h.register:
if h.clients[client.userID] == nil {
h.clients[client.userID] = make(map[*Client]bool)
}
h.clients[client.userID][client] = true

case client := <-h.unregister:
if clients, ok := h.clients[client.userID]; ok {
delete(clients, client)
close(client.send)
if len(clients) == 0 {
delete(h.clients, client.userID)
}
}

case message := <-h.broadcast:
// Доставка на все устройства получателя
if clients, ok := h.clients[message.RecipientID]; ok {
for client := range clients {
select {
case client.send <- message.Data:
default:
close(client.send)
delete(clients, client)
}
}
}
}
}
}

Это базовая структура, которая обеспечивает доставку на все устройства пользователя — ключевое требование для мессенджера.

Вопрос 3. Чем отличается доставка сообщений в групповые чаты от персональных и какие дополнительные компоненты для этого нужны?

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

Ответ собеседника: Правильный. Групповые чаты отличаются тем, что сообщение нужно доставить множеству пользователей (до 500). Для этого сервис сообщений сначала обращается к сервису чатов для получения списка участников группы. Далее сообщение рассылается всем участникам. Оценка нагрузки: при 50 млн одновременно онлайн пользователей и 10000 WebSocket-соединений на сервер требуется ~5000 серверов. Поскольку участники группы могут быть подключены к разным серверам, предложено использовать брокер сообщений (Kafka): сообщение с полным списком получателей пишется один раз, а каждый WebSocket-сервер читает его и доставляет только своим подключённым пользователям из списка. Это позволяет избежать отправки 500 отдельных сообщений.

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

Ответ собеседника правильный и хорошо описывает ключевые отличия. Дополним техническими деталями и архитектурными паттернами.

Ключевые отличия доставки в групповые чаты

Персональные чаты (1 на 1)

  • Один отправитель, один получатель.
  • Простая маршрутизация: найти сервер получателя, доставить.
  • Минимальная нагрузка на инфраструктуру.

Групповые чаты

  • Один отправитель, N получателей (от 2 до 200 000).
  • Необходимость получения списка участников.
  • Fan-out проблема: одно сообщение нужно доставить множеству пользователей.
  • Участники распределены по разным WebSocket-серверам.

Дополнительные компоненты для групповых чатов

1. Сервис чатов (Chat Service)

Хранит метаданные чатов и списки участников:

CREATE TABLE chats (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- 'private', 'group', 'channel'
name VARCHAR(255),
created_by BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE chat_members (
chat_id BIGINT REFERENCES chats(id),
user_id BIGINT NOT NULL,
role VARCHAR(20) DEFAULT 'member', -- 'admin', 'moderator', 'member'
joined_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (chat_id, user_id)
);

CREATE INDEX idx_chat_members_user ON chat_members(user_id);

2. Fan-out сервис

Отвечает за рассылку сообщений участникам группы. Два основных подхода:

Fan-out on write (при записи) — сообщение записывается в очередь каждого получателя:

func (s *MessageService) SendToGroup(ctx context.Context, msg *GroupMessage) error {
// Получаем список участников
members, err := s.chatService.GetMembers(ctx, msg.ChatID)
if err != nil {
return err
}

// Записываем сообщение в БД
if err := s.messageRepo.Save(ctx, msg); err != nil {
return err
}

// Определяем серверы для каждого участника
userServers := s.discovery.GetUserServers(members)

// Группируем по серверам и отправляем через Kafka
for serverID, userIDs := range userServers {
delivery := &DeliveryTask{
MessageID: msg.ID,
UserIDs: userIDs,
ServerID: serverID,
}
if err := s.kafka.Publish(ctx, "deliveries", delivery); err != nil {
return err
}
}

return nil
}

Fan-out on read (при чтении) — сообщение хранится одно, пользователи запрашивают его при открытии чата. Подходит для каналов с большой аудиторией.

3. Оптимизация для крупных групп

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

type FanOutStrategy int

const (
DirectPush FanOutStrategy = iota // для малых групп (< 100)
KafkaBroadcast // для средних групп (100-10000)
PullBased // для крупных групп и каналов (> 10000)
)

func (s *MessageService) determineStrategy(memberCount int) FanOutStrategy {
switch {
case memberCount < 100:
return DirectPush
case memberCount < 10000:
return KafkaBroadcast
default:
return PullBased
}
}

4. Кэширование списков участников

Для частых запросов списка участников используется Redis:

func (s *ChatService) GetMembers(ctx context.Context, chatID int64) ([]int64, error) {
cacheKey := fmt.Sprintf("chat:%d:members", chatID)

// Пробуем из кэша
members, err := s.redis.SMembers(ctx, cacheKey).Result()
if err == nil && len(members) > 0 {
return parseIntSlice(members), nil
}

// Загружаем из БД
membersDB, err := s.chatRepo.GetMembers(ctx, chatID)
if err != nil {
return nil, err
}

// Сохраняем в кэш
pipe := s.redis.Pipeline()
pipe.SAdd(ctx, cacheKey, intSliceToInterface(membersDB)...)
pipe.Expire(ctx, cacheKey, 5*time.Minute)
_, _ = pipe.Exec(ctx)

return membersDB, nil
}

5. Оценка нагрузки

При 50 млн онлайн пользователей и 10 000 соединений на WebSocket-сервер:

  • Требуется ~5 000 серверов.
  • Сообщение в группу из 500 человек может затронуть до 500 серверов (в худшем случае).
  • Использование Kafka с одним сообщением вместо 500 отдельных снижает нагрузку на сеть и сервис сообщений.

Итоговая архитектура

[Sender] → [Message Service] → [Chat Service (участники)]

[Kafka: deliveries]

[WebSocket Server 1] → [User A, User B]
[WebSocket Server 2] → [User C, User D]
[WebSocket Server N] → [User ...]

Такой подход обеспечивает масштабируемость и надёжность доставки в групповые чаты.

Вопрос 4. Какие функции регистрации, авторизации, монетизации и статусов будут в мессенджере?

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

Ответ собеседника: Неполный. Вопрос был поднят, но детально не обсуждался — было решено ограничить функционал и не углубляться в звонки и прочие дополнительные фичи, чтобы не раздувать объём проектирования.

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

Регистрация и авторизация

1. Способы регистрации

  • По номеру телефона с верификацией через SMS.
  • По email с подтверждением через ссылку.
  • OAuth через сторонние провайдеры (Google, Apple, GitHub).

2. Авторизация

  • JWT-токены (access + refresh).
  • Двухфакторная аутентификация (2FA).
  • Сессии с привязкой к устройству.
type AuthService struct {
userRepo UserRepository
tokenRepo TokenRepository
smsService SMSService
hasher PasswordHasher
}

func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) error {
// Валидация входных данных
if err := validatePhoneNumber(req.PhoneNumber); err != nil {
return err
}

// Проверка уникальности
exists, _ := s.userRepo.ExistsByPhone(ctx, req.PhoneNumber)
if exists {
return ErrUserAlreadyExists
}

// Хеширование пароля
hashedPassword, err := s.hasher.Hash(req.Password)
if err != nil {
return err
}

// Создание пользователя
user := &User{
PhoneNumber: req.PhoneNumber,
PasswordHash: hashedPassword,
VerificationCode: generateCode(),
IsVerified: false,
}

if err := s.userRepo.Create(ctx, user); err != nil {
return err
}

// Отправка кода верификации
return s.smsService.SendVerificationCode(req.PhoneNumber, user.VerificationCode)
}

func (s *AuthService) Login(ctx context.Context, req *LoginRequest) (*TokenPair, error) {
user, err := s.userRepo.FindByPhone(ctx, req.PhoneNumber)
if err != nil {
return nil, ErrInvalidCredentials
}

if !s.hasher.Verify(req.Password, user.PasswordHash) {
return nil, ErrInvalidCredentials
}

if !user.IsVerified {
return nil, ErrPhoneNotVerified
}

// Генерация токенов
return s.generateTokenPair(user.ID, req.DeviceID)
}

3. Управление сессиями

CREATE TABLE user_sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
device_id VARCHAR(255) NOT NULL,
device_name VARCHAR(255),
refresh_token_hash VARCHAR(255) NOT NULL,
ip_address INET,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
last_active_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, device_id)
);

Монетизация

1. Модели монетизации

  • Freemium: базовые функции бесплатно, премиум за подписку.
  • Подписка (monthly/yearly): расширенные лимиты, эксклюзивные стикеры.
  • Продажа стикерпаков и тем.
  • Рекомина в каналах (для создателей контента).

2. Подписки

CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
plan VARCHAR(50) NOT NULL, -- 'free', 'premium', 'business'
status VARCHAR(20) NOT NULL, -- 'active', 'cancelled', 'expired'
started_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
auto_renew BOOLEAN DEFAULT true,
payment_provider VARCHAR(50),
payment_method_id VARCHAR(255)
);

CREATE TABLE subscription_plans (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
price_monthly DECIMAL(10,2),
price_yearly DECIMAL(10,2),
max_file_size_mb INT DEFAULT 2000,
max_group_size INT DEFAULT 200000,
max_pinned_chats INT DEFAULT 5,
features JSONB
);
type SubscriptionService struct {
subRepo SubscriptionRepository
paymentSvc PaymentService
}

func (s *SubscriptionService) CheckPremium(ctx context.Context, userID int64) (bool, error) {
sub, err := s.subRepo.GetActive(ctx, userID)
if err != nil {
return false, err
}

if sub == nil {
return false, nil
}

return sub.Plan != "free" && sub.ExpiresAt.After(time.Now()), nil
}

func (s *SubscriptionService) EnforceLimit(ctx context.Context, userID int64, feature string) error {
sub, _ := s.subRepo.GetActive(ctx, userID)
plan := "free"
if sub != nil {
plan = sub.Plan
}

limits := map[string]map[string]int{
"free": {
"max_file_size_mb": 100,
"max_group_size": 200,
"max_pinned_chats": 3,
},
"premium": {
"max_file_size_mb": 2000,
"max_group_size": 200000,
"max_pinned_chats": 10,
},
}

return nil // Проверка конкретного лимита
}

3. Платежи

type PaymentService interface {
CreateSubscription(ctx context.Context, userID int64, plan string, method PaymentMethod) (*PaymentResult, error)
CancelSubscription(ctx context.Context, subscriptionID int64) error
ProcessWebhook(ctx context.Context, payload []byte) error
}

Статусы пользователей

1. Типы статусов

  • Онлайн / Офлайн.
  • «Был(а) недавно» — в последние 5 минут.
  • «Был(а) X минут назад» — конкретное время.
  • «Сейчас набирает текст...».
  • «Печатает в группе [название]».

2. Реализация через Redis

CREATE TABLE user_status (
user_id BIGINT PRIMARY KEY,
last_seen_at TIMESTAMP NOT NULL,
is_online BOOLEAN DEFAULT false,
current_chat_id BIGINT -- для показа "печатает в..."
);
type StatusService struct {
redis *redis.Client
}

const (
OnlineTTL = 5 * time.Minute
TypingTTL = 10 * time.Second
RecentlyWindow = 5 * time.Minute
)

func (s *StatusService) SetOnline(ctx context.Context, userID int64) error {
key := fmt.Sprintf("status:%d", userID)
pipe := s.redis.Pipeline()
pipe.Set(ctx, key, "online", OnlineTTL)
pipe.Publish(ctx, "status_updates", fmt.Sprintf("%d:online", userID))
_, err := pipe.Exec(ctx)
return err
}

func (s *StatusService) SetTyping(ctx context.Context, userID int64, chatID int64) error {
key := fmt.Sprintf("typing:%d:%d", userID, chatID)
return s.redis.Set(ctx, key, "1", TypingTTL).Err()
}

func (s *StatusService) GetStatus(ctx context.Context, userID int64) (*UserStatusInfo, error) {
key := fmt.Sprintf("status:%d", userID)
val, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
// Проверяем last_seen в БД
return s.getLastSeenFromDB(ctx, userID)
}
if err != nil {
return nil, err
}

return &UserStatusInfo{
UserID: userID,
IsOnline: val == "online",
}, nil
}

func (s *StatusInfo) DisplayString() string {
if s.IsOnline {
return "в сети"
}
minutesAgo := time.Since(s.LastSeenAt).Minutes()
if minutesAgo < 5 {
return "был(а) недавно"
}
if minutesAgo < 60 {
return fmt.Sprintf("был(а) %.0f мин. назад", minutesAgo)
}
return s.LastSeenAt.Format("был(а) 02.01 в 15:04")
}

3. Подписка на обновления статусов

Для отслеживания статусов контактов используется Pub/Sub:

func (s *StatusService) SubscribeToContacts(ctx context.Context, userID int64) (<-chan *StatusUpdate, error) {
contacts := s.contactRepo.GetContactIDs(ctx, userID)
updates := make(chan *StatusUpdate, 100)

pubsub := s.redis.Subscribe(ctx, statusChannels(contacts)...)
go func() {
defer close(updates)
for msg := range pubsub.Channel() {
update := parseStatusUpdate(msg.Payload)
if update != nil {
updates <- update
}
}
}()

return updates, nil
}

Итоговая структура сервисов

[Auth Service] — регистрация, логин, токены
[User Service] — профиль, настройки
[Status Service] — онлайн/офлайн, печатает
[Subscription Service] — подписки, лимиты
[Payment Service] — обработка платежей

Вопрос 5. Какую ожидаемую нагрузку должен выдерживать мессенджер — сколько пользователей, сообщений в день, и какой объём хранилища потребуется для текста и видео?

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

Ответ собеседника: Правильный. Приняли модель нагрузки уровня WhatsApp: 500 млн пользователей, ~50 сообщений в день на пользователя, что дает ~25 млрд сообщений в день. Объём текста: ~3,7 ТБ/день (при 150 байт на сообщение). Объём видео: ~1,5 ПБ/день (при оценке ~3 МБ на видео, одно видео в день на пользователя). В сумме получается очень большой объём данных, требующий масштабируемого хранилища.

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

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

Модель нагрузки

Основные метрики

  • 500 млн пользователей.
  • ~50 сообщений в день на пользователя.
  • 25 млрд сообщений в день.
  • ~300 тысяч сообщений в секунду (пик).

Расчёт объёмов хранилища

Текстовые сообщения

  • Средний размер: 150 байт (текст + метаданные).
  • В день: 25 млрд × 150 байт = 3,75 ТБ.
  • В год: ~1,4 ПБ.

Медиафайлы

  • Изображения: средний размер ~200 КБ, ~1 изображение на пользователя в день.
  • Видео: средний размер ~3 МБ, ~0,5 видео на пользователя в день.
  • В день: (200 КБ + 1,5 МБ) × 500 млн ≈ 875 ТБ.
  • В год: ~320 ПБ.

Итого

  • Текст: ~1,4 ПБ/год.
  • Медиа: ~320 ПБ/год.
  • Общий объём: ~322 ПБ/год.

Распределение типов контента

ТипДоляСредний размерОбъём/день
Текст60%150 Б2,25 ТБ
Изображения25%200 КБ25 ТБ
Видео10%3 МБ150 ТБ
Голосовые4%500 КБ10 ТБ
Другое1%1 МБ2,5 ТБ

Инфраструктурные требования

1. Хранение сообщений (PostgreSQL)

Для текстовых сообщений и метаданных:

CREATE TABLE messages (
id BIGINT GENERATED ALWAYS AS IDENTITY,
chat_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
content_type VARCHAR(20) NOT NULL,
content TEXT,
media_id BIGINT REFERENCES media_files(id),
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (chat_id, id)
) PARTITION BY RANGE (created_at);

-- Партиционирование по месяцам
CREATE TABLE messages_2024_01 PARTITION OF messages
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

2. Хранение медиа (S3-совместимое хранилище)

CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL,
file_type VARCHAR(20) NOT NULL, -- 'image', 'video', 'audio', 'document'
storage_key VARCHAR(500) NOT NULL, -- путь в S3
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
width INT, -- для изображений/видео
height INT,
duration INT, -- для аудио/видео в секундах
created_at TIMESTAMP DEFAULT NOW()
);

3. Сервис загрузки медиа

type MediaService struct {
storage ObjectStorage // S3/MinIO
mediaRepo MediaRepository
cdnURL string
}

func (s *MediaService) Upload(ctx context.Context, req *UploadRequest) (*MediaFile, error) {
// Генерация уникального ключа
storageKey := fmt.Sprintf("%d/%s/%s",
req.UserID,
time.Now().Format("2006/01/02"),
uuid.New().String(),
)

// Загрузка в хранилище
if err := s.storage.Put(ctx, storageKey, req.Data); err != nil {
return nil, err
}

// Сохранение метаданных
media := &MediaFile{
OwnerID: req.UserID,
FileType: req.FileType,
StorageKey: storageKey,
FileSize: int64(len(req.Data)),
MimeType: req.MimeType,
}

if err := s.mediaRepo.Save(ctx, media); err != nil {
s.storage.Delete(ctx, storageKey) // откат
return nil, err
}

return media, nil
}

func (s *MediaService) GetURL(ctx context.Context, mediaID int64) (string, error) {
media, err := s.mediaRepo.FindByID(ctx, mediaID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s", s.cdnURL, media.StorageKey), nil
}

Стратегии оптимизации хранения

1. Дедупликация медиа

CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
content_hash VARCHAR(64) UNIQUE NOT NULL, -- SHA-256 для дедупликации
storage_key VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
reference_count INT DEFAULT 1
);

2. Хранение по уровням (Tiered Storage)

  • Hot (SSD): сообщения за последние 30 дней.
  • Warm (HDD): сообщения от 30 дней до 1 года.
  • Cold (S3 Glacier): старше 1 года.

3. Сжатие

func compressMessage(content []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(content); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

Оценка количества серверов

КомпонентНагрузкаСерверов
WebSocket50 млн соединений, 10K на сервер5 000
Message Service300K msg/sec100-200
Chat Service100K requests/sec50-100
Media Service50K uploads/sec100-150
PostgreSQL25 млрд записей/день50-100 (шарды)
Redis100M активных ключей20-30
Kafka300K msg/sec15-20 брокеров

Вопрос 6. Какие основные компоненты должны быть в высокоуровневой архитектуре мессенджера и какие технологии хранения данных для них подойдут?

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

Ответ собеседника: Правильный. Выделены ключевые компоненты: сервис авторизации (обычная реляционная БД), сервис чатов и пользователей (реляционная БД), сервис хранения сообщений (отдельное хранилище как источник истины + поисковый движок, например Elasticsearch, для полнотекстового поиска), хранилище медиафайлов с разделением на горячее (объектное хранилище типа S3 для свежих данных) и холодное (медленные дешёвые диски или магнитные ленты для старых данных). Также предложено сжатие медиафайлов для экономии места.

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

Ответ собеседника правильный и покрывает основные компоненты. Дополним архитектуру недостающими элементами и уточним выбор технологий.

Высокоуровневая архитектура мессенджера

1. API Gateway / Load Balancer

Точка входа для всех клиентов. Маршрутизация запросов к соответствующим сервисам, rate limiting, аутентификация.

Технологии: Nginx, Envoy, AWS ALB.

2. WebSocket Gateway

Поддерживает постоянные соединения с клиентами для real-time доставки сообщений.

Технологии: собственный на Go, Centrifugo.

type WebSocketGateway struct {
upgrader websocket.Upgrader
hub *Hub
auth AuthService
}

func (g *WebSocketGateway) HandleConnection(w http.ResponseWriter, r *http.Request) {
// Верификация токена
token := r.URL.Query().Get("token")
claims, err := g.auth.ValidateToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Установка WebSocket соединения
conn, err := g.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}

client := &Client{
conn: conn,
userID: claims.UserID,
send: make(chan []byte, 256),
}

g.hub.register <- client

go client.writePump()
go client.readPump()
}

3. Auth Service

Регистрация, авторизация, управление сессиями и токенами.

Хранение: PostgreSQL (пользователи, сессии) + Redis (кеш токенов).

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
phone_number VARCHAR(20) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
avatar_url VARCHAR(500),
is_verified BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

4. User Service

Управление профилями, контактами, настройками приватности.

Хранение: PostgreSQL + Redis (кеш профилей).

CREATE TABLE contacts (
user_id BIGINT NOT NULL,
contact_id BIGINT NOT NULL,
added_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, contact_id)
);

CREATE TABLE user_settings (
user_id BIGINT PRIMARY KEY,
last_seen_visibility VARCHAR(20) DEFAULT 'everyone', -- 'everyone', 'contacts', 'nobody'
read_receipts_enabled BOOLEAN DEFAULT true,
notifications JSONB DEFAULT '{}'
);

5. Chat Service

Управление чатами, группами, каналами, участниками.

Хранение: PostgreSQL (метаданные) + Redis (кеш участников).

CREATE TABLE chats (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- 'private', 'group', 'channel'
name VARCHAR(255),
description TEXT,
avatar_url VARCHAR(500),
created_by BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE chat_members (
chat_id BIGINT REFERENCES chats(id),
user_id BIGINT NOT NULL,
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (chat_id, user_id)
);

6. Message Service

Обработка, хранение и доставка сообщений.

Хранение: PostgreSQL (источник истины) + Elasticsearch (полнотекстовый поиск) + Redis (последние сообщения).

type MessageService struct {
messageRepo MessageRepository
searchRepo SearchRepository // Elasticsearch
cache *redis.Client
kafka KafkaProducer
}

func (s *MessageService) SaveAndDeliver(ctx context.Context, msg *Message) error {
// Сохранение в БД
if err := s.messageRepo.Save(ctx, msg); err != nil {
return err
}

// Индексация для поиска
if msg.ContentType == "text" {
if err := s.searchRepo.Index(ctx, msg); err != nil {
log.Error("failed to index message", err)
}
}

// Обновление кеша последних сообщений
cacheKey := fmt.Sprintf("chat:%d:last_messages", msg.ChatID)
s.cache.LPush(ctx, cacheKey, msg)
s.cache.LTrim(ctx, cacheKey, 0, 99)
s.cache.Expire(ctx, cacheKey, 24*time.Hour)

// Отправка в Kafka для доставки
return s.kafka.Publish(ctx, "messages", msg)
}

7. Media Service

Загрузка, обработка и хранение медиафайлов.

Хранение: S3/MinIO (файлы) + PostgreSQL (метаданные) + CDN (раздача).

CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL,
content_hash VARCHAR(64) UNIQUE NOT NULL,
storage_key VARCHAR(500) NOT NULL,
file_type VARCHAR(20) NOT NULL,
mime_type VARCHAR(100),
file_size BIGINT NOT NULL,
width INT,
height INT,
duration INT,
reference_count INT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);

8. Status Service

Отслеживание онлайн-статусов и индикаторов набора текста.

Хранение: Redis (Pub/Sub для real-time обновлений).

9. Notification Service

Push-уведомления для мобильных устройств.

Хранение: PostgreSQL (настройки уведомлений) + FCM/APNS (доставка).

10. Discovery Service

Отслеживание того, на каком WebSocket-сервере находится каждый пользователь.

Хранение: Redis.

type DiscoveryService struct {
redis *redis.Client
}

func (s *DiscoveryService) RegisterUser(ctx context.Context, userID int64, serverID string) error {
key := fmt.Sprintf("user:%d:server", userID)
return s.redis.Set(ctx, key, serverID, 5*time.Minute).Err()
}

func (s *DiscoveryService) GetUserServer(ctx context.Context, userID int64) (string, error) {
key := fmt.Sprintf("user:%d:server", userID)
return s.redis.Get(ctx, key).Result()
}

Сводная таблица технологий хранения

КомпонентОсновное хранилищеКэшПоиск
AuthPostgreSQLRedis-
UsersPostgreSQLRedis-
ChatsPostgreSQLRedis-
MessagesPostgreSQLRedisElasticsearch
MediaS3/MinIOCDN-
StatusRedis--
NotificationsPostgreSQL--
DiscoveryRedis--

Общая схема архитектуры

[Client] → [API Gateway] → [Auth Service]
→ [User Service]
→ [Chat Service]
→ [Message Service]
→ [Media Service]

[Client] ↔ [WebSocket Gateway] ↔ [Kafka] ↔ [Message Service]

[Message Service] → [PostgreSQL]
→ [Elasticsearch]
→ [Redis]

Вопрос 7. Как обеспечить порядок сообщений в чате — относительно чего сортировать?

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

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

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

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

Проблема упорядочивания

В распределённой системе сообщения могут приходить в разном порядке из-за:

  • Разницы во времени на серверах (clock skew).
  • Разной задержки доставки.
  • Параллельной обработки.

Подходы к упорядочиванию

1. Время отправки клиента (Client Timestamp)

Самый простой подход, но ненадёжный:

  • Часы клиента могут быть неточными.
  • Пользователь может подделать время.
type Message struct {
ID string `json:"id"`
ChatID int64 `json:"chat_id"`
SenderID int64 `json:"sender_id"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"` // время клиента
}

2. Время сервера (Server Timestamp)

Более надёжный вариант — время приёма сообщения сервером:

func (s *MessageService) SaveMessage(ctx context.Context, msg *Message) error {
msg.ServerTimestamp = time.Now().UnixNano()
return s.messageRepo.Save(ctx, msg)
}

Проблема: при параллельной обработке два сообщения могут получить одинаковое время или не в том порядке.

3. Монотонно возрастающий ID (Sequence Number)

Лучший подход для строгого упорядочивания. Используется либо автоинкремент в БД, либо распределённый генератор ID.

Вариант А: ID из БД

CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);

-- Запрос с гарантированным порядком
SELECT * FROM messages WHERE chat_id = $1 ORDER BY id DESC LIMIT 50;

Вариант B: Snowflake ID

Распределённый генератор ID, который монотонно возрастает:

type SnowflakeID struct {
timestamp int64
workerID int64
sequence int64
}

func (s *SnowflakeID) Generate() int64 {
now := time.Now().UnixMilli()
if now == s.timestamp {
s.sequence++
} else {
s.sequence = 0
s.timestamp = now
}
return (s.timestamp << 22) | (s.workerID << 12) | s.sequence
}

4. Логические часы (Lamport Timestamps / Vector Clocks)

Для систем с жёсткими требованиями к причинному порядку:

type LamportClock struct {
counter int64
mu sync.Mutex
}

func (c *LamportClock) Tick() int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.counter++
return c.counter
}

func (c *LamportClock) Update(remote int64) {
c.mu.Lock()
defer c.mu.Unlock()
if remote > c.counter {
c.counter = remote
}
c.counter++
}

Рекомендуемое решение для мессенджера

Гибридный подход:

CREATE TABLE messages (
id BIGINT PRIMARY KEY, -- Snowflake ID
chat_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
content TEXT NOT NULL,
client_timestamp BIGINT, -- для отображения
server_timestamp TIMESTAMP DEFAULT NOW(), -- для отладки
created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_messages_chat_id ON messages(chat_id, id DESC);
type MessageService struct {
idGenerator *SnowflakeID
messageRepo MessageRepository
}

func (s *MessageService) SaveMessage(ctx context.Context, msg *IncomingMessage) (*Message, error) {
message := &Message{
ID: s.idGenerator.Generate(),
ChatID: msg.ChatID,
SenderID: msg.SenderID,
Content: msg.Content,
ClientTimestamp: msg.Timestamp,
ServerTimestamp: time.Now(),
}

if err := s.messageRepo.Save(ctx, message); err != nil {
return nil, err
}

return message, nil
}

func (s *MessageService) GetMessages(ctx context.Context, chatID int64, beforeID int64, limit int) ([]*Message, error) {
return s.messageRepo.FindByChatBeforeID(ctx, chatID, beforeID, limit)
}

Запрос с cursor-based пагинацией

-- Получение сообщений перед определённым ID
SELECT * FROM messages
WHERE chat_id = $1 AND id < $2
ORDER BY id DESC
LIMIT $3;

Итоговое сравнение подходов

ПодходСтрогий порядокМасштабируемостьСложность
Client timestampНетВысокаяНизкая
Server timestampЧастичноВысокаяНизкая
DB auto-incrementДаСредняяНизкая
Snowflake IDДаВысокаяСредняя
Lamport clocksПричинныйВысокаяВысокая

Для мессенджера оптимально использовать Snowflake ID — он обеспечивает строгий порядок, масштабируемость и не требует координации между серверами.

Вопрос 8. Как спроектировать индикатор онлайн-статуса пользователей?

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

Ответ собеседника: Правильный. Предложено хранить в базе данных время последней активности каждого пользователя. Если пользователь был активен менее минуты назад — он считается онлайн. Если более минуты — отображается «был в сети N минут назад». Клиент периодически отправляет heartbeat-пинг на сервер для обновления статуса. Сервис индикатора онлайна выделен как отдельный, слабо связанный с сервисом сообщений.

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

Ответ собеседника правильный и покрывает основные аспекты. Дополним техническими деталями и рассмотрим крайние случаи.

Архитектура сервиса статусов

1. Хранение данных

Используем Redis как основное хранилище для быстрого доступа и PostgreSQL для персистентности:

CREATE TABLE user_status (
user_id BIGINT PRIMARY KEY,
last_seen_at TIMESTAMP NOT NULL,
is_online BOOLEAN DEFAULT false,
updated_at TIMESTAMP DEFAULT NOW()
);

В Redis:

type StatusRepository struct {
redis *redis.Client
}

const OnlineTTL = 2 * time.Minute

func (r *StatusRepository) SetOnline(ctx context.Context, userID int64) error {
key := fmt.Sprintf("status:%d", userID)
pipe := r.redis.Pipeline()
pipe.Set(ctx, key, "online", OnlineTTL)
pipe.Set(ctx, fmt.Sprintf("status:%d:last_seen", userID), time.Now().Unix(), 0)
_, err := pipe.Exec(ctx)
return err
}

func (r *StatusRepository) SetOffline(ctx context.Context, userID int64) error {
key := fmt.Sprintf("status:%d", userID)
pipe := r.redis.Pipeline()
pipe.Del(ctx, key)
pipe.Set(ctx, fmt.Sprintf("status:%d:last_seen", userID), time.Now().Unix(), 0)
_, err := pipe.Exec(ctx)
return err
}

func (r *StatusRepository) IsOnline(ctx context.Context, userID int64) (bool, error) {
key := fmt.Sprintf("status:%d", userID)
exists, err := r.redis.Exists(ctx, key).Result()
return exists > 0, err
}

func (r *StatusRepository) GetLastSeen(ctx context.Context, userID int64) (time.Time, error) {
key := fmt.Sprintf("status:%d:last_seen", userID)
ts, err := r.redis.Get(ctx, key).Int64()
if err != nil {
return time.Time{}, err
}
return time.Unix(ts, 0), nil
}

2. Heartbeat механизм

Клиент отправляет heartbeat каждые 30-60 секунд:

type StatusService struct {
repo StatusRepository
kafka KafkaProducer
heartbeatInterval time.Duration
}

func (s *StatusService) HandleHeartbeat(ctx context.Context, userID int64) error {
if err := s.repo.SetOnline(ctx, userID); err != nil {
return err
}

// Уведомляем подписчиков об изменении статуса
return s.kafka.Publish(ctx, "status_updates", &StatusUpdate{
UserID: userID,
IsOnline: true,
Timestamp: time.Now(),
})
}

func (s *StatusService) HandleDisconnect(ctx context.Context, userID int64) error {
if err := s.repo.SetOffline(ctx, userID); err != nil {
return err
}

return s.kafka.Publish(ctx, "status_updates", &StatusUpdate{
UserID: userID,
IsOnline: false,
Timestamp: time.Now(),
})
}

3. Определение статуса для отображения

type StatusInfo struct {
UserID int64
IsOnline bool
LastSeen time.Time
}

func (s *StatusService) GetDisplayStatus(ctx context.Context, userID int64) (*StatusInfo, error) {
isOnline, _ := s.repo.IsOnline(ctx, userID)
lastSeen, _ := s.repo.GetLastSeen(ctx, userID)

return &StatusInfo{
UserID: userID,
IsOnline: isOnline,
LastSeen: lastSeen,
}, nil
}

func (si *StatusInfo) DisplayString() string {
if si.IsOnline {
return "в сети"
}

minutesAgo := time.Since(si.LastSeen).Minutes()
if minutesAgo < 1 {
return "был(а) только что"
}
if minutesAgo < 5 {
return "был(а) недавно"
}
if minutesAgo < 60 {
return fmt.Sprintf("был(а) %.0f мин. назад", minutesAgo)
}
hoursAgo := minutesAgo / 60
if hoursAgo < 24 {
return fmt.Sprintf("был(а) %.0f ч. назад", hoursAgo)
}
return si.LastSeen.Format("был(а) 02.01.2006")
}

4. Подписка на обновления статусов

Для real-time обновления статусов контактов:

type StatusSubscriber struct {
redis *redis.Client
}

func (s *StatusSubscriber) SubscribeToContacts(ctx context.Context, userID int64) (<-chan *StatusUpdate, error) {
contactIDs := s.getContactIDs(ctx, userID)
channels := make([]string, len(contactIDs))
for i, id := range contactIDs {
channels[i] = fmt.Sprintf("status_updates:%d", id)
}

updates := make(chan *StatusUpdate, 100)
pubsub := s.redis.Subscribe(ctx, channels...)

go func() {
defer close(updates)
for msg := range pubsub.Channel() {
var update StatusUpdate
if err := json.Unmarshal([]byte(msg.Payload), &update); err == nil {
updates <- &update
}
}
}(), nil

return updates, nil
}

5. Настройки приватности

CREATE TABLE user_privacy_settings (
user_id BIGINT PRIMARY KEY,
last_seen_visibility VARCHAR(20) DEFAULT 'everyone',
-- 'everyone', 'contacts', 'nobody'
exceptions_allow BIGINT[] DEFAULT '{}',
exceptions_deny BIGINT[] DEFAULT '{}'
);
func (s *StatusService) CanSeeStatus(ctx context.Context, viewerID, targetID int64) (bool, error) {
settings, err := s.privacyRepo.GetSettings(ctx, targetID)
if err != nil {
return false, err
}

switch settings.LastSeenVisibility {
case "everyone":
return true, nil
case "nobody":
return false, nil
case "contacts":
return s.contactRepo.AreContacts(ctx, viewerID, targetID)
default:
return true, nil
}
}

6. Обработка крайних случаев

Множественные устройства:

func (s *StatusService) HandleDeviceConnect(ctx context.Context, userID int64, deviceID string) error {
key := fmt.Sprintf("user:%d:devices", userID)
s.redis.SAdd(ctx, key, deviceID)
s.redis.Expire(ctx, key, OnlineTTL)

// Если это первое устройство — устанавливаем онлайн
count, _ := s.redis.SCard(ctx, key).Result()
if count == 1 {
return s.HandleHeartbeat(ctx, userID)
}
return nil
}

func (s *StatusService) HandleDeviceDisconnect(ctx context.Context, userID int64, deviceID string) error {
key := fmt.Sprintf("user:%d:devices", userID)
s.redis.SRem(ctx, key, deviceID)

// Если устройств не осталось — устанавливаем офлайн
count, _ := s.redis.SCard(ctx, key).Result()
if count == 0 {
return s.HandleDisconnect(ctx, userID)
}
return nil
}

Graceful shutdown WebSocket-сервера:

func (s *StatusService) HandleServerShutdown(ctx context.Context, serverID string) error {
// Получаем всех пользователей на этом сервере
users, err := s.discovery.GetUsersOnServer(ctx, serverID)
if err != nil {
return err
}

for _, userID := range users {
// Проверяем, есть ли другие активные устройства
if !s.hasOtherActiveDevices(ctx, userID, serverID) {
s.HandleDisconnect(ctx, userID)
}
}
return nil
}

Итоговая архитектура

[Client] → [WebSocket Gateway] → [Status Service]

[Redis: status]

[Kafka: status_updates]

[WebSocket Gateway] → [Subscribed Clients]

Ключевые принципы:

  • Redis с TTL для автоматического перехода в офлайн.
  • Kafka для рассылки обновлений статусов подписчикам.
  • Поддержка множественных устройств.
  • Учёт настроек приватности.
  • Graceful обработка отключений.