المستوى: متوسط — يفترض إنك تعرف 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) مش لوحده؟ لأن الـ timestamps ممكن تتساوى لصفّين أو أكتر (خصوصاً في الـ bulk inserts)، فلازم tiebreaker — وهو الـ id — عشان الترتيب يبقى deterministic ومافيش صف بيتكرّر أو يضيع بين الصفحات.