Masalah N+1 terjadi saat server menjalankan 1 query untuk mengambil daftar, lalu 1 query lagi untuk tiap item di daftar itu. DataLoader memperbaikinya: ia mengumpulkan semua permintaan kecil dulu, lalu mengambilnya sekaligus. Bayangin kasir Toko Buku yang menyiapkan 3 pesanan. Cara lambat: ambil pesanan pertama, jalan ke gudang cari data penulisnya, balik, lalu jalan lagi untuk pesanan kedua, dan seterusnya. Cara cepat: catat dulu semua penulis yang dibutuhkan, baru jalan ke gudang sekali dengan satu daftar.
🧪 Tebakan cepat: Toko Buku punya 3 buku. Tiap resolver langsung membaca database. Berapa query SQL yang jalan untuk
{ books { title author { name } } }? Jawabannya ada di bagian "Menghitung N+1".
Menghitung N+1
Ini resolver yang "jujur": tiap resolver menjalankan query SQL-nya sendiri. sql() mencatat query lalu mengembalikan hasilnya, sama seperti Lab di halaman ini:
const resolvers = {
Query: {
books: () => sql("SELECT * FROM books"),
},
Book: {
// Dipanggil sekali untuk TIAP buku.
author: (book) => sql(`SELECT * FROM authors WHERE id = ${book.authorId}`),
},
};
Di pelajaran "Resolvers" kamu sudah lihat urutannya: Query.books dulu, lalu Book.author sekali untuk tiap buku. Kalau dicatat:
| # | Resolver | SQL yang jalan |
|---|---|---|
| 1 | Query.books |
SELECT * FROM books |
| 2 | Book.author (Laskar Pelangi) |
SELECT * FROM authors WHERE id = 1 |
| 3 | Book.author (Bumi Manusia) |
SELECT * FROM authors WHERE id = 2 |
| 4 | Book.author (Cantik Itu Luka) |
SELECT * FROM authors WHERE id = 3 |
Empat query: 1 untuk daftar, ditambah N = 3 untuk penulis. Itu jawaban tebakannya. Namanya N+1 karena jumlahnya ikut panjang daftar: 100 buku berarti 101 query, padahal datanya bisa diambil dengan 2. Masalah yang sama muncul di { authors { name books { title } } }: 1 query penulis, lalu 1 query buku per penulis, jadi 5 query untuk 4 penulis.
DataLoader: kumpulkan dulu, ambil sekali
Resolver gak bisa melihat saudaranya, jadi Book.author untuk buku 1 gak tahu bahwa buku 2 dan 3 juga butuh penulis. Triknya: resolver jangan langsung query. Ia cuma bilang "aku butuh penulis 1" lewat load(1), lalu menunggu. Ini DataLoader versi tulisan sendiri:
class DataLoader {
constructor(batchFn) {
this.batchFn = batchFn;
this.cache = new Map(); // key -> Promise, berlaku selama satu request
this.queue = []; // key yang menunggu batch berikutnya
}
load(key) {
if (this.cache.has(key)) return this.cache.get(key);
const promise = new Promise((resolve, reject) => {
this.queue.push({ key, resolve, reject });
// load pertama menjadwalkan batch, setelah kode yang sedang jalan selesai
if (this.queue.length === 1) queueMicrotask(() => this.dispatch());
});
this.cache.set(key, promise);
return promise;
}
async dispatch() {
const queue = this.queue;
this.queue = [];
try {
const values = await this.batchFn(queue.map((item) => item.key));
queue.forEach((item, i) => (values[i] instanceof Error ? item.reject(values[i]) : item.resolve(values[i])));
} catch (error) {
queue.forEach((item) => item.reject(error));
}
}
}
const authorLoader = new DataLoader(async (ids) => {
const rows = await sql(`SELECT * FROM authors WHERE id IN (${ids.join(", ")})`);
return ids.map((id) => rows.find((a) => a.id === id) ?? new Error(`Author ${id} not found`));
});
const resolvers = {
Book: {
author: (book) => authorLoader.load(book.authorId),
},
};
load(key)menaruh key di antrean dan mengembalikan Promise. Belum ada query.queueMicrotaskmenundadispatch()sampai kode yang sedang jalan selesai. KetigaBook.authordipanggil dalam satu putaran yang sama, jadi saatdispatch()jalan, antreannya sudah[1, 2, 3].batchFnmenerima semua key sekaligus dan menjalankan satu query:SELECT * FROM authors WHERE id IN (1, 2, 3). Total jadi 2 query, berapa pun jumlah bukunya.- Urutan dan panjang hasil harus sama dengan
ids. Database bebas mengembalikan baris dalam urutan apa pun, makanyaids.map(...)mencari baris untuk tiap id. Nilai ke-i jadi jawabanloadke-i. - Error per key: kalau penulis 9 gak ada, posisi itu diisi
new Error(...). Cumaload(9)yang gagal; key lain tetap dapat datanya. - Cache:
load(1)yang kedua (misalnya Sang Pemimpi, juga karya Andrea Hirata) memakai Promise yang sama. Key 1 cuma sekali masuk batch.
Paket dataloader
Di proyek sungguhan, pakai paket dataloader (versi 2.2.3, dari GraphQL Foundation). Aturannya sama persis:
import DataLoader from "dataloader";
const authorLoader = new DataLoader(async (ids) => {
const { rows } = await db.query("SELECT * FROM authors WHERE id = ANY($1)", [ids]); // node-postgres
return ids.map((id) => rows.find((a) => a.id === id) ?? new Error(`Author ${id} not found`));
});
- Kalau array yang dikembalikan panjangnya beda, semua
loaddi batch itu gagal dengan pesanDataLoader must be constructed with a function which accepts Array<key> and returns Promise<Array<value>>, but the function did not return a Promise of an Array of the same length as the Array of keys. - Jadwalnya sedikit lebih sabar dari versi kita: di Node.js batch dikirim setelah semua Promise yang sedang antre selesai (
process.nextTick), di browser lewatsetImmediateatausetTimeout. - Opsi yang sering dipakai:
cache: false(tetap batching, tanpa cache; key yang sama bisa masuk dua kali),maxBatchSize, dancacheKeyFnuntuk key berupa objek. - Cache-nya pakai
Map, jadi1dan"1"dianggap key berbeda. Samakan tipe key-nya.
Satu loader per request
Cache DataLoader menyimpan hasil selama loader itu hidup. Kalau loader dibuat sekali untuk seluruh server, data penulis yang diubah tetap dijawab versi lama, dan data yang boleh dilihat satu user bisa ikut terbawa ke user lain. Karena itu loader dibuat baru di context, yang memang dibuat baru untuk tiap request:
await startStandaloneServer(server, {
context: async () => ({
loaders: {
authorById: new DataLoader(batchAuthors),
booksByAuthor: new DataLoader(batchBooksByAuthor),
},
}),
});
const resolvers = {
Book: {
author: (book, _, { loaders }) => loaders.authorById.load(book.authorId),
},
Author: {
books: (author, _, { loaders }) => loaders.booksByAuthor.load(author.id),
},
};
booksByAuthor mengembalikan satu array per key: ids.map((id) => rows.filter((b) => b.authorId === id)). Penulis tanpa buku, seperti Ahmad Tohari, dapat [].
Query batching di HTTP: masalah lain
Ada satu istilah yang mirip. Query batching artinya client mengirim beberapa operasi dalam satu request HTTP, sebagai array, dan server menjawab dengan array juga:
[
{ "query": "{ book(id: 1) { title } }" },
{ "query": "{ book(id: 2) { title } }" }
]
Di Apollo Server ini mati secara default (allowBatchedHttpRequests: false), dan request berbentuk array dijawab status 400 dengan pesan Operation batching disabled.. Di client, Apollo Client punya BatchHttpLink untuk mengirimnya.
Bedanya: query batching mengurangi jumlah request dari client ke server. DataLoader mengurangi jumlah query dari server ke database. Keduanya bisa dipakai bersamaan.
⚠️ Jebakan yang sering ketemu
- Loader global. Dibuat sekali di luar
context, cache-nya dipakai semua request: data basi dan bocor antar-user. Buat baru tiap request. - Hasil batch gak diurutkan. Mengembalikan
rowsapa adanya membuat buku dapat penulis yang salah. Selaluids.map(...). - Panjang hasil beda. Satu id gak ketemu lalu dibuang dari array: seluruh batch gagal. Isi posisinya dengan
nullataunew Error(...). - Tipe key campur.
load("1")danload(1)gak saling memakai cache.
TL;DR: N+1 = 1 query untuk daftar + 1 query per item; di Toko Buku, { books { title author { name } } } menjalankan 4 query, dan 100 buku berarti 101. DataLoader membuat resolver memanggil load(key) alih-alih query langsung; semua key dalam satu putaran dikumpulkan, lalu batchFn dipanggil sekali (WHERE id IN (1, 2, 3)), jadi totalnya 2 query. batchFn harus mengembalikan array dengan panjang dan urutan yang sama dengan key (pakai ids.map), dengan Error untuk key yang gagal. Cache-nya membuat key yang sama cuma diambil sekali. Buat loader baru di context untuk tiap request. Query batching di HTTP itu hal lain: beberapa operasi dalam satu request HTTP, mati secara default di Apollo Server.