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

بقلم فريق تقني · (آخر تحديث: )· برمجة
Cursor Pagination أم Offset: أيهما تختار لتصميم API؟

استعلام LIMIT 20 OFFSET 40 على جدول بألف صف يعود في أجزاء من الميلي ثانية، فلا يتوقف عنده أحد في مراجعة الكود. النقاش يُفتح متأخراً: حين يتجاوز الجدول ملايين الصفوف ويطلب أول عميل الصفحة رقم 5000، وتتحول أرخص عبارة في الاستعلام إلى أبطأ جزء في الواجهة كلها. وثمة عطب ثانٍ أخطر لأنه لا يظهر في أي مقياس أداء: سجلات تتكرر وأخرى تختفي أثناء التصفح دون خطأ واحد مسجّل. الفرق بين offset وkeyset يمتد من سلوك المحرك في مستوى SQL إلى تصميم المؤشر المعتم في عقد الواجهة، وعلى الطريق سؤال عملي: متى يصح الاحتفاظ بـoffset وتحسينه بدل هجره؟

ما الذي يفعله كل استعلام داخل المحرك

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;

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

كم يكلّف العدّ من الصفر؟ أرقام Shopify

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

اختبار أداء من 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 مرة في المتوسط. لا توجد عتبة رقمية ثابتة تصلح لكل جدول؛ العتبة الفعلية تتوقف على الحجم والفهرسة، لكن الاتجاه واحد: الكلفة تنمو خطياً مع رقم الصفحة.

التكرار والتخطي: العطب الذي لا تكشفه السجلات

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

بناء keyset لا ينكسر: زوج الفرز والاتجاهات وNULL

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

مقارنة الصفوف: ما يفهمه PostgreSQL ولا يستغله MySQL

في PostgreSQL تُكتب المقارنة المركّبة بصيغة row comparison المدعومة منذ الإصدار 8.4، وتُقارن ترتيباً معجمياً وتستهلك فهرساً مركّباً مباشرة:

SELECT * FROM posts
WHERE (created_at, id) < ('2026-08-01 10:00:00', 4213)
ORDER BY created_at DESC, id DESC
LIMIT 20;

مع فهرس يطابق الترتيب: CREATE INDEX ON posts (created_at DESC, id DESC);

MySQL يقيّم الصيغة نفسها بنتيجة صحيحة لكنه لا يستخدمها كـaccess predicate على الفهرس، كما يوثّق Markus Winand في Use The Index Luke. البديل هناك التوسيع اليدوي (a < x) OR (a = x AND b < y)، أو صيغة Winand الأكفأ: sale_date <= ? AND NOT (sale_date = ? AND sale_id >= ?)، حيث يعمل الشرط الأول كـaccess predicate يقود الفهرس، والثاني كـfilter predicate يرشّح بعده. ومكتبة jOOQ توفّر SEEK clause يحاكي ذلك تلقائياً على القواعد التي لا تدعم الصيغة أصلاً.

الاتجاهات المختلطة وفخ القيم الفارغة

فهرس DESC أحادي العمود لا يلزم عادة؛ PostgreSQL يمسح الفهرس العادي عكسياً. الفهرس الموجّه يلزم فقط عند مزج الاتجاهات مثل ORDER BY score DESC, id ASC — وعندها لا تصلح row comparison أصلاً ويجب التوسيع إلى OR/AND كما في مثال jOOQ: (score < 949) OR (score = 949 AND player_id > 15).

أما NULL فقاتل صامت: في PostgreSQL يعني ASC ضمنياً NULLS LAST وDESC يعني NULLS FIRST، وأي NULL داخل زوج المقارنة يجعل نتيجتها unknown فيسقط الصف بصمت من كل الصفحات. لهذا يرفض Laravel في cursorPaginate وDjango REST Framework في CursorPagination الأعمدة القابلة لـNULL نصاً في توثيقهما، ويشترط DRF عموداً فريداً لا يتغير — افتراضيه -created — لأن الفرز على updated_at خطأ منهجي: القيمة تتحرك مع كل تعديل فيقفز السجل بين طلبين متتاليين.

المؤشر المعتم: ماذا يوضع داخله ومتى تنتهي صلاحيته

المؤشر عملياً هو قيم أعمدة الفرز للصف الأخير، مغلّفة. Laravel يضعها JSON مرمّزاً base64؛ مثال من توثيقه: eyJpZCI6MTUs... يفكّ إلى {"id":15,"_pointsToNextItems":true}. الترميز الآمن للروابط هو base64url من RFC 4648 حتى لا تكسر محارف + و/ معاملات URL. وتوصية قوقل في AIP-158 صريحة: المؤشر يجب أن يكون معتماً لا يفكّه المستخدم ولا يبني عليه، وbase64 وحده «تمويه غير كافٍ»، ويجوز أن تنتهي صلاحيته — تقترح الوثيقة نحو 3 أيام. Slack يطبّق ذلك فعلاً: مؤشر تالف أو قديم يعيد خطأ invalid_cursor، وتوثيقه ينص حرفياً على عدم تخزين المؤشرات ساعات أو أياماً، وتُعرف نهاية النتائج من next_cursor فارغ. وعند Stripe لا تغليف أصلاً: المؤشر هو معرّف الكائن نفسه.

وسبب هذا التشدد أن المؤشر جزء من عقد الواجهة الخارجي مثله مثل مفتاح idempotency لمنع تكرار الطلب في واجهات الدفع: كلاهما وعد سلوكي إن كسرته لاحقاً كسرت عملاء لا تراهم.

العدّ الكلي أغلى مما تظن

إظهار «صفحة 3 من 812» يتطلب COUNT(*)، وهو بطيء على الجداول الضخمة في PostgreSQL بسبب MVCC: فحص visibility لكل صف على حدة. البدائل ثلاثة: حقل has_more منطقي فقط كما تفعل Zendesk بإرجاع meta[has_more]، أو تقدير رخيص من إحصاءات الجدول:

SELECT reltuples::bigint FROM pg_class WHERE oid = 'posts'::regclass;

وهو رقم يحدّثه VACUUM وANALYZE لا كل إدخال. أو الاستغناء الصريح: GitLab يُسقط ترويسة x-total كلياً فوق 10,000 سجل لأسباب أداء معلنة.

كيف تنفّذها المنصات الكبرى

Stripe: معرّف الكائن نفسه هو المؤشر

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

Slack: ترميز مكشوف وصلاحية قصيرة

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

GitHub: تراجع معلن عن offset ومواصفة Relay في GraphQL

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

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

X API v2: مؤشر لا تنتهي صلاحيته

معامل pagination_token يُملأ من next_token في الاستجابة السابقة ولا تنتهي صلاحيته، فيمكن حفظ نقطة التوقف بلا قلق — عكس فلسفة Slack تماماً، وكلا الخيارين مشروع ما دام معلناً في العقد.

حين لا تستطيع هجر offset: الهجين وdeferred join

أحياناً يفرض العقد الخارجي أرقام صفحات قابلة للمشاركة، وهنا لا يوفّرها keyset لأن كل مؤشر يعرف سابقه فقط لا موقعاً مطلقاً. المنصات الكبيرة تحل ذلك بالمزج: GitLab يدعم الطريقتين معاً، offset افتراضياً بسقف 50,000 وkeyset عبر pagination=keyset. Zendesk يقيّد offset بأول 100 صفحة أي 10,000 مورد ويعيد HTTP 400 بعدها، بينما cursor بلا حد. وElasticsearch يقيّد from+size بإعداد max_result_window الافتراضي 10,000 ويقدّم search_after مع عمود حاسم بديلاً.

وإن أردت تحسين offset نفسه دون كسر العقد، فالحيلة deferred join: استعلام داخلي يجلب id فقط عبر الفهرس، ثم JOIN لجلب بقية الأعمدة للصفوف العشرين وحدها:

SELECT p.* FROM posts p
JOIN (SELECT id FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 60000) t
ON p.id = t.id;

نتيجة واقعية أوردها Aaron Francis في مقاله: مطوّر طبّق الحيلة على تطبيق Laravel يعمل على MySQL فهبطت الصفحة 3000 من 30 ثانية إلى نحو 250 ميلي ثانية — رقم تطبيق محدد لا معيار معمم، لكنه يوضح حجم المكسب الممكن مع بقاء أرقام الصفحات قابلة للمشاركة.

أي نمط يناسب واجهتك؟

وضع واجهتكالخيار الأنسب
لوحة إدارية على بيانات صغيرة شبه ثابتةoffset البسيط يكفي
عقد خارجي يشترط أرقام صفحات قابلة للمشاركةoffset محسّن بـdeferred join مع سقف صفحات على طريقة Zendesk
تمرير لا نهائي أو تغذية لحظيةkeyset بمؤشر معتم
API عام على بيانات ضخمة سريعة التغيّرkeyset مع has_more بدل العدّ الكلي
جمهوران مختلفان لواجهة واحدةهجين على طريقة GitLab: offset افتراضياً بسقف، وkeyset عند الطلب

السؤال الحاسم واحد: هل يحتاج مستخدمك فعلاً مشاركة رابط «الصفحة 40»؟ إن كانت الإجابة نعم فحسّن offset وقيّده؛ وإن كان ما تعرضه تدفقاً متحركاً فاذهب إلى المؤشر المعتم.

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

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