اعمل Pagination بالـ Cursor يتصفّح ملايين الصفوف بسرعة ثابتة
هذا المقال يتطلب مستوى متوسط. بيفترض إنك تعرف تكتب استعلام SQL بسيط، وتعرف يعني إيهindex، واشتغلت قبل كده معLIMIT. مش لازم تكون خبير قواعد بيانات.
في آخر المقال هتقدر تبني ترقيم صفحات (pagination) بيثبّت زمن الاستجابة عند حوالي 8 مللي ثانية سواء المستخدم على الصفحة الأولى أو الصفحة رقم 100,000. الطريقة الشائعة بـ OFFSET بتفشل بالظبط في النقطة دي.
المشكلة باختصار
لو عندك feed أو timeline فيه 12 مليون صف، والمستخدم بيعمل infinite scroll، كل طلب بيجيب 10 صفوف بـ LIMIT 10 OFFSET N. أول صفحة سريعة. لكن مع نزول المستخدم، الـ N بتكبر. عند الصفحة رقم 10,000 الاستعلام بيبقى OFFSET 100000، وقاعدة البيانات بتقرأ 100,010 صف عشان ترمي منهم 100,000 وتديك 10 بس. ده اللي بيحصل فعلاً جوّه المحرّك.
ليه OFFSET بطيء؟ الفكرة بمثال بسيط الأول
تخيّل رواية 900 صفحة، وكل يوم عايز تكمّل من حيث وقفت. طريقة OFFSET زي إنك كل مرة تبدأ من صفحة 1 وتعدّ بإيدك صفحة صفحة لحد ما توصل لموضعك. كل ما تتقدّم في الرواية، العدّ بيطول. طريقة الـ Cursor زي إنك حاطط فاصل كتاب (bookmark): بتفتح على المكان مباشرة، بصرف النظر إنت في أول الرواية ولا آخرها.
علميًا: OFFSET N مش بيقول لقاعدة البيانات انطّي N صف. هو بيقولها هات كل الصفوف بالترتيب، عُدّ منهم N، ارميهم، وابدأ إدّيني من اللي بعدهم. التكلفة خطّية مع N. الـ Cursor (المعروف باسم keyset pagination) بيستبدل العدّ بشرط WHERE على عمود مفهرس، فقاعدة البيانات بتعمل index seek: بتقفز للموضع مباشرة زي فاصل الكتاب.
يعني إيه index seek؟
الـ index شبه فهرس آخر الكتاب: مرتّب، ولمّا تدوّر على كلمة بتفتح على رقم صفحتها على طول من غير ما تقرأ الكتاب كله. قاعدة البيانات بتخزّن الـ index كشجرة اسمها B-Tree. الـ seek هو النزول في الشجرة للوصول لأول صف مطلوب في خطوات لوغاريتمية، مش مرور خطّي على كل الصفوف.
ابنِها خطوة بخطوة
الافتراض هنا إن عندك عمود ترتيب مستقر وشبه فريد. هنستخدم (created_at, id) عشان لو صفّين ليهم نفس created_at يفضل الترتيب حاسم (deterministic).
- اعمل index مركّب على أعمدة الترتيب. من غيره الطريقة كلها بتقع على Seq Scan.
النتيجة المتوقّعة: أي استعلام مرتّب بنفس الأعمدة هيقدر يعمل index scan.
CREATE INDEX idx_posts_feed ON posts (created_at DESC, id DESC);