> Какие методы использовались для достижения оптимизации процесса закрытия операционного дня (Go)

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

Компании: Wildberries

Стек: Go

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

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

Для оптимизации закрытия операционного дня в Go применяются: асинхронная обработка задач через worker pool, шардирование данных по ключам, батчинг запросов к БД, кэширование промежуточных результатов, идемпотентные операции с retry-механизмом, профилирование через pprof и трассировка через OpenTelemetry. Ключевой подход - декомпозиция монолитного процесса на независимые этапы с параллельным выполнением и контролем консистентности через транзакции или outbox pattern.

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

Операционный день - это последовательность бизнес-операций: сверка остатков, расчет комиссий, формирование отчетов, закрытие счетов. Основные методы оптимизации:

  1. Параллелизация через goroutines - разбиение работы на независимые подзадачи (по клиентам, счетам, валютам). Используется errgroup для управления жизненным циклом и сбора ошибок.

  2. Шардирование данных - разделение по ключу (например, account_id % N) для параллельной обработки без конфликтов на общих ресурсах.

  3. Батчинг и bulk-операции - замена N одиночных INSERT/UPDATE на пакетные запросы. В PostgreSQL - COPY или многострочные INSERT ... ON CONFLICT.

  4. Кэширование справочных данных - загрузка курсов валют, лимитов, тарифов в память перед началом процесса, чтобы избежать N+1 запросов.

  5. Идемпотентность и чекпоинты - сохранение прогресса после каждого этапа. При сбое процесс продолжается с последнего чекпоинта, а не с начала.

  6. Outbox pattern - запись событий в отдельную таблицу в той же транзакции, что и бизнес-изменения. Отдельный consumer отправляет события в очередь, обеспечивая атомарность и надежность.

  7. Профилирование и трассировка - pprof для CPU/memory, распределенная трассировка для поиска узких мест. Особенно важно для обнаружения блокировок на БД и неоптимальных запросов.

  8. Управление ресурсами - пулы соединений с БД, ограничение concurrency через semaphore, контроль размера буферов каналов.

На практике

Типичный сценарий: процесс закрытия дня запускается по расписанию (cron, scheduler). На входе - список счетов. Этапы:

  • Валидация - проверка полноты данных, параллельно по шардам.
  • Расчет - комиссии, проценты, курсовые разницы. Каждый расчет - чистая функция, легко параллелится.
  • Применение - запись изменений в БД. Используются транзакции с уровнем изоляции READ COMMITTED и оптимистичные блокировки через version поле.
  • Формирование отчетов - генерация файлов, отправка в хранилище.
  • Финализация - обновление статуса дня, аудит-лог.

На практике важно: не делать параллельно операции, которые пишут в одни и те же строки - это вызовет deadlocks. Лучше группировать по шардам и обрабатывать шарды последовательно, но внутри шарда - параллельно.

Пример кода

GO
package main
import (
"context"
"fmt"
"sync"
"golang.org/x/sync/errgroup"
)
type Account struct {
ID int64
Balance float64
}
// processShard обрабатывает группу счетов
func processShard(ctx context.Context, accounts []Account) error {
for _, acc := range accounts {
// бизнес-логика: расчет комиссии, обновление баланса
fmt.Printf("processing account %d\n", acc.ID)
}
return nil
}
func closeOperationalDay(ctx context.Context, accounts []Account, shardCount int) error {
// шардирование по ID
shards := make([][]Account, shardCount)
for _, acc := range accounts {
shard := acc.ID % int64(shardCount)
shards[shard] = append(shards[shard], acc)
}
g, ctx := errgroup.WithContext(ctx)
// ограничение concurrency - semaphore
sem := make(chan struct{}, 4)
for _, shard := range shards {
shard := shard // capture loop variable
g.Go(func() error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
return processShard(ctx, shard)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("close day failed: %w", err)
}
return nil
}
func main() {
ctx := context.Background()
accounts := []Account{{ID: 1, Balance: 100}, {ID: 2, Balance: 200}}
if err := closeOperationalDay(ctx, accounts, 2); err != nil {
panic(err)
}
}

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

Начните с общей архитектуры: декомпозиция процесса, определение зависимостей между этапами. Затем переходите к конкретике: параллелизм через errgroup, шардирование, батчинг. Упомяните trade-off между скоростью и консистентностью - например, почему нельзя просто запустить все goroutines без ограничений. Покажите понимание идемпотентности и чекпоинтов - это критично для операционного дня, где сбой на 3-м часу работы не должен приводить к полному перезапуску. Если спросят про БД - расскажите про оптимистичные блокировки, SELECT ... FOR UPDATE SKIP LOCKED для очередей задач, и почему READ COMMITTED предпочтительнее SERIALIZABLE в этом сценарии.

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

  • Понимание конкурентности в Go: goroutines, channels, errgroup, semaphore.
  • Умение проектировать отказоустойчивые процессы: чекпоинты, retry, идемпотентность.
  • Знание работы с БД: транзакции, изоляция, блокировки, батчинг.
  • Способность видеть узкие места: N+1 запросы, блокировки на общих данных, неоптимальные индексы.
  • Практический опыт с профилированием и трассировкой.

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

  • Запуск неограниченного числа goroutines - приводит к исчерпанию памяти и деградации БД.
  • Игнорирование контекста отмены - при сбое одного шарда остальные продолжают работу, создавая несогласованное состояние.
  • Параллельная запись в одни и те же строки без шардирования - deadlocks и retry-шторм.
  • Отсутствие чекпоинтов - при сбое весь процесс начинается заново, что неприемлемо для длительных операций.
  • Пренебрежение идемпотентностью - повторный запуск этапа приводит к двойному списанию или начислению.
  • Использование SERIALIZABLE без необходимости - резкое падение производительности из-за частых сериализационных ошибок.

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

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