Pagination — offset va cursor based, Mongoose bilan
Muammodan boshlaylik
Ijtimoiy tarmoq lentasi skip/limit bilan:
await Post.find().sort({ createdAt: -1 }).skip((sahifa - 1) * 20).limit(20)
Muammolar:
- 1-sahifa — 5 ms. 5000-sahifa — 2 soniya: MongoDB 100 000 ta postni o'qib, tashlab yuboradi
- Foydalanuvchi 2-sahifani o'qiyotganda 3 ta yangi post qo'shildi. 3-sahifaga o'tdi — 2-sahifaning oxirgi 3 ta posti yana ko'rinadi
- 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:
_idqo'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$orsharti - Cursor — base64 opaque satr; tekshiring
- Ikkalasida ham noyob tartib va mos compound index
Keyingi dars: 57-dars — Soft Delete — deletedAt, mongoose-delete plugin