Problem N+1
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:
- Zrozum lazy loading — to domyślne zachowanie większości ORM-ów i jest w nim ono ryzyko w pętlach
- Używaj eager loading (
include,joinedload,JOIN FETCH) gdy znasz z góry potrzebne relacje - Używaj batch loadingu gdy struktura zapytań jest dynamiczna
- Loguj zapytania SQL w środowisku deweloperskim — to najszybszy sposób na wykrycie problemu
- 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.