Problem N+1

#Backend#Bazy danych#Wydajność

Twoja aplikacja działa. Testy przechodzą. Code review zaliczony. A jednak w produkcji, przy kilkuset użytkownikach, coś zaczyna się sypać. Strony ładują się wolno, baza danych jest przeciążona, a w logach widzisz setki identycznych zapytań SQL wykonywanych w kółko.

Witaj w świecie problemu N+1.

Czym jest problem N+1?

Problem N+1 to jeden z najczęstszych błędów wydajnościowych w aplikacjach backendowych, szczególnie tych korzystających z ORM-ów (Object-Relational Mappers). Jego nazwa pochodzi wprost od liczby zapytań, które są wykonywane zamiast jednego.

Klasyczny scenariusz: chcesz wyświetlić listę postów na blogu wraz z imieniem autora każdego z nich.

Wersja z problemem N+1:

-- 1 zapytanie: pobierz wszystkie posty
SELECT * FROM posts;

-- N zapytań: dla każdego z N postów, pobierz autora osobno
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 1;  -- ten sam autor, kolejne zapytanie
SELECT * FROM users WHERE id = 3;
-- ... i tak dla każdego posta

Zamiast 2 zapytań (jedno po posty, jedno po użytkowników), wykonujesz 1 + N zapytań, gdzie N to liczba postów. Przy 100 postach — 101 zapytań. Przy 10 000 postach — 10 001 zapytań.

Jak to wygląda w kodzie?

Problem najczęściej pojawia się niewinnie. Oto przykład w Pythonie z SQLAlchemy:

# Problem N+1 — wygląda niewinnie
posts = session.query(Post).all()

for post in posts:
    # Każde odwołanie do post.author wywołuje osobne zapytanie!
    print(f"{post.title}{post.author.name}")

Albo w Prisma (TypeScript):

// Problem N+1
const posts = await prisma.post.findMany();

for (const post of posts) {
  // Każda pętla robi osobne zapytanie do bazy!
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
  console.log(`${post.title}${author.name}`);
}

W obu przypadkach kod wygląda logicznie. Ale baza danych płacze.

Dlaczego ORM-y są podatne?

ORM-y abstrakcją ukrywają SQL. To ich zaleta — i jednocześnie pułapka. Gdy piszesz post.author, nie widzisz, że pod spodem wykonywane jest zapytanie SQL. Lazy loading (leniwe ładowanie) to domyślne zachowanie wielu ORM-ów: dane powiązane są ładowane tylko wtedy, gdy są faktycznie potrzebne.

Samo w sobie nie jest to złe — jeśli potrzebujesz autora tylko dla jednego posta, lazy loading jest wydajny. Problem pojawia się w pętlach, gdzie wykonujesz tę samą operację N razy dla N obiektów.

Rozwiązanie: eager loading

Zamiast ładować dane relacyjne leniwie (jedno zapytanie na obiekt), ładujemy je z wyprzedzeniem (eager loading) — w jednym lub kilku zoptymalizowanych zapytaniach.

Prisma — rozwiązanie:

// Eager loading z include
const posts = await prisma.post.findMany({
  include: {
    author: true,
  },
});

// Teraz post.author jest dostępny bez dodatkowych zapytań
for (const post of posts) {
  console.log(`${post.title}${post.author.name}`);
}

SQLAlchemy — rozwiązanie:

from sqlalchemy.orm import joinedload

# Eager loading z joinedload
posts = session.query(Post).options(joinedload(Post.author)).all()

for post in posts:
    # Brak dodatkowych zapytań — author już załadowany
    print(f"{post.title}{post.author.name}")

Hibernate / JPA (Java) — rozwiązanie:

// JPQL z JOIN FETCH
List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p JOIN FETCH p.author", Post.class)
    .getResultList();

Rozwiązanie alternatywne: batch loading

Gdy eager loading nie jest możliwy lub odpowiedni, można użyć batch loadingu — zamiast N zapytań po jednym rekordzie, wykonujesz jedno zapytanie z listą ID.

// Zamiast N zapytań w pętli...
const authorIds = posts.map(post => post.authorId);

// ...jedno zapytanie z listą ID
const authors = await prisma.user.findMany({
  where: { id: { in: authorIds } },
});

// Budujemy mapę dla O(1) dostępu
const authorsById = new Map(authors.map(a => [a.id, a]));

for (const post of posts) {
  const author = authorsById.get(post.authorId);
  console.log(`${post.title}${author.name}`);
}

To rozwiązanie jest szczególnie przydatne, gdy struktura zapytania jest dynamiczna i trudno użyć statycznego include.

Jak wykryć problem N+1?

Problem N+1 jest podstępny — nie powoduje błędów, tylko spowolnienie. Oto jak go znaleźć:

1. Logowanie zapytań SQL w development

Włącz logowanie zapytań i obserwuj, czy te same zapytania powtarzają się w kółko:

// Prisma — włącz query log
const prisma = new PrismaClient({
  log: ['query'],
});
# SQLAlchemy
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

2. Narzędzia do profilowania

  • Django Debug Toolbar — pokazuje liczbę zapytań na każdą stronę HTTP
  • Bullet (Ruby/Rails) — automatycznie wykrywa N+1 i loguje ostrzeżenia
  • Hibernate Statistics — zlicza zapytania w JVM
  • EXPLAIN ANALYZE w PostgreSQL — pozwala analizować plan wykonania konkretnego zapytania

3. Testy z asercją na liczbę zapytań

# Django — asercja na liczbę zapytań
from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
    response = client.get('/posts/')

assert len(ctx.captured_queries) <= 3, (
    f"Zbyt wiele zapytań: {len(ctx.captured_queries)}"
)

Kiedy eager loading to za dużo?

Eager loading rozwiązuje N+1, ale może wprowadzić inny problem: over-fetching — ładowanie danych, których nie potrzebujesz.

Wyobraź sobie tabelę users z 50 kolumnami. Jeśli potrzebujesz tylko imienia autora, include: { author: true } pobierze wszystkie 50 kolumn dla każdego autora — niepotrzebnie obciążając sieć i pamięć.

Rozwiązanie: select konkretnych pól.

const posts = await prisma.post.findMany({
  include: {
    author: {
      select: {
        name: true,
        avatarUrl: true,
      },
    },
  },
});

Zasada: pobieraj dokładnie tyle danych, ile potrzebujesz — nie więcej, nie mniej.

Przypadki graniczne

Zagnieżdżone relacje

Problem N+1 może występować rekursywnie. Jeśli każdy autor ma przypisaną firmę, a ty pobierasz posty → autorów → firmy, możesz mieć problem (N+1)².

// Głębokie eager loading
const posts = await prisma.post.findMany({
  include: {
    author: {
      include: {
        company: true,
      },
    },
  },
});

GraphQL i DataLoader

W GraphQL problem N+1 jest szczególnie powszechny, ponieważ resolvery są wywoływane niezależnie dla każdego elementu listy. Standardowym rozwiązaniem jest DataLoader — biblioteka od Meta, która automatycznie batchuje zapytania.

import DataLoader from 'dataloader';

const userLoader = new DataLoader(async (ids: readonly number[]) => {
  const users = await prisma.user.findMany({
    where: { id: { in: [...ids] } },
  });
  return ids.map(id => users.find(u => u.id === id));
});

// W resolverze GraphQL — każde wywołanie jest batchowane automatycznie
const author = await userLoader.load(post.authorId);

Podsumowanie

Problem N+1 to cichy złodziej wydajności. Nie krzyczy, nie rzuca błędami — po prostu sprawia, że aplikacja jest powolna i droga w utrzymaniu.

Kluczowe wnioski:

  1. Zrozum lazy loading — to domyślne zachowanie większości ORM-ów i jest w nim ono ryzyko w pętlach
  2. Używaj eager loading (include, joinedload, JOIN FETCH) gdy znasz z góry potrzebne relacje
  3. Używaj batch loadingu gdy struktura zapytań jest dynamiczna
  4. Loguj zapytania SQL w środowisku deweloperskim — to najszybszy sposób na wykrycie problemu
  5. Pisz testy z asercją na liczbę zapytań — to najskuteczniejszy sposób, żeby problem nie wrócił

Większość poważnych problemów wydajnościowych w aplikacjach webowych sprowadza się do nieefektywnej komunikacji z bazą danych. Problem N+1 jest najpowszechniejszym z nich — i na szczęście jednym z najłatwiejszych do naprawienia, gdy już go zidentyfikujesz.