📌 ءPagination رو باOffsetپیاده کنیم یاCursor؟
یکی از اون تصمیمهاییه که موقع ساختن API خیلی راحت از کنارش رد میشیم.
مثلاً میخوایم لیست سفارشها رو برگردونیم.
خب معلومه دیگه 😎
میزنیم:
SELECT *
FROM Orders
ORDER BY Id
OFFSET 100000
LIMIT 20;
یا توی EF Core:
var orders = await db.Orders
.OrderBy(x => x.Id)
.Skip(100000)
.Take(20)
.ToListAsync();
تموم شد رفت.
هم سادهست، هم خواناست، هم برای صفحهبندی خیلی راحت میتونیم بگیم:
page=1page=2page=3و الی آخر...
ولی یک لحظه صبر کن... 🤨
واقعاً فکر میکنی وقتی رسیدیم به صفحهی مثلاً 10,000، دیتابیس میگه:
«چشم قربان، دقیقاً 20 تا رکوردت رو از این وسط برمیدارم»؟ 😎
نه دقیقاً!
OFFSET به دیتابیس میگه:«این 100 هزار رکورد اول رو رد کن، بعد 20 تای بعدی رو بده.»
یعنی رکوردهایی که قرار نیست به Application برسن، باز هم باید در سمت دیتابیس پردازش بشن.
خود PostgreSQL هم صراحتاً میگه رکوردهایی که توسط
OFFSET رد میشن، همچنان باید توسط Server محاسبه بشن و OFFSETهای بزرگ میتونن inefficient باشن.پس اگر دیتاست کوچیکه؟
احتمالاً اصلاً مسئلهی خاصی نداری.
ولی اگر داری با میلیونها رکورد و Pagination عمیق سروکله میزنی، داستان فرق میکنه.
حالا بریم سراغ گزینهی دوم:
🎯 Cursor / Keyset Pagination
اینجا بهجای اینکه به دیتابیس بگیم:
«100 هزار تا رکورد رو رد کن»
میگیم:
«من تا اینجا اومدم؛ از بعدِ این رکورد ادامه بده.»
مثلاً:
SELECT *
FROM Orders
WHERE Id > 100000
ORDER BY Id
LIMIT 20;
یا در EF Core:
var orders = await db.Orders
.Where(x => x.Id > lastSeenId)
.OrderBy(x => x.Id)
.Take(20)
.ToListAsync();
اینجا دیگه مفهوم اصلی
page number نیست.مفهوم اصلی اینه:
🧠 من آخرین چیزی که دیدم چی بود؟
مثلاً Response اول:
{
"items": [...],
"nextCursor": "100020"
}Request بعدی:
GET /orders?cursor=100020
و دیتابیس میگه:
WHERE Id > 100020
ORDER BY Id
LIMIT 20
اگر روی
Id ایندکس داشته باشیم، دیتابیس میتونه خیلی مستقیمتر به محدودهی موردنظر برسه.ءMicrosoft هم در مستندات EF Core، برای Paginationهایی که فقط حرکت صفحهبهصفحه لازم دارند، Keyset Pagination را بهعنوان جایگزین مناسب
Skip/Take معرفی میکند.اما داستان فقط Performance نیست! 👀
فرض کن کاربر صفحهی 2 رو گرفته.
بعد وسط کار، یک Order جدید وارد سیستم میشه.
اگر از
OFFSET استفاده کنیم، موقع درخواست صفحهی بعدی ممکنه مجموعهی نتایج نسبت به درخواست قبلی جابهجا شده باشه و در شرایط تغییر همزمان دادهها، بعضی رکوردها دوباره دیده بشن یا بعضیها از دست برن.ولی Keyset میگه:
«من آخرین رکوردی که دیدم رو میدونم؛ از همون نقطه ادامه بده.»
به همین دلیل برای چیزهایی مثل:
📰 Feed
🛒 Order List
💬 Message List
📜 Activity Log
🔔 Notification List
و سیستمهایی که کاربر معمولاً فقط میخواد:
Next → Next → Nextبره جلو، Cursor Pagination خیلی جذاب میشه.
اماااااا... 😏
اینجا هم قرار نیست بگیم: Cursor > Offset
و تمام!
چون Cursor یک محدودیت مهم داره.
فرض کن کاربر میگه:
«برو صفحه 873!»
با Cursor این کار به اون سادگی Offset نیست.
چون Cursor اساساً برای حرکت ترتیبی روی یک Result Set طراحی شده، نه Jump کردن مستقیم به یک Page Number.
پس مثلاً برای یک: 👨💼 Admin Panel
که کاربر میخواد بگه:
Page 1 | 2 | 3 | ... | 50و مستقیماً بره صفحه 30...
Offset Paginationهنوز میتونه انتخاب کاملاً معقولی باشه.
اما برای یک: 📱 Infinite Scroll
که کاربر فقط میگه:
«بیشتر بیار» Cursor معمولاً انتخاب بهتریه.
پس اگر بخوام خیلی خلاصه تصمیم بگیرم:
🟢 Offset Pagination
وقتی:
▫️تعداد داده خیلی زیاد نیست
▫️کاربر باید مستقیماً به یک Page خاص بره
▫️ءUX بر اساس Page Number طراحی شده
▫️سادگی Implementation برات مهمه
🔵 Cursor / Keyset Pagination
وقتی:
▫️ءDataset بزرگه
▫️ءPagination عمیقه
▫️کاربر معمولاً Next/Previous میکنه
▫️ءInfinite Scroll داری
▫️دادهها مرتباً Insert/Delete میشن
▫️ءPerformance در صفحات عمیق مهمه
و یک نکتهی خیلی مهم:
اگر Pagination داری، Order باید deterministic و ترجیحاً unique باشه.
مثلاً فقط:
.OrderByDescending(x => x.CreatedAt)
ممکنه کافی نباشه، چون چند رکورد میتونن
CreatedAt یکسان داشته باشن.بهتره مثلاً:
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
داشته باشی تا ترتیب کاملاً مشخص باشه. Microsoft هم روی unique بودن ترتیب برای Pagination تأکید کرده.
🔖هشتگها:
#Pagination #OffsetPagination #CursorPagination #KeysetPagination