الرئيسيةمن أناالدوراتالجلساتالمدونةسوق الأوامرالمناهج والباقاتالشركاء

دورات عربية متخصصة في التقنية والبرمجة والذكاء الاصطناعي.

المنصة مبنية على الوضوح، التطبيق، والنتيجة النافعة: شرح مرتب يساعدك تفهم الأدوات، تكتب كودًا أفضل، وتستخدم الذكاء الاصطناعي بوعي داخل العمل الحقيقي.

المنصة

  • الرئيسية
  • من أنا
  • الدورات
  • الجلسات الفردية
  • المناهج والباقات
  • سوق الأوامر
  • المدونة

الدعم

  • الأسئلة الشائعة
  • تواصل معنا
  • سياسة الخصوصية
  • شروط استخدام التطبيق
  • سياسة الاسترجاع

© 2026 أحمد حايس. جميع الحقوق محفوظة.

الرئيسيةالدوراتالجلساتالمدونةالدخول
How To Make It

اعمل Pagination بالـ Cursor: صفحات ثابتة السرعة على 12 مليون صف

متوسط7 يوليو 20265 دقائق قراءة
اعمل Pagination بالـ Cursor: صفحات ثابتة السرعة على 12 مليون صف

اعمل Pagination بالـ Cursor يتصفّح ملايين الصفوف بسرعة ثابتة

هذا المقال يتطلب مستوى متوسط. بيفترض إنك تعرف تكتب استعلام SQL بسيط، وتعرف يعني إيه index، واشتغلت قبل كده مع LIMIT. مش لازم تكون خبير قواعد بيانات.

في آخر المقال هتقدر تبني ترقيم صفحات (pagination) بيثبّت زمن الاستجابة عند حوالي 8 مللي ثانية سواء المستخدم على الصفحة الأولى أو الصفحة رقم 100,000. الطريقة الشائعة بـ OFFSET بتفشل بالظبط في النقطة دي.

مخطط يقارن ترقيم OFFSET الذي يمسح 100010 صف ويتجاهل 100000 منها بزمن 1200 مللي ثانية مقابل ترقيم المؤشر Cursor الذي يقرأ 10 صفوف فقط عبر فهرس B-Tree بزمن 8 مللي ثانية

المشكلة باختصار

لو عندك 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).

  1. اعمل index مركّب على أعمدة الترتيب. من غيره الطريقة كلها بتقع على Seq Scan.
    SQL
    CREATE INDEX idx_posts_feed
      ON posts (created_at DESC, id DESC);
    النتيجة المتوقّعة: أي استعلام مرتّب بنفس الأعمدة هيقدر يعمل index scan.

هل استفدت من المقال؟

اطّلع على المزيد من المقالات والدروس المجانية من نفس المسار المعرفي.

تصفّح المدونة
  • هات الصفحة الأولى من غير أي cursor:
    SQL
    SELECT id, author, body, created_at
    FROM posts
    ORDER BY created_at DESC, id DESC
    LIMIT 10;
    النتيجة: أحدث 10 صفوف.
  • طلّع الـ cursor من آخر صف رجع لك. الـ cursor هو ببساطة قيمة (created_at, id) بتاعة آخر صف في الصفحة.
  • هات الصفحة التالية بشرط أصغر-من على الصفّ كله (row value comparison)، مش على عمود واحد:
    SQL
    SELECT id, author, body, created_at
    FROM posts
    WHERE (created_at, id) < ($1, $2)
    ORDER BY created_at DESC, id DESC
    LIMIT 10;
    دي بتشتغل صح في PostgreSQL و MySQL. المقارنة على (created_at, id) بتضمن إنك متفوّتش ولا تكرّر أي صف لو فيه تساوي في created_at.
  • غلّفها في API بحيث الـ cursor يطلع للعميل كـ token مبهم بدل ما تكشف قيمك:
    JavaScript
    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 يساوي encodeCursor(lastRow). العميل يبعته في الطلب اللي بعده.
  • الأرقام اللي بتفرق فعلاً

    القياس ده على جدول 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 على استعلام الصفحة التالية:

    SQL
    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