Przejdź do głównej zawartości

Batching: jedno zapytanie zamiast pięciuset — WHERE id IN, DataLoader, addBatch i COPY

· 11 min aby przeczytać
Przemysław Majdak
Full-Stack Developer, Automation Engineer & Web Security Specialist

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 bazaRTT501 zapytań2 zapytania
lokalny socket uniksowy0,05 ms25 ms0,1 ms
ta sama sieć / VPC0,5 ms250 ms1 ms
inna strefa dostępności1,5 ms750 ms3 ms
inny region15 ms7,5 s30 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, inne IN (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łada customers.length === ids.length, prędzej czy później dostanie undefined w 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 await zabija batchowanie. for (const id of ids) await loader.load(id) wykonuje po jednym zapytaniu na iterację, bo każde await koń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 przez DISTINCT ON w źródłowym SELECT — jest obowiązkowa, a nie kosmetyczna. Przy strumieniu zdarzeń duplikaty pojawią się prędzej niż później.
  • WHERE ... IS DISTINCT FROM odcina 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 to COPY do tabeli tymczasowej lub UNLOGGED, a potem jeden INSERT INTO ... SELECT DISTINCT ON (...) FROM staging ... ON CONFLICT .... Dwa kroki, ale oba zbiorcze.
  • Wszystko albo nic. Jeden wiersz z błędem przerywa cały COPY i wycofuje go w całości. Od PostgreSQL 17 jest opcja ON_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 COPY to 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. BatchUpdateException w 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_statements sygnatura N+1 jest charakterystyczna: jeden tekst zapytania z ogromną liczbą w calls i mikroskopijnym mean_exec_time. Sortowanie po calls malejąco znajduje to szybciej niż sortowanie po total_exec_time.
  • Po stronie aplikacji policz zapytania i zapisz to jako asercję w teścieassertNumQueries w Django, assert_queries w Rails, licznik na $on('query') w Prisma, SessionFactory#getStatistics w 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ć

  1. N+1 to problem round-tripów, nie bazy. Zanim zaczniesz stroić zapytanie, policz, ile razy jest wykonywane.
  2. Odczyty batchuj przez WHERE id = ANY($1::…[]) — jeden parametr, jeden plan, brak limitu 65535 i brak zaśmiecania cache planów.
  3. Sklejaj wyniki mapą po kluczu, nigdy pozycją w tablicy, i zawsze obsłuż brakujący wiersz.
  4. 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.
  5. Przy zapisach sprawdź, czy sterownik faktycznie skleja batche (reWriteBatchedInserts, rewriteBatchedStatements) i czy wszystko dzieje się w jawnej transakcji. Zmierz round-tripy, nie ufaj samemu addBatch().
  6. Upserty deduplikuj po kluczu konfliktu i sortuj paczkę przed wysłaniem — pierwsze chroni przed błędem 21000, drugie przed deadlockami.
  7. COPY bierz przy dużych ładunkach, ale zaplanuj tabelę pośrednią, bo ON CONFLICT tam nie działa, a jeden zły wiersz wycofuje całość.
  8. Postaw asercję liczby zapytań w teście. Bez niej naprawiony N+1 wraca przy najbliższym refaktorze i nikt się o tym nie dowie.