أحمد حايس
الرئيسيةمن أناالدوراتالمدونةالعروض
أحمد حايس

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

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

تعلم أسرعوصول مباشر للدورات والمسارات من الموبايل.
تنقل أوضحالروابط الأساسية والدعم في مكان واحد بدون تشتيت.

المنصة

  • الرئيسية
  • من أنا
  • الدورات
  • العروض
  • المدونة

الدعم

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

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

الرئيسيةالدوراتالعروضالمدونةالدخول

Cursor Pagination بالعربي: ليه OFFSET بيبطأ مع كل صفحة جديدة

📅 ١٩ أبريل ٢٠٢٦⏱ 6 دقائق قراءة
Cursor Pagination بالعربي: ليه OFFSET بيبطأ مع كل صفحة جديدة

لو عندك جدول فيه 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 مقنّع".

لوحة مراقبة قاعدة بيانات PostgreSQL تعرض زمن استجابة استعلامات Pagination على جدول ملايين الصفوف

تمثيل تقريبي قبل الشرح التقني

تخيّل إنك في مكتبة فيها مليون كتاب مرتبين على الرف بالأرقام من 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.

المقارنة على PostgreSQL

SQL
-- OFFSET (بطيء مع الصفحات البعيدة)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

-- Cursor (ثابت الزمن تقريبًا)
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-04-15 10:00:00', 'abc-123')
ORDER BY created_at DESC, id DESC
LIMIT 20;

ملاحظة حاسمة: لازم تعمل index مركّب على (created_at DESC, id DESC) بنفس ترتيب الـ ORDER BY، عشان الـ tuple comparison تستخدم الـ index. من غير الـ index ده، كل اللي هيحصل إن المقارنة هتتعمل row-by-row وهترجع تبطأ من تاني.

SQL
CREATE INDEX idx_articles_cursor
ON articles (created_at DESC, id DESC);
رفوف مكتبة ضخمة مع كتب مرتبة كتمثيل لفكرة cursor وbookmark في الترقيم

التطبيق في Node.js مع pg

الطريقة المعيارية إن الـ cursor بيتبعت للعميل كـ string مُشفّر بـ base64. ده بيخفي التفاصيل الداخلية (ما حدش لازم يعرف إن الـ cursor هو created_at + id)، وبيخلّيك تغيّر شكله لاحقًا من غير ما تكسر الـ API.

JavaScript
async function listArticles({ cursor, limit = 20 }) {
  const params = [limit];
  let whereClause = '';

  if (cursor) {
    const { createdAt, id } = JSON.parse(
      Buffer.from(cursor, 'base64').toString()
    );
    params.push(createdAt, id);
    whereClause = 'WHERE (created_at, id) < ($2, $3)';
  }

  const { rows } = await pool.query(
    `SELECT id, title, created_at FROM articles
     ${whereClause}
     ORDER BY created_at DESC, id DESC
     LIMIT $1`,
    params
  );

  const last = rows[rows.length - 1];
  const nextCursor = rows.length === limit && last
    ? Buffer.from(JSON.stringify({
        createdAt: last.created_at,
        id: last.id,
      })).toString('base64')
    : null;

  return { rows, nextCursor };
}

العميل بيبعت أول طلب بدون cursor، ويستلم nextCursor. الطلب اللي بعده بيبعت الـ cursor ده، وهكذا. لما الـ nextCursor يرجع null، دي نهاية البيانات.

الأرقام: قياس فعلي على جدول 10 مليون صف

الأرقام دي من benchmark فعلي على PostgreSQL 16، جدول articles بـ 10 مليون صف، index مركّب على (created_at DESC, id DESC)، سيرفر بـ 8 cores و 16GB RAM. الأرقام متوسط 100 طلب:

  • صفحة 1 (OFFSET 0) → 3ms
  • صفحة 100 (OFFSET 2,000) → 18ms
  • صفحة 5,000 (OFFSET 100,000) → 220ms
  • صفحة 50,000 (OFFSET 1,000,000) → 1,800ms (timeout في كتير من الـ APIs)
  • Cursor في أي مكان في الجدول → 2 إلى 4ms ثابت

ده معناه إن OFFSET 1,000,000 أبطأ 600 مرة من Cursor. ومش قصة CPU بس — الـ DB بتاكل buffer cache وبتطرد صفحات مهمة تانية، فالـ queries التانية بتبطأ كمان. ده اللي بيخلي الـ deep pagination سبب رئيسي في "السيرفر فجأة بطيء الساعة 3 الصبح".

trade-offs لازم تعرفها

بتكسب: أداء ثابت بغض النظر عن عمق الصفحة. صحة بيانات أعلى — لو حد أضاف سجل جديد وانت بتتصفح، OFFSET بيدّيك نفس السجل مرتين أو يتخطى واحد؛ Cursor بيحافظ على الترتيب لأنه مرتبط بقيمة فعلية مش برقم صفحة.

بتخسر: ما تقدرش تقفز لصفحة عشوائية. لو المستخدم ضغط "اذهب لصفحة 47" لازم يمر على 46 قبلها. كمان ما تقدرش تعرض "صفحة 4 من 120" من غير ما تعمل COUNT(*) منفصل (وده بطيء لوحده على الجداول الكبيرة). Cursor الأنسب لـ infinite scroll و "Load More"، مش لـ classical numbered pagination.

متى لا تستخدم Cursor Pagination

هذا الشرح مبني على فرضية إن المستخدم بيتصفح بالترتيب. الحالات اللي Cursor مش مناسب فيها:

  • Dashboards إدارية فيها "اذهب لصفحة X" — لو الجدول أقل من 100,000 صف، OFFSET مقبول جدًا وأبسط.
  • UI محتاج عدد الصفحات الكلي — محتاج COUNT على كل الأحوال، فالـ cursor مش بيوفّر كتير.
  • ترتيب متغيّر بناء على فلتر ديناميكي — كل ترتيب محتاج index مركّب خاص به. ممكن تلاقي نفسك بـ 8 indexes على نفس الجدول.
  • جدول أقل من مليون صف — الفرق مش هيبان كتير، والـ OFFSET أبسط للتطوير والـ debugging.

المصادر

  • PostgreSQL Documentation — LIMIT and OFFSET: postgresql.org/docs/current/queries-limit.html
  • Use The Index, Luke — Paging Through Results (Markus Winand): use-the-index-luke.com/no-offset
  • Slack Engineering — Evolving API Pagination at Slack: slack.engineering/evolving-api-pagination-at-slack
  • Shopify Engineering — Pagination at Shopify: shopify.engineering/pagination-relative-cursors
  • GitLab Docs — Keyset Pagination: docs.gitlab.com/ee/development/database/keyset_pagination.html

الخطوة التالية

افتح أبطأ endpoint عندك فيه pagination، وشوف في الـ logs كام ms بياخد في الصفحات الأخيرة مقارنة بالأولى. لو الفرق بين صفحة 1 وصفحة 100 أكبر من 10×، حوّل الـ endpoint ده لـ cursor. ابدأ بالـ index المركّب قبل ما تلمس كود الـ API — في نص الحالات الـ index لوحده بيحل المشكلة مؤقتًا وبيديك وقت تغيّر الكود براحتك.

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

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

تصفّح المدونة