DEV Community

Cover image for ترقيم الصفحات بالمؤشر أم بالإزاحة؟ أيهما الأنسب لـ API الخاص بك؟
Yusuf Khalidd
Yusuf Khalidd

Posted on Originally published at apidog.com

ترقيم الصفحات بالمؤشر أم بالإزاحة؟ أيهما الأنسب لـ API الخاص بك؟

ترقيم صفحات API: الإزاحة أم المؤشر؟

يواجه كلّ API يعيد قائمة السؤال نفسه: كيف تقسم مليوني طلب إلى صفحات يمكن للعميل تصفحها؟ تمنحك الإزاحة (Offset Pagination) استعلامات SQL بسيطة وأرقام صفحات مفهومة، بينما تمنحك المؤشرات (Cursor-Based Pagination) نتائج أكثر استقرارًا وزمن استجابة ثابتًا، لكن من دون إمكانية القفز مباشرة إلى الصفحة 47.

جرّب Apidog اليوم

تختار معظم الفرق الإزاحة لأنها الافتراضية في أغلب الدروس التعليمية. لكن عندما يصل جدول الطلبات إلى ملايين الصفوف، قد تنتهي الصفحة 4,000 بمهلة انتظار، وقد يرى المستخدم السجل نفسه مرتين أثناء التمرير.

في هذا الدليل سنقارن الطريقتين، ونوضح مشكلات الإزاحة، وسبب استخدام Stripe وSlack للمؤشرات، ثم نختبر كلا الأسلوبين في Apidog.

للمقارنة الأوسع بين استراتيجيات الترقيم، راجع دليل ترقيم الصفحات في API.

كيف يعمل ترقيم الصفحات بالإزاحة؟

يرتبط هذا الأسلوب مباشرةً بـ SQL. يرسل العميل رقم الصفحة وحجمها، ويحوّله الخادم إلى LIMIT وOFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

يعيد الاستعلام الصفحة الثالثة، بواقع 25 صفًا في الصفحة:

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

مثال على الاستجابة:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

مزايا الإزاحة واضحة:

  • يمكن للمستخدم القفز إلى أي صفحة.
  • يمكن عرض العدد الإجمالي وعدد الصفحات.
  • تنفيذها سهل.
  • تناسب الجداول الإدارية الصغيرة.

للمزيد عن تنفيذها في REST، راجع ترقيم الصفحات في REST APIs.

لكنها تواجه مشكلتين تظهران عادةً في الإنتاج، لا أثناء التطوير.

المشكلة الأولى: انزياح الصفحات

تحسب OFFSET الصفوف من بداية النتيجة المرتبة، لكنها لا تعرف الصفوف التي شاهدها العميل سابقًا.

لنفترض أن المستخدم حمّل الصفحة الأولى، التي تحتوي على الصفوف من 1 إلى 25، مرتبة من الأحدث إلى الأقدم. أثناء القراءة، أُضيفت ثلاثة طلبات جديدة. عند طلب الصفحة الثانية (OFFSET 25)، تنتقل الصفوف 23 و24 و25 إلى المواضع 26 و27 و28، فيراها المستخدم مرة أخرى.

النتيجة: سجلات مكررة.

والحذف يسبب العكس. إذا حُذفت ثلاثة صفوف من الصفحة الأولى، فسيتخطى OFFSET 25 ثلاثة صفوف لم يرها المستخدم أصلًا.

النتيجة: سجلات مفقودة بصمت.

لذلك تكون الإزاحة مناسبة لتقرير ثابت أو واجهة لا تتغير أثناء التصفح، لكنها خطرة في:

  • خلاصات النشاط.
  • التمرير اللانهائي.
  • نقاط نهاية المزامنة.
  • المهام التي تتصفح الصفحات برمجيًا بينما تستمر عمليات الكتابة.

المشكلة الثانية: الإزاحات العميقة مكلفة

لا تنتقل قاعدة البيانات مباشرةً إلى الصف رقم 500,001 عند تنفيذ:

OFFSET 500000
Enter fullscreen mode Exit fullscreen mode

بل تفحص الإدخالات السابقة، وتتخلص منها، ثم تعيد النتائج المطلوبة. لذلك تزداد التكلفة خطيًا مع العمق: O(n) حيث تمثل n قيمة الإزاحة.

على جدول PostgreSQL يحتوي على مليوني صف وفهرس على created_at:

  • LIMIT 25 OFFSET 0: قراءة 25 إدخال فهرس فقط، غالبًا خلال بضعة ميلي ثانية.
  • LIMIT 25 OFFSET 100000: قراءة 100,025 إدخالًا والتخلص من 100,000، وقد يستغرق ذلك عشرات الميلي ثانية.
  • LIMIT 25 OFFSET 1500000: قراءة 1.5 مليون إدخال، مع استهلاك أكبر للمعالج والذاكرة وزمن استجابة قد يصل إلى مئات الميلي ثانية.

يوضح شرح no-offset هذه التكلفة باستخدام خطط الاستعلام. وفي الإنتاج، تظهر المشكلة غالبًا في سجل الاستعلامات البطيئة، خصوصًا عندما يتصفح عميل واحد كل صفحات API العامة.

كيف يعمل ترقيم الصفحات بالمؤشر؟

يسمى هذا الأسلوب أيضًا Keyset Pagination. بدلًا من قول:

تخطَّ 50 صفًا

يقول العميل:

أعد الصفوف التي تأتي بعد هذا السجل.

يحدد المؤشر آخر صف شاهده العميل، فينتقل الخادم مباشرةً إلى الدفعة التالية:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

استخدم مفتاح ترتيب حتميًا

لا يكفي استخدام created_at وحده؛ فقد تحتوي عدة طلبات على القيمة الزمنية نفسها. يجب إضافة عمود فريد مثل id إلى الترتيب:

ORDER BY created_at DESC, id DESC
Enter fullscreen mode Exit fullscreen mode

ثم إنشاء فهرس مركب:

CREATE INDEX orders_created_at_id_idx
ON orders (created_at DESC, id DESC);
Enter fullscreen mode Exit fullscreen mode

بهذا تستطيع قاعدة البيانات تحديد موضع البداية وقراءة 25 إدخالًا فقط، سواء كانت الصفحة الأولى أو الصفحة رقم 60,000. تصبح تكلفة كل صفحة تقريبًا ثابتة: O(1) لكل صفحة.

اجعل المؤشر غير شفاف

لا تعرض قيم الفرز الخام في عقد API. شفّرها في رمز غير شفاف، غالبًا باستخدام Base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

الغموض هنا قرار تصميم، وليس وسيلة أمان بحد ذاته. عندما لا يستطيع العميل تحليل المؤشر، يمكنك تغيير مفتاح الفرز أو إضافة معلومات داخلية أو تبديل محرك التخزين دون كسر العملاء.

يصبح العقد بسيطًا:

أعد الرمز الذي أعطيتك إياه في الطلب التالي.

المقابل هو عدم وجود أرقام صفحات عشوائية. يستطيع العميل التحرك إلى الأمام صفحةً تلو الأخرى، أو إلى الخلف إذا أصدرت previous_cursor. كما أن العدد الإجمالي يحتاج إلى استعلام منفصل. راجع تصميم ترقيم صفحات API لملايين السجلات لمزيد من اعتبارات التوسع.

المقارنة السريعة

البعد الإزاحة المؤشر
القفز إلى صفحة عشوائية نعم لا، تصفح تسلسلي
العدد الإجمالي سهل التضمين يحتاج إلى استعلام COUNT منفصل
أداء الصفحات العميقة O(n) ويتدهور مع العمق ثابت تقريبًا
الاستقرار أثناء الكتابة قد ينتج تكرارات وفجوات ثابت بالنسبة إلى مفتاح الترتيب
تكلفة التنفيذ منخفضة متوسطة
متطلبات الترتيب أي ORDER BY مفتاح فريد ومفهرس
تخزين عناوين الصفحات مؤقتًا سهل أصعب
تعقيد العميل منخفض منخفض إذا كان الغلاف واضحًا

النقطة الأهم: يحتاج المؤشر إلى ترتيب حتمي. إذا سمحت بالفرز حسب عمود قابل للتغيير وغير فريد مثل status، فسيصبح منطق keyset معقدًا. الإزاحة أكثر تسامحًا مع الترتيب غير الدقيق؛ المؤشرات ليست كذلك.

أي أسلوب تختار؟

طابق الأسلوب مع طريقة استهلاك البيانات:

الجداول الإدارية ولوحات التحكم: الإزاحة

استخدمها عندما:

  • تكون البيانات محدودة نسبيًا.
  • يتصفحها البشر.
  • تحتاج إلى أرقام صفحات.
  • تحتاج إلى عرض 1,848 نتيجة.
  • يكون الوصول العشوائي ميزة أساسية.

التمرير اللانهائي: المؤشر

لا يحتاج مستخدمو خلاصات الأخبار إلى الصفحة 47. هم يطلبون «المزيد»، بينما تستمر عمليات الكتابة. هنا تمنع المؤشرات التكرارات وتضمن تكلفة ثابتة.

واجهات API العامة: المؤشر

لا يمكنك التحكم في طريقة استخدام المستهلكين لـ API. سيكتب أحدهم حلقة تتصفح كل الصفحات، وقد تجعل الإزاحات العميقة مشكلة أداء في وقت غير مناسب. يسمح المؤشر أيضًا بتغيير التفاصيل الداخلية خلف رمز غير شفاف.

للاطلاع على اتفاقيات المعلمات والرؤوس، راجع دليل ترقيم الصفحات في REST API.

التصدير والمزامنة: المؤشر

تحتاج مهمة تسحب مليوني سجل إلى:

  1. عدم فقدان الصفوف أثناء عمليات الكتابة المتزامنة.
  2. تكلفة ثابتة لكل صفحة.
  3. نقطة استئناف إذا توقفت عند الصف 1.4 مليون.

لا توفر الإزاحة هذه الضمانات، بينما يوفر المؤشر نقطة الاستئناف تلقائيًا.

قاعدة عملية: استخدم الإزاحة للواجهات الصغيرة التي يتصفحها البشر وتحتاج إلى أرقام وإجماليات. استخدم المؤشرات لأي بيانات كبيرة أو متغيرة أو عامة.

كيف تتعامل واجهات API الحقيقية مع الترقيم؟

Stripe

تستخدم Stripe المؤشرات في جميع نقاط نهاية القوائم تقريبًا. تقبل النقاط:

  • starting_after
  • limit

وتعيد has_more. لجلب الصفحة التالية، مرّر معرّف آخر كائن استلمته. توضح وثائق ترقيم الصفحات في Stripe النمط، ولا تتضمن عادةً عددًا إجماليًا، وهو قرار مناسب لحجم البيانات لديهم.

GitHub

ما زالت معظم نقاط REST API في GitHub تعرض page وper_page، مع رأس Link للصفحات التالية والأخيرة. توصي وثائق ترقيم الصفحات في GitHub باتباع رأس Link بدل إنشاء عناوين URL يدويًا.

وتستخدم نقاط النهاية الأحدث المؤشرات في بعض الحالات، لأن التصفح العميق بالإزاحة عبر مستودعات ضخمة مكلف.

Slack

تستخدم Slack المؤشرات في Web API، وتعتبرها النهج المعتمد للطرق الجديدة. تعيد طرق مثل conversations.history الحقل:

{
  "response_metadata": {
    "next_cursor": "..."
  }
}
Enter fullscreen mode Exit fullscreen mode

ويعني المؤشر الفارغ الوصول إلى النهاية، وفقًا لـوثائق ترقيم الصفحات في Slack.

الاتجاه واضح: كلما زاد حجم البيانات وحركة الكتابة، زادت أهمية المؤشرات.

صمّم غلاف استجابة يمكن التنبؤ به

مثال عملي:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

اتبع هذه القواعد:

  1. أعد has_more دائمًا. لا تستنتج النهاية من قِصر الصفحة؛ فقد تكون قصيرة بسبب التصفية بعد الجلب.
  2. أعد next_cursor: null في الصفحة الأخيرة، ووثّق ذلك. يمكن استخدام سلسلة فارغة بدلًا منها، لكن لا تخلط بين الصيغتين.
  3. ارفض المؤشرات غير الصالحة بالحالة 400، لا باستجابة 200 تحتوي على قائمة فارغة.
  4. وقّع حمولة المؤشر أو أضف إصدارًا لها إذا كانت تحتوي على معلومات تتجاوز مفتاح الفرز.

اختبر الترقيم المتسلسل في Apidog

تظهر أخطاء الترقيم عند الحدود: الصفحة الأخيرة، الصفحة الفارغة، أو حذف الصف الذي بُني عليه المؤشر. لا يكفي الاختبار اليدوي؛ استخدم سيناريو اختبار متسلسل.

لإنشاء اختبار لنقطة نهاية تعتمد على المؤشر:

  1. استدعِ نقطة النهاية الأولى واستخرج $.next_cursor باستخدام معالج لاحق للطلب.
  2. خزّن القيمة في متغير مثل nextCursor.
  3. كرر الطلب في خطوة ForEach أو حلقة، ومرّر {{nextCursor}} كمعامل cursor.
  4. استخرج المؤشر الجديد في كل دورة.
  5. أوقف الحلقة عندما يصبح has_more مساويًا لـ false.
  6. تحقّق من عدم تكرار أي id ومن عدم تجاوز عدد العناصر لقيمة limit.

يساعدك شرح تعيين التأكيدات واستخراج المتغيرات باستخدام JSONPath في إعداد الاستخراج والتأكيدات.

أما في اختبار الإزاحة:

  • زد قيمة page في كل دورة.
  • تحقّق من أن طول data يساوي per_page حتى الصفحة الأخيرة.
  • تأكد من بقاء total ثابتًا طوال التصفح.
  • افحص عدم وجود سجلات مكررة أو مفقودة.

أضف حالات الحافة

اختبر كل حالة صراحةً:

  • صفحة فارغة: استخدم مرشحًا لا يطابق أي صف، وتأكد من data: [] وhas_more: false والحالة 200.
  • مؤشر غير صالح: أرسل cursor=not-a-real-cursor، وتأكد من الحالة 400 ووجود رمز خطأ قابل للمعالجة آليًا.
  • حذف الصف الأساسي: أنشئ طلبًا، احصل على مؤشر يعتمد عليه، احذف الطلب، ثم استخدم المؤشر. يجب أن يستمر التصفح من الموضع الصحيح بدلًا من الفشل.

تتعامل مقارنة keyset مع حذف الصف الأساسي طبيعيًا؛ فهي تبحث عن موضع الحد، ولا تشترط وجود الصف نفسه:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

بعد نجاح السيناريو محليًا، شغّله في CI عند كل دمج. يمكنك تحميل Apidog مجانًا وتشغيل سيناريو كامل يتضمن الحلقات والتأكيدات خلال أقل من نصف ساعة.

الأسئلة الشائعة

هل المؤشر أفضل دائمًا؟

لا. الإزاحة أفضل عندما تحتاج إلى أرقام صفحات وإجماليات ووصول عشوائي عبر مجموعة بيانات متواضعة. المؤشرات أفضل للبيانات الكبيرة، والكتابات المتكررة، وواجهات API العامة.

المشكلة الشائعة هي اختيار الإزاحة افتراضيًا لنقطة نهاية عامة، ثم اكتشاف تكلفة O(n) بعد الإطلاق.

كيف أحصل على العدد الإجمالي مع المؤشرات؟

نفّذ استعلامًا منفصلًا:

SELECT COUNT(*)
FROM orders
WHERE ...;
Enter fullscreen mode Exit fullscreen mode

يمكن تقديمه عبر نقطة نهاية منفصلة أو معامل اختياري مثل:

GET /v1/orders?include_count=true
Enter fullscreen mode Exit fullscreen mode

خزّن النتيجة مؤقتًا؛ فالعدد التقريبي المحدث كل دقيقة يكفي لمعظم الواجهات. كما أن Stripe تتجاهل الإجماليات تمامًا، ما يوضح أن العملاء لا يحتاجون إليها دائمًا.

هل يمكن دعم الأسلوبين في نقطة نهاية واحدة؟

يمكن ذلك، لكن لا يُنصح به في API جديدة. ستحتاج إلى مجموعتين من حالات الحافة والاختبارات، وقد يلتبس على العميل الأسلوب الذي يجب استخدامه.

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

ماذا يحدث عند حذف الصف الذي يشير إليه المؤشر؟

لا يتعطل keyset pagination. لا تتطلب المقارنة وجود الصف الأساسي؛ فهي تواصل البحث بعد زوج القيم (created_at, id).

وهذه حالة حافة مهمة يجب اختبارها في Apidog قبل أن يكتشفها أحد المستهلكين في الإنتاج.

Top comments (0)