Chapter Strategi Pagination menutup dengan satu petunjuk: sortir pada pasangan kunci bisnis dan _id adalah fondasi pola keyset pagination. Chapter ini membangun pola itu utuh: kunci majemuk (kategori, judul_buku, _id) sebagai penanda posisi, filter $lt/$gt yang dibangun otomatis dari document terakhir halaman, fungsi pembantu yang bisa dipakai berulang, lalu bukti bahwa halaman tidak bergeser ketika data baru masuk di tengah jalan.

Peta materi Chapter ini:

  • Kunci majemuk dan perluasan filter $or untuk perbandingan berurut.
  • Fungsi ambilHalaman() yang menerima cursor dari halaman sebelumnya.
  • Uji ketahanan terhadap insert baru, keyset vs skip.
  • Biaya konstan dengan index majemuk, diukur lewat executionStats.

Kunci Majemuk sebagai Penanda Posisi #

Umpan transaksi yang alami secara bisnis jarang diurutkan berdasar _id — pengguna toko buku kita ingin melihat daftar penjualan yang dikelompokkan per kategori lalu diurutkan per judul_buku. Kombinasi (kategori, judul_buku) belum unik karena buku yang sama bisa terjual lebih dari sekali, jadi _id tetap dibutuhkan sebagai pemecah seri. Mekanismenya: cursor menyimpan nilai ketiga field dari document terakhir halaman sebelumnya, dan halaman berikutnya adalah semua document yang “lebih besar” secara leksikografis — kategorinya lebih besar, atau kategorinya sama tapi judulnya lebih besar, atau keduanya sama tapi _id-nya lebih besar.

Siapkan 12 transaksi dan fungsi pembantunya:

js
use latihan_perf
db.dropDatabase()
db.penjualan.insertMany([
  { _id: 1, waktu: ISODate("2026-03-02T09:15:00Z"), kategori: "filsafat", judul_buku: "Filsafat Ilmu", jumlah: 1, harga_total: 95000, kota: "Jakarta", channel: "toko" },
  { _id: 2, waktu: ISODate("2026-03-02T11:40:00Z"), kategori: "filsafat", judul_buku: "Filosofi Teras", jumlah: 1, harga_total: 89000, kota: "Bandung", channel: "online" },
  { _id: 3, waktu: ISODate("2026-03-02T15:20:00Z"), kategori: "novel", judul_buku: "Laut Bercerita", jumlah: 1, harga_total: 98000, kota: "Jakarta", channel: "online" },
  { _id: 4, waktu: ISODate("2026-03-03T08:05:00Z"), kategori: "novel", judul_buku: "Bumi", jumlah: 2, harga_total: 178000, kota: "Surabaya", channel: "toko" },
  { _id: 5, waktu: ISODate("2026-03-03T10:30:00Z"), kategori: "filsafat", judul_buku: "Filosofi Teras", jumlah: 2, harga_total: 178000, kota: "Yogyakarta", channel: "toko" },
  { _id: 6, waktu: ISODate("2026-03-03T14:45:00Z"), kategori: "novel", judul_buku: "Laut Bercerita", jumlah: 1, harga_total: 98000, kota: "Bandung", channel: "toko" },
  { _id: 7, waktu: ISODate("2026-03-04T09:00:00Z"), kategori: "filsafat", judul_buku: "Eksistensialisme", jumlah: 1, harga_total: 72000, kota: "Jakarta", channel: "online" },
  { _id: 8, waktu: ISODate("2026-03-04T13:55:00Z"), kategori: "novel", judul_buku: "Rumah Kaca", jumlah: 1, harga_total: 89000, kota: "Surabaya", channel: "online" },
  { _id: 9, waktu: ISODate("2026-03-05T10:15:00Z"), kategori: "pengembangan diri", judul_buku: "Atomic Habits", jumlah: 3, harga_total: 345000, kota: "Jakarta", channel: "toko" },
  { _id: 10, waktu: ISODate("2026-03-05T16:40:00Z"), kategori: "pengembangan diri", judul_buku: "Deep Work", jumlah: 1, harga_total: 115000, kota: "Bandung", channel: "online" },
  { _id: 11, waktu: ISODate("2026-03-06T09:25:00Z"), kategori: "pengembangan diri", judul_buku: "Atomic Habits", jumlah: 1, harga_total: 115000, kota: "Yogyakarta", channel: "online" },
  { _id: 12, waktu: ISODate("2026-03-06T15:10:00Z"), kategori: "pengembangan diri", judul_buku: "The Power of Habit", jumlah: 2, harga_total: 210000, kota: "Jakarta", channel: "toko" }
])

function ambilHalaman(terakhir, ukuran = 4) {
  const filter = terakhir ? {
    $or: [
      { kategori: { $gt: terakhir.kategori } },
      { kategori: terakhir.kategori, judul_buku: { $gt: terakhir.judul_buku } },
      { kategori: terakhir.kategori, judul_buku: terakhir.judul_buku, _id: { $gt: terakhir._id } }
    ]
  } : {}
  return db.penjualan.find(filter).sort({ kategori: 1, judul_buku: 1, _id: 1 }).limit(ukuran).toArray()
}
const label = d => `${d.kategori} | ${d.judul_buku} (#${d._id})`

Output yang Dihasilkan:

text
switched to db latihan_perf
{ ok: 1, dropped: 'latihan_perf' }
{
  acknowledged: true,
  insertedIds: {
    '0': 1,
    '1': 2,
    '2': 3,
    '3': 4,
    '4': 5,
    '5': 6,
    '6': 7,
    '7': 8,
    '8': 9,
    '9': 10,
    '10': 11,
    '11': 12
  }
}

[Function: ambilHalaman]

Ambil tiga halaman berurutan — setiap halaman lahir dari document terakhir halaman sebelumnya:

js
const p1 = ambilHalaman(null)
print("halaman 1:", p1.map(label).join("  ~  "))
const p2 = ambilHalaman(p1[p1.length - 1])
print("halaman 2:", p2.map(label).join("  ~  "))
const p3 = ambilHalaman(p2[p2.length - 1])
print("halaman 3:", p3.map(label).join("  ~  "))
print("halaman 4 (kosong?):", ambilHalaman(p3[p3.length - 1]).length, "document")

Output yang Dihasilkan:

text
halaman 1: filsafat | Eksistensialisme (#7)  ~  filsafat | Filosofi Teras (#2)  ~  filsafat | Filosofi Teras (#5)  ~  filsafat | Filsafat Ilmu (#1)

halaman 2: novel | Bumi (#4)  ~  novel | Laut Bercerita (#3)  ~  novel | Laut Bercerita (#6)  ~  novel | Rumah Kaca (#8)

halaman 3: pengembangan diri | Atomic Habits (#9)  ~  pengembangan diri | Atomic Habits (#11)  ~  pengembangan diri | Deep Work (#10)  ~  pengembangan diri | The Power of Habit (#12)

halaman 4 (kosong?): 0 document

Urutan judul mengikuti perbandingan string byte demi byte — Filosofi Teras tampil sebelum Filsafat Ilmu karena huruf o lebih kecil daripada s pada posisi keempat. Mari kita telaah bagian penting polanya:

  1. Cabang pertama { kategori: { $gt: ... } } mengambil semua document pada kategori berikutnya — “filsafat” selesai, lanjut “novel”, lalu “pengembangan diri”.
  2. Cabang kedua menangani document pada kategori yang sama dengan judul yang lebih besar — inilah perpindahan ke buku berikutnya di dalam satu kategori.
  3. Cabang ketiga adalah pemecah seri: kategori dan judul sama persis (buku yang sama terjual lagi), maka bandingkan _id.
  4. limit(ukuran) memotong hasil di batas halaman; sort() wajib memakai urutan kunci yang sama persis dengan filter, jika tidak perbandingannya tidak konsisten.
  5. Halaman keempat mengembalikan 0 document — cara termurah mendeteksi akhir umpan tanpa countDocuments() tambahan.

Dalam API nyata, cursor ini biasanya diserialisasi menjadi token buram (misalnya base64 dari tiga nilai kunci) lalu dikirim ke klien dan diterima kembali sebagai parameter ?cursor=. Arah urutan terbalik hanya membalik operator: sort menurun memakai $lt pada setiap cabang.

Tahan terhadap Insert Baru #

skip(4) berarti “document ke-5 sampai ke-8 pada urutan saat ini” — posisi, bukan isi. Ketika transaksi baru masuk di urutan atas, semua posisi bergeser satu dan halaman yang sama menampilkan document yang sudah pernah dilihat pengguna. Keyset tidak menghitung posisi: cursor adalah nilai kunci yang dibekukan, dan document baru hanya berpengaruh bila nilainya lebih besar dari cursor.

Buktikan dengan insert yang jatuh di tengah halaman pertama:

js
print("halaman 2 sebelum insert:", p2.map(label).join("  ~  "))
db.penjualan.insertOne({ _id: 90, waktu: ISODate("2026-03-06T18:30:00Z"), kategori: "filsafat", judul_buku: "Antropologi Filosofis", jumlah: 1, harga_total: 88000, kota: "Bandung", channel: "online" })
const p2keyset = ambilHalaman(p1[p1.length - 1])
print("halaman 2 keyset        :", p2keyset.map(label).join("  ~  "))
const p2skip = db.penjualan.find().sort({ kategori: 1, judul_buku: 1, _id: 1 }).skip(4).limit(4).toArray()
print("halaman 2 skip          :", p2skip.map(label).join("  ~  "))

Output yang Dihasilkan:

text
halaman 2 sebelum insert: novel | Bumi (#4)  ~  novel | Laut Bercerita (#3)  ~  novel | Laut Bercerita (#6)  ~  novel | Rumah Kaca (#8)

{ acknowledged: true, insertedId: 90 }

halaman 2 keyset        : novel | Bumi (#4)  ~  novel | Laut Bercerita (#3)  ~  novel | Laut Bercerita (#6)  ~  novel | Rumah Kaca (#8)

halaman 2 skip          : filsafat | Filsafat Ilmu (#1)  ~  novel | Bumi (#4)  ~  novel | Laut Bercerita (#3)  ~  novel | Laut Bercerita (#6)

Transaksi baru Antropologi Filosofis urutannya paling atas di kategori “filsafat”. Halaman 2 versi keyset tidak berubah satu document pun — cursor tetap menunjuk Filsafat Ilmu (#1), dan “lebih besar dari cursor” tidak terpengaruh document yang lebih kecil. Halaman 2 versi skip bergeser: Filsafat Ilmu (#1) yang sudah tampil di halaman 1 muncul lagi, sementara Rumah Kaca (#8) terdorong ke halaman 3 — pengguna melihat duplikat dan kehilangan satu item tanpa sadar.

Biaya Konstan dengan Index Majemuk #

Pola keyset baru benar-benar murah bila ketiga cabang filter dilayani index. Buat index majemuk yang urutannya sama dengan sort:

js
db.penjualan.createIndex({ kategori: 1, judul_buku: 1, _id: 1 })
const akhir = p2[p2.length - 1]
const sKeyset = db.penjualan.find({
  $or: [
    { kategori: { $gt: akhir.kategori } },
    { kategori: akhir.kategori, judul_buku: { $gt: akhir.judul_buku } },
    { kategori: akhir.kategori, judul_buku: akhir.judul_buku, _id: { $gt: akhir._id } }
  ]
}).sort({ kategori: 1, judul_buku: 1, _id: 1 }).limit(4).explain("executionStats").executionStats
const sSkip = db.penjualan.find().sort({ kategori: 1, judul_buku: 1, _id: 1 }).skip(8).limit(4).explain("executionStats").executionStats
print("keyset halaman 3 -> nReturned:", sKeyset.nReturned, "| keys:", sKeyset.totalKeysExamined, "| docs:", sKeyset.totalDocsExamined)
print("skip   halaman 3 -> nReturned:", sSkip.nReturned, "| keys:", sSkip.totalKeysExamined, "| docs:", sSkip.totalDocsExamined)

Output yang Dihasilkan:

text
kategori_1_judul_buku_1__id_1

keyset halaman 3 -> nReturned: 4 | keys: 4 | docs: 4

skip   halaman 3 -> nReturned: 4 | keys: 12 | docs: 4

Keyset memeriksa kunci dalam jumlah yang sama dengan ukuran halaman karena tiga cabang $or masing-masing melompat tepat ke posisi rentangnya di index. skip(8) harus melalui 12 kunci untuk mengembalikan 4 document — dan seperti sudah kita ukur di Chapter Strategi Pagination, selisih ini tumbuh linear seiring kedalaman halaman. Kombinasi kunci unik + index majemuk + filter rentang adalah satu paket yang membuat umpan tetap responsif pada data sebesar apa pun.

Rangkuman #

  • Kunci majemuk (kategori, judul_buku, _id) memberi setiap document posisi unik; filter $or tiga cabang adalah bentuk perbandingan “lebih besar dari” untuk kunci majemuk.
  • Fungsi ambilHalaman(terakhir) menerima document terakhir halaman sebelumnya, membangun filter, dan mengembalikan halaman berikutnya; halaman kosong menandai akhir umpan.
  • Cursor berbasis nilai tahan terhadap insert baru, sedangkan skip berbasis posisi bergeser dan menduplikasi item yang sudah dilihat.
  • Index majemuk dengan urutan field yang sama dengan sort membuat biaya per halaman konstan — pelengkap temuan linear-growth skip dari Chapter Strategi Pagination.