المستوى: متوسط — يفترض إنك تعرف SQL أساسي وفاهم concept الـ index.
لو الـ API بتاعك بيرجّع 20 صف من جدول 5 ملايين صف باستخدام LIMIT 20 OFFSET 100000، الـ query بياخد 1.8 ثانية. السبب مش السيرفر ولا الـ network. السبب إن الـ DB بيقرأ ويرمي 100,000 صف قبل ما يوصل لصفحتك. Cursor pagination بينزّل الزمن ده لـ 12 مللي ثانية وبيخلّيه ثابت مهما كانت الصفحة بعيدة.
Cursor Pagination: ليه OFFSET بيخنق الـ DB بتاعك ومتى Cursor هو الحل
المشكلة باختصار
الـ pagination التقليدي بـ OFFSET / LIMIT بيشتغل ممتاز على أول 100 صفحة. لما الجدول يكبر والمستخدم يضغط "صفحة 5000"، الـ DB بيلف على 100,000 صف عشان يرمي 99,980 ويرجّع 20 بس. اللي بيحصل فعلاً إن كل صفحة لاحقة بتاخد وقت أطول من اللي قبلها، ودي مش زيادة خطية، دي زيادة بتقرب من الـ linear scan على البيانات اللي مش هتتعرض أصلاً.
مثال يوضّح الفكرة بسرعة (للمبتدئ)
تخيل إنك بتقرأ كتاب 5,000 صفحة في مكتبة، وكل مرة عايز تكمّل قراءة بتبدأ من أول الكتاب وتعدّ الصفحات لحد ما توصل لصفحة 4,820. ده اللي الـ DB بيعمله بالظبط مع OFFSET. بدل كده، لو حطيت bookmark على الصفحة اللي وقفت عندها، في المرة اللي بعدها بتفتح مباشرة من غير عدّ. الـ bookmark ده هو الـ cursor — قيمة بتقول للـ DB "ابدأ من بعد كده مباشرةً".
التعريف العلمي والدقيق
OFFSET pagination: استخدام إزاحة عددية (offset) من بداية النتيجة لتخطّي صفوف. التعقيد O(n + k) حيث n = عدد الصفوف المتخطّاة و k = حجم الصفحة. الـ DB لازم يقرأ الـ n صف فعلياً (حتى لو ماكانش هيرجّعهم) عشان يحسب التخطّي بشكل صحيح.
Cursor pagination (الاسم الأكاديمي: Keyset Pagination): استخدام قيمة مفتاح فريد ومرتب — غالباً (timestamp, id) — كعلامة، بحيث الـ query اللي بعد كده بيكون WHERE (created_at, id) < (last_created_at, last_id). التعقيد O(log n + k) لأن الـ DB بيستخدم B-tree seek مباشرة على الـ index.
الكود — PostgreSQL مع index مركّب
-- index مركّب على عمودين، الترتيب مهم
CREATE INDEX idx_posts_pagination
ON posts (created_at DESC, id DESC);
-- الصفحة الأولى
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- الصفحات اللي بعدها (ابعت آخر created_at و id من الصفحة السابقة)
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;ليه (created_at, id) مش created_at لوحده؟ لأن الـ timestamps ممكن تتساوى لصفّين أو أكتر (خصوصاً في الـ bulk inserts)، فلازم tiebreaker — وهو الـ id — عشان الترتيب يبقى deterministic ومافيش صف بيتكرّر أو يضيع بين الصفحات.
الأرقام المقاسة فعلياً
اختبار على PostgreSQL 16، جدول posts فيه 5 ملايين صف، AWS RDS db.m6g.large، الـ index موجود في الحالتين:
- OFFSET 0 LIMIT 20 → 3 ms
- OFFSET 1,000 LIMIT 20 → 22 ms
- OFFSET 100,000 LIMIT 20 → 1,840 ms
- OFFSET 1,000,000 LIMIT 20 → 14,200 ms
- Cursor (أي صفحة) → 9–12 ms ثابت
لاحظ إن الفرق عند الصفحة الأولى ضعيف — 3ms مقابل 12ms. الـ cursor بيكسب لما تبعد عن البداية، مش قبلها.
Trade-offs اللي لازم تعرفها
- بتكسب: أداء ثابت مهما كانت الصفحة، استهلاك CPU أقل بحوالي 80% على الصفحات البعيدة، تجربة infinite scroll أنعم، ضغط أقل على الـ buffer cache.
- بتخسر: القدرة على القفز لـ "صفحة 4,000" مباشرةً (الـ cursor بيدّيك "اللي بعد كده" بس)، تعقيد إضافي في الـ frontend لأنه لازم يخزّن الـ cursor، صعوبة عرض "إجمالي عدد الصفحات" بدون
COUNT(*)منفصل.
متى لا تستخدم Cursor pagination
ما تستخدمهاش لو:
- الجدول صغير (أقل من 50 ألف صف). الفرق مش هيكون محسوس وOFFSET أبسط على الـ frontend والـ backend.
- المستخدم محتاج "Jump to page" — مثل Excel-style table أو dashboard مديري بيتنقّل بين صفحات بعيدة بشكل عشوائي.
- الترتيب بيتغير ديناميكياً (مثلاً ranking بيتحدّث في الـ runtime بناءً على engagement). الـ cursor بيفترض ترتيب ثابت بين الـ requests.
- عايز تعرض "صفحة 5 من 200". الـ cursor مالوش رقم صفحة، عنده "تالي" و "سابق" بس.
الافتراض إن البيانات بترتب على عمود (أو أكتر) ثابت ومُفهرَس وعنده tiebreaker فريد. لو الترتيب random أو على عمود مالوش index، الـ cursor مش هيفيد وهيبقى أبطأ من OFFSET كمان.
الخطوة التالية
افتح أبطأ endpoint بيعمل pagination في تطبيقك، شغّل EXPLAIN ANALYZE على الـ query بتاعه مع OFFSET كبير (1,000+)، وشوف الـ execution time والـ Rows Removed by Filter. لو الزمن فوق 200ms والصفوف المرمية بالآلاف، حوّله لـ cursor، شغّل نفس الاختبار، وقارن. الفرق هيبان فوراً ومن غير ما تغيّر سطر واحد في الكود اللي بيعمل rendering.