> Как пересчитывать и хранить агрегации для текущих периодов (час, день) для актуальных данных (Go)

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

Компании: BrightPattern

Стек: Go

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

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

Для пересчёта агрегаций по текущим периодам (час, день) используйте материализованные представления с инкрементальной стратегией: храните сырые события в отдельной таблице, агрегаты - в таблицах с ключом периода, и пересчитывайте только затронутые окна по расписанию или по триггеру. В Go это реализуется через фоновые воркеры с time.Ticker и атомарными обновлениями в БД. Для актуальности данных применяйте двухуровневую схему: быстрый слой (Redis) для последних минут и точный слой (PostgreSQL/ClickHouse) для завершённых периодов.

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

Основная проблема - баланс между актуальностью и стоимостью вычислений. Полный пересчёт каждого часа для всех данных неэффективен, поэтому используют инкрементальный подход.

Архитектура:

  • Сырые данные: таблица событий с временной меткой, партиционированная по времени.
  • Агрегаты: таблицы вида (period_start, metric, value) с уникальным ключом по периоду.
  • Механизм обновления:
    • для текущего незавершённого часа/дня - пересчёт по запросу или каждые N секунд;
    • для завершённых периодов - однократный расчёт и заморозка.

Ключевые решения:

  • Гранулярность хранения: храните агрегаты на уровне часа и дня отдельно, не выводите день из часов - это упрощает пересчёт.
  • Идемпотентность: пересчёт должен быть безопасен при повторном запуске - используйте INSERT ... ON CONFLICT DO UPDATE.
  • Консистентность: для текущего периода возможна гонка между записью события и чтением агрегата - решается через чтение из сырых данных для "горячего" окна.

В Go типичная реализация - фоновый воркер:

GO
ticker := time.NewTicker(time.Minute)
for range ticker.C {
recalcCurrentHour(ctx, db)
}

На практике

Для продакшена используйте комбинацию:

  1. Redis - храните агрегаты за текущий час с TTL, обновляйте при каждом событии через INCR или HINCRBY.
  2. PostgreSQL/ClickHouse - для завершённых периодов, пересчёт по cron (например, каждые 5 минут для текущего часа, раз в час для текущего дня).
  3. Backfill - при изменении логики агрегации запускайте полный пересчёт за нужный диапазон.

Пример стратегии для часа:

  • каждую минуту пересчитываем текущий час из сырых данных;
  • в начале следующего часа фиксируем результат предыдущего часа без изменений.

Для дня аналогично, но с шагом в 5-10 минут для текущего дня.

Пример кода

GO
type Aggregator struct {
db *sql.DB
}
func (a *Aggregator) RecalcHour(ctx context.Context, hour time.Time) error {
// hour - начало часа, за который пересчитываем
query := `
INSERT INTO hourly_metrics (period_start, metric, value)
SELECT date_trunc('hour', created_at), 'orders_count', COUNT(*)
FROM events
WHERE created_at >= $1 AND created_at < $1 + interval '1 hour'
GROUP BY 1
ON CONFLICT (period_start, metric) DO UPDATE SET value = EXCLUDED.value
`
_, err := a.db.ExecContext(ctx, query, hour)
return err
}
func (a *Aggregator) Run(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now().UTC()
currentHour := now.Truncate(time.Hour)
if err := a.RecalcHour(ctx, currentHour); err != nil {
log.Printf("recalc hour %v: %v", currentHour, err)
}
}
}
}

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

Начните с постановки проблемы: актуальность против стоимости. Затем опишите двухслойную схему: быстрый слой для текущего периода, точный - для завершённых. Упомяните инкрементальный пересчёт и идемпотентность. Приведите пример на Go с time.Ticker. Если спросят про масштабирование - скажите про партиционирование и шардирование по времени. Завершите trade-off: Redis быстрее, но менее точен; БД точнее, но дороже.

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

  • Понимание разницы между OLTP и OLAP нагрузками.
  • Умение проектировать инкрементальные вычисления.
  • Знание паттернов фоновых задач в Go.
  • Осознание проблем консистентности при чтении текущих данных.
  • Способность обосновать выбор хранилища под задачу.

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

  • Предложение пересчитывать все агрегаты каждый раз - не масштабируется.
  • Игнорирование гонок при чтении текущего периода.
  • Хранение агрегатов только в памяти без персистентности.
  • Отсутствие обработки ошибок при повторном пересчёте (неидемпотентные запросы).
  • Использование time.Now() без UTC - приводит к смещению периодов.
  • Забывают про backfill при изменении логики агрегации.

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

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