atomic-n-plus-one-getbyid.md 3.9 KB

N+1 запросы в GetByID — три отдельных round-trip

Контекст: В backend/internal/services/places.go:112-136 метод GetByID делает 3 отдельных запроса к БД вместо одного JOIN. При массовом вызове (например, список избранного) это даёт N*3 запросов.

Проблема

// services/places.go:112-136 — ❌ N+1
func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
    place, err := s.placeRepo.GetByID(ctx, id)          // 1-й запрос
    if place == nil { return nil, nil }

    if fetchTags {
        tags, err := s.placeRepo.GetTags(ctx, id)       // 2-й запрос
        features, err := s.placeRepo.GetFeatures(ctx, id) // 3-й запрос
        place.Tags = tags
        place.Features = features
    }
    return place, nil
}

Для одного места — приемлемо (3 round-trips). Но если вызвать GetByID в цикле для 10 мест — уже 30 запросов.

Почему не JOIN

  1. Разделение ответственности: GetTags и GetFeatures — отдельные методы репозитория, переиспользуемые в List с batch
  2. Сложность JOIN: places → place_tags → tags — many-to-many, JOIN даёт дублирование строк
  3. Редкий вызов: GetByID вызывается только при запросе конкретного места, не в списках

Когда это станет проблемой

  • Когда появится функциональность "избранное" или "сравнение мест" — фронтенд будет запрашивать GET /places/{id} для каждого места
  • Когда к GetByID добавятся теги услуг, отзывов, бронирований — 3 превратится в 6+

Решение

// ✅ один запрос с JOIN
func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
    place, err := s.placeRepo.GetByIDWithDetails(ctx, id)  // JOIN: place + tags + features
    if place == nil { return nil, nil }
    return place, nil
}
// repository/places.go
func (r *PlaceRepo) GetByIDWithDetails(ctx context.Context, id string) (*models.Place, error) {
    query := `
        SELECT p.*, 
               COALESCE(json_agg(DISTINCT jsonb_build_object('id', t.id, 'name', t.name)) 
                       FILTER (WHERE t.id IS NOT NULL), '[]') as tags,
               COALESCE(json_agg(DISTINCT jsonb_build_object('id', f.id, 'name', f.name)) 
                       FILTER (WHERE f.id IS NOT NULL), '[]') as features
        FROM places p
        LEFT JOIN place_tags pt ON pt.place_id = p.id
        LEFT JOIN tags t ON t.id = pt.tag_id
        LEFT JOIN place_features pf ON pf.place_id = p.id
        LEFT JOIN features f ON f.id = pf.feature_id
        WHERE p.id = $1
        GROUP BY p.id
    `
    // ...
}

Альтернативы

Подход Плюсы Минусы
JOIN + json_agg 1 запрос, атомарно Сложный SQL, GROUP BY может быть медленным
Три параллельных запроса (goroutines) 1 round-trip по времени Сложнее, нужно управлять goroutines
Оставить как есть (3 последовательных) Просто N+1 при массовом вызове

Связанные заметки

  • [[MOC-backend-patterns]] — таблица антипаттернов

Источник

Code review PhotoPlaces 2026-06. services/places.go:112-136.

Теги

#performance #database #golang #N+1 #anti-pattern