Pagination — offset va cursor based, Mongoose bilan

~20 daq · MongoDB · Amaliy va Professional

Muammodan boshlaylik

Ijtimoiy tarmoq lentasi skip/limit bilan:

await Post.find().sort({ createdAt: -1 }).skip((sahifa - 1) * 20).limit(20)

Muammolar:

  1. 1-sahifa — 5 ms. 5000-sahifa — 2 soniya: MongoDB 100 000 ta postni o'qib, tashlab yuboradi
  2. Foydalanuvchi 2-sahifani o'qiyotganda 3 ta yangi post qo'shildi. 3-sahifaga o'tdi — 2-sahifaning oxirgi 3 ta posti yana ko'rinadi
  3. Post o'chirilsa — bitta post ko'rinmay o'tib ketadi

Ikki yondashuv

Offset (skip/limit) Cursor (keyset)
So'rov "21–40-o'rinlarni ber" "Shu postdan keyingi 20 tasini ber"
Tezlik Sahifa chuqurlashgan sari sekinlashadi Har doim bir xil tez
Ma'lumot o'zgarganda Takrorlanish va tushib qolish Barqaror
Ixtiyoriy sahifaga o'tish (37-sahifa) ✅ ❌
Jami sahifalar soni ✅ Odatda yo'q
Qachon Admin jadvallar, kichik ro'yxatlar Lenta, cheksiz scroll, API, katta ro'yxatlar

Hayotiy o'xshatma

Qalin kitobni o'qish. Offset — "har safar boshidan 340 bet sanab, keyin o'qishni davom ettirish". Cursor — xatcho'p: "xatcho'p turgan joydan davom etaman". Kitobga yangi bob qo'shilsa ham, xatcho'p o'z joyida turadi.


Offset pagination

router.get('/admin/mahsulotlar', async (req, res) => {
  const sahifa = Math.max(1, Number(req.query.sahifa) || 1)
  const hajm = Math.min(100, Number(req.query.hajm) || 20)

  const [royxat, jami] = await Promise.all([
    Mahsulot.find()
      .sort({ createdAt: -1, _id: -1 })
      .skip((sahifa - 1) * hajm)
      .limit(hajm)
      .lean(),
    Mahsulot.countDocuments(),
  ])

  res.json({ royxat, jami, sahifa, sahifalar: Math.ceil(jami / hajm) })
})
  • Tartib noyob bo'lsin: _id qo'shing
  • countDocuments() ham katta collection da sekin — kerak bo'lmasa olib tashlang yoki keshlang
  • Chuqur sahifalarni cheklang (masalan, 100-sahifadan keyin "qidiruvdan foydalaning")

Cursor pagination

G'oya: oxirgi ko'rilgan elementning tartiblash qiymatini eslab qolish va "undan keyingilarni" so'rash.

Faqat _id bo'yicha

router.get('/postlar', async (req, res) => {
  const hajm = Math.min(50, Number(req.query.hajm) || 20)
  const filtr = {}

  if (req.query.cursor) {
    filtr._id = { $lt: new mongoose.Types.ObjectId(req.query.cursor) }
  }

  const postlar = await Post.find(filtr)
    .sort({ _id: -1 })
    .limit(hajm + 1)          // bitta ortiqcha — keyingisi bormi?
    .lean()

  const keyingisiBor = postlar.length > hajm
  if (keyingisiBor) postlar.pop()

  res.json({
    postlar,
    keyingiCursor: keyingisiBor ? postlar.at(-1)._id : null,
  })
})
GET /postlar               → 20 ta post, keyingiCursor: "6730f1..."
GET /postlar?cursor=6730f1 → keyingi 20 ta

ObjectId vaqt bo'yicha o'sadi — _id bo'yicha tartib ≈ yaratilish vaqti bo'yicha.

Boshqa maydon bo'yicha — noyob juftlik

Narx bo'yicha tartib — narxlar takrorlanadi, shuning uchun (narx, _id) juftligi:

const cursorOch = (s) => JSON.parse(Buffer.from(s, 'base64url').toString())
const cursorYop = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')

router.get('/mahsulotlar', async (req, res) => {
  const hajm = 20
  const filtr = { kategoriya: req.query.kategoriya }

  if (req.query.cursor) {
    const { narx, id } = cursorOch(req.query.cursor)
    filtr.$or = [
      { narx: { $gt: narx } },
      { narx, _id: { $gt: new mongoose.Types.ObjectId(id) } },
    ]
  }

  const royxat = await Mahsulot.find(filtr)
    .sort({ narx: 1, _id: 1 })
    .limit(hajm + 1)
    .lean()

  const keyingisiBor = royxat.length > hajm
  if (keyingisiBor) royxat.pop()
  const oxirgi = royxat.at(-1)

  res.json({
    royxat,
    keyingiCursor: keyingisiBor ? cursorYop({ narx: oxirgi.narx, id: oxirgi._id }) : null,
  })
})

Index: { kategoriya: 1, narx: 1, _id: 1 } — ESR (29-dars).

[!INFO] Cursor ni shaffof bo'lmagan (opaque) satr sifatida bering — base64. Frontend uning ichini bilishi shart emas; keyinchalik tartiblash maydonini o'zgartirsangiz, API shartnomasi buzilmaydi.


Frontend — cheksiz scroll

let cursor = null
let tugadi = false

async function keyingisiniYukla() {
  if (tugadi) return
  const url = cursor ? `/api/postlar?cursor=${cursor}` : '/api/postlar'
  const { postlar, keyingiCursor } = await (await fetch(url)).json()
  chiz(postlar)
  cursor = keyingiCursor
  tugadi = !keyingiCursor
}

new IntersectionObserver(([e]) => e.isIntersecting && keyingisiniYukla())
  .observe(document.querySelector('#oxiri'))

Tez-tez uchraydigan xatolar

Noyob bo'lmagan maydon bo'yicha cursor

{ narx: { $gt: oxirgiNarx } } — bir xil narxli mahsulotlar tushib qoladi. Har doim _id bilan juftlik.

Index yo'q

Cursor pagination tezligi to'liq index ga bog'liq — tartiblash maydonlari va filtr bitta compound index da bo'lsin.

Cursor ni tekshirmaslik

Foydalanuvchi buzilgan cursor yuborsa — JSON.parse yoki ObjectId xatosi 500 ga aylanadi. try/catch → 400.


Xulosa

  • Offset (skip/limit) — oddiy, ixtiyoriy sahifa; chuqur sahifalarda sekin, o'zgarishda beqaror
  • Cursor — "shu elementdan keyingilar": doim tez va barqaror; lenta va API uchun
  • limit(hajm + 1) — keyingisi borligini bilish
  • Takrorlanadigan maydon — (maydon, _id) juftligi va $or sharti
  • Cursor — base64 opaque satr; tekshiring
  • Ikkalasida ham noyob tartib va mos compound index

Keyingi dars: 57-dars — Soft Delete — deletedAt, mongoose-delete plugin