> Как шардировать таблицы пользователей, чатов и сообщений (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: Ozon

Стек: Go

> Пример ответа

Короткий ответ

Шардирование таблиц пользователей, чатов и сообщений - это горизонтальное масштабирование БД, где данные распределяются по нескольким физическим узлам на основе ключа шардирования. Для пользователей - по user_id, для чатов - по chat_id, для сообщений - по chat_id (или composite key). Главный trade-off - выбор ключа: он должен обеспечивать равномерное распределение и минимизировать cross-shard запросы. Для сообщений часто используют шардирование по chat_id, чтобы все сообщения одного чата лежали на одном шарде, что упрощает пагинацию и чтение.

Подробное объяснение

Основные принципы

Шардирование - это разделение данных на независимые части (шарды), каждая из которых хранится на отдельном сервере. Ключевая задача - выбрать ключ шардирования (shard key), который определяет, на какой узел попадает запись.

Для таблицы пользователей

  • Ключ: user_id (hash-based или range-based)
  • Hash-шардирование: user_id % N или consistent hashing - обеспечивает равномерное распределение, но усложняет range-запросы
  • Range-шардирование: по диапазонам user_id (например, 1-1000000 на шард 1) - удобно для последовательных ID, но возможен перекос при неравномерной регистрации
  • Глобальный lookup: если нужен поиск по email или username, потребуется отдельный индекс или secondary index

Для таблицы чатов

  • Ключ: chat_id
  • Проблема: чаты бывают личные (2 участника) и групповые (много участников)
  • Для личных чатов можно шардировать по composite key (min(user_id), max(user_id)) - это гарантирует, что оба участника попадут на один шард
  • Для групповых - просто по chat_id

Для таблицы сообщений

  • Ключ: chat_id (основной вариант) или (chat_id, message_id)
  • Шардирование по chat_id: все сообщения чата на одном шарде - удобно для чтения истории, но возможен перекос (большие чаты создают hot spots)
  • Шардирование по (chat_id, message_id): позволяет распределять сообщения одного чата по разным шардам, но усложняет пагинацию и чтение истории (нужен scatter-gather)
  • Альтернатива: шардирование по message_id с отдельным индексом chat_id - но это требует глобального secondary index

Взаимосвязь таблиц

  • Пользователь → чаты: нужен индекс по user_id в таблице чатов (или отдельная таблица chat_members)
  • Чат → сообщения: сообщения шардируются по chat_id, поэтому чтение истории - это запрос на один шард
  • Сообщение → пользователь: для отображения автора нужен join с таблицей пользователей - это cross-shard запрос, если пользователь на другом шарде

Стратегии для cross-shard запросов

  • Денормализация: хранить в сообщениях копию user_name или user_avatar
  • Глобальный secondary index: отдельная таблица для поиска по неключевым полям
  • Scatter-gather: параллельные запросы на все шарды с последующей агрегацией

На практике

Выбор количества шардов

  • Начинать с 4-8 шардов, масштабировать по мере роста
  • Использовать consistent hashing для минимизации решардинга при добавлении узлов

Рекомендации по ключам

  • Пользователи: hash по user_id (равномерное распределение)
  • Чаты: hash по chat_id
  • Сообщения: hash по chat_id (для простоты) или composite (chat_id, message_id) для больших чатов

Решение проблемы hot spots

  • Для больших чатов (например, с миллионами сообщений) - использовать sub-sharding: chat_id + bucket (например, chat_id * 100 + bucket_id)
  • Или шардировать сообщения по (chat_id, message_id) и использовать кэширование для часто читаемых чатов

Миграция и решардинг

  • Двойная запись (dual write) на старый и новый шарды
  • Постепенный перенос данных с фоновым процессом
  • Использование proxy-слоя (например, Vitess, Citus) для автоматического роутинга

Инфраструктура

  • В Go: использовать клиентские библиотеки для роутинга запросов (например, sharding middleware)
  • На уровне приложения: определить функцию shardKey(userID) → shardID, которая используется во всех запросах

Пример кода

GO
package sharding
import (
"hash/fnv"
"strconv"
)
type ShardManager struct {
shardCount int
}
func NewShardManager(count int) *ShardManager {
return &ShardManager{shardCount: count}
}
// ShardKeyForUser возвращает номер шарда для user_id
func (sm *ShardManager) ShardKeyForUser(userID int64) int {
return sm.hash(userID)
}
// ShardKeyForChat возвращает номер шарда для chat_id
func (sm *ShardManager) ShardKeyForChat(chatID int64) int {
return sm.hash(chatID)
}
// ShardKeyForMessage возвращает номер шарда для сообщения
// Шардируем по chat_id, чтобы все сообщения чата были на одном шарде
func (sm *ShardManager) ShardKeyForMessage(chatID int64, messageID int64) int {
// Для простоты используем chat_id
// Для больших чатов можно использовать composite: hash(chatID*1000 + messageID%100)
return sm.hash(chatID)
}
func (sm *ShardManager) hash(key int64) int {
h := fnv.New32a()
h.Write([]byte(strconv.FormatInt(key, 10)))
return int(h.Sum32() % uint32(sm.shardCount))
}
// Пример использования в репозитории
type MessageRepository struct {
shards []*Shard // каждый шард - это отдельное подключение к БД
manager *ShardManager
}
func (r *MessageRepository) GetMessages(chatID int64, limit int) ([]Message, error) {
shardID := r.manager.ShardKeyForChat(chatID)
shard := r.shards[shardID]
// Выполняем запрос на конкретном шарде
return shard.QueryMessages(chatID, limit)
}
func (r *MessageRepository) CreateMessage(msg *Message) error {
shardID := r.manager.ShardKeyForChat(msg.ChatID)
shard := r.shards[shardID]
return shard.InsertMessage(msg)
}

Как отвечать на собеседовании

Начните с определения ключа шардирования для каждой таблицы и обоснуйте выбор. Покажите понимание trade-off между равномерным распределением и простотой запросов. Обязательно упомяните проблему cross-shard запросов и предложите решения (денормализация, глобальные индексы). Для сообщений подчеркните, что шардирование по chat_id - это стандартный подход, но для больших чатов нужны дополнительные меры. Если спросят про решардинг - опишите consistent hashing и стратегии миграции. В конце спросите интервьюера про масштаб системы, чтобы уточнить ответ.

Что проверяет интервьюер

  • Понимание принципов горизонтального масштабирования БД
  • Умение выбирать ключ шардирования с учётом паттернов доступа
  • Знание проблем hot spots и способов их решения
  • Понимание последствий для запросов (cross-shard, scatter-gather)
  • Практический опыт с реальными системами (не только теория)
  • Умение рассуждать о trade-off и предлагать компромиссы

Типичные ошибки

  • Шардирование сообщений по message_id без учёта chat_id - приводит к сложной пагинации и медленному чтению истории
  • Игнорирование проблемы hot spots для популярных чатов
  • Отсутствие плана решардинга при росте данных
  • Предложение использовать JOIN между шардами без понимания стоимости
  • Выбор range-шардирования без учёта перекосов (например, по времени создания)
  • Забывают про secondary index для поиска по неключевым полям (email, username)
  • Не учитывают, что личные чаты нужно шардировать так, чтобы оба участника попадали на один шард

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью