Semua query daftar sejauh ini memakai pola yang sama: find + sort + limit. Pola tersebut cukup untuk belasan artikel, tetapi blog yang sehat terus bertambah — dan dua masalah muncul bersamaan. Pertama, query makin lambat karena memindai seluruh collection lalu mengurutkan di memori. Kedua, pembaca yang membuka “halaman 2” bisa melihat artikel yang sama dua kali bila artikel baru diterbitkan di antara dua kunjungan. Bagian penutup ini menyelesaikan keduanya dengan pagination keyset, index compound, dan suite tes yang memverifikasi seluruh proyek.
Kelemahan skip dan limit #
Cara klasik memecah daftar adalah skip((nomor - 1) * ukuran).limit(ukuran): halaman pertama melewati 0 document, halaman kelima melewati 20. Server tetap membaca dan membuang 20 document sebelum mengirim isi halaman kelima — makin dalam halaman, makin mahal biayanya. Pergeseran hasil mengikuti sebagai masalah kedua: satu artikel baru di halaman 1 mendorong seluruh isi turun, sehingga item terakhir halaman 1 muncul lagi di halaman 2.
Keyset pagination menyerang akar keduanya. Halaman berikutnya tidak dimulai dari “document ke-N” melainkan dari kursor: pasangan nilai published_at dan _id milik artikel terakhir halaman sebelumnya. Server melompat langsung ke posisi kursor lewat index — tidak ada document yang dibuang — dan artikel baru yang menyusul di depan tidak pernah menggeser isi halaman berikutnya.
Kursor dengan Tie-breaker _id #
Seluruh daftar diurutkan dengan { published_at: -1, _id: -1 }. Filter halaman berikutnya memakai $or dua cabang:
published_atlebih kecil dari kursor — semua artikel yang lebih lama, ataupublished_atsama dengan kursor dan_idlebih kecil — pemecah seri.
Tie-breaker ini bukan hiasan. Dua artikel seed kita terbit di hari yang sama, 16 April 2026, dan keduanya berurutan di halaman 1 dan 2 pada output di bawah. Tanpa _id, nilai published_at yang sama membuat urutan keduanya tidak menentu — artikel bisa terlewati atau tampil dua kali saat berpindah halaman. Dengan _id, urutan seluruh collection menjadi total dan kursor selalu menunjuk satu posisi yang pasti.
Index Compound Pendukung #
Skrip kita memasang satu index compound: { status: 1, published_at: -1, _id: -1 }. Susunannya mengikuti aturan chapter Compound Index: field equality (status) berdiri paling depan, dua field sort menyusul dengan arah yang sejajar query. Satu index sekaligus menyaring status dan menyediakan urutan published_at + _id — stage SORT di memori hilang, dan limit tinggal memotong traversal index. Sebagai bonus, index ini juga melayani query yang hanya memfilter status tanpa sort.
Skrip Pagination #
// blog-pagination.js — pagination keyset dan index pendukung
// Jalankan setelah blog-comments.js:
// mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/blog-pagination.js
var blog = db.getSiblingDB("blog")
function buatIndexPagination() {
blog.artikel.createIndex({ status: 1, published_at: -1, _id: -1 })
print("index pagination siap:", blog.artikel.getIndexes()
.map(function (i) { return i.name })
.join(", "))
}
function halamanArtikel(ukuran, kursor) {
const filter = { status: "terbit" }
if (kursor) {
filter.$or = [
{ published_at: { $lt: kursor.published_at } },
{ published_at: kursor.published_at, _id: { $lt: kursor._id } },
]
}
const dokumen = blog.artikel
.find(filter)
.sort({ published_at: -1, _id: -1 })
.limit(ukuran + 1)
.toArray()
const adaLagi = dokumen.length > ukuran
const isi = adaLagi ? dokumen.slice(0, ukuran) : dokumen
const terakhir = isi[isi.length - 1]
return {
artikel: isi,
kursorBerikutnya: adaLagi
? { published_at: terakhir.published_at, _id: terakhir._id }
: null,
}
}
function tampilHalaman(nomor, hasil) {
print("== halaman", nomor, "==")
hasil.artikel.forEach(function (a) {
print(" ", a.judul, "|", a.published_at.toISOString().slice(0, 10), "|", a._id)
})
if (hasil.kursorBerikutnya) {
const k = hasil.kursorBerikutnya
print(" kursor berikutnya:", k.published_at.toISOString().slice(0, 10), "|", k._id)
} else {
print(" (halaman terakhir)")
}
}
function rencanaQueryPagination() {
const rencana = blog.artikel
.find({ status: "terbit" })
.sort({ published_at: -1, _id: -1 })
.limit(3)
.explain()
printjson(rencana.queryPlanner.winningPlan)
}
print("== demo pagination keyset ==")
buatIndexPagination()
rencanaQueryPagination()
var kursor = null
var nomor = 1
while (true) {
const hasil = halamanArtikel(3, kursor)
tampilHalaman(nomor, hasil)
if (!hasil.kursorBerikutnya) break
kursor = hasil.kursorBerikutnya
nomor += 1
}Jalankan setelah blog-comments.js:
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/blog-pagination.jsOutput yang Dihasilkan:
== demo pagination keyset ==
index pagination siap: _id_, slug_1, status_1_published_at_-1__id_-1
{
isCached: false,
stage: 'LIMIT',
limitAmount: 3,
inputStage: {
stage: 'FETCH',
nss: 'blog.artikel',
inputStage: {
stage: 'IXSCAN',
nss: 'blog.artikel',
keyPattern: {
status: 1,
published_at: -1,
_id: -1
},
indexName: 'status_1_published_at_-1__id_-1',
isMultiKey: false,
multiKeyPaths: {
status: [],
published_at: [],
_id: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
status: [
'["terbit", "terbit"]'
],
published_at: [
'[MaxKey, MinKey]'
],
_id: [
'[MaxKey, MinKey]'
]
}
}
}
}
== halaman 1 ==
Menjaga Skema Tetap Rapi | 2026-05-21 | ObjectId('678000000000000000000012')
Vector Search untuk Pencarian Semantik | 2026-05-07 | ObjectId('678000000000000000000011')
Change Streams untuk Notifikasi Real-time | 2026-04-16 | ObjectId('678000000000000000000010')
kursor berikutnya: 2026-04-16 | ObjectId('678000000000000000000010')
== halaman 2 ==
Apa itu Replica Set | 2026-04-16 | ObjectId('678000000000000000000009')
Rutinitas Pagi Programmer | 2026-03-19 | ObjectId('678000000000000000000007')
Memilih Keyboard untuk Produktivitas | 2026-03-05 | ObjectId('678000000000000000000006')
kursor berikutnya: 2026-03-05 | ObjectId('678000000000000000000006')
== halaman 3 ==
Dasar Aggregation Pipeline | 2026-02-23 | ObjectId('678000000000000000000004')
Index Compound untuk Query Umum | 2026-02-09 | ObjectId('678000000000000000000003')
Membandingkan Skema SQL dan Document | 2026-01-26 | ObjectId('678000000000000000000002')
kursor berikutnya: 2026-01-26 | ObjectId('678000000000000000000002')
== halaman 4 ==
Pengenalan Document Database | 2026-01-12 | ObjectId('678000000000000000000001')
(halaman terakhir)Bedah Fungsi demi Fungsi #
buatIndexPagination()membuat index compound lalu mencetak daftar index — tiga buah:_id_,slug_1dari Bagian 1, danstatus_1_published_at_-1__id_-1yang baru.halamanArtikel()memakai triklimit(ukuran + 1): satu document ekstra diambil semata sebagai bukti bahwa halaman berikutnya masih ada, tanpa perlucountDocuments(). Document ekstra dibuangslice(0, ukuran), lalu kursor diambil dari artikel terakhir yang benar-benar ditampilkan — bukan dari document ekstra.tampilHalaman()mencetak judul, tanggal, dan_idsetiap artikel._idikut ditampilkan agar kerja tie-breaker terlihat langsung di output.rencanaQueryPagination()menjalankanexplain()pada bentuk query pagination:LIMIT→FETCH→IXSCANmemakaistatus_1_published_at_-1__id_-1.indexBoundsmenampilkanstatus: ["terbit","terbit"]sebagai titik masuk dan[MaxKey, MinKey]pada dua field berikutnya — traversal dari ujung ke ujung sesuai arah sort. Bandingkan dengan Bagian 2: tanpa index ini, rencananyaSORTdi atasCOLLSCAN.- Perulangan halaman berjalan sampai
kursorBerikutnyabernilainull. Halaman 1 berisi tiga artikel termasuk kedua artikel 16 April; kursor berikutnya menunjuk2026-04-16dengan_id…010; halaman 2 dibuka Apa itu Replica Set yang bertanggal sama dengan_id…009 — pasangan sehari terbit berpindah halaman tanpa terulang maupun terlewati. Halaman 4 hanya memuat satu artikel dan ditutup(halaman terakhir).
Jelajah Lanjut #
Tiga percobaan penutup layak dicoba: ubah ukuran halaman menjadi 2 atau 5 dan amati kursor menyesuaikan; terbitkan satu artikel baru dengan published_at terkini, lalu telusuri kembali kursor lama — isinya tidak bergeser; dan jalankan explain() pada filter $or kursor untuk membuktikan kedua cabangnya dievaluasi di atas traversal index yang sama, bukan COLLSCAN.
Suite Tes Penutup #
Empat bagian proyek kini lengkap; saatnya memastikan semuanya tetap benar sebagai satu kesatuan. Suite tes berikut bersifat mandiri: data uji dibuat sendiri di database blog_tes, tidak bergantung pada skrip src/, dan membersihkan dirinya setelah selesai:
// tests/semua-tes.js — suite tes mandiri proyek blog
// Tes ini tidak bergantung pada src/: data uji dibuat sendiri lalu dibersihkan.
// Jalankan dari folder proyek:
// mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js
var tes = db.getSiblingDB("blog_tes")
function assertSama(nyata, harapan, pesan) {
const a = EJSON.stringify(nyata)
const h = EJSON.stringify(harapan)
if (a !== h) {
throw new Error("GAGAL - " + pesan + " | harapan: " + h + " | nyata: " + a)
}
print("lulus -", pesan)
}
function idArtikel(n) {
return ObjectId("6780000000000000000000" + n.toString().padStart(2, "0"))
}
function siapkanData() {
tes.dropDatabase()
tes.kategori.insertMany([
{ _id: "teknologi", nama: "Teknologi" },
{ _id: "database", nama: "Database" },
{ _id: "produktivitas", nama: "Produktivitas" },
])
const penulis = { nama: "Budi Santoso", email: "[email protected]" }
tes.artikel.insertMany([
{
_id: idArtikel(1), judul: "Belajar Document", slug: "belajar-document",
konten: "Pengantar document database untuk pemula.", kategori_id: "database",
penulis: penulis, tag: ["dasar"], status: "terbit",
published_at: ISODate("2026-03-01T08:00:00Z"),
},
{
_id: idArtikel(2), judul: "Index untuk Pemula", slug: "index-untuk-pemula",
konten: "Cara kerja index dalam satu halaman.", kategori_id: "database",
penulis: penulis, tag: ["index"], status: "terbit",
published_at: ISODate("2026-03-05T08:00:00Z"),
},
{
_id: idArtikel(3), judul: "Latihan Aggregation", slug: "latihan-aggregation",
konten: "Latihan pipeline bertahap.", kategori_id: "database",
penulis: penulis, tag: ["aggregation"], status: "terbit",
published_at: ISODate("2026-03-10T08:00:00Z"),
},
{
_id: idArtikel(4), judul: "Notepad Programmer", slug: "notepad-programmer",
konten: "Memilih aplikasi catatan.", kategori_id: "produktivitas",
penulis: penulis, tag: ["alat"], status: "terbit",
published_at: ISODate("2026-03-10T08:00:00Z"),
},
{
_id: idArtikel(5), judul: "Kebiasaan Fokus", slug: "kebiasaan-fokus",
konten: "Menjaga fokus saat menulis kode.", kategori_id: "produktivitas",
penulis: penulis, tag: ["kebiasaan"], status: "terbit",
published_at: ISODate("2026-03-15T08:00:00Z"),
},
{
_id: idArtikel(6), judul: "Rancangan Skema Uji", slug: "rancangan-skema-uji",
konten: "Draf tentang pola akses.", kategori_id: "database",
penulis: penulis, tag: ["skema"], status: "draf", published_at: null,
},
])
tes.artikel.createIndex({ slug: 1 }, { unique: true })
}
function tesTulisDanTerbitkan() {
tes.artikel.insertOne({
_id: idArtikel(7), judul: "Riset Kata Kunci", slug: "riset-kata-kunci",
konten: "Menemukan topik yang dicari pembaca.", kategori_id: "produktivitas",
penulis: { nama: "Budi Santoso", email: "[email protected]" },
tag: ["riset"], status: "draf", published_at: null,
})
const draf = tes.artikel.findOne({ slug: "riset-kata-kunci" })
assertSama(draf.status, "draf", "artikel baru dimulai sebagai draf")
assertSama(draf.published_at, null, "draf belum memiliki published_at")
tes.artikel.updateOne(
{ slug: "riset-kata-kunci", status: "draf" },
{ $set: { status: "terbit", published_at: ISODate("2026-03-18T08:00:00Z") } }
)
const terbit = tes.artikel.findOne({ slug: "riset-kata-kunci" })
assertSama(terbit.status, "terbit", "artikel bisa diterbitkan")
assertSama(terbit.published_at, ISODate("2026-03-18T08:00:00Z"), "published_at terisi saat terbit")
let kode = null
try {
tes.artikel.insertOne({ slug: "belajar-document", judul: "duplikat" })
} catch (galat) {
kode = galat.code
}
assertSama(kode, 11000, "duplikat slug ditolak index unik (E11000)")
const perKategori = tes.artikel
.find({ kategori_id: "database", status: "terbit" })
.sort({ published_at: -1 })
.projection({ _id: 0, judul: 1 })
.toArray()
.map(function (a) { return a.judul })
assertSama(perKategori, ["Latihan Aggregation", "Index untuk Pemula", "Belajar Document"],
"daftar artikel per kategori terurut terbaru")
}
function tesKomentar() {
const artikel = tes.artikel.findOne({ slug: "belajar-document" }, { _id: 1 })
for (let i = 1; i <= 12; i++) {
const komentar = {
artikel_id: artikel._id, nama: "Pembaca " + i, email: "pembaca" + i + "@example.com",
isi: "komentar pembaca ke-" + i, dibuat: ISODate("2026-03-01T09:00:00Z"),
}
tes.comments.insertOne(komentar)
tes.artikel.updateOne(
{ _id: artikel._id },
{ $push: { komentar_terbaru: { $each: [komentar], $position: 0, $slice: 10 } } }
)
}
const sesudah12 = tes.artikel.findOne({ slug: "belajar-document" })
assertSama(sesudah12.komentar_terbaru.length, 10, "subset memuat maksimal 10 komentar")
assertSama(tes.comments.countDocuments({ artikel_id: artikel._id }), 12,
"collection comments menyimpan seluruh 12 komentar")
assertSama(sesudah12.komentar_terbaru[0].isi, "komentar pembaca ke-12",
"komentar terbaru berada di depan subset")
const komentarBaru = {
artikel_id: artikel._id, nama: "Pembaca 13", email: "[email protected]",
isi: "komentar pembaca ke-13", dibuat: ISODate("2026-03-02T09:00:00Z"),
}
tes.comments.insertOne(komentarBaru)
tes.artikel.updateOne(
{ _id: artikel._id },
{ $push: { komentar_terbaru: { $each: [komentarBaru], $position: 0, $slice: 10 } } }
)
const sesudah13 = tes.artikel.findOne({ slug: "belajar-document" })
assertSama(sesudah13.komentar_terbaru.length, 10, "subset tetap 10 setelah komentar baru")
assertSama(tes.comments.countDocuments({ artikel_id: artikel._id }), 13,
"collection penuh menyimpan 13 komentar")
assertSama(sesudah13.komentar_terbaru[9].isi, "komentar pembaca ke-4",
"komentar lama terdorong keluar dari subset")
const gabung = tes.artikel.aggregate([
{ $match: { slug: "belajar-document" } },
{ $lookup: {
from: "comments", localField: "_id", foreignField: "artikel_id",
as: "semua_komentar",
} },
{ $project: { _id: 0, jumlah: { $size: "$semua_komentar" } } },
]).toArray()
assertSama(gabung[0].jumlah, 13, "$lookup menggabungkan seluruh komentar")
}
function tesPagination() {
tes.artikel.createIndex({ status: 1, published_at: -1, _id: -1 })
function halaman(ukuran, kursor) {
const filter = { status: "terbit" }
if (kursor) {
filter.$or = [
{ published_at: { $lt: kursor.published_at } },
{ published_at: kursor.published_at, _id: { $lt: kursor._id } },
]
}
const dokumen = tes.artikel
.find(filter)
.sort({ published_at: -1, _id: -1 })
.limit(ukuran + 1)
.toArray()
const adaLagi = dokumen.length > ukuran
const isi = adaLagi ? dokumen.slice(0, ukuran) : dokumen
const terakhir = isi[isi.length - 1]
return {
judul: isi.map(function (a) { return a.judul }),
kursorBerikutnya: adaLagi
? { published_at: terakhir.published_at, _id: terakhir._id }
: null,
}
}
const hal1 = halaman(2, null)
assertSama(hal1.judul, ["Riset Kata Kunci", "Kebiasaan Fokus"], "halaman 1 benar")
const hal2 = halaman(2, hal1.kursorBerikutnya)
assertSama(hal2.judul, ["Notepad Programmer", "Latihan Aggregation"],
"tie-breaker _id mengurutkan published_at yang sama")
const hal3 = halaman(2, hal2.kursorBerikutnya)
assertSama(hal3.judul, ["Index untuk Pemula", "Belajar Document"], "halaman 3 benar")
assertSama(hal3.kursorBerikutnya, null, "kursor null menandai halaman terakhir")
}
siapkanData()
tesTulisDanTerbitkan()
tesKomentar()
tesPagination()
print("SEMUA TES LULUS")
tes.dropDatabase()Jalankan dari folder proyek:
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.jsOutput yang Dihasilkan:
lulus - artikel baru dimulai sebagai draf
lulus - draf belum memiliki published_at
lulus - artikel bisa diterbitkan
lulus - published_at terisi saat terbit
lulus - duplikat slug ditolak index unik (E11000)
lulus - daftar artikel per kategori terurut terbaru
lulus - subset memuat maksimal 10 komentar
lulus - collection comments menyimpan seluruh 12 komentar
lulus - komentar terbaru berada di depan subset
lulus - subset tetap 10 setelah komentar baru
lulus - collection penuh menyimpan 13 komentar
lulus - komentar lama terdorong keluar dari subset
lulus - $lookup menggabungkan seluruh komentar
lulus - halaman 1 benar
lulus - tie-breaker _id mengurutkan published_at yang sama
lulus - halaman 3 benar
lulus - kursor null menandai halaman terakhir
SEMUA TES LULUSTujuh belas assertion mengulang jalur proyek dalam miniatur: artikel lahir sebagai draf lalu terbit dengan published_at terisi, duplikat slug ditolak index unik dengan kode E11000, daftar per kategori terurut, subset komentar berhenti di sepuluh sementara collection penuh mencapai tiga belas, $lookup menghitung seluruh komentar, dan pagination membelah data uji menjadi tiga halaman — termasuk kasus published_at kembar yang dipecahkan tie-breaker _id. Fungsi assertSama() membandingkan lewat EJSON.stringify() dan melempar Error pada selisih pertama, sehingga suite tidak pernah mencetak SEMUA TES LULUS palsu.
Penutup Proyek #
Proyek blog tuntas: tiga collections berelasi lewat reference, alur draf-terbit dijaga filter status, comments hidup di dua lapis subset pattern, dan daftar artikel dibelah keyset pagination di atas index compound. Selanjutnya, Proyek API Manajemen Tugas membawa kemampuan yang sama keluar dari mongosh — menjadi REST API dengan Node.js dan MongoDB driver.