Saat performa GraphQL mulai turun pada dataset yang membesar, masalahnya sering bukan hanya “GraphQL lambat”, melainkan kombinasi dari resolver N+1, query SQL yang tidak efisien, dan pagination yang mahal. Gejalanya biasanya terlihat sebagai lonjakan jumlah query database, latency yang naik tajam pada nested field, dan halaman data yang makin lambat saat offset membesar.
Artikel ini membahas alur diagnosis dan perbaikannya secara praktis: mulai dari membaca log query, profiling resolver, menjalankan EXPLAIN/EXPLAIN ANALYZE, lalu memperbaiki akses data dengan batching/DataLoader, composite index yang sesuai pola akses, dan cursor-based pagination yang stabil. Fokusnya bukan sekadar teori, tetapi hubungan nyata antara layer GraphQL dan SQL di bawahnya.
Contoh masalah: query GraphQL terlihat sederhana, SQL di bawahnya meledak
Misalkan kita punya skema GraphQL untuk menampilkan daftar posting beserta penulis dan beberapa komentar terbaru.
type Query {
posts(first: Int!, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
cursor: String!
node: Post!
}
type Post {
id: ID!
title: String!
createdAt: String!
author: User!
comments(first: Int!): [Comment!]!
}
type User {
id: ID!
name: String!
}
type Comment {
id: ID!
body: String!
createdAt: String!
}Query dari client:
query {
posts(first: 20) {
edges {
cursor
node {
id
title
createdAt
author {
id
name
}
comments(first: 3) {
id
body
createdAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Secara bentuk, query ini tampak wajar. Namun implementasi resolver yang naif bisa menghasilkan pola seperti ini:
- 1 query untuk mengambil 20 post
- 20 query untuk mengambil author tiap post
- 20 query untuk mengambil 3 comment terbaru tiap post
Totalnya menjadi 41 query untuk satu request. Inilah pola klasik N+1: 1 query awal, lalu N query tambahan untuk relasi yang di-resolve satu per satu.
Bagaimana mengenali bottleneck: GraphQL layer atau database layer?
1. Aktifkan log query database
Langkah pertama adalah melihat fakta, bukan menebak. Catat minimal:
- jumlah query per request
- durasi tiap query
- parameter query
- urutan eksekusi query
Jika satu request GraphQL menghasilkan puluhan atau ratusan query kecil yang polanya berulang, kemungkinan besar masalah utama ada di layer resolver. Jika query-nya sedikit tetapi masing-masing lambat, besar kemungkinan bottleneck ada di layer database.
Petunjuk cepat: banyak query kecil berulang biasanya mengarah ke N+1. Sedikit query tetapi mahal biasanya mengarah ke index, sort, join, atau scan yang buruk.
2. Profiling resolver
GraphQL memudahkan client memilih field, tetapi itu juga berarti biaya eksekusi tersebar di banyak resolver. Tambahkan metrik atau tracing per resolver agar terlihat:
- resolver mana yang paling sering dipanggil
- resolver mana yang total waktunya paling besar
- field mana yang memicu query database
Sering kali root cause bukan di query utama posts, melainkan resolver nested seperti Post.author atau Post.comments.
3. Jalankan EXPLAIN atau EXPLAIN ANALYZE
Untuk query SQL yang paling mahal, jalankan EXPLAIN atau EXPLAIN ANALYZE sesuai database yang dipakai. Tujuannya adalah melihat apakah database:
- melakukan full table scan
- menggunakan index yang salah atau tidak menggunakan index sama sekali
- melakukan sort mahal karena urutan data tidak didukung index
- membaca banyak row lalu membuang sebagian besar hasilnya
Jangan langsung menambah index sebelum memahami pola akses sebenarnya. Index yang baik harus mengikuti filter dan urutan query yang nyata dipakai aplikasi.
Resolver N+1: akar masalah dan cara memperbaikinya
Implementasi resolver yang naif
// Pseudo-code
Query.posts = async (_, { first, after }, { db }) => {
return db.query(`
SELECT id, title, author_id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT ?
`, [first]);
};
Post.author = async (post, _, { db }) => {
return db.queryOne(`
SELECT id, name
FROM users
WHERE id = ?
`, [post.author_id]);
};
Post.comments = async (post, { first }, { db }) => {
return db.query(`
SELECT id, body, created_at
FROM comments
WHERE post_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
`, [post.id, first]);
};Kode di atas mudah dibaca, tetapi buruk saat jumlah post membesar. Resolver author dan comments dipanggil per post, sehingga query database ikut berulang.
Perbaikan dengan batching dan DataLoader
Prinsip DataLoader sederhana: kumpulkan semua key yang diminta dalam satu tick eksekusi, lalu lakukan satu query batch untuk semua key tersebut. Hasilnya dipetakan kembali ke urutan key semula. Pendekatan ini efektif untuk relasi many-to-one atau lookup berdasarkan ID.
// Pseudo-code
function createLoaders(db) {
return {
userById: new DataLoader(async (userIds) => {
const rows = await db.query(`
SELECT id, name
FROM users
WHERE id IN (?)
`, [userIds]);
const map = new Map(rows.map(r => [String(r.id), r]));
return userIds.map(id => map.get(String(id)) || null);
})
};
}
Post.author = async (post, _, { loaders }) => {
return loaders.userById.load(post.author_id);
};Dengan ini, 20 query author dapat turun menjadi 1 query batch. Ini sangat membantu jika banyak post ditulis oleh author berbeda.
Catatan penting: DataLoader bukan obat untuk semua kasus
Untuk relasi seperti comments(first: 3) per post, batching lebih rumit karena tiap parent meminta top-N per grup. Jika diimplementasikan sembarangan, Anda mungkin tetap memindahkan N+1 ke bentuk query besar yang juga buruk.
Pilihan yang umum:
- jika komentar tidak wajib, tunda pemuatan sampai benar-benar dibutuhkan
- jika jumlah parent kecil, ambil dengan query terpisah tetapi tetap ukur biayanya
- jika perlu efisien, gunakan pendekatan SQL yang memang mendukung top-N per parent, atau ubah desain query agar aksesnya lebih mudah diindeks
Intinya, batching efektif untuk lookup berbasis key, tetapi tidak otomatis menyelesaikan semua nested relation.
Hubungan langsung ke SQL: dari banyak query kecil ke query yang bisa diindeks
Contoh SQL sebelum optimasi
Masalah pertama adalah jumlah query:
-- 1x
SELECT id, title, author_id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 20x
SELECT id, name
FROM users
WHERE id = ?;
-- 20x
SELECT id, body, created_at
FROM comments
WHERE post_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 3;Masalah kedua sering tersembunyi di indeks. Misalnya query comment di atas akan tetap lambat jika tabel comments hanya punya index pada post_id tetapi tidak mendukung urutan created_at DESC, id DESC. Database mungkin masih harus membaca banyak row lalu melakukan sort tambahan.
SQL sesudah batching author
SELECT id, name
FROM users
WHERE id IN (?, ?, ?, ...);Ini baru efektif jika kolom users.id memang indexed, yang pada praktiknya biasanya sudah menjadi primary key.
SQL yang dioptimalkan untuk feed post
Untuk daftar post yang diurutkan berdasarkan waktu pembuatan dan ID, pola query tipikal adalah:
SELECT id, title, author_id, created_at
FROM posts
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;Agar query ini efisien, database membutuhkan index yang mengikuti filter dan urutan tersebut. Secara umum, composite index pada kolom yang dipakai untuk pencarian dan sorting akan jauh lebih efektif daripada beberapa single-column index terpisah.
-- Konsep umum
CREATE INDEX idx_posts_created_id ON posts (created_at, id);Pada beberapa kasus, jika query feed juga selalu memfilter tenant, status, atau author, urutan index perlu disesuaikan dengan pola itu, misalnya:
-- Contoh pola, sesuaikan dengan query nyata
CREATE INDEX idx_posts_tenant_status_created_id
ON posts (tenant_id, status, created_at, id);Jangan menyalin pola ini mentah-mentah. Urutan kolom index harus mengikuti query yang paling sering dan paling mahal di sistem Anda.
Strategi indexing yang relevan untuk GraphQL
GraphQL tidak mengubah cara kerja database, tetapi GraphQL sering menghasilkan variasi akses data yang lebih dinamis. Karena itu, strategi index harus berangkat dari field yang paling sering diminta dan pola filter/sort yang paling umum.
Prinsip memilih composite index
- Mulai dari WHERE yang paling selektif dan konsisten: misalnya
tenant_id,status, ataupost_id. - Ikuti dengan kolom ORDER BY bila query butuh hasil terurut tanpa sort mahal.
- Pastikan index mendukung pagination, terutama jika menggunakan cursor berbasis kolom urutan.
Contoh untuk comments per post
Jika query paling umum adalah:
SELECT id, body, created_at
FROM comments
WHERE post_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 3;Maka index yang relevan secara konsep adalah:
CREATE INDEX idx_comments_post_created_id
ON comments (post_id, created_at, id);Kenapa bukan hanya (post_id)? Karena database tidak hanya mencari comment untuk satu post, tetapi juga perlu mengurutkan hasilnya. Dengan composite index yang sejalan dengan pola query, database bisa mengambil row yang tepat lebih cepat dan mengurangi biaya sort.
Kesalahan umum saat menambah index
- menambah banyak single-column index lalu berharap optimizer menggabungkannya secara ideal
- membuat index tanpa melihat query nyata dari log produksi
- mengabaikan biaya write: setiap index tambahan memperlambat insert/update/delete
- membuat index untuk field yang jarang dipakai atau selektivitasnya rendah
Pagination stabil: kenapa OFFSET makin mahal
Masalah pagination berbasis OFFSET
Banyak implementasi awal memakai pola seperti ini:
SELECT id, title, author_id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Masalahnya ada dua:
- Biaya membesar seiring offset: database tetap harus melewati banyak row sebelum mengembalikan 20 row berikutnya.
- Hasil tidak stabil: jika ada row baru masuk di tengah, pengguna bisa melihat data lompat, duplikat, atau terlewat antar halaman.
Pada GraphQL, masalah ini sering muncul di field list atau connection yang dibuka bertahap oleh client.
Cursor-based pagination sebagai solusi
Pendekatan yang lebih stabil adalah menggunakan cursor berdasarkan kolom urutan yang deterministik, misalnya pasangan (created_at, id). Mengapa dua kolom? Karena created_at saja belum tentu unik. id dipakai sebagai tie-breaker agar urutan total tetap konsisten.
SELECT id, title, author_id, created_at
FROM posts
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;Cursor yang dikirim ke client biasanya adalah encoding dari created_at dan id. Bentuk encoding bisa bervariasi, yang penting:
- isinya cukup untuk melanjutkan urutan secara tepat
- urutannya deterministik
- server memvalidasi dan mendekode cursor dengan aman
Pseudo-code resolver pagination stabil
Query.posts = async (_, { first, after }, { db }) => {
let cursorFilter = "";
let params = [];
if (after) {
const cursor = decodeCursor(after); // { createdAt, id }
cursorFilter = "WHERE (created_at, id) < (?, ?)";
params.push(cursor.createdAt, cursor.id);
}
const rows = await db.query(`
SELECT id, title, author_id, created_at
FROM posts
${cursorFilter}
ORDER BY created_at DESC, id DESC
LIMIT ?
`, [...params, first + 1]);
const hasNextPage = rows.length > first;
const sliced = rows.slice(0, first);
return {
edges: sliced.map(row => ({
cursor: encodeCursor({ createdAt: row.created_at, id: row.id }),
node: row
})),
pageInfo: {
hasNextPage,
endCursor: sliced.length
? encodeCursor({
createdAt: sliced[sliced.length - 1].created_at,
id: sliced[sliced.length - 1].id
})
: null
}
};
};Mengambil first + 1 row adalah pola umum untuk menentukan hasNextPage tanpa perlu query count tambahan pada setiap request.
Trade-off cursor-based pagination
- Kelebihan: lebih stabil, lebih efisien untuk data besar, cocok dengan index dan urutan deterministik.
- Kekurangan: tidak cocok untuk lompat langsung ke “halaman 500” dengan mudah seperti OFFSET.
- Konsekuensi desain: client perlu menyimpan cursor, bukan nomor halaman tradisional.
Jika kebutuhan produk benar-benar butuh nomor halaman arbitrer, offset bisa tetap dipakai pada dataset kecil atau hasil yang sudah dipersempit. Namun untuk feed besar yang berubah terus, cursor-based pagination hampir selalu lebih masuk akal.
Alur diagnosis end-to-end yang praktis
1. Ambil satu query GraphQL yang terasa lambat
Jangan mulai dari seluruh sistem. Pilih satu request yang jelas bermasalah, lalu ukur:
- latency total
- jumlah query SQL
- durasi query terlama
- resolver yang paling sering dipanggil
2. Cocokkan field GraphQL dengan SQL yang dihasilkan
Buat peta sederhana:
Query.postsmenghasilkan query daftar postPost.authormenghasilkan lookup userPost.commentsmenghasilkan query comment per post
Pemetaan ini penting agar Anda tidak salah menyalahkan GraphQL padahal masalah utamanya ada di SQL, atau sebaliknya.
3. Cari pola berulang
Tanda-tanda umum:
- query
SELECT ... WHERE id = ?yang berulang puluhan kali - query nested relation yang dipanggil sekali per parent
- query dengan
OFFSETbesar - query dengan
ORDER BYpada kolom yang tidak didukung index
4. Jalankan EXPLAIN pada query yang paling mahal
Perhatikan apakah database:
- scan terlalu banyak row
- sort hasil dalam jumlah besar
- tidak memakai composite index yang sesuai
- kehilangan efisiensi karena urutan filter dan sort tidak sejalan dengan index
5. Perbaiki dari lapisan yang tepat
- jika masalahnya banyak query kecil: perbaiki resolver dengan batching/DataLoader
- jika masalahnya query tunggal yang lambat: perbaiki index, bentuk query, atau pagination
- jika dua-duanya terjadi: selesaikan keduanya, karena GraphQL dan database saling memperbesar dampak bottleneck
Checklist: bottleneck ada di GraphQL layer atau database layer?
Lebih cenderung masalah di GraphQL layer jika:
- jumlah query SQL per request sangat tinggi
- banyak query identik hanya berbeda parameter ID
- latency naik seiring banyaknya nested field yang diminta
- setelah dibatching, latency turun walau SQL per query tidak banyak berubah
Lebih cenderung masalah di database layer jika:
- jumlah query sedikit tetapi salah satunya sangat lambat
- EXPLAIN menunjukkan scan besar atau sort mahal
- query lambat terutama saat filter/sort tertentu dipakai
- OFFSET besar membuat halaman berikutnya makin mahal
Biasanya keduanya jika:
- resolver N+1 menghasilkan banyak query, dan tiap query juga tidak diindeks dengan baik
- client meminta nested field yang dalam pada dataset besar
- query root sudah mahal, lalu nested resolver menambah beban lagi
Kesalahan umum yang sering terjadi
- Menambahkan DataLoader tetapi membuat instance global. Loader sebaiknya dibuat per request agar cache tidak bocor antar user atau antar konteks otorisasi.
- Mengandalkan cache untuk menutupi query buruk. Cache bisa membantu, tetapi tidak menggantikan desain query dan index yang benar.
- Memakai cursor yang tidak unik. Jika cursor hanya memakai timestamp tanpa tie-breaker, urutan bisa tidak stabil.
- Menambah index tanpa evaluasi write cost. Pada tabel dengan write tinggi, terlalu banyak index bisa menjadi bottleneck baru.
- Mengukur hanya latency GraphQL total. Tanpa breakdown per resolver dan per query SQL, diagnosis jadi spekulatif.
Ringkasan implementasi yang layak diprioritaskan
- Aktifkan observability: log query SQL, tracing resolver, dan metrik latency per field penting.
- Hilangkan N+1 untuk lookup berbasis ID dengan batching/DataLoader per request.
- Tinjau query list utama: pastikan filter dan
ORDER BYdidukung composite index yang sesuai. - Ganti OFFSET besar dengan cursor-based pagination berbasis urutan deterministik seperti
(created_at, id). - Validasi dengan EXPLAIN/EXPLAIN ANALYZE setelah perubahan, bukan sebelum saja.
Pada sistem GraphQL yang mulai besar, performa jarang membaik hanya dengan satu trik. Anda biasanya perlu memperbaiki pola eksekusi resolver dan akses database secara bersamaan. Resolver N+1 membuat jumlah query membengkak, index yang tidak tepat membuat tiap query makin mahal, dan OFFSET besar memperburuk daftar data yang aktif dipaginasi. Kombinasi batching, composite index yang sesuai, dan cursor-based pagination adalah fondasi yang paling sering memberi hasil nyata dan stabil.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!