π N+1 Query β Deteksi & Eliminasi Sesi 15:00 Technical Exploration Β· Database Β· level 1
π― Tujuan Belajar
Setelah sesi ini kamu bisa:
- Mendeteksi N+1 dari luar aplikasi (log Postgres,
pg_stat_statements) tanpa membaca kode. - Memilih strategi loading yang benar per bentuk relasi (to-one vs collection), bukan asal
with(). - Mengunci regresi N+1 di CI supaya tidak balik lagi setelah 6 bulan.
Skill yang dipakai besok: baca calls di pg_stat_statements β langsung tahu endpoint mana yang bocor.
ποΈ Arsitektur & Alur Teknis
Definisi operasional. 1 query untuk mengambil N parent + 1 query per parent untuk child = N+1 query. Total 1 + N. Kalau child punya child lagi: 1 + N + NΒ·M (disebut juga N+1 berlapis).
Kenapa mahal. Biayanya bukan CPU database β biayanya round-trip. Setiap query = 1 network RTT + 1 parse/plan + 1 context switch. Jadi biaya β N Γ RTT. Ini alasan N+1 sering tak terlihat di lokal (RTT 0.05 ms) tapi meledak di produksi (RTT 0.5β2 ms appβDB, apalagi cross-AZ).
Tiga jalur perbaikan, beda karakter:
| Strategi | Query | Baris terkirim | Cocok untuk |
|---|---|---|---|
| Lazy (default) | 1+N |
minimal per query | tidak pernah untuk list |
| Eager JOIN | 1 |
Ξ£ child (parent terduplikasi) |
to-one (belongsTo, ManyToOne) |
Eager IN-batch (prefetch, selectin) |
2 (atau βN/500β+1) |
Ξ£ child, tanpa duplikasi |
collection (hasMany, ManyToMany) |
Aturan pemilihan (INI yang penting, bukan "best practice"):
- to-one β JOIN. Tidak ada duplikasi baris, 1 query.
- collection β IN-batch. JOIN pada collection menggandakan kolom parent per child β traffic naik, dan kalau 2 collection di-JOIN bersamaan β kartesian product.
- collection besar (>500β1000 parent) β IN-batch tetap, tapi sadar ada chunking. SQLAlchemy
selectinmemecah per 500 PK (docs SQLAlchemy 2.1).
Alur deteksi yang benar (dari luar ke dalam):
1. pg_stat_statements β query mana yang calls-nya tinggi, mean rendah
2. log Postgres β pola pengulangan dalam 1 request (application_name)
3. OpenTelemetry span β parent span punya N child span SQL identik
4. test / CI β assert jumlah query per request
Langkah 1 & 2 tidak butuh akses kode atau deploy. Itu sebabnya dilakukan dulu.
π» Kode Nyata / Konfigurasi
1. Deteksi dari Postgres β tanpa sentuh aplikasi
pg_stat_statements menormalkan literal jadi $1, jadi query yang diulang N kali digabung jadi satu baris dengan calls tinggi. Itu sidik jari N+1.
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
calls,
round(mean_exec_time::numeric, 3) AS mean_ms,
round(total_exec_time::numeric, 1) AS total_ms,
round((total_exec_time / NULLIF(calls,0))::numeric, 3) AS avg_ms,
left(regexp_replace(query, '\s+', ' ', 'g'), 110) AS q
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat%'
AND calls > 1000 -- sering dipanggil
AND mean_exec_time < 1 -- tapi tiap panggilan murah
ORDER BY calls DESC
LIMIT 20;
Cara baca: baris dengan calls sangat tinggi + mean_ms < 1 ms + query SELECT ... WHERE "x_id" = $1 = hampir pasti N+1. Query sehat untuk endpoint list biasanya calls rendah, mean_ms lebih besar.
Versi 18 (dan 17) menambahkan kolom toplevel β pakai WHERE toplevel supaya query di dalam function/PL-pgSQL tidak mendistorsi angka (PostgreSQL docs, pg_stat_statements).
Konfirmasi di level request β application_name per request ID, lalu lihat pola:
-- app set: SET application_name = 'req-8f2a';
SELECT seq, duration, left(query, 80)
FROM pg_stat_activity -- hanya snapshot; untuk historis pakai log
Log-based (lebih akurat untuk pola berurutan):
# postgresql.conf
log_min_duration_statement = 0 # sementara saja, sangat verbose
log_line_prefix = '%m [%p] app=%a '
log_statement = 'none'
# 1 request = 1 application_name β hitung query per request
grep 'app=req-' /var/log/postgresql/postgresql-18-main.log \
| awk '{print $3}' | sort | uniq -c | sort -rn | head
Request dengan hitungan query puluhan/ratusan sementara latency DB per query ~0.2 ms = N+1.
2. Laravel 13.x β auto eager load + guard
Laravel 13 (docs current) sudah punya dua alat yang saling melengkapi (Eloquent: Relationships):
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// 1) Auto eager-load: akses relasi pertama pada collection β langsung
// lazy-eager-load untuk SELURUH collection, bukan per model.
Model::automaticallyEagerLoadRelationships();
// 2) Guard: di non-produksi, lazy load = exception. Bikin N+1 ketahuan saat dev/test.
Model::preventLazyLoading(! $this->app->isProduction());
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
report(new \RuntimeException(
"Lazy load [{$relation}] on ".$model::class." (id={$model->getKey()})"
));
});
}
Per-collection saja (kalau tak mau global):
$users = User::where('vip', true)->get()->withRelationshipAutoloading();
Catatan penting: automaticallyEagerLoadRelationships() memperbaiki bentuk, bukan menggantikan with(). Ia menyelamatkan kode yang sudah terlanjur ditulis, tapi query-nya tetap hasil tebakan β untuk endpoint yang butuh filter/limit pada relasi, tetap deklarasikan eksplisit:
$users = User::query()
->with([
'posts' => fn ($q) => $q->latest()->limit(5),
'posts.comments:id,post_id,body', // kolom dibatasi β hindari over-fetch
'team:id,name',
])
->paginate(20);
with() + limit() pada collection di Laravel lama menghasilkan query per parent (karena limit diterapkan per group). Di Laravel 12+/13 eager load dengan limit sudah ditangani lewat window function β tapi verifikasi di versi kamu (EXPLAIN), jangan asumsi. Status: DIINFERENSI dari changelog, bukan dokumentasi eksplisit.
Fitur ini masuk sejak Laravel 12.8 (Model::automaticallyEagerLoadRelationships(), withRelationshipAutoloading()).
3. Prisma β relationLoadStrategy (masih Preview)
Kondisi per 30 Oktober 2026 (docs resmi): Prisma ORM 8 release candidate (prisma@8.0.0-rc.29, @prisma/orm-postgres@8.0.0-rc.22); Prisma ORM 7 GA (@prisma/client@7.10.1); Prisma ORM 6 hanya security patch sampai 19 November 2026. Jangan upgrade buta β prisma di npm latest sekarang menunjuk ke CLI v8 yang tidak membaca schema.prisma dan tidak punya generate/migrate dev (release status).
// package.json β kunci ke v7 kalau proyek masih v7
{ "devDependencies": { "prisma": "^7" }, "dependencies": { "@prisma/client": "^7" } }
N+1 di Prisma biasanya muncul justru di GraphQL resolver, karena resolver posts dipanggil sekali per user:
// β N+1: findMany sekali per user
const User = objectType({
name: "User",
definition(t) {
t.field("posts", {
type: Post,
resolve: (parent, _a, ctx) =>
ctx.prisma.post.findMany({ where: { authorId: parent.id } }),
});
},
});
Dua perbaikan valid (query optimization v7):
// β
A. Dataloader otomatis β findUnique di tick yang sama di-batch oleh Prisma
resolve: (parent, _a, ctx) => ctx.prisma.user.findUnique({ where: { id: parent.id } }).posts(),
// β
B. JOIN di level DB, satu query
const posts = await prisma.post.findMany({
relationLoadStrategy: "join", // PostgreSQL: LATERAL JOIN + JSON aggregation
where: { authorId: { in: userIds } },
});
relationLoadStrategy masih Preview di docs v7 β butuh previewFeatures = ["relationJoins"] di generator. Strategi join pakai LATERAL JOIN + JSON aggregation di Postgres, query kirim satu query per tabel lalu join di app. Untuk dataset aneh, query bisa lebih cepat β profil dulu, jangan tebak.
4. Django 6.x β select_related vs prefetch_related vs Prefetch
Django 6.0 rilis 3 Desember 2025, wajib Python β₯ 3.12; 6.0.1 patch 6 Januari 2026.
from django.db.models import Prefetch
# β 1 + N + N*M
for book in Book.objects.all():
print(book.author.country.name, [a.name for a in book.authors.all()])
# β
FK / OneToOne β JOIN. M2M / reverse FK β IN-batch.
books = (
Book.objects
.select_related("author__country") # to-one: JOIN, 0 query tambahan
.prefetch_related("authors__country") # collection: 1 query per level
)
# β
Prefetch dengan queryset terfilter β tetap 2 query, bukan N
books = Book.objects.prefetch_related(
Prefetch("reviews", queryset=Review.objects.filter(rating__gte=4).only("id","book_id","rating"),
to_attr="good_reviews")
)
Jebakan yang bikin prefetch berubah jadi N+1 lagi:
- obj.authors.all().order_by("-name") β QuerySet baru di luar prefetch β query DB. Pakai Prefetch(..., queryset=Author.objects.order_by("-name")).
- obj.authors.count() β hitung di Python (len(obj.authors.all()) setelah prefetch) atau pakai annotate(Count(...)).
- Prefetch tidak menyentuh .filter() di dalam loop.
Guard di test:
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.db import connection
class BookListQueryCountTest(TestCase):
def test_list_endpoint_query_count(self):
# seed 50 books, 3 authors each
with CaptureQueriesContext(connection) as ctx:
self.client.get("/api/books/")
# 1 books + 1 authors + 1 countries + session = konstanta, bukan fungsi N
self.assertLessEqual(len(ctx), 8, [q["sql"] for q in ctx.captured_queries])
Assertion harus konstanta, bukan <= 1 + N. Kalau kamu tulis 1 + N di test, test-nya ikut mensabotase.
5. SQLAlchemy 2.x β pilih loader, jangan lazy="select" untuk list
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload
# Collection β selectinload (IN-batch, chunk 500 PK)
stmt = (
select(User)
.options(selectinload(User.addresses), selectinload(User.orders).selectinload(Order.items))
.limit(50)
)
# Many-to-one β joinedload (1 query, tanpa duplikasi baris)
stmt = select(Order).options(joinedload(Order.customer)).limit(50)
Kalau joinedload dipakai pada collection, wajib .unique() sebelum .scalars() β kalau tidak, SQLAlchemy raise InvalidRequestError: The unique() method must be invoked on this Result. Ini error yang paling sering muncul saat orang "memperbaiki" N+1.
Guard global untuk dev:
# Set lazy="raise" di relationship yang selalu harus eager
class User(Base):
__tablename__ = "users"
orders = relationship("Order", lazy="raise_on_sql", back_populates="user")
lazy="raise_on_sql" = boleh akses kalau sudah ter-load, raise kalau butuh SQL baru. Lebih toleran daripada lazy="raise".
π Angka & Benchmark
Pengukuran saya sendiri, dijalankan sesi ini (SQLite in-memory, 200 user Γ 4000 post, RTT per query disimulasikan 0.5 ms untuk mengisolasi biaya round-trip β RTT tipikal appβPostgres same-AZ):
| Strategi | Query | Waktu total (median) |
|---|---|---|
| N+1 | 201 | 100.50 ms |
| IN-batch | 2 | 1.00 ms |
| JOIN | 1 | 0.50 ms |
Angka SQL engine-nya sendiri (tanpa RTT) untuk ketiga strategi semuanya ~3.8β4.4 ms β praktis identik. Artinya: 99% biaya N+1 adalah round-trip, bukan kerja database. Itu inti pelajarannya, dan itu sebabnya optimasi index tidak akan menyelamatkan N+1.
Skrip benchmark: ada di bagian Latihan.
Angka rujukan dari dokumentasi (bukan pengukuran saya):
- SQLAlchemy docs: 100 objek + akses collection = 101 statement (lazy) vs 2 (selectin) vs 1 (joined, Ξ£ baris membengkak). selectin maksimal 500 PK per SELECT, jadi >500 parent = 1 SELECT per batch 500.
- Prisma docs: contoh GraphQL allUsers { posts } = 1 query user + 1 query per user untuk posts; relationLoadStrategy: "join" = 1 query.
Status versi terverifikasi (30 Okt 2026):
| Tool | Versi | Status |
|---|---|---|
| Prisma ORM 8 | 8.0.0-rc.29 (CLI) / 8.0.0-rc.22 (library) |
RC, GA diperkirakan Okt 2026 |
| Prisma ORM 7 | @prisma/client@7.10.1 |
GA, didukung 18 bln setelah v8 GA |
| Prisma ORM 6 | β | security patch s/d 19 Nov 2026 |
| Django | 6.0 (3 Des 2025), 6.0.1 (6 Jan 2026), 6.1 in dev | Python β₯ 3.12 |
| Laravel | 13.x current docs; auto eager load sejak 12.8 | β |
| SQLAlchemy | 2.1 docs latest | β |
βοΈ Trade-off & Alternatif
| Pendekatan | Menang | Kalah | Kapan salah |
|---|---|---|---|
join / select_related |
1 query, latency terendah | baris parent terduplikasi; 2 collection β kartesian | collection besar, atau >1 collection |
IN-batch / prefetch / selectin |
tanpa duplikasi, aman untuk nested | 2+ query, IN clause besar, chunking | to-one sederhana (JOIN lebih murah) |
subqueryload |
INNER JOIN selalu bisa | subquery dieksekusi ulang, butuh ORDER BY | hampir selalu kalah dari selectin |
| Dataloader (GraphQL) | otomatis per-request, per-resolver | butuh request-scoped context; batch lintas user bisa bocor data kalau salah scope | REST (tidak perlu) |
| Auto eager load (Laravel) | hilangkan N+1 tanpa refactor | query hasil tebakan, sulit di-EXPLAIN, bisa over-fetch |
endpoint butuh filter/limit per relasi |
Denormalisasi / counter_cache |
hilangkan query agregat sepenuhnya | konsistensi harus dijaga manual, write lebih mahal | data harus real-time ketat |
| Cache (Redis) di child | hilangkan round-trip | invalidation; cache stampede | child sering berubah |
Yang sering salah diklaim "solusi": menambah index. Index mempercepat query individual; N+1 tetap N round-trip. Kecuali calls sudah tinggi dan mean_ms juga tinggi β baru index relevan.
π Use Case Produksi & Failure Mode Umum
Failure mode 1 β N+1 yang tidak terlihat di lokal. Dev pakai SQLite/Postgres di localhost: RTT ~0.05 ms. 200 query = 10 ms. Di produksi cross-AZ RTT 1 ms β 200 ms. Endpoint lolos review, meledak saat traffic naik. Mitigasi: ukur jumlah query, bukan waktu lokal.
Failure mode 2 β N+1 di dalam serialization layer. Query-nya tidak ada di controller. Ada di API Resource / serializer / template. Contoh: Laravel PostResource mengakses $this->author->avatar β 1 query per post, padahal controller sudah Post::paginate(50). Mitigasi: preventLazyLoading() di non-produksi menangkap ini di test.
Failure mode 3 β loop yang menyembunyikan query. foreach dengan Http::get() atau Storage::exists() di dalamnya. Ini saudara N+1 (N network call), tapi pg_stat_statements tidak melihatnya β tidak ada SQL. Mitigasi: APM/tracing, cari span child dengan nama sama berulang.
Failure mode 4 β prefetch yang tidak terpakai. ->with('posts') di query yang tidak pernah memakai posts. Biaya lebih besar, hasil nol. Mitigasi: Bullet gem (Rails) / query log check untuk "unused eager loading".
Failure mode 5 β kartesian product terselubung. with('posts', 'comments') pada collection yang keduanya hasMany β Laravel menjalankan 3 query terpisah (aman), tapi select_related berlapis di Django pada reverse FK tidak bisa β error. Di SQLAlchemy joinedload pada 2 collection = N_posts Γ N_comments baris. Mitigasi: satu collection besar per query, sisanya IN-batch.
Use case produksi yang paling sering kena: dashboard admin dengan tabel + relasi (user β team β plan), feed (post β author β avatar β likes count), dan GraphQL endpoint apa pun yang tidak pakai DataLoader.
π οΈ Latihan Praktis Kecil
Tujuan: reproduksi angka di tabel benchmark, di laptop sendiri, < 2 menit, tanpa install apa pun (stdlib sqlite3).
# nplus1_bench.py β python3 nplus1_bench.py
import sqlite3, time, statistics
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE posts(id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT);
CREATE INDEX idx_posts_user ON posts(user_id);
""")
db.executemany("INSERT INTO users VALUES (?,?)", [(i, f"u{i}") for i in range(1, 201)])
db.executemany("INSERT INTO posts VALUES (?,?,?)",
[(i, (i % 200) + 1, f"p{i}") for i in range(1, 4001)])
db.commit()
RTT = 0.0005 # 0.5 ms per round-trip, disimulasikan
def n_plus_one():
c, t = db.cursor(), RTT
users = c.execute("SELECT id,name FROM users").fetchall()
for u in users:
c.execute("SELECT id,title FROM posts WHERE user_id=?", (u[0],)).fetchall()
t += RTT
return t
def batch():
c, t = db.cursor(), RTT
users = c.execute("SELECT id,name FROM users").fetchall()
ids = [u[0] for u in users]
c.execute("SELECT user_id,id,title FROM posts WHERE user_id IN (%s)"
% ",".join("?" * len(ids)), ids).fetchall()
return t + RTT
def joined():
db.execute("SELECT u.id,u.name,p.id,p.title FROM users u "
"LEFT JOIN posts p ON p.user_id=u.id").fetchall()
return RTT
for name, fn in [("N+1", n_plus_one), ("IN-batch", batch), ("JOIN", joined)]:
ms = statistics.median([fn() * 1000 for _ in range(20)])
print(f"{name:9s} {ms:7.2f} ms")
Output yang saya dapat: N+1 100.50 / IN-batch 1.00 / JOIN 0.50.
Variasi yang wajib dicoba: naikkan RTT ke 0.002 (2 ms, cross-region). N+1 jadi ~400 ms, IN-batch tetap ~2 ms. Rasio memburuk linear terhadap RTT, bukan terhadap jumlah data. Itu bukti bahwa masalahnya topologi, bukan volume.
Latihan kedua (di repo sendiri, 10 menit): jalankan test suite dengan jumlah query di-assert. Cari satu test endpoint list, tambahkan assertLessEqual(len(ctx), KONSTANTA). Satu test itu akan menangkap regresi N+1 selamanya.
β Cek Pemahaman
-
pg_stat_statementsmenunjukkan querySELECT ... FROM "order_items" WHERE "order_id" = $1dengancalls = 84.000danmean_exec_time = 0.08 ms. Apakah ini masalah? Kalau ya, kenapamean_exec_timeyang kecil justru buktinya? -
Kamu punya
User β hasMany Order β hasMany Item. Endpoint mengambil 100 user. Bandingkan jumlah query dan jumlah baris terkirim untuk (a)joinedload(User.orders).joinedload(Order.items)dan (b)selectinload(User.orders).selectinload(Order.items). Mana yang kamu pilih dan kenapa? -
Model::automaticallyEagerLoadRelationships()sudah aktif, tapi endpoint masih lambat. Sebutkan dua penyebab yang tidak diselesaikan fitur itu.
π Poin Kunci
- N+1 = masalah round-trip, bukan masalah database. 201 query vs 2 query: engine cost sama (~4 ms), total beda 100Γ karena RTT. Index tidak menyelamatkan.
- Deteksi dulu dari luar aplikasi.
pg_stat_statements:callstinggi +mean_exec_timerendah = sidik jari N+1. Tidak butuh deploy. - to-one β JOIN, collection β IN-batch. JOIN pada collection = duplikasi baris; dua collection di-JOIN = kartesian.
- Guard > disiplin.
preventLazyLoading,lazy="raise_on_sql",assertNumQueriesdengan konstanta. Test yang menulis1 + Nikut mensabotase. - Auto eager load memperbaiki bentuk, bukan niat. Query hasil tebakan; untuk filter/limit per relasi tetap deklarasikan eksplisit.
- Versi berubah cepat dan berbahaya.
npm install prismahari ini memberi CLI v8 RC yang tidak punyamigrate dev. Kunci versi dipackage.json.
β‘οΈ Eksplorasi Lanjut
auto_explainβ log execution plan otomatis untuk query > N ms. Pasangan wajibpg_stat_statements: yang satu memprioritaskan, yang lain menjelaskan.- Postgres 18
pg_stat_statements.toplevelβ memisahkan query dari dalam function/PL-pgSQL supaya angka tidak terdistorsi. - GraphQL DataLoader β batch + cache per request; pahami kenapa context harus request-scoped (kebocoran data lintas user kalau module-scoped).
pg_stat_monitor(Percona) β grouping multi-dimensi (client_ip,planid), lebih tajam daripadaqueryidtunggal.- N+1 di luar SQL β loop berisi HTTP/gRPC call, S3
headObject, RedisGETper key. Tidak terdeteksipg_stat_statements; butuh tracing. EXPLAIN (ANALYZE, BUFFERS)β sesi berikutnya yang natural untuk topik ini.
Batas verifikasi: angka benchmark adalah pengukuran saya sendiri di SQLite in-memory dengan RTT disimulasikan β bukan Postgres nyata, dan bukan tolok ukur absolut. Yang valid adalah rasionya, bukan nilai ms absolutnya. Klaim Laravel with() + limit() untuk collection berstatus DIINFERENSI dari changelog (window function), tidak saya verifikasi di source code β cek EXPLAIN di proyekmu sebelum mengandalkannya. relationLoadStrategy masih Preview per docs Prisma v7 (30 Okt 2026); bisa berubah sebelum v8 GA.