يضمن الترقيم بالمؤشر ثبات القوائم عند تغيّر البيانات. تعلّم لماذا ينهار الترقيم بالزاحة عند الإدخالات والحذف، وكيف تنفّذ مؤشرات نظيفة.

تفتح خلاصة، تمرّر قليلًا، وكل شيء يبدو طبيعيًا حتى لا يكون كذلك. ترى نفس العنصر مرتين. شيء كنت متأكدًا أنه كان موجودًا اختفى. صف كنت على وشك النقر عليه يتحرّك للأسفل، فتصل إلى صفحة تفاصيل خاطئة.
هذه أخطاء يراها المستخدمون، حتى لو بدت استجابات API "صحيحة" عندما تُفحص كل على حدة. الأعراض الاعتيادية سهلة الملاحظة:
يزداد الأمر سوءًا على الموبايل. الناس يتوقفون، يبدلون التطبيقات، يفقدون الاتصال، ثم يُكملون لاحقًا. خلال ذلك الوقت، تدخل عناصر جديدة، تُحذف أخرى، ويُحرّر بعضها. إذا واصل تطبيقك طلب "الصفحة 3" باستخدام offset، قد تتحرك حدود الصفحات بينما المستخدم في منتصف التمرير. النتيجة خلاصة تشعر بعدم الثبات وعدم المصداقية.
الهدف بسيط: بمجرد أن يبدأ المستخدم التمرير للأمام، يجب أن تتصرف القائمة كما لو أنها لقطة ثابتة. يمكن أن توجد عناصر جديدة، لكن لا ينبغي أن تعيد ترتيب ما يتصفحه المستخدم بالفعل. يجب أن يحصل المستخدم على تسلسل سلس ومتوقع.
لا توجد طريقة ترقيم مثالية. الأنظمة الحقيقية بها عمليات كتابة متزامنة وتعديلات وخيارات فرز متعددة. لكن الترقيم بالمؤشر عادةً أكثر أمانًا من الترقيم بالزاحة لأنّه يمرّر من موقع محدد في ترتيب ثابت، بدلًا من الاعتماد على عدد صفوف متحرك.
الترقيم بالزاحة هو أسلوب "تخطي N، أخذ M" لتقطيع القائمة. تخبر الـ API كم عنصر تتخطى (offset) وكم تعيد (limit). مع limit=20 تحصل على 20 عنصرًا في الصفحة.
مفاهيميًا:
GET /items?limit=20\u0026offset=0 (الصفحة الأولى)GET /items?limit=20\u0026offset=20 (الصفحة الثانية)GET /items?limit=20\u0026offset=40 (الصفحة الثالثة)تتضمن الاستجابة عادةً العناصر بالإضافة إلى معلومات كافية لطلب الصفحة التالية.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
هذا الأسلوب شائع لأنه يتناسب جيدًا مع الجداول، قوائم الإدارة، نتائج البحث، والخلاصات البسيطة. كما أنه سهل التنفيذ في SQL باستخدام LIMIT وOFFSET.
لكن الفخ هو الافتراض الخفي: أن مجموعة البيانات ثابتة بينما المستخدم يتصفح. في التطبيقات الحقيقية، تُدرج صفوف جديدة، تُحذف صفوف، وتتغير مفاتيح الفرز. هنا تبدأ "الأخطاء الغامضة".
يفترض الترقيم بالزاحة أن القائمة تبقى ثابتة بين الطلبات. لكن القوائم الحقيقية تتحرك. عندما تتحرك القائمة، لا يعود offset مثل "تخطي 20" يشير إلى نفس العناصر.
تخيّل خلاصة مرتبة بـ created_at desc (الأحدث أولًا)، حجم الصفحة 3.
تحميل الصفحة 1 مع offset=0, limit=3 يعطي [A, B, C].
الآن تُنشأ عنصر جديد X ويظهر في الأعلى. تصبح القائمة [X, A, B, C, D, E, F, ...]. تطلب الصفحة 2 مع offset=3, limit=3. يتخطى السيرفر [X, A, B] ويعيد [C, D, E].
لقد رأيت C مرة أخرى (تكرار)، ولاحقًا ستفقد عنصرًا لأن كل شيء انزلق للأسفل.
الحذف يتسبب في الفشل العكسي. ابدأ بـ [A, B, C, D, E, F, ...]. تحمل الصفحة 1 وترى [A, B, C]. قبل الصفحة 2، يُحذف B، فتصبح القائمة [A, C, D, E, F, ...]. الصفحة 2 مع offset=3 تتخطى [A, C, D] وتعيد [E, F, G]. يصبح D ثغرة لم تطلبها أبدًا.
في الخلاصات الأحدث أولًا، تحدث الإدخالات في الأعلى، وهذا بالضبط ما يحرك كل offset لاحق.
«القائمة الثابتة» هي ما يتوقعه المستخدمون: عند التمرير للأمام، لا تقفز العناصر أو تتكرر أو تختفي دون سبب واضح. الأمر أقل عن تجميد الزمن وأكثر عن جعل الترقيم متوقعًا.
هناك فكرتان غالبًا ما يُخلَط بينهما:
created_at مع فاصل تعادل مثل id) بحيث تعيد نفس الترتيب لطلبات بنفس المدخلات.التحديث وإكمال التمرير عمليتان مختلفتان. التحديث يعني "أرني الجديد الآن"، لذا يمكن أن يتغيّر الأعلى. التمرير للأمام يعني "تابع من حيث توقفت"، لذا لا ينبغي أن ترى تكرارات أو ثغرات غير متوقعة ناجمة عن تحرك حدود الصفحات.
قاعدة بسيطة تمنع معظم أخطاء الترقيم: التمرير للأمام يجب ألا يعرض تكرارات أبدًا.
الترقيم بالمؤشر يتنقل خلال القائمة باستخدام إشارة مرجعية بدلًا من رقم صفحة. بدلاً من "أعطني الصفحة 3" يقول العميل "واصل من هنا".
العقد سهل:
هذا يتحمّل الإدخالات والحذف بشكل أفضل لأن المؤشر يثبت لموضع في الترتيب، وليس على عدد صفوف متحرك.
المطلب غير القابل للتفاوض هو وجود ترتيب حتمي. تحتاج إلى قاعدة ترتيب ثابتة وكاسر تعادل متسق، وإلا فلن يكون المؤشر إشارة مرجعية موثوقة.
ابدأ باختيار ترتيب واحد يتوافق مع طريقة قراءة الناس للقائمة. الخلاصات والرسائل وسجلات النشاط عادةً ما تكون الأحدث أولًا. السجلات التاريخية مثل الفواتير وسجلات التدقيق قد يكون من الأسهل عرضها الأقدم أولًا.
يجب أن يحدد المؤشر موضعًا فريدًا في ذلك الترتيب. إذا كان عنصران يمكنهما مشاركة نفس قيمة المؤشر، ستحصل في نهاية المطاف على تكرارات أو ثغرات.
خيارات شائعة وما يجب الحذر منه:
created_at فقط: بسيط، لكنه غير آمن إذا شاركت كثير من الصفوف نفس الطابع الزمني.id فقط: آمن إذا كانت المعرفات تصاعدية، لكنه قد لا يطابق ترتيب المنتج الذي تريده.created_at + id: مزيج جيد عادةً (الزمن لترتيب المنتج، وid ككاسر تعادل).updated_at كفرز أساسي: محفوف بالمخاطر للتمرير اللانهائي لأن التعديلات قد تنقل العناصر بين الصفحات.إذا قدّمت أوضاع فرز متعددة، عامل كل وضع كقائمة مختلفة بقواعد مؤشر خاصة بها. المؤشر له معنى فقط لترتيب محدد بدقة.
يمكنك المحافظة على واجهة API بسيطة: مدخلان ومخرجان.
أرسل limit (كم عنصر تريد) ومؤشر اختياري cursor (من أين تتابع). إذا لم يوجد مؤشر، يعيد السيرفر الصفحة الأولى.
مثال طلب:
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
أعد العناصر وnext_cursor. إذا لم توجد صفحة تالية فأعد next_cursor: null. يجب على العملاء التعامل مع المؤشر كرمز لا يعدّلون فيه.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
المنطق على جانب السيرفر بكلمات بسيطة: فرز بترتيب ثابت، تصفية باستخدام المؤشر، ثم تطبيق الحد.
إذا فرزت الأحدث أولًا بـ (created_at DESC, id DESC), فكّ الشفرة من المؤشر إلى (created_at, id), ثم اجلب الصفوف حيث (created_at, id) أقلّ (strictly less) من زوج القيم في المؤشر، وطبق نفس الترتيب وخذ limit صفًا.
يمكنك تشفير المؤشر كـ JSON مشفّر بقاعدة64 (سهل) أو كرمز موقع/مشفّر (مزيد من العمل). الغامض أكثر أمانًا لأنه يتيح لك تغيير البنية الداخلية لاحقًا دون كسر العملاء.
حدد أيضًا افتراضات منطقية: قيمة افتراضية معقولة للموبايل (غالبًا 20–30)، قيمة افتراضية للويب (غالبًا 50)، وحد أقصى صارم على السيرفر حتى لا يطلب عميل معطوب 10,000 صف.
الخلاصة الثابتة تتعلق غالبًا بوعد واحد: بمجرد أن يبدأ المستخدم التمرير للأمام، لا ينبغي أن تقفز العناصر التي لم يرها بالفعل لأن شخصًا آخر أنشأ أو حذف أو حرّر سجلات.
مع ترقيم المؤشر، الإدخالات أسهل. يجب أن تظهر العناصر الجديدة عند التحديث، لا في منتصف الصفحات المحمّلة بالفعل. إذا فرزت بـ created_at DESC, id DESC، تعيش العناصر الجديدة طبيعيًا قبل الصفحة الأولى، لذا يستمر المؤشر الحالي إلى عناصر أقدم.
لا ينبغي أن تؤدي الحذوفات إلى إعادة ترتيب القائمة. إذا حُذف عنصر، ببساطة لن يُعاد عندما تصل نقطته. إذا كنت بحاجة للحفاظ على ثبات حجم الصفحة، واصل الجلب حتى تجمع limit عناصر مرئية.
التعديلات هي حيث يعيد الفرق عرض أخطاء. السؤال الرئيسي: هل يمكن أن يغير التعديل موضع العنصر في الفرز؟
سلوك اللقطة مناسب عادة لقوائم التمرير: صف ب مفتاح غير قابل للتغيير مثل created_at. يمكن أن يتغير محتوى العنصر عند التعديل، لكن العنصر لا يقفز إلى موضع جديد.
السلوك الحي يرتّب حسب شيء مثل edited_at. هذا يمكن أن يسبب قفزات (عنصر قديم يُحرَّر وينتقل إلى الأعلى). إذا اخترت هذا، عامل القائمة باعتبارها متغيرة باستمرار وصمّم واجهة المستخدم حول التحديث.
لا تجعل المؤشر يعتمد على "إيجاد هذا الصف بالضبط". رمّز الموقع بدلًا من ذلك، كـ {created_at, id} لآخر عنصر مُعاد. ثم يبنى الاستعلام التالي على القيم، وليس على وجود الصف:
WHERE (created_at, id) < (:created_at, :id)id) لتفادي التكراراتالتمرير للأمام هو الجزء السهل. الأسئلة الأصعب في تجربة المستخدم هي التصفح للخلف، والتحديث، والوصول العشوائي.
للتصفح للخلف، نهجان يعملان عادةً:
next_cursor للعناصر الأقدم وprev_cursor للأحدث) مع الاحتفاظ بترتيب واحد على الشاشة.القفز العشوائي أصعب مع المؤشرات لأن "الصفحة 20" لا معنى ثابتًا عندما تتغير القائمة. إذا كنت تحتاج القفز فعليًا، اقفز إلى مرساة مثل "حول هذا الطابع الزمني" أو "بدءًا من هذا message id"، وليس بمؤشر الصفحة.
على الموبايل، التخزين المؤقت مهم. خزّن مؤشرات لكل حالة قائمة (استعلام + فلاتر + فرز)، وعامل كل تبويب/عرض كقائمة مستقلة. هذا يمنع سلوك "تبديل التبويبات فيؤدي إلى فوضى".
معظم مشاكل ترقيم المؤشر ليست عن قاعدة البيانات. تأتي من تناقضات صغيرة بين الطلبات تظهر فقط تحت حركة مرور حقيقية.
الأكبر مجرِمون:
created_at فقط) فتصنع التعادلات تكرارات أو عناصر مفقودة.next_cursor لا تتوافق مع آخر صف تم إرجاعه فعليًا.offset وcursor في نفس الواجهة.إذا بنيت تطبيقات على منصات مثل Koder.ai، تظهر هذه الحواف سريعًا لأن عملاء الويب والموبايل غالبًا ما يشاركون نفس الـ endpoint. وجود عقد مؤشر صريحة وقاعدة ترتيب حتمية واحدة يحافظ على اتساق كلا العميلين.
قبل أن تقول إن الترقيم "مكتمل"، تحقّق من السلوك تحت الإدخالات والحذف والمحاولات المتكررة.
next_cursor مأخوذ من آخر صف تم إرجاعهlimit حد أقصى آمن وإفتراضي موثقللتحديث، اختر قاعدة واضحة واحدة: إما يجلب المستخدم العناصر الأحدث بالسحب للتحديث، أو تفحص دوريًا "هل هنالك شيء أحدث من أول عنصر لدي؟" وتعرض زر "عناصر جديدة". الاتساق هو ما يجعل القائمة تبدو ثابتة بدلًا من مسكونة.
تخيل صندوق دعم يستخدمه الوكلاء على الويب، بينما يراجعه المدير على الموبايل. القائمة مرتبة بالأحدث أولًا. يتوقع الناس شيئًا واحدًا: عند التمرير للأمام، لا تقفز العناصر أو تتكرر أو تختفي.
مع الترقيم بالزاحة، يحمل الوكيل الصفحة 1 (عناصر 1-20)، ثم يمرّر إلى الصفحة 2 (offset=20). أثناء قراءته، تصل رسالتان جديدتان إلى الأعلى. الآن offset=20 يشير لموضع مختلف عما كان عليه قبل ثانية. يرى المستخدم تكرارات أو يفقد رسائل.
مع ترقيم المؤشر، يطلب التطبيق "العشرين التالية بعد هذا المؤشر"، حيث المؤشر مبني على آخر عنصر رآه المستخدم (غالبًا (created_at, id)). يمكن أن تصل رسائل جديدة طوال اليوم، لكن الصفحة التالية ستبدأ دائمًا بعد آخر رسالة رآها المستخدم.
طريقة بسيطة للاختبار قبل الإطلاق:
إذا كنت تطوّر سريعًا، يمكن لـ Koder.ai مساعدتك على إنشاء مخطط الـ endpoint وتدفقات العميل من موجه محادثة، ثم التكرار بأمان باستخدام وضع التخطيط بالإضافة إلى لقطات واسترجاع عندما يفاجئك تغيير الترقيم في الاختبار.
يشير الترقيم بالزاحة إلى «تخطي N صفوف»، لذا عندما تُدرج صفوف جديدة أو تُحذف صفوف قد يتغير عدد الصفوف. نفس الـ offset قد يشير فجأة إلى عناصر مختلفة عمّا كان يشير إليه قبل لحظة، وهذا يسبب التكرارات والثغرات أثناء التمرير.
يستخدم ترقيم المؤشر «علامة مرجعية» تمثل "الموقع بعد آخر عنصر رأيته". الطلب التالي يستمر من ذلك الموقع بنفس ترتيب الحسم، لذا الإدخالات في الأعلى والحذوف في الوسط لا تغير حد الصفحة بنفس طريقة الـ offset.
استخدم ترتيبًا حتميًا مع فاصل تعادلٍ، وفي الغالب (created_at, id) بنفس الاتجاه. created_at يعطي الترتيب المرغوب من منظور المنتج، وid يجعل كل موقع فريدًا حتى لا تتكرر أو تفقد عناصر عندما تتطابق الطوابع الزمنية.
الفرز حسب updated_at قد يجعل العناصر تقفز بين الصفحات عند تعديلها، وهذا يكسر توقع «تمرير ثابت للأمام». إذا كنت بحاجة إلى عرض حي «الأحدث تعديلًا»، فصمّم واجهة المستخدم لتقبل إعادة الترتيب عند التحديث بدلاً من وعد تمرير لانهائي ثابت.
أعد رمزًا شفافًا مثل next_cursor ودع العميل يعيده دون تغييره. نهج بسيط هو تشفير (created_at, id) لآخر عنصر في قطعة JSON ثم تحويله إلى base64، لكن الأهم أن تعامل المؤشر كقيمة غامضة (opaque) حتى تتمكن من تغيير البنية الداخلية لاحقًا.
ابنِ الاستعلام التالي من قيم المؤشر، وليس من "ابحث عن هذا الصف بالضبط". إذا حُذف العنصر الأخير، فإن (created_at, id) المخزن لا يزال يعرف موضعًا، فيمكن الاستمرار بأمان باستخدام شرط «أصغر من» أو «أكبر من» بترتيبك نفسه.
استخدم مقارنة صارمة وفاصل تعادل فريد، وخُذ دائمًا المؤشر من آخر صف تم إرجاعه فعليًا. معظم أخطاء التكرار تنتج عن استخدام <= بدلًا من <، أو حذف فاصل التعادل، أو توليد next_cursor اعتمادًا على الصف الخطأ.
اختر قاعدة واضحة: التحديث (refresh) يجلب العناصر الأحدث إلى الأعلى، بينما متابعة التمرير تتجه إلى العناصر الأقدم من المؤشر الحالي. لا تخلط سلوكيات "التحديث" ضمن نفس تدفق المؤشر، وإلا سيرى المستخدمون إعادة ترتيب ويظنون أن القائمة غير موثوقة.
المؤشر صالح فقط لترتيب ومجموعة فلاتر محددة. إذا غير العميل وضع الفرز أو الاستعلام أو الفلاتر، فعليه بدء جلسة ترقيم جديدة بدون مؤشر وتخزين مؤشرات منفصلة لكل حالة قائمة.
ترقيم المؤشر ممتاز للتصفح التسلسلي لكنه ليس مناسبًا للقفز الثابت إلى "الصفحة 20" لأن مجموعة البيانات قد تتغير. إذا احتجت إلى قفز، فاقفز إلى مرساة مثل "حوالى هذا الطابع الزمني" أو "بدءًا من هذا id"، ثم ابدأ الترقيم بالمؤشرات من هناك.
احتفظ بمؤشرات منفصلة لكل حالة قائمة (استعلام + فلاتر + فرز)، وخزنها محليًا على الموبايل. هذا يمنع مشكلة "تبديل التبويبات فيتشتت كل شيء" لأن كل تبويب/عرض له قائمة مستقلة.