اعمل 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); - هات الصفحة الأولى من غير أي cursor:
النتيجة: أحدث 10 صفوف.
SELECT id, author, body, created_at FROM posts ORDER BY created_at DESC, id DESC LIMIT 10; - طلّع الـ cursor من آخر صف رجع لك. الـ cursor هو ببساطة قيمة
(created_at, id)بتاعة آخر صف في الصفحة. - هات الصفحة التالية بشرط أصغر-من على الصفّ كله (row value comparison)، مش على عمود واحد:
دي بتشتغل صح في PostgreSQL و MySQL. المقارنة على
SELECT id, author, body, created_at FROM posts WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 10;(created_at, id)بتضمن إنك متفوّتش ولا تكرّر أي صف لو فيه تساوي فيcreated_at. - غلّفها في API بحيث الـ cursor يطلع للعميل كـ token مبهم بدل ما تكشف قيمك:
النتيجة: كل استجابة بترجّع الصفوف زائد
function encodeCursor(row) { const raw = row.created_at.toISOString() + '|' + row.id; return Buffer.from(raw).toString('base64url'); } async function getFeed(cursor) { const limit = 10; if (!cursor) { return db.query( 'SELECT id, author, body, created_at FROM posts ' + 'ORDER BY created_at DESC, id DESC LIMIT $1', [limit] ); } const parts = Buffer.from(cursor, 'base64url').toString().split('|'); return db.query( 'SELECT id, author, body, created_at FROM posts ' + 'WHERE (created_at, id) < ($1, $2) ' + 'ORDER BY created_at DESC, id DESC LIMIT $3', [parts[0], parts[1], limit] ); }nextCursorيساوي . العميل يبعته في الطلب اللي بعده.
الأرقام اللي بتفرق فعلاً
القياس ده على جدول 12 مليون صف، PostgreSQL 16، 10 صفوف لكل صفحة. لاحظ إن Cursor بيفضل ثابت بينما OFFSET بيتسلّق مع عمق الصفحة:
الصفحة رقم 100,000 نزلت من حوالي 1200 مللي ثانية بـ OFFSET إلى 9 مللي ثانية بالـ Cursor. في feed بـ 50 ألف مستخدم يوميًا وبيعملوا scroll عميق، الفرق ده بيوفّر ضغط كبير على الـ CPU وقت الذروة.
الـ trade-offs بصراحة
- بتكسب سرعة ثابتة على أي عمق. بتخسر القدرة على القفز لصفحة رقم محدد (زي روح للصفحة 500). الـ Cursor بيعرف اللي بعد كذا، مش الصفحة رقم كذا.
- محتاج عمود ترتيب مستقر وفريد أو شبه فريد. لو بترتّب حسب قيمة بتتغير (مثلاً درجة شعبية متحرّكة)، الترتيب بيبوظ والـ cursor بيتوه.
- عرض إجمالي عدد الصفحات (Total pages) مش سهل هنا، لأنك مش بتعدّ من البداية أصلاً.
متى لا تستخدم هذه الطريقة
لو الجدول صغير (بضع آلاف صف)، OFFSET أبسط وكفاية، والفرق مش هيتحسّ. ولو المستخدم لازم يقفز لأرقام صفحات محددة (تقارير إدارية بترقيم كلاسيكي)، الـ keyset مش مناسب. وكمان لو الترتيب عشوائي بالكامل، مفيش cursor مستقر تبني عليه.
التحقق من إنه بيشتغل
شغّل EXPLAIN ANALYZE على استعلام الصفحة التالية:
EXPLAIN ANALYZE
SELECT id FROM posts
WHERE (created_at, id) < (now(), 999999999)
ORDER BY created_at DESC, id DESC LIMIT 10;لو الأمور تمام، هتشوف Index Scan using idx_posts_feed ومفيش سطر Rows Removed by Filter بأرقام ضخمة. لو لسه شايف Seq Scan أو ملايين الصفوف اتشالت، يبقى الـ index مش مطابق لترتيبك، راجع اتجاه الأعمدة في CREATE INDEX.
الخطوة التالية
افتح أبطأ endpoint فيه قائمة بترقيم عندك، شغّل عليه EXPLAIN ANALYZE على أعمق صفحة ممكنة، وشوف رقم Rows Removed by Filter. لو طلع بعشرات الآلاف، حوّله لـ keyset بالخطوات دي وقيس الفرق قبل وبعد.
المصادر
- PostgreSQL Documentation — LIMIT and OFFSET: postgresql.org/docs/current/queries-limit.html
- PostgreSQL Documentation — Row Constructor Comparison: postgresql.org/docs/current/functions-comparisons.html
- Markus Winand — Use The Index, Luke (No Offset): use-the-index-luke.com/no-offset