Batching: jedno zapytanie zamiast pięciuset — WHERE id IN, DataLoader, addBatch i COPY
Kod, który w pętli odpytuje bazę pięćset razy, nie jest wolny dlatego, że baza jest wolna. Każde z tych zapytań kosztuje na serwerze dziesiątki mikrosekund, a cała reszta to czekanie na sieć — i to czekanie skaluje się liniowo z liczbą iteracji. Na lokalnym Postgresie problem jest niewidoczny, na produkcji w innej strefie dostępności ten sam kod to timeout. Poniżej cztery poziomy batchowania, od WHERE id IN (...) po COPY, z pułapkami, które w każdym z nich wywracają sprawę.
Koszt nie leży w bazie
SELECT * FROM customers WHERE id = $1 po kluczu głównym to dla Postgresa kilkadziesiąt mikrosekund. Round-trip sieciowy do tego samego serwera w tej samej strefie to rząd wielkości więcej. Przy N+1 płacisz ten round-trip N razy, a czas wykonania po stronie bazy jest w tym rachunku szumem.
Prosta arytmetyka dla 500 wierszy — nie benchmark, tylko mnożenie:
| gdzie stoi baza | RTT | 501 zapytań | 2 zapytania |
|---|---|---|---|
| lokalny socket uniksowy | 0,05 ms | 25 ms | 0,1 ms |
| ta sama sieć / VPC | 0,5 ms | 250 ms | 1 ms |
| inna strefa dostępności | 1,5 ms | 750 ms | 3 ms |
| inny region | 15 ms | 7,5 s | 30 ms |
Stąd bierze się klasyczny scenariusz „u mnie działa": różnica między pierwszym a ostatnim wierszem to trzysta razy, a kod jest ten sam.
Do tego dochodzi drugi koszt, o którym łatwiej zapomnieć: każde z 501 zapytań trzyma połączenie z puli. Przy dwudziestu równoległych żądaniach i puli na dziesięć połączeń to nie jest już 250 ms, tylko kolejkowanie — i wtedy N+1 przestaje być kwestią wydajności pojedynczego endpointu, a staje się przyczyną, dla której cała aplikacja przestaje odpowiadać.
Poziom 1: WHERE id IN (...)
Punkt wyjścia, czyli pętla, którą pisze się odruchowo:
const orders = await db.order.findMany({ where: { userId } });
for (const o of orders) {
o.customer = await db.customer.findUnique({ where: { id: o.customerId } }); // N zapytań
}
Wersja zbatchowana — dwa zapytania niezależnie od liczby zamówień:
const orders = await db.order.findMany({ where: { userId } });
const ids = [...new Set(orders.map((o) => o.customerId))];
const customers = ids.length
? await db.customer.findMany({ where: { id: { in: ids } } })
: [];
const byId = new Map(customers.map((c) => [c.id, c]));
for (const o of orders) o.customer = byId.get(o.customerId) ?? null;
Cztery rzeczy w tym kodzie są tam celowo i każda odpowiada jednej pułapce:
- Pusta lista.
IN ()to błąd składni w SQL. Sterowniki i ORM-y radzą sobie z tym różnie — jedne generująWHERE false, inneIN (NULL), które nie zwróci nic, ale też nie krzyknie. Sprawdzaj długość przed wywołaniem. - Deduplikacja. Trzysta zamówień tego samego klienta to trzysta identycznych identyfikatorów w liście parametrów bez żadnej korzyści.
- Mapa zamiast indeksu. Baza nie gwarantuje kolejności zwracanych wierszy bez
ORDER BY. Sklejanie wyników „po indeksie w tablicy" to błąd, który nie wywala się głośno — po prostu przypisuje dane jednego klienta do zamówienia innego. ?? null. Wynik może być krótszy od wejścia: wiersz mógł zostać usunięty albo odfiltrowany przez politykę dostępu. Kod, który zakładacustomers.length === ids.length, prędzej czy później dostanieundefinedw miejscu, gdzie spodziewał się obiektu.
Piąta pułapka jest po stronie protokołu. W PostgreSQL jedna wiadomość Bind przenosi maksymalnie 65535 parametrów (licznik szesnastobitowy), a poza twardym limitem każda inna długość listy to inny tekst zapytania — czyli osobny wpis w cache planów i w pg_stat_statements. Rozwiązanie: przekaż tablicę jako jeden parametr.
SELECT id, email FROM customers WHERE id = ANY($1::bigint[]);
Jeden tekst zapytania, jeden parametr, dowolna liczba identyfikatorów. Przy naprawdę długich listach i tak warto ciąć na paczki po ~1000 — planista i tak musi tę tablicę przetworzyć, a pamięć nie jest darmowa.
I uwaga ogólniejsza: jeśli lista identyfikatorów pochodzi z zapytania do tej samej bazy, batching bywa objawem, a nie lekarstwem. Przeciąganie 50 tysięcy kluczy do aplikacji tylko po to, żeby odesłać je z powrotem w IN, jest gorsze niż JOIN albo EXISTS.
Większość ORM-ów robi to za ciebie, o ile się je poprosi: prefetch_related w Django, selectinload w SQLAlchemy, includes w Rails, default_batch_fetch_size i @BatchSize w Hibernate, include w Prisma. Wszystkie generują dokładnie to samo IN.
Poziom 2: DataLoader — batching, którego nie widać w miejscu wywołania
IN działa, dopóki masz jedno miejsce, w którym znasz całą listę identyfikatorów. W resolverach GraphQL, w serwisach domenowych czy w kodzie renderującym drzewo komentarzy tej listy nie ma — każde wywołanie zna tylko swój jeden klucz. Wtedy batchowanie trzeba przenieść piętro niżej.
DataLoader zbiera wywołania .load() wykonane w obrębie jednego cyklu pętli zdarzeń i odpala funkcję batchującą raz, z całą zebraną listą:
const customerLoader = new DataLoader(async (ids) => {
const { rows } = await pool.query(
'SELECT id, email FROM customers WHERE id = ANY($1::bigint[])',
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null); // ta sama długość, ta sama kolejność
});
Ostatnia linia to cały kontrakt: tablica wynikowa musi mieć tę samą długość i tę samą kolejność co tablica kluczy. Zwrócenie rows wprost jest najczęstszym błędem w tym wzorcu i najbardziej podstępnym — nic się nie wywraca, po prostu użytkownicy dostają cudze dane. Klucz bez wyniku to null; klucz, dla którego zapytanie się nie powiodło, to instancja Error na tej pozycji, nie wyjątek z całej funkcji.
Trzy rzeczy poza kontraktem kolejności:
- Instancja per żądanie, nigdy globalna. DataLoader ma wbudowany cache i to jest cache pamięciowy bez unieważniania. Loader współdzielony między żądaniami serwuje nieświeże dane, a w aplikacji wielodostępnej potrafi podać rekord jednego najemcy drugiemu. To już nie jest kwestia wydajności, tylko izolacji danych.
- Sekwencyjne
awaitzabija batchowanie.for (const id of ids) await loader.load(id)wykonuje po jednym zapytaniu na iterację, bo każdeawaitkończy cykl. Musi byćPromise.all(ids.map((id) => loader.load(id))). - Ustaw
maxBatchSize. Bez tego jedno żądanie o dużą kolekcję wygeneruje zapytanie z dziesięcioma tysiącami kluczy — czyli dokładnie ten problem, przed którym uciekaliśmy w poprzedniej sekcji, tylko przesunięty w inne miejsce.
DataLoader nie łączy różnych zasobów i nie spłaszcza grafu: zamienia N zapytań na jedno na poziom zagnieżdżenia i typ encji. Zapytanie GraphQL o pięć poziomów w głąb nadal wygeneruje pięć rund — ale pięć, a nie pięćset.
Poziom 3: zapisy — addBatch() i wielowierszowy INSERT
Po stronie odczytów batching jest oczywisty. Po stronie zapisów pętla for z pojedynczym INSERT kosztuje dokładnie tak samo, a dodatkowo — jeśli połączenie jest w trybie autocommit — każda iteracja to osobna transakcja i osobny fsync WAL-a.
JDBC:
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO events (user_id, kind, payload) VALUES (?, ?, ?)")) {
int i = 0;
for (Event e : events) {
ps.setLong(1, e.userId());
ps.setString(2, e.kind());
ps.setString(3, e.payload());
ps.addBatch();
if (++i % 500 == 0) ps.executeBatch();
}
ps.executeBatch();
}
conn.commit();
Pułapka, o której warto wiedzieć zanim zmierzy się efekt: samo addBatch() nie gwarantuje jednego round-tripu. To, co dzieje się dalej, zależy od sterownika. Sterownik PostgreSQL wysyła zbatchowane polecenia jednym rzutem (pipelining), a z parametrem reWriteBatchedInserts=true dodatkowo przepisuje je na jeden INSERT z wieloma zestawami VALUES. Connector/J dla MySQL bez rewriteBatchedStatements=true wysyła je natomiast pojedynczo — czyli addBatch() jest wtedy tylko ładniejszym zapisem tej samej pętli. Oba parametry ustawia się w URL-u połączenia i oba są domyślnie wyłączone.
W Hibernate obowiązuje hibernate.jdbc.batch_size plus order_inserts i order_updates (bez sortowania batche rozpadają się na kawałki przy każdej zmianie tabeli). I jedna rzecz, która regularnie zaskakuje: GenerationType.IDENTITY wyłącza batchowanie insertów, bo Hibernate potrzebuje wygenerowanego identyfikatora natychmiast po każdym wierszu. Jeśli zależy ci na batchach, generuj klucze z sekwencji.
Na poziomie samego SQL-a wielowierszowy INSERT ma ten sam problem z arnością co IN: VALUES ($1,$2,$3), ($4,$5,$6), ... przy trzech kolumnach mieści się w 65535 parametrach do około 21 tysięcy wierszy, a każda liczba wierszy to inny tekst zapytania. Wersja o stałej liczbie parametrów:
INSERT INTO events (user_id, kind, payload)
SELECT * FROM unnest($1::bigint[], $2::text[], $3::jsonb[]);
Trzy parametry niezależnie od tego, czy wstawiasz dziesięć wierszy, czy dziesięć tysięcy.
Upsert w tej samej konwencji:
INSERT INTO customers (id, email, updated_at)
SELECT * FROM unnest($1::bigint[], $2::text[], $3::timestamptz[])
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
updated_at = EXCLUDED.updated_at
WHERE customers.email IS DISTINCT FROM EXCLUDED.email;
Trzy szczegóły, które robią różnicę na produkcji:
- Duplikaty wewnątrz jednej paczki wywalają całe polecenie. PostgreSQL zwróci
ON CONFLICT DO UPDATE command cannot affect row a second time(SQLSTATE 21000), bo jeden wiersz nie może być zaktualizowany dwa razy w ramach tego samego polecenia. Deduplikacja po kluczu konfliktu — w aplikacji albo przezDISTINCT ONw źródłowymSELECT— jest obowiązkowa, a nie kosmetyczna. Przy strumieniu zdarzeń duplikaty pojawią się prędzej niż później. WHERE ... IS DISTINCT FROModcina aktualizacje, które niczego nie zmieniają. Bez tego każdy przebieg importu produkuje martwe krotki, zapis do WAL-a, ruch na replikach i odpalone triggery — dla danych, które są identyczne.- Sortuj paczkę po kluczu przed wysłaniem. Dwie równoległe paczki dotykające tych samych wierszy w różnej kolejności to podręcznikowy deadlock. Wspólny porządek blokowania rozwiązuje to jedną linijką i jest znacznie tańszy niż ponawianie transakcji.
Poziom 4: COPY, gdy ładunek jest naprawdę duży
Przy dziesiątkach i setkach tysięcy wierszy — ładowanie początkowe, ETL, nocne importy — nawet wielowierszowy INSERT przestaje wystarczać, bo płacisz za parsowanie i planowanie każdego polecenia. COPY ... FROM STDIN omija tę ścieżkę i wpycha dane strumieniem.
with conn.cursor() as cur:
with cur.copy("COPY events (user_id, kind, payload) FROM STDIN") as cp:
for e in events:
cp.write_row((e.user_id, e.kind, e.payload))
Odpowiedniki: CopyManager z PGConnection w Javie, pg-copy-streams w Node, \copy w psql.
Czego COPY nie robi i o czym trzeba wiedzieć zawczasu:
- Nie zna
ON CONFLICT. Standardowy wzorzec toCOPYdo tabeli tymczasowej lubUNLOGGED, a potem jedenINSERT INTO ... SELECT DISTINCT ON (...) FROM staging ... ON CONFLICT .... Dwa kroki, ale oba zbiorcze. - Wszystko albo nic. Jeden wiersz z błędem przerywa cały
COPYi wycofuje go w całości. Od PostgreSQL 17 jest opcjaON_ERROR ignore, ale dotyczy ona wyłącznie błędów konwersji typów — naruszenie ograniczenia czy klucza obcego nadal wywraca całość. - Indeksy, triggery i klucze obce kosztują tak samo jak przy
INSERT. Przy ładowaniu do pustej tabeli zwykle opłaca się wgrać dane, a indeksy zbudować po fakcie. - Jeden wielki
COPYto jedna długa transakcja. Trzyma blokady, blokuje czyszczenie martwych krotek i potrafi rozjechać repliki. Przy imporcie ciągłym lepiej mieć paczki po kilkadziesiąt tysięcy wierszy niż jeden przebieg na dziesięć milionów.
Kiedy batching szkodzi
Wzorzec ma koszt i warto go znać, zanim ktoś zacznie zwiększać batch_size „bo szybciej":
- Opóźnienie kontra przepustowość. Paczka pięciu tysięcy wierszy to jedna długa transakcja, długo trzymane blokady, duży zapis WAL i opóźnienie replikacji. Sumaryczny czas spada, ale najgorszy przypadek pojedynczej operacji rośnie.
- Pamięć aplikacji. Wciągnięcie 50 tysięcy wierszy do procesu tylko po to, żeby zestawić je w pary, bywa droższe niż
JOIN, który zrobiłaby baza. Batching nie jest odpowiedzią na wszystko. - Zasięg błędu. Jeden felerny wiersz wycofuje 999 poprawnych. Praktyczne wyjście to podział paczki na pół przy niepowodzeniu (wyszukiwanie binarne winowajcy) albo fallback na pojedyncze zapisy — pod warunkiem, że ponowienie jest idempotentne.
BatchUpdateExceptionw JDBC zwraca liczniki, które pozwalają ustalić, gdzie przerwało, ale to, ile z paczki faktycznie zostało zastosowane, zależy od sterownika. - DataLoader dokłada cykl pętli zdarzeń i trzyma cache w obrębie żądania. W żądaniu, które w środku modyfikuje dane, trzeba po zapisie wywołać
clear()— inaczej odczyt po zapisie zwróci stan sprzed. - Batching nie zastąpi indeksu. Zapytanie skanujące całą tabelę wykonane raz zamiast pięciuset razy nadal skanuje całą tabelę.
Jak to zmierzyć, a nie zgadywać
Metryką, która wykrywa N+1, nie jest średni czas zapytania — ten przy N+1 wygląda wzorowo, bo każde pojedyncze zapytanie jest błyskawiczne. Metryką jest liczba round-tripów na żądanie.
- W
pg_stat_statementssygnatura N+1 jest charakterystyczna: jeden tekst zapytania z ogromną liczbą wcallsi mikroskopijnymmean_exec_time. Sortowanie pocallsmalejąco znajduje to szybciej niż sortowanie pototal_exec_time. - Po stronie aplikacji policz zapytania i zapisz to jako asercję w teście —
assertNumQueriesw Django,assert_queriesw Rails, licznik na$on('query')w Prisma,SessionFactory#getStatisticsw Hibernate. Test, który przewraca się przy wzroście z 3 do 300 zapytań, jest jedynym mechanizmem, który wyłapie regresję, zanim zrobi to użytkownik. Bez niego batching cofa się sam — wystarczy jeden dopisany getter, który po cichu dociąga relację. - W APM patrz na liczbę spanów bazodanowych w śladzie, nie na ich czas.
Co z tego zabrać
- N+1 to problem round-tripów, nie bazy. Zanim zaczniesz stroić zapytanie, policz, ile razy jest wykonywane.
- Odczyty batchuj przez
WHERE id = ANY($1::…[])— jeden parametr, jeden plan, brak limitu 65535 i brak zaśmiecania cache planów. - Sklejaj wyniki mapą po kluczu, nigdy pozycją w tablicy, i zawsze obsłuż brakujący wiersz.
- DataLoader tworzy się per żądanie, jego funkcja batchująca musi zwrócić tyle samo elementów, ile dostała kluczy, w tej samej kolejności, a wywołania muszą iść przez
Promise.all. - Przy zapisach sprawdź, czy sterownik faktycznie skleja batche (
reWriteBatchedInserts,rewriteBatchedStatements) i czy wszystko dzieje się w jawnej transakcji. Zmierz round-tripy, nie ufaj samemuaddBatch(). - Upserty deduplikuj po kluczu konfliktu i sortuj paczkę przed wysłaniem — pierwsze chroni przed błędem 21000, drugie przed deadlockami.
COPYbierz przy dużych ładunkach, ale zaplanuj tabelę pośrednią, boON CONFLICTtam nie działa, a jeden zły wiersz wycofuje całość.- Postaw asercję liczby zapytań w teście. Bez niej naprawiony N+1 wraca przy najbliższym refaktorze i nikt się o tym nie dowie.