Cursor Pagination أم Offset: أيهما تختار لتصميم API؟

بقلم فريق تقني ·· برمجة
Cursor Pagination أم Offset: أيهما تختار لتصميم API؟

اختيار offset أو cursor ليس تفضيلاً في الأسلوب، بل قرار يحدّد ما إذا كان API الخاص بك يتحمّل آلاف السجلات المتزايدة، أم يتعثر بمجرد أن يطلب أحد المستخدمين الصفحة رقم 500. الفرق يبدأ من عبارة SQL واحدة: offset يعدّ الصفوف من البداية، وcursor يبحث عن قيمة فعلية في عمود مرتّب. يبدو الاثنان متكافئين على جدول صغير هادئ، لكن الفجوة بينهما تتّسع مع كل صف جديد يدخل الجدول، ومع كل مستخدم يتصفّح أثناء تعديل البيانات نفسها.

الفرق الآلي بين الاثنين

offset يبني الاستعلام هكذا:

SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100;

بينما cursor، ويُسمّى أيضاً keyset pagination، يستبدل العدّ بشرط WHERE على آخر قيمة رآها المستخدم:

SELECT * FROM posts WHERE id > 4213 ORDER BY id LIMIT 20;

الفرق الحقيقي هنا في طريقة تفكير قاعدة البيانات: الاستعلام الأول يحدّد نقطة البداية برقم تسلسلي مجرّد لا صلة له بهوية أي صف حقيقي، أما الثاني فيحدّدها بقيمة فعلية موجودة داخل البيانات نفسها، فيعرف المحرّك بالضبط أين يقف دون حاجة للعدّ من الصفر.

لماذا يتباطأ OFFSET الكبير

السبب تقني بحت ويعود إلى خطة التنفيذ نفسها: لا يملك مخطّط PostgreSQL طريقة للقفز مباشرة إلى الصف رقم 100,001 دون المرور فعلياً بكل ما قبله. فمع OFFSET 100000 ينفّذ المحرّك مسحاً يزور 100,000 صف ثم يتجاهلها واحداً واحداً قبل أن يبدأ بإرجاع أي نتيجة، تماماً كمن يقلّب كتاباً صفحة صفحة بحثاً عن فصل بدل فتحه مباشرة على رقم الصفحة. هذا السلوك موثّق رسمياً في دليل PostgreSQL نفسه ولا يتغيّر مهما كانت خطة الاستعلام. أما keyset فيعمل بمنطق مختلف تماماً: فهرس B-tree على العمود المرتّب يقود المحرّك مباشرة إلى أول قيمة أكبر من آخر مؤشر عبر بحث فهرسي لا مسح تسلسلي، فتبقى كلفته شبه ثابتة سواء كان المستخدم في الصفحة الثانية أو الألف.

اختبار أداء رسمي من Shopify Engineering يضع أرقاماً على هذا الفارق: عند offset=100,000 استغرق OFFSET نحو 2,221.60 ميلي ثانية مقابل 5.24 ميلي ثانية فقط لطريقة last-id، أي أسرع بأكثر من 400 مرة. عند offset=10,000 كان التحسّن 92.43%، من 79.82 إلى 6.04 ميلي ثانية. أما عند offset=10 فالفارق طفيف، 18.65% فقط، لأن قاعدة البيانات لم تتخطَّ عدداً كبيراً من الصفوف بعد. في الإنتاج الفعلي على نقطة /admin/products.json كان الأسلوب النسبي أسرع بنحو 11 مرة في المتوسط. لا توجد عتبة رقمية ثابتة موثّقة رسمياً يصلح تعميمها على كل جدول؛ العتبة الفعلية تتوقف على حجم الجدول وفهرسته، واختبار أداء Shopify مرجعية أدق من أي رقم شائع في مدونات غير رسمية.

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

العطب الأعمق في offset لا علاقة له بالسرعة إطلاقاً، بل بمنطقه الداخلي: هو يحدّد الصف برقم موضعه في النتيجة، لا بهويته الحقيقية في الجدول. لنأخذ مثالاً رقمياً: طلب أول يجلب الصفوف من الموضع 1 إلى 20 بترتيب تنازلي حسب التاريخ. قبل أن يطلب المستخدم الصفحة التالية بـOFFSET 20، يُحذف صف كان يحتل الموضع رقم 7. كل صف بعده يزحف موضعاً واحداً إلى الأعلى: الصف الذي كان في الموضع 20 يصبح في الموضع 19، والصف الذي كان سيظهر لاحقاً في الموضع 21 يظهر الآن في الموضع 20 نفسه — أي أنه يتكرر في الصفحتين معاً. لو حدثت إضافة بدل حذف تنعكس النتيجة تماماً: صف كامل يختفي من عرض المستخدم دون أن يسجَّل أي خطأ في أي مكان. cursor يتفادى هذا العطب من جذوره، لأن كل طلب يسأل صراحة عن كل ما هو أحدث من قيمة محددة بالفعل، بصرف النظر عمّا طرأ على الصفوف التي سبقتها.

كيف تبني الشركات الكبرى pagination فعلياً

Stripe

يستخدم starting_after وending_before، وهما من نوع cursor بترتيب زمني عكسي، بحد افتراضي 10 عناصر ونطاق مسموح من 1 إلى 100. المعاملان متبادلان حصرياً، لا يُستخدمان معاً في نفس الطلب.

Slack

معامل cursor في الطلب، وحقل next_cursor داخل response_metadata في الرد، بحد موصى به بين 100 و200 وحد أقصى 1000. المؤشر نفسه مرمّز بـbase64، ومثال حقيقي عليه: dXNlcjpXMDdRQ1JQQTQ= يفكّ إلى user:W07QCRPA4. أي أن أي طرف يعترض الطلب يمكنه فك المؤشر فوراً؛ هذا ترميز لا تشفير.

GitHub

تقليدياً تستخدم واجهة REST معاملَي page وper_page بحد أقصى 100، مع ترويسة Link تحمل روابط rel="next" وprev وfirst وlast. لكن في أكتوبر 2025 أزالت GitHub معاملات offset بالكامل من واجهة Dependabot alerts، وأبقت before وafter وper_page فقط — إقرار عملي من شركة تعمل على نطاق ضخم بأن offset لا يصمد. أما واجهة GraphQL فتستخدم first/last بنطاق 1-100 مع after/before، والمؤشر يُقرأ من pageInfo.endCursor وpageInfo.hasNextPage:

{
  repository(owner: "org", name: "repo") {
    issues(first: 50, after: "<cursor من pageInfo.endCursor>") {
      nodes { title }
      pageInfo { endCursor hasNextPage }
    }
  }
}

X API v2

معامل pagination_token يُملأ من next_token في الاستجابة السابقة، ولا ينتهي صلاحيته، ما يسمح بحفظ نقطة التوقف بلا قلق من انتهائها.

عيوب cursor الأقل وضوحاً

لا سبيل للقفز مباشرة إلى صفحة رقم N، لأن كل مؤشر يعرف الصف السابق فقط لا موقعاً مطلقاً. كما يحتاج عموداً مرتّباً فريداً أو مركّباً حتى لا يتساوى صفّان بالقيمة نفسها؛ وغالباً هذا العمود هو المفتاح الأساسي نفسه، وهنا يتقاطع القرار مع اختيار بنية ذلك المفتاح أصلاً — UUID أم bigint: كيف تختار المفتاح الأساسي لجدولك يشرح كيف تؤثر عشوائية UUIDv4 على فهرسة B-tree وترتيب الإدخال، وهو بالضبط ما يحدد سلاسة عمود المؤشر. أضف إلى ذلك تعقيد الترميز، وأن أي تغيير في معيار الفرز أثناء الجلسة يكسر المؤشرات القديمة تماماً.

جدول القرار

الحالةالخيار الأنسب
لوحة تحكم إدارية، بيانات صغيرة شبه ثابتةoffset يكفي
حاجة فعلية لأرقام صفحات قابلة للنقر أو مشاركة رابط صفحة محددةoffset، لأن cursor لا يوفّر هذا
تغذية لحظية أو تمرير لا نهائيcursor ضروري
API عام بحجم بيانات كبير أو سريع التغيّر (كتابة وحذف متكرران أثناء التصفح)cursor ضروري

أخطاء شائعة تكسر cursor pagination

استخدام عمود غير فريد كمؤشر وحيد يُنتج تعادلاً بين صفوف متعددة؛ الحل زوج (created_at, id) مع فهرس مركّب عليهما معاً. والخلط بين base64 والتشفير خطأ متكرر: base64 قابل لفك فوري ولا يحمي من كشف معرّفات داخلية، فإن احتجت حماية فعلية استخدم HMAC أو تشفيراً حقيقياً للمؤشر. ومن الأخطاء أيضاً توقّع أن يدعم cursor الانتقال المباشر لصفحة رقم 50، وإعادة استخدام مؤشر قديم بعد تغيير معيار الفرز، ونسيان ORDER BY صريح؛ PostgreSQL يوثّق رسمياً أن غيابه يعطي نتائج غير متوقعة بين الصفحات المتتالية.

اقرأ أيضاً