ПИШЕМ API СЕРВИС НА СОБЕСЕДОВАНИИ В VK / ТЕХНИЧЕСКОЕ ИНТЕРВЬЮ НА GO-РАЗРАБОТЧИКА (ВТОРОЙ ЭТАП)
Сегодня мы разберем второй этап технического собеседования на позицию разработчика в ВК, где кандидату предлагалось реализовать production-ready микросервис на Go для подсчёта уникальных кликов по публикациям авторов. В ходе часового сессии кандидат продемонстрировал способность самостоятельно спроектировать структуру данных, написать рабочий код с обработкой запросов, валидацией и конкурентным доступом, а затем уверенно обсудил с интервьюером вопросы масштабирования, персистентности и надёжности сервиса — включая шардирование, репликацию, батчинг и режимы хранения в Redis. Несмотря на техническое прерывание трансляции в конце, кандидат успешно прошёл этап и получил приглашение на финальное собеседование в команду VK Clips.
Вопрос 1. Расскажите о вашем опыте работы и технологическом стеке.
Таймкод: 00:02:47
Ответ собеседника: Правильный. Кандидат работает в Яндексе в бизнес-юните Яндекс 360 (Телемост, мессенджер, почта, календарь). Зона ответственности — счётчики прочитанных сообщений, диалоги и чаты в B2B-мессенджере. Счётчики хранятся в Redis как горячие данные, холодное хранилище сканирует данные при протухании горячих. Бэкграунд-джобы обновляют счётчики раз в час полной перезагрузкой данных, без диффов.
Правильный ответ:
Это вводный вопрос, и кандидал дал структурированный ответ, описав текущую роль, продукт, архитектурные детали и паттерны работы с данными. Для полноценного ответа на подобный вопрос рекомендуется дополнительно раскрыть следующие аспекты:
Технологический стек
Стоит явно перечислить основные технологии и инструменты, с которыми работал кандидат: языки программирования (Go, Python и т. д.), базы данных (Redis, PostgreSQL, ClickHouse и др.), брокеры сообщений (Kafka, RabbitMQ), системы оркестрации (Kubernetes), мониторинг (Grafana, Prometheus, Jaeger), CI/CD-пайплайны.
Масштаб и нагрузка
Важно указывать количественные характеристики: RPS, объём данных, количество пользователей, latency-требования. Например: «Система обрабатывала X млн событий в сутки с p99 latency не более Y мс».
Архитектурные решения и влияние
Описать, какие архитектурные решения принимал кандидат, какие проблемы решал и какие результаты получил. Например: «Перевёл сервис с монолитной архитектуры на микросервисную, что позволило сократить время деплоя с 30 минут до 2 минут».
Ключевые достижения
Подчеркнуть конкретные результаты: повышение производительности, снижение затрат на инфраструктуру, внедрение новых паттернов (circuit breaker, rate limiting, graceful degradation).
Пример расширенного ответа:
«Работаю в Яндексе в команде B2B-мессенджера. Моя зона ответственности — подсистема счётчиков непрочитанных сообщений. Горячие данные хранятся в Redis Cluster, холодное хранилище — в PostgreSQL. Для обновления счётчиков запускаются фоновые джобы на основе Temporal, которые пересчитывают состояние раз в час. Ранее данные обновлялись инкрементально по диффам, но мы перешли на полную перезагрузку из-за накопления рассинхрона — это повысило консистентность ценой увеличения нагрузки на БД. Стек — Go, gRPC, Redis, PostgreSQL, Kafka для event sourcing, Kubernetes для оркестрации. Нагрузка порядка 500K RPS на пике, p99 latency на чтение счётчиков — менее 10 мс».
Вопрос 2. Как бы вы реализовали сервис для подсчёта уникальных кликов по публикациям автора за календарные сутки?
Таймкод: 00:07:01
Ответ собеседника: Неполный. Кандидат предложил in-memory структуру данных (map[authorID]map[date]map[userID]struct{}), реализовал базовую валидацию, логику записи клика и получения количества уникальных кликов за вчера, механизм очистки старых данных. Однако код не был завершён и не протестирован. Выявлены проблемы: отсутствие персистентности, гонки данных при конкурентном доступе к lastCleanupDay, использование Mutex вместо RWMutex для операций чтения.
Правильный ответ:
Архитектура сервиса
Сервис должен состоять из нескольких слоёв: HTTP-хендлер для приёма запросов, бизнес-логика подсчёта уникальных кликов, слой хранения данных и фоновый процесс очистки устаревших записей.
Выбор структуры данных
Для in-memory хранения подходит структура map[int64]map[string]map[int64]struct{}, где первый ключ — ID автора, второй — дата в формате YYYY-MM-DD, третий — ID пользователя. Пустые структуры struct{} не занимают памяти и идеальны для реализации множества.
Однако для production-решения лучше использовать Redis с структурой HyperLogLog или Redis Sets. HyperLogLog даёт приближённый подсчёт с погрешностью ~0.81%, но потребляет фиксированные 12 КБ на ключ. Redis Sets дают точный подсчёт, но потребляют больше памяти при большом количестве уникальных пользователей.
Конкурентный доступ
Для защиты от гонок данных необходимо использовать sync.RWMutex. Операции чтения (GET-запросы) блокируют мьютекс на чтение (RLock), операции записи (POST-запросы и очистка) — на полную блокировку (Lock). Это позволяет параллельно обрабатывать множество запросов на чтение.
Реализация на Go
package main
import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
)
type ClickService struct {
mu sync.RWMutex
data map[int64]map[string]map[int64]struct{} // authorID -> date -> set of userIDs
lastCleanup time.Time
}
func NewClickService() *ClickService {
return &ClickService{
data: make(map[int64]map[string]map[int64]struct{}),
}
}
func (s *ClickService) RecordClick(authorID, userID int64) {
today := time.Now().UTC().Format("2006-01-02")
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[authorID]; !ok {
s.data[authorID] = make(map[string]map[int64]struct{})
}
if _, ok := s.data[authorID][today]; !ok {
s.data[authorID][today] = make(map[int64]struct{})
}
s.data[authorID][today][userID] = struct{}{}
s.cleanupIfNeeded()
}
func (s *ClickService) GetUniqueClicksYesterday(authorID int64) int {
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
s.mu.RLock()
defer s.mu.RUnlock()
if authorData, ok := s.data[authorID]; ok {
if dayData, ok := authorData[yesterday]; ok {
return len(dayData)
}
}
return 0
}
func (s *ClickService) cleanupIfNeeded() {
today := time.Now().UTC().Truncate(24 * time.Hour)
if s.lastCleanup.Before(today) {
cutoff := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
for authorID, authorData := range s.data {
for date := range authorData {
if date < cutoff {
delete(authorData, date)
}
}
if len(authorData) == 0 {
delete(s.data, authorID)
}
}
s.lastCleanup = time.Now().UTC()
}
}
func (s *ClickService) handleClick(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authorID, err := strconv.ParseInt(r.URL.Query().Get("author_id"), 10, 64)
if err != nil || authorID <= 0 {
http.Error(w, "Invalid author_id", http.StatusBadRequest)
return
}
userID, err := strconv.ParseInt(r.URL.Query().Get("user_id"), 10, 64)
if err != nil || userID <= 0 {
http.Error(w, "Invalid user_id", http.StatusBadRequest)
return
}
s.RecordClick(authorID, userID)
w.WriteHeader(http.StatusOK)
}
func (s *ClickService) handleGetClicks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authorID, err := strconv.ParseInt(r.URL.Query().Get("author_id"), 10, 64)
if err != nil || authorID <= 0 {
http.Error(w, "Invalid author_id", http.StatusBadRequest)
return
}
count := s.GetUniqueClicksYesterday(authorID)
fmt.Fprintf(w, "%d", count)
}
func main() {
service := NewClickService()
http.HandleFunc("/click", service.handleClick)
http.HandleFunc("/clicks", service.handleGetClicks)
http.ListenAndServe(":8080", nil)
}
Альтернативное решение на Redis
Для production-среды рекомендуется использовать Redis:
func (s *RedisClickService) RecordClick(ctx context.Context, authorID, userID int64) error {
today := time.Now().UTC().Format("2006-01-02")
key := fmt.Sprintf("clicks:%d:%s", authorID, today)
return s.client.SAdd(ctx, key, userID).Err()
}
func (s *RedisClickService) GetUniqueClicksYesterday(ctx context.Context, authorID int64) (int64, error) {
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
key := fmt.Sprintf("clicks:%d:%s", authorID, yesterday)
return s.client.SCard(ctx, key).Result()
}
Ключи в Redis автоматически удаляются по TTL (например, 48 часов), что избавляет от необходимости ручной очистки.
Масштабирование
При росте нагрузки стоит рассмотреть:
- Шардирование по authorID для распределения нагрузки
- Использование HyperLogLog вместо Sets для экономии памяти (PFADD/PFCOUNT)
- Асинхронную запись через очередь сообщений (Kafka) с батчевой обработкой
- Кэширование результатов подсчёта для часто запрашиваемых авторов
Вопрос 3. Почему текущая реализация сервиса ненадёжна и что произойдёт при деплое в продакшн?
Таймкод: 01:03:10
Ответ собеседника: Правильный. Кандидат верно указал на потерю данных при перезапуске сервиса из-за in-memory хранения без персистентности.
Правильный ответ:
In-memory реализация имеет ряд критических проблем для production-использования:
Потеря данных при перезапуске
При любом перезапуске процесса — деплой, OOM kill, падение контейнера, обрыв питания — все данные полностью теряются. Это неприемлемо для систем, где важна консистентность и доступность данных.
Отсутствие горизонтального масштабирования
Сервис хранит состояние локально. При запуске нескольких реплик каждая будет иметь свой изолированный набор данных. Запросы от одного и того же пользователя, попавшие на разные реплики, дадут разные результаты. Это нарушает корректность подсчёта уникальных кликов.
Проблемы консистентности
Даже в рамках одного процесса при высокой конкурентности возможны гонки данных, если неправильно реализована синхронизация. В текущей реализации переменная lastCleanup изменяется внутри блокировки, но при неаккуратной рефакторингии легко допустить race condition.
Отсутствие мониторинга и observability
Нет метрик (RPS, latency, error rate), нет структурированных логов, нет трейсинга. При возникновении проблем в продакшне диагностика будет крайне затруднена.
Нет graceful shutdown
При остановке сервиса нет обработки сигналов SIGTERM/SIGINT для завершения текущих запросов и корректного освобождения ресурсов.
Нет rate limiting и защиты от DDoS
Отсутствие ограничения частоты запросов делает сервис уязвимым к злоупотреблениям и перегрузке.
Рекомендуемые улучшения
Для production-деплоя необходимо вынести хранение данных в персистентное хранилище (Redis, PostgreSQL), добавить мониторинг через Prometheus и Grafana, реализовать graceful shutdown, настроить health-check эндпоинты, добавить rate limiting и circuit breaker. Также следует предусмотреть механизм миграции данных при изменении схемы хранения.
Вопрос 4. Как увеличить количество обрабатываемых RPS (запросов в секунду)?
Таймкод: 01:04:49
Ответ собеседника: Неполный. Кандидат предложил увеличить количество ядер процессора, что является верным, но неполным ответом. Не упомянуто горизонтальное масштабирование, балансировка нагрузки, оптимизация кэширования и переход на внешнее хранилище.
Правильный ответ:
Увеличение RPS — это комплексная задача, требующая оптимизации на нескольких уровнях.
Горизонтальное масштабирование
Самый эффективный способ — запуск нескольких реплик сервиса за балансировщиком нагрузки (Nginx, HAProxy, cloud LB). Это позволяет линейно наращивать пропускную способность. Для корректной работы необходимо вынести состояние из процесса во внешнее хранилище (Redis Cluster, база данных), чтобы все реплики работали с единым источником данных.
Оптимизация блокировок
В текущей реализации используется sync.RWMutex, который блокирует все операции записи. Для высоконагруженных систем стоит рассмотреть:
- Шардирование мьютексов — отдельный мьютекс для каждого автора или группы авторов
- Lock-free структуры данных через
sync/atomicдля простых счётчиков - Использование
sync.Mapпри большом количестве ключей и редкой записи
type ShardedMutex struct {
shards [256]sync.RWMutex
}
func (s *ShardedMutex) getShard(key int64) *sync.RWMutex {
return &s.shards[key%256]
}
Кэширование и батчинг
Для операций чтения можно кэшировать результаты подсчёта с коротким TTL (1–5 секунд). Для записи — использовать батчинг: накапливать клики в буфере и отправлять пачками в хранилище.
type ClickBatch struct {
mu sync.Mutex
clicks []ClickEvent
maxSize int
flushInterval time.Duration
}
Оптимизация сетевого взаимодействия
- Использовать connection pooling для соединений с базой данных или Redis
- Включить HTTP keep-alive
- Рассмотреть gRPC вместо REST для межсервисного взаимодействия (меньше накладных расходов на сериализацию)
- Использовать протobuf вместо JSON для внутренней коммуникации
Оптимизация на уровне инфраструктуры
- Увеличение количества CPU и памяти (вертикальное масштабирование) — даёт временный эффект
- Размещение сервиса ближе к пользователям (CDN edge, региональные дата-центры)
- Использование более производительных сетевых интерфейсов
Профилирование и оптимизация горячих путей
Необходимо профилировать сервис с помощью pprof для выявления узких мест:
import _ "net/http/pprof"
go func() {
http.ListenAndServe(":6060", nil)
}()
Анализ показателей через go tool pprof позволяет найти функции с наибольшим потреблением CPU и памяти, и оптимизировать именно их.
Выбор правильного хранилища
Для записи с высокой RPS подходит Redis Cluster с шардированием. Для подсчёта уникальных значений — HyperLogLog (PFADD/PFCOUNT), который потребляет фиксированные 12 КБ на ключ независимо от количества элементов.
Вопрос 5. Как обеспечить отказоустойчивость сервиса при выходе из строя одного дата-центра?
Таймкод: 01:05:27
Ответ собеседника: Правильный. Кандидат предложил горизонтальное масштабирование с несколькими инстансами в разных дата-центрах и балансировку нагрузки.
Правильный ответ:
Обеспечение отказоустойчивости при выходе из строя целого дата-центра требует многоуровневой стратегии.
Мульти-датацентровая архитектура (Multi-DC)
Сервис должен быть развёрнут минимум в двух-трёх географически распределённых дата-центрах. Балансировщик нагрузки уровня DNS (Route 53, Cloudflare) или Anycast маршрутизация распределяют трафик между дата-центрами. При недоступности одного ДЦ трафик автоматически перенаправляется на оставшиеся.
Репликация данных между дата-центрами
Для Redis используется Redis Sentinel или Redis Cluster с кросс-ДЦ репликацией. Для баз данных — асинхронная или полусинхронная репляция. Важно понимать компромисс между консистентностью и доступностью (CAP-теорема): при разделении сети между ДЦ нужно выбрать между CP (отказ в записи) или AP (возможная потеря данных).
Стратегия failover
- Active-Active — все ДЦ обрабатывают трафик одновременно. Требует разрешения конфликтов при записи в оба ДЦ.
- Active-Passive — один ДЦ основной, второй горячий резерв. При падении основного происходит переключение (failover) на резервный.
- Active-Passive с warm standby — резервный ДЦ получает реплику данных, но не обрабатывает трафик до момента переключения.
Автоматическое обнаружение отказов
Используются health-check механизмы на уровне балансировщика и service discovery (Consul, etcd). При обнаружении недоступности ДЦ балансировщик исключает его из пула и перенаправляет трафик.
Идемпотентность и retry-логика
Клиенты должны реализовывать retry с экспоненциальным откатом (exponential backoff) и jitter для предотвращения thundering herd. Операции записи должны быть идемпотентными, чтобы повторные запросы не приводили к дублированию данных.
func (c *Client) DoWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err := c.httpClient.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
lastErr = err
time.Sleep(time.Duration(math.Pow(2, float64(attempt))) * time.Millisecond)
}
return nil, lastErr
}
Graceful degradation
При недоступности части инфраструктуры сервис должен продолжать работать в ограниченном режиме: возвращать кэшированные данные, временно отключать некритичные функции, использовать circuit breaker для предотвращения каскадных отказов.
Мониторинг и алертинг
Необходим мониторинг здоровья каждого ДЦ, репликации данных, задержек между ДЦ. Алерты должны настраиваться на отклонения от нормы для быстрого реагирования инцидент-менеджментом.
Вопрос 6. Как решить проблему разделения данных между несколькими инстансами сервиса?
Таймкод: 01:06:22
Ответ собеседника: Правильный. Кандидат предложил использовать внешнее хранилище данных для обеспечения единого источника данных между инстансами.
Правильный ответ:
Проблема split-brain (разделения данных) возникает, когда каждый инстанс сервиса хранит своё состояние локально и не синхронизирует его с другими. Решения делятся на несколько категорий.
Централизованное хранилище
Самый простой и распространённый подход — вынести состояние во внешнее хранилище, доступное всем инстансам.
Redis Cluster — распределённое хранилище с шардированием данных. Каждый инстанс сервиса обращается к Redis, который гарантирует консистентность данных.
type RedisClickStore struct {
client *redis.ClusterClient
}
func (s *RedisClickStore) RecordClick(ctx context.Context, authorID, userID int64) error {
today := time.Now().UTC().Format("2006-01-02")
key := fmt.Sprintf("clicks:%d:%s", authorID, today)
return s.client.SAdd(ctx, key, userID).Err()
}
PostgreSQL — для строгой консистентности и сложных запросов. Подходит, когда важна ACID-семантика.
CREATE TABLE clicks (
author_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
click_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (author_id, user_id, click_date)
);
-- Подсчёт уникальных кликов за вчера
SELECT COUNT(DISTINCT user_id)
FROM clicks
WHERE author_id = $1 AND click_date = CURRENT_DATE - INTERVAL '1 day';
Распределённые алгоритмы консенсуса
Для систем, где недопустима единая точка отказа хранилища, используются алгоритмы консенсуса:
- Raft (реализован в etcd, Consul) — лидер координирует запись, фолловеры реплицируют изменения
- Paxos — более сложный алгоритм, используется в Google Spanner, Apache ZooKeeper
Шардирование по инстансам
Если каждый инстанс обслуживает свой набор данных (например, по хешу authorID), проблема разделения решается на уровне маршрутизации. Балансировщик направляет запросы конкретного автора всегда на один и тот же инстанс (consistent hashing).
func GetInstance(authorID int64, instances []string) string {
hash := fnv.New64a()
hash.Write([]byte(strconv.FormatInt(authorID, 10)))
return instances[hash.Sum64()%uint64(len(instances))]
}
Event Sourcing и CQRS
События кликов записываются в неизменяемый лог (Kafka), а инстансы строят локальное представление (projection) из этого лога. При перезапуске инстанс восстанавливает состояние, перечитывая события.
Выбор стратегии
Для большинства случаев оптимально централизованное хранилище (Redis или PostgreSQL). Выбор зависит от требований: Redis — для высокой пропускной способности и низкой латентности, PostgreSQL — для строгой консистентности и сложной аналитики.
Вопрос 7. Как обеспечить сохранность данных при падении инстанса Redis?
Таймкод: 01:07:55
Ответ собеседника: Правильный. Кандидат предложил репликацию Redis с основным инстансом и репликами, а также персистентность через AOF и RDB.
Правильный ответ:
Redis — это in-memory хранилище, и без дополнительных механизмов данные теряются при перезапуске. Для обеспечения сохранности используется комбинация репликации и персистентности.
Репликация (Replication)
Redis поддерживает асинхронную репликацию: мастер отправляет поток команд репликам, которые воспроизводят изменения. При падении мастера одна из реплик может быть повышена до мастера.
Конфигурация реплики в redis.conf:
replicaof 192.168.1.1 6379
masterauth your
#### **Вопрос 8**. Почему режим персистентности в Redis не включён по умолчанию и какие у него недостатки?
**Таймкод:** <YouTubeSeekTo id="hJekwE1uVBM" time="01:11:33"/>
**Ответ собеседника:** **Правильный**. Кандидат указал на добавление задержки из-за записи на диск и дополнительную нагрузку на систему.
**Правильный ответ:**
Redis по умолчанию работает как чистое in-memory хранилище, и это осознанное архитектурное решение.
**Почему персистентность отключена по умолчанию**
Redis проектировался как кэш и брокер сообщений, где скорость важнее долговременного хранения. Запись на диск — это блокирующая операция, которая снижает пропускную способность. Включение персистентности по умолчанию ухудшило бы производительность для сценариев, где данные не критичны (кэш сессий, rate limiting, временные очереди).
**Недостатки RDB (snapshotting)**
RDB создаёт снимок данных в определённые интервалы (например, каждые 5 минут при 100 изменениях). Недостатки:
- Потеря данных между снимками — при падении теряются все изменения с последнего сохранения
- Fork процесса — для создания снимка Redis вызывает `fork()`, что удваивает потребление памяти в момент сохранения
- Блокировка на время fork при больших объёмах данных
save 300 100 -- сохранить через 300 секунд, если было 100 изменений
**Недостатки AOF (Append Only File)**
AOF записывает каждую команду изменения в лог-файл. Недостатки:
- Разрастание файла — AOF-файл может значительно превышать объём данных в памяти (одно и то же значение перезаписывается многократно)
- Замедление записи — каждая операция записи дублируется в файл на диске
- Время восстановления — при перезапуске Redis воспроизводит все команды из AOF, что может занимать минуты при большом логе
- Необходимость периодического перезаписи (rewrite) для компактизации
appendfsync everysec -- компромисс между скоростью и надёжностью
**Комбинированный подход (Redis 7.0+)**
Начиная с Redis 7.0, поддерживается комбинированный режим, где AOF используется для записи инкрементных изменений, а RDB-снимок хранит начальное состояние. Это ускоряет восстановление и снижает потери данных.
**Рекомендации**
Для production-систем рекомендуется включать оба механизма одновременно и использовать Redis Sentinel или Redis Cluster для автоматического failover. Также стоит настроить мониторинг размера AOF-файла и запускать `BGREWRITEAWO` по расписанию.
#### **Вопрос 9**. Как решить проблему нехватки места для хранения данных при росте объёма?
**Таймкод:** <YouTubeSeekTo id="hJekwE1uVBM" time="01:12:19"/>
**Ответ собеседника:** **Неполный**. Кандидат предложил добавление дисков и шардирование Redis по author ID, но не упомянул проблему неравномерного распределения нагрузки при шардировании.
**Правильный ответ:**
**Вертикальное масштабование (Scale Up)**
Увеличение объёма памяти и дискового пространства на существующих серверах — самый простой подход, но он имеет физические и экономические ограничения. Стоимость растёт нелинейно, и в какой-то момент становится дешевле добавить новый сервер, чем модернизировать существующий.
**Горизонтальное масштабирование через шардирование**
Шардирование — распределение данных между несколькими узлами. Redis Cluster использует 16384 хеш-слота, распределённых между мастерами.
```go
// Определение слота ключа в Redis Cluster
slot := crc16(key) % 16384
Проблема hot keys
При шардировании по authorID популярные авторы (celebrities) создают неравномерную нагрузку на один шард. Решения:
- Вынос hot keys в отдельный шард с увеличенным количеством реплик
- Использование локального кэша на инстансе сервиса для частых запросов
- Динамическое перераспределение слотов в Redis Cluster
# Перемещение слота между нодами Redis Cluster
redis-cli --cluster reshard 127.0.0.1:7000
TTL и политика вытеснения
Установка TTL на ключи позволяет автоматически удалять устаревшие данные:
// Установка TTL 48 часа для ключа кликов
client.Expire(ctx, key, 48*time.Hour)
Политики вытеснения при нехватке памяти настраиваются в redis.conf:
maxmemory 4gb
maxmemory-policy allkeys-lru
Доступные политики:
noeviction— ошибка при нехватке памяти (данные не теряются, но записи блокируются)allkeys-lru— вытеснение наименее используемых ключейvolatile-lru— вытеснение ключей с установленным TTLallkeys-lfu— вытеснение наименее часто используемых ключей (Redis 4.0+)
Сжатие данных
Использование более компактных структур данных. Для подсчёта уникальных значений HyperLogLog потребляет фиксированные 12 КБ на ключ вместо потенциально гигабайтов для Set:
// PFADD вместо SAdd — погрешность ~0.81%, но фиксированный размер
client.PFAdd(ctx, key, userID)
count, _ := client.PFCount(ctx, key)
Архитектура с горячими и холодными данными
Разделение данных по частоте доступа:
- Горячие данные (последние 24–48 часов) хранятся в Redis для быстрого доступа
- Холодные данные перемещаются в колоночное хранилище (ClickHouse, S3) для аналитики
- Фоновый процесс переносит данные между слоями
Partitioning в реляционных базах
При использовании PostgreSQL — партиционирование таблицы по дате:
CREATE TABLE clicks (
author_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
click_date DATE NOT NULL
) PARTITION BY RANGE (click_date);
CREATE TABLE clicks_2024_01 PARTITION OF clicks
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
Старые партиции можно архивировать или удалять без влияния на производительность активных данных.
Вопрос 10. Как решить проблему горячего шарда при шардировании по author ID?
Таймкод: 01:15:56
Ответ собеседника: Правильный. Кандидат предложил бакетирование — разделение на диапазоны author ID для более равномерного распределения нагрузки.
Правильный ответ:
Проблема hot shard возникает, когда небольшое количество ключей генерирует непропорционально большую нагрузку. Для популярных авторов один шард может получать в сотни раз больше запросов, чем остальные.
Бакетирование (Key Splitting)
Разделение одного логического ключа на несколько физических. Например, клики автора распределяются по N бакетам, а при чтении результаты агрегируются:
const numBuckets = 64
func RecordClick(ctx context.Context, authorID, userID int64) error {
today := time.Now().UTC().Format("2006-01-02")
bucket := userID % numBuckets
key := fmt.Sprintf("clicks:%d:%s:%d", authorID, today, bucket)
return client.SAdd(ctx, key, userID).Err()
}
func GetUniqueClicks(ctx context.Context, authorID int64) (int64, error) {
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
pipe := client.Pipeline()
cmds := make([]*redis.IntCmd, numBuckets)
for i := 0; i < numBuckets; i++ {
key := fmt.Sprintf("clicks:%d:%s:%d", authorID, yesterday, i)
cmds[i] = pipe.SCard(ctx, key)
}
_, err := pipe.Exec(ctx)
if err != nil {
return 0, err
}
var total int64
for _, cmd := range cmds {
total += cmd.Val()
}
return total, nil
}
Недостаток: операция чтения требует обращения к нескольким шардам, что увеличивает latency.
Дедicated шарды для hot keys
Выделение отдельных узлов Redis для наиболее популярных авторов. Service discovery или конфигурация маршрутизирует запросы к hot авторам на выделенные ресурсы.
type Router struct {
hotAuthors map[int64]*redis.Client
defaultShard *redis.Client
}
func (r *Router) getClient(authorID int64) *redis.Client {
if client, ok := r.hotAuthors[authorID]; ok {
return client
}
return r.defaultShard
}
Локальный кэш на инстансе сервиса
Кэширование результатов подсчёта в памяти инстанса сервиса с коротким TTL (1–5 секунд). Это снижает нагрузку на Redis для часто запрашиваемых авторов:
type LocalCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
}
type cacheEntry struct {
value int64
expiresAt time.Time
}
func (c *LocalCache) Get(key string) (int64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok || time.Now().After(entry.expiresAt) {
return 0, false
}
return entry.value, true
}
HyperLogLog для приближённого подсчёта
Для сценариев, где допустима погрешность ~0.81%, HyperLogLog значительно снижает потребление памяти и нагрузку:
// Запись: O(1) по памяти — фиксированные 12 КБ на ключ
client.PFAdd(ctx, fmt.Sprintf("hll:%d:%s", authorID, today), userID)
// Чтение: O(1)
count, _ := client.PFCount(ctx, fmt.Sprintf("hll:%d:%s", authorID, today))
Rate limiting на уровне авторов
Ограничение частоты запросов от одного автора для защиты от злоупотреблений и сглаживания пиков:
limiter := rate.NewLimiter(rate.Limit(1000), 2000) // 1000 RPS, burst 2000
func (s *Service) handleClick(w http.ResponseWriter, r *http.Request) {
authorID := getAuthorID(r)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// ...
}
Комбинированный подход
На практике используется комбинация: бакетирование для равномерного распределения записи, локальный кэш для горячих чтений, HyperLogLog для экономии памяти, и dedicated шарды для экстремально hot авторов.
Вопрос 11. Как батчинг запросов может помочь с проблемой горячего шарда?
Таймкод: 01:21:18
Ответ собеседника: Неполный. Кандидат не до конца раскрыл, как батчинг решает проблему горячего шарда, упомянув только уменьшение количества операций записи.
Правильный ответ:
Батчинг действительно не решает проблему неравномерного распределения данных напрямую, но он существенно снижает негативные последствия горячего шарда.
Снижение количества сетевых вызовов
Без батчинга каждый клик — это отдельный запрос к Redis. При 10 000 RPS на одного автора это 10 000 сетевых вызовов в секунду к одному шарду. Батчинг объединяет N кликов в один запрос:
type ClickBatcher struct {
mu sync.Mutex
buffer map[string]map[int64]struct{} // key -> set of userIDs
maxSize int
interval time.Duration
client *redis.Client
done chan struct{}
}
func NewClickBatcher(client *redis.Client, maxSize int, interval time.Duration) *ClickBatcher {
b := &ClickBatcher{
buffer: make(map[string]map[int64]struct{}),
maxSize: maxSize,
interval: interval,
client: client,
done: make(chan struct{}),
}
go b.flushLoop()
return b
}
func (b *ClickBatcher) Add(authorID, userID int64) {
key := b.buildKey(authorID)
b.mu.Lock()
if _, ok := b.buffer[key]; !ok {
b.buffer[key] = make(map[int64]struct{})
}
b.buffer[key][userID] = struct{}{}
shouldFlush := len(b.buffer) >= b.maxSize
b.mu.Unlock()
if shouldFlush {
b.Flush()
}
}
func (b *ClickBatcher) Flush() {
b.mu.Lock()
buffer := b.buffer
b.buffer = make(map[string]map[int64]struct{})
b.mu.Unlock()
pipe := b.client.Pipeline()
for key, userIDs := range buffer {
members := make([]interface{}, 0, len(userIDs))
for userID := range userIDs {
members = append(members, userID)
}
pipe.SAdd(context.Background(), key, members)
}
pipe.Exec(context.Background())
}
func (b *ClickBatcher) flushLoop() {
ticker := time.NewTicker(b.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.Flush()
case <-b.done:
b.Flush()
return
}
}
}
Дедупликация в буфере
При батчинге повторные клики одного пользователя в рамках одного батча автоматически дедуплицируются в локальном буфере (map[int64]struct{}). Это снижает нагрузку на Redis и уменьшает размер передаваемых данных.
Сглаживание пиков
Батчинг с фиксированным интервалом превращает burst-нагрузку в равномерную. Вместо 1000 запросов в секунду пиковой нагрузки шард получает 10 батчей по 100 элементов каждые 100 мс.
Команда MSET / Pipeline
Redis Pipeline позволяет отправить несколько команд одним TCP-пакетом:
pipe := client.Pipeline()
pipe.SAdd(ctx, "clicks:1:2024-01-15", 101, 102, 103)
pipe.SAdd(ctx, "clicks:2:2024-01-15", 201, 202)
pipe.Expire(ctx, "clicks:1:2024-01-15", 48*time.Hour)
_, err := pipe.Exec(ctx)
Ограничения батчинга
- Увеличение latency — данные записываются с задержкой до размера интервала батчинга
- Потенциальная потеря данных — при падении процесса неотправленный буфер теряется
- Не решает проблему чтения — горячие чтения по-прежнему создают нагрузку на шард
Для полного решения проблемы горячего шарда батчинг комбинируется с бакетированием, локальным кэшированием и горизонтальным масштабированием шарда.
