# N+1 запросы в GetByID — три отдельных round-trip **Контекст**: В `backend/internal/services/places.go:112-136` метод `GetByID` делает 3 отдельных запроса к БД вместо одного JOIN. При массовом вызове (например, список избранного) это даёт N*3 запросов. ## Проблема ```go // 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+ ## Решение ```go // ✅ один запрос с 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 } ``` ```go // 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 при массовом вызове | ## Services (GET /services/:id) **Было:** 2 отдельных запроса (service + GetTags) **Стало:** LEFT JOIN с `json_agg` + FILTER — 1 запрос ```go // repository/services.go — GetByIDWithTags COALESCE(json_agg(json_build_object('id', t.id, 'name', t.name, 'category', t.category)) FILTER (WHERE t.id IS NOT NULL), '[]'::json) AS tags ``` Файл: `repository/services.go:50-82` ## Связанные заметки - [[MOC-backend-patterns]] — таблица антипаттернов - [[architecture-overview]] — статус P1 - [[atomic-extra-batch-pagination]] — аналогичный N+1 при пагинации ## Источник Code review PhotoPlaces 2026-06 (places) и 2026-07-01 (services). ## Теги #performance #database #golang #N+1 #anti-pattern