TL;DR Kartu Blog tanpa N+1 Query
Satu field kecil di halaman list nyaris memicu satu query per artikel. Solusinya satu query batch IN, tetap dua query per halaman.
Kartu artikel di halaman list blog butuh box TL;DR. Kecil aja, dua-tiga baris di atas judul. Masalahnya, TL;DR itu nggak disimpan di tabel `articles_posts`. Dia tidur di tabel postmeta gaya WordPress: satu baris `meta_key` 'tldr', satu `meta_value` berisi teksnya. Dan endpoint list API-nya udah lama sengaja skip semua meta rows, demi ringan. Jadi frontend manggil, API balikin list artikel yang bersih, tanpa TL;DR sama sekali.
Opsi paling ngawur: tiap artikel di halaman, fetch meta-nya satu per satu. Sepuluh artikel di halaman, berarti sepuluh query tambahan cuma buat satu field kecil. Pas nulis query pertamanya saya udah merasa aneh, tapi entah kenapanya baru nyata pas saya hitung: ini persis masalah N+1 klasik, cuma lewat jalur yang nggak biasa [4].
N+1 versi postmeta lebih licik
Kalo N+1 muncul dari lazy loading ORM, biasanya ada jejaknya. Ada relationship di model, ada warning di log, ada nama yang bisa dicari di dokumentasi. Di sini nggak ada semua itu. Nggak ada ORM relationship antara post dan postmeta, murni dua tabel yang kebetulan nyambung lewat `post_id`. Jadi nggak ada yang bikin saya mikir "wah ini bakal nembak query per baris". Yang ada cuma "oh butuh satu field, ya fetch aja".
Itu yang bikin N+1 di postmeta gampang lewat dari radar. Loop-nya keliatan innocent. Sepuluh iterasi, tiap iterasi satu query kecil, secara individual masing-masing cepet. Baru di traffic beneran atau di halaman dengan pagination lebih gede, pola 1 + N ini keliatan matanya.
Ada satu lapisan lagi yang bikin hal ini gampang kelewat: tabel postmeta itu desain yang bagus buat datanya jarang dimutakin. Field opsional kayak TL;DR nggak mau saya tarik jadi kolom di tabel utama, karena kolom kosong itu jelek dan skema jadi kaku. Pola metadata terpisah ini yang dipakai WordPress bertahun-tahun dan nggak ada yang salah sama desainnya. Yang salah cuma cara mengambilnya di halaman list.
Satu query batch, beres
Fixnya nggak perlu ganti skema, nggak perlu nambah relationship. Satu query tambahan doang:
ids = [row.id for row in rows]
tldr_map: dict[int, str] = {}
if rows:
meta_res = await session.exec(
select(ArticlePostmetaTable).where(
ArticlePostmetaTable.meta_key == "tldr",
ArticlePostmetaTable.post_id.in_(ids),
)
)
tldr_map = {m.post_id: m.meta_value for m in meta_res.all()}
# sisipkan balik: baris yang punya TLDR dibungkus meta-nya,
# yang nggak punya dilewatin apa adanya
return [
_to_domain(row, [meta(row.id, "tldr", tldr_map[row.id])])
if row.id in tldr_map
else _to_domain(row, [])
for row in rows
]
Kumpulin semua id post di halaman, satu query ambil semua baris meta yang `meta_key`-nya 'tldr' sekaligus, petakan ke dict, sisipkan balik ke tiap artikel. `in_()` sendiri merender list Python jadi deretan bound parameter `IN (?, ?, ?)`, jadi nggak ada string interpolation aneh-aneh [2].
Hasil akhirnya: tetap dua query per halaman. Satu buat list artikel, satu buat meta. Bukan 1 + N.
Satu catatan jujur soal batasannya: pola ini saya pakai di halaman ber-pagination, jadi jumlah id di `in_()`-nya wajar, belasan sampai puluhan. Kalo kamu harus batch-load jutaan id sekaligus, bentuk IN-list ini bukan alat yang pas. Tapi buat kasus satu halaman web, batas itu jauh di atas kebutuhan.
Kenapa pola ini masuk akal
Pas nulis ini saya sadar polanya sama persis sama `selectinload` di SQLAlchemy, yang secara sengaja nge-load koleksi buat deretan objek pake satu query terpisah [5]. Bedanya cuma itu built-in dan punya nama, sementara saya nulis versi manualnya karena nggak ada relationship buat di-load.
Biayanya flat per halaman, nggak numbuh bareng jumlah artikel. Halaman muat 20 artikel atau 50 artikel, tetap dua query. Buat kebutuhan UI list yang butuh satu field dari tabel lain, itu sinyal buat batch-load dari awal, bukan nunggu query per baris kebentuk dulu baru disadari.
Jadi sekarang aturan pribadi saya begini: kalo list page butuh satu field kecil dari tabel terpisah dan nggak ada relationship ORM-nya, langsung tulis query batch-nya hari itu juga. Nggak nunggu ngerasa "kok API-nya lambat ya" dulu.
Sumber
- [2] SQLAlchemy: Column Elements and Expressions (in_)
- [4] SQLAlchemy ORM Tutorial: Working with Related Objects
- [5] SQLAlchemy: SELECT and Related Constructs (selectinload)
Konteks nyambung: related semantic, TL;DR, dan search live, related articles dalam satu fetch paralel, dan homepage blog yang live dari API.