لو عندك جدول فيه 10 مليون سجل، والـ query بتاعتك بتجيب الصفحة رقم 500 بـ LIMIT 20 OFFSET 10000، قاعدة البيانات مش بتتخطى 10,000 سجل زي ما اسمه بيوحي. هي فعليًا بتقرأهم كلهم من الـ disk، بتعدّهم، ثم بترميهم. النتيجة: كل ما تروح لصفحة أبعد، الـ query تبقى أبطأ. المقال ده بيوريك ليه بيحصل كده، وبيقدّم البديل اللي بيشتغل في 2ms ثابت.
Cursor Pagination: الحل اللي بيثبت على 10 مليون صف
المشكلة باختصار
أغلب الـ APIs العربية لسه بتستخدم ?page=5&limit=20 اللي بيتحوّل داخليًا لـ OFFSET 80 LIMIT 20. ده شغّال تمام لما الجدول صغير. المشكلة بتبدأ لما البيانات تكبر وتحصل حاجة اسمها deep pagination — مستخدم بيطلب صفحة 5000. هنا الـ OFFSET بيتحوّل من "أداة ترقيم" لـ "full table scan مقنّع".
تمثيل تقريبي قبل الشرح التقني
تخيّل إنك في مكتبة فيها مليون كتاب مرتبين على الرف بالأرقام من 1 لـ 1,000,000. طلبت من المكتبي يجيبلك الـ 20 كتاب اللي أرقامهم من 500,021 لـ 500,040. هنا فيه طريقتين:
- طريقة OFFSET: المكتبي بيبدأ من أول رف، بيعدّ 1، 2، 3... لحد 500,020، بعدين بيدّيك العشرين اللي بعدهم. كل ما تطلب صفحة أبعد، هو بيعدّ أكتر من الأول.
- طريقة Cursor: أنت قايله "عندي bookmark على الكتاب رقم 500,020 — هات الـ 20 اللي بعد الـ bookmark ده بس". المكتبي بيروح للرف مباشرة، يلاقي الـ bookmark، يدّيك العشرين. الزمن ثابت سواء الـ bookmark في أول المكتبة ولا في آخرها.
دي الفكرة بالظبط. الـ OFFSET تعقيده خطّي مع رقم الصفحة (O(N))، والـ Cursor ثابت تقريبًا (O(log N) لأنه بيستخدم الـ B-tree index مباشرة). الفرق ده مش نظري — هتشوفه في الأرقام بعد شوية.
ليه الـ OFFSET بيبطأ فعليًا على مستوى الـ DB
قاعدة البيانات ما عندهاش طريقة سحرية تعرف فين الصف رقم 500,020 على الـ disk من غير ما تمر على الصفوف اللي قبله. حتى مع وجود index، الـ OFFSET بيفرض عليها تقرأ كل الـ index entries اللي قبل الرقم ده. الـ PostgreSQL documentation بتقول الجملة دي حرفيًا: "the rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient".
يعني الـ OFFSET مش optimization — هو "ارمي أول N صف بعد ما تحسبهم". ده اللي بيخلي الفرق بين صفحة 1 وصفحة 5000 فرق حقيقي في الـ CPU والـ I/O.
الحل: Cursor Pagination
الفكرة بسيطة: بدل ما تقول "هات الصفحة رقم X"، قول "هات النتايج اللي بعد القيمة دي". القيمة دي لازم تكون عمود (أو مجموعة أعمدة) مُفهرسة وفريدة — غالبًا id وحده مش كفاية لو الترتيب بـ created_at، لأن ممكن يبقى فيه سجلات بنفس الـ created_at. الحل: (created_at, id) كـ tuple.