ترقيم صفحات API: الإزاحة أم المؤشر؟
يواجه كلّ API يعيد قائمة السؤال نفسه: كيف تقسم مليوني طلب إلى صفحات يمكن للعميل تصفحها؟ تمنحك الإزاحة (Offset Pagination) استعلامات SQL بسيطة وأرقام صفحات مفهومة، بينما تمنحك المؤشرات (Cursor-Based Pagination) نتائج أكثر استقرارًا وزمن استجابة ثابتًا، لكن من دون إمكانية القفز مباشرة إلى الصفحة 47.
تختار معظم الفرق الإزاحة لأنها الافتراضية في أغلب الدروس التعليمية. لكن عندما يصل جدول الطلبات إلى ملايين الصفوف، قد تنتهي الصفحة 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;
يعيد الاستعلام الصفحة الثالثة، بواقع 25 صفًا في الصفحة:
GET /v1/orders?page=3&per_page=25
مثال على الاستجابة:
{
"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
}
مزايا الإزاحة واضحة:
- يمكن للمستخدم القفز إلى أي صفحة.
- يمكن عرض العدد الإجمالي وعدد الصفحات.
- تنفيذها سهل.
- تناسب الجداول الإدارية الصغيرة.
للمزيد عن تنفيذها في REST، راجع ترقيم الصفحات في REST APIs.
لكنها تواجه مشكلتين تظهران عادةً في الإنتاج، لا أثناء التطوير.
المشكلة الأولى: انزياح الصفحات
تحسب OFFSET الصفوف من بداية النتيجة المرتبة، لكنها لا تعرف الصفوف التي شاهدها العميل سابقًا.
لنفترض أن المستخدم حمّل الصفحة الأولى، التي تحتوي على الصفوف من 1 إلى 25، مرتبة من الأحدث إلى الأقدم. أثناء القراءة، أُضيفت ثلاثة طلبات جديدة. عند طلب الصفحة الثانية (OFFSET 25)، تنتقل الصفوف 23 و24 و25 إلى المواضع 26 و27 و28، فيراها المستخدم مرة أخرى.
النتيجة: سجلات مكررة.
والحذف يسبب العكس. إذا حُذفت ثلاثة صفوف من الصفحة الأولى، فسيتخطى OFFSET 25 ثلاثة صفوف لم يرها المستخدم أصلًا.
النتيجة: سجلات مفقودة بصمت.
لذلك تكون الإزاحة مناسبة لتقرير ثابت أو واجهة لا تتغير أثناء التصفح، لكنها خطرة في:
- خلاصات النشاط.
- التمرير اللانهائي.
- نقاط نهاية المزامنة.
- المهام التي تتصفح الصفحات برمجيًا بينما تستمر عمليات الكتابة.
المشكلة الثانية: الإزاحات العميقة مكلفة
لا تنتقل قاعدة البيانات مباشرةً إلى الصف رقم 500,001 عند تنفيذ:
OFFSET 500000
بل تفحص الإدخالات السابقة، وتتخلص منها، ثم تعيد النتائج المطلوبة. لذلك تزداد التكلفة خطيًا مع العمق: 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;
استخدم مفتاح ترتيب حتميًا
لا يكفي استخدام created_at وحده؛ فقد تحتوي عدة طلبات على القيمة الزمنية نفسها. يجب إضافة عمود فريد مثل id إلى الترتيب:
ORDER BY created_at DESC, id DESC
ثم إنشاء فهرس مركب:
CREATE INDEX orders_created_at_id_idx
ON orders (created_at DESC, id DESC);
بهذا تستطيع قاعدة البيانات تحديد موضع البداية وقراءة 25 إدخالًا فقط، سواء كانت الصفحة الأولى أو الصفحة رقم 60,000. تصبح تكلفة كل صفحة تقريبًا ثابتة: O(1) لكل صفحة.
اجعل المؤشر غير شفاف
لا تعرض قيم الفرز الخام في عقد API. شفّرها في رمز غير شفاف، غالبًا باستخدام Base64:
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
الغموض هنا قرار تصميم، وليس وسيلة أمان بحد ذاته. عندما لا يستطيع العميل تحليل المؤشر، يمكنك تغيير مفتاح الفرز أو إضافة معلومات داخلية أو تبديل محرك التخزين دون كسر العملاء.
يصبح العقد بسيطًا:
أعد الرمز الذي أعطيتك إياه في الطلب التالي.
المقابل هو عدم وجود أرقام صفحات عشوائية. يستطيع العميل التحرك إلى الأمام صفحةً تلو الأخرى، أو إلى الخلف إذا أصدرت previous_cursor. كما أن العدد الإجمالي يحتاج إلى استعلام منفصل. راجع تصميم ترقيم صفحات API لملايين السجلات لمزيد من اعتبارات التوسع.
المقارنة السريعة
| البعد | الإزاحة | المؤشر |
|---|---|---|
| القفز إلى صفحة عشوائية | نعم | لا، تصفح تسلسلي |
| العدد الإجمالي | سهل التضمين | يحتاج إلى استعلام COUNT منفصل |
| أداء الصفحات العميقة |
O(n) ويتدهور مع العمق |
ثابت تقريبًا |
| الاستقرار أثناء الكتابة | قد ينتج تكرارات وفجوات | ثابت بالنسبة إلى مفتاح الترتيب |
| تكلفة التنفيذ | منخفضة | متوسطة |
| متطلبات الترتيب | أي ORDER BY
|
مفتاح فريد ومفهرس |
| تخزين عناوين الصفحات مؤقتًا | سهل | أصعب |
| تعقيد العميل | منخفض | منخفض إذا كان الغلاف واضحًا |
النقطة الأهم: يحتاج المؤشر إلى ترتيب حتمي. إذا سمحت بالفرز حسب عمود قابل للتغيير وغير فريد مثل status، فسيصبح منطق keyset معقدًا. الإزاحة أكثر تسامحًا مع الترتيب غير الدقيق؛ المؤشرات ليست كذلك.
أي أسلوب تختار؟
طابق الأسلوب مع طريقة استهلاك البيانات:
الجداول الإدارية ولوحات التحكم: الإزاحة
استخدمها عندما:
- تكون البيانات محدودة نسبيًا.
- يتصفحها البشر.
- تحتاج إلى أرقام صفحات.
- تحتاج إلى عرض
1,848 نتيجة. - يكون الوصول العشوائي ميزة أساسية.
التمرير اللانهائي: المؤشر
لا يحتاج مستخدمو خلاصات الأخبار إلى الصفحة 47. هم يطلبون «المزيد»، بينما تستمر عمليات الكتابة. هنا تمنع المؤشرات التكرارات وتضمن تكلفة ثابتة.
واجهات API العامة: المؤشر
لا يمكنك التحكم في طريقة استخدام المستهلكين لـ API. سيكتب أحدهم حلقة تتصفح كل الصفحات، وقد تجعل الإزاحات العميقة مشكلة أداء في وقت غير مناسب. يسمح المؤشر أيضًا بتغيير التفاصيل الداخلية خلف رمز غير شفاف.
للاطلاع على اتفاقيات المعلمات والرؤوس، راجع دليل ترقيم الصفحات في REST API.
التصدير والمزامنة: المؤشر
تحتاج مهمة تسحب مليوني سجل إلى:
- عدم فقدان الصفوف أثناء عمليات الكتابة المتزامنة.
- تكلفة ثابتة لكل صفحة.
- نقطة استئناف إذا توقفت عند الصف 1.4 مليون.
لا توفر الإزاحة هذه الضمانات، بينما يوفر المؤشر نقطة الاستئناف تلقائيًا.
قاعدة عملية: استخدم الإزاحة للواجهات الصغيرة التي يتصفحها البشر وتحتاج إلى أرقام وإجماليات. استخدم المؤشرات لأي بيانات كبيرة أو متغيرة أو عامة.
كيف تتعامل واجهات API الحقيقية مع الترقيم؟
Stripe
تستخدم Stripe المؤشرات في جميع نقاط نهاية القوائم تقريبًا. تقبل النقاط:
starting_afterlimit
وتعيد has_more. لجلب الصفحة التالية، مرّر معرّف آخر كائن استلمته. توضح وثائق ترقيم الصفحات في Stripe النمط، ولا تتضمن عادةً عددًا إجماليًا، وهو قرار مناسب لحجم البيانات لديهم.
GitHub
ما زالت معظم نقاط REST API في GitHub تعرض page وper_page، مع رأس Link للصفحات التالية والأخيرة. توصي وثائق ترقيم الصفحات في GitHub باتباع رأس Link بدل إنشاء عناوين URL يدويًا.
وتستخدم نقاط النهاية الأحدث المؤشرات في بعض الحالات، لأن التصفح العميق بالإزاحة عبر مستودعات ضخمة مكلف.
Slack
تستخدم Slack المؤشرات في Web API، وتعتبرها النهج المعتمد للطرق الجديدة. تعيد طرق مثل conversations.history الحقل:
{
"response_metadata": {
"next_cursor": "..."
}
}
ويعني المؤشر الفارغ الوصول إلى النهاية، وفقًا لـوثائق ترقيم الصفحات في 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"
}
اتبع هذه القواعد:
- أعد
has_moreدائمًا. لا تستنتج النهاية من قِصر الصفحة؛ فقد تكون قصيرة بسبب التصفية بعد الجلب. - أعد
next_cursor: nullفي الصفحة الأخيرة، ووثّق ذلك. يمكن استخدام سلسلة فارغة بدلًا منها، لكن لا تخلط بين الصيغتين. - ارفض المؤشرات غير الصالحة بالحالة
400، لا باستجابة200تحتوي على قائمة فارغة. - وقّع حمولة المؤشر أو أضف إصدارًا لها إذا كانت تحتوي على معلومات تتجاوز مفتاح الفرز.
اختبر الترقيم المتسلسل في Apidog
تظهر أخطاء الترقيم عند الحدود: الصفحة الأخيرة، الصفحة الفارغة، أو حذف الصف الذي بُني عليه المؤشر. لا يكفي الاختبار اليدوي؛ استخدم سيناريو اختبار متسلسل.
لإنشاء اختبار لنقطة نهاية تعتمد على المؤشر:
- استدعِ نقطة النهاية الأولى واستخرج
$.next_cursorباستخدام معالج لاحق للطلب. - خزّن القيمة في متغير مثل
nextCursor. - كرر الطلب في خطوة
ForEachأو حلقة، ومرّر{{nextCursor}}كمعاملcursor. - استخرج المؤشر الجديد في كل دورة.
- أوقف الحلقة عندما يصبح
has_moreمساويًا لـfalse. - تحقّق من عدم تكرار أي
idومن عدم تجاوز عدد العناصر لقيمةlimit.
يساعدك شرح تعيين التأكيدات واستخراج المتغيرات باستخدام JSONPath في إعداد الاستخراج والتأكيدات.
أما في اختبار الإزاحة:
- زد قيمة
pageفي كل دورة. - تحقّق من أن طول
dataيساويper_pageحتى الصفحة الأخيرة. - تأكد من بقاء
totalثابتًا طوال التصفح. - افحص عدم وجود سجلات مكررة أو مفقودة.
أضف حالات الحافة
اختبر كل حالة صراحةً:
-
صفحة فارغة: استخدم مرشحًا لا يطابق أي صف، وتأكد من
data: []وhas_more: falseوالحالة200. -
مؤشر غير صالح: أرسل
cursor=not-a-real-cursor، وتأكد من الحالة400ووجود رمز خطأ قابل للمعالجة آليًا. - حذف الصف الأساسي: أنشئ طلبًا، احصل على مؤشر يعتمد عليه، احذف الطلب، ثم استخدم المؤشر. يجب أن يستمر التصفح من الموضع الصحيح بدلًا من الفشل.
تتعامل مقارنة keyset مع حذف الصف الأساسي طبيعيًا؛ فهي تبحث عن موضع الحد، ولا تشترط وجود الصف نفسه:
WHERE (created_at, id) < (?, ?)
بعد نجاح السيناريو محليًا، شغّله في CI عند كل دمج. يمكنك تحميل Apidog مجانًا وتشغيل سيناريو كامل يتضمن الحلقات والتأكيدات خلال أقل من نصف ساعة.
الأسئلة الشائعة
هل المؤشر أفضل دائمًا؟
لا. الإزاحة أفضل عندما تحتاج إلى أرقام صفحات وإجماليات ووصول عشوائي عبر مجموعة بيانات متواضعة. المؤشرات أفضل للبيانات الكبيرة، والكتابات المتكررة، وواجهات API العامة.
المشكلة الشائعة هي اختيار الإزاحة افتراضيًا لنقطة نهاية عامة، ثم اكتشاف تكلفة O(n) بعد الإطلاق.
كيف أحصل على العدد الإجمالي مع المؤشرات؟
نفّذ استعلامًا منفصلًا:
SELECT COUNT(*)
FROM orders
WHERE ...;
يمكن تقديمه عبر نقطة نهاية منفصلة أو معامل اختياري مثل:
GET /v1/orders?include_count=true
خزّن النتيجة مؤقتًا؛ فالعدد التقريبي المحدث كل دقيقة يكفي لمعظم الواجهات. كما أن Stripe تتجاهل الإجماليات تمامًا، ما يوضح أن العملاء لا يحتاجون إليها دائمًا.
هل يمكن دعم الأسلوبين في نقطة نهاية واحدة؟
يمكن ذلك، لكن لا يُنصح به في API جديدة. ستحتاج إلى مجموعتين من حالات الحافة والاختبارات، وقد يلتبس على العميل الأسلوب الذي يجب استخدامه.
اختر أسلوبًا واحدًا لكل نقطة نهاية، وحافظ على اتساق أسماء المعلمات عبر الواجهة.
ماذا يحدث عند حذف الصف الذي يشير إليه المؤشر؟
لا يتعطل keyset pagination. لا تتطلب المقارنة وجود الصف الأساسي؛ فهي تواصل البحث بعد زوج القيم (created_at, id).
وهذه حالة حافة مهمة يجب اختبارها في Apidog قبل أن يكتشفها أحد المستهلكين في الإنتاج.
Top comments (0)