Открытое System Design интервью на Senior Go-разработчика
Сегодня мы разберём системный дизайн мессенджера уровня 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-группы) читают и доставляют своим подключённым пользователям. Для доставки на несколько устройства используется паттерн:
- Топик с партиционировацией по
user_id— все сообщения конкретного пользователя попадают в одну партицию. - Каждый WebSocket-сервер подписывается на все партиции (или использует consumer group с одним потребителем на сервер).
- Сервер доставляет только тем пользователям, которые к нему подключены.
Альтернатива — отдельный топик на каждый 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
}
Оценка количества серверов
| Компонент | Нагрузка | Серверов |
|---|---|---|
| WebSocket | 50 млн соединений, 10K на сервер | 5 000 |
| Message Service | 300K msg/sec | 100-200 |
| Chat Service | 100K requests/sec | 50-100 |
| Media Service | 50K uploads/sec | 100-150 |
| PostgreSQL | 25 млрд записей/день | 50-100 (шарды) |
| Redis | 100M активных ключей | 20-30 |
| Kafka | 300K msg/sec | 15-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()
}
Сводная таблица технологий хранения
| Компонент | Основное хранилище | Кэш | Поиск |
|---|---|---|---|
| Auth | PostgreSQL | Redis | - |
| Users | PostgreSQL | Redis | - |
| Chats | PostgreSQL | Redis | - |
| Messages | PostgreSQL | Redis | Elasticsearch |
| Media | S3/MinIO | CDN | - |
| Status | Redis | - | - |
| Notifications | PostgreSQL | - | - |
| Discovery | Redis | - | - |
Общая схема архитектуры
[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 обработка отключений.
