Cursor Pagination هي طريقة لتحميل البيانات المقسمة إلى صفحات تستخدم مؤشراً فريداً للتنقل عبر مجموعة مرتبة من السجلات. وفقاً لـ مواصفات GraphQL (2025)، فإن التصفح القائم على المؤشر هو المعيار الموصى به لواجهات API التي تعمل مع البيانات الديناميكية. التصفح بالمؤشر يلغي العيوب الرئيسية لمنهج Offset: عدم الاستقرار أثناء الإضافات وتدهور الأداء على الإزاحات الكبيرة.
الخلاصة
Cursor Pagination هي طريقة تحميل مقسمة إلى صفحات حيث يعيد الخادم مؤشراً خاصاً مع البيانات. يستخدم العميل هذا المؤشر في الطلب التالي للحصول على الدفعة التالية من السجلات. المؤشر هو معرف فريد للعنصر الأخير في الصفحة الحالية.
على عكس التصفح بـ Offset، حيث يقول العميل «أعطني الصفحة 5 بـ 20 سجلاً»، يعمل التصفح بالمؤشر بشكل مختلف: «أعطني 20 سجلاً بعد السجل ذي ID = 83». ينفذ الخادم استعلاماً بـ WHERE id > 83 و LIMIT 20. هذا النهج يضمن أن كل سجل يقع بالضبط في صفحة واحدة بغض النظر عن الإضافات.
انتشر مفهوم التصفح بالمؤشر على نطاق واسع بفضل مواصفة Relay Connection (GraphQL)، التي جعلت التصفح القائم على المؤشر معياراً لواجهات API الحديثة. تحدد Relay تنسيق الاستجابة: edges (مصفوفة من السجلات مع المؤشرات)، pageInfo (hasNextPage، hasPreviousPage، startCursor، endCursor).
التصفح بالمؤشر ليس تقنية جديدة — فقد كانت تُستخدم في قواعد البيانات قبل ظهور الويب بوقت طويل. في SQL يُسمى keyset pagination أو طريقة seek. أصبحت هذه الطريقة شائعة في APIs بعد نشر مواصفة Relay في 2015، التي وحدت تنسيق المؤشر كسلسلة مشفرة بـ base64 للتوحيد عبر نقل HTTP.
المبدأ الأساسي للتصفح بالمؤشر هو أن الاستعلام يستخدم شرط WHERE على حقل مفهرس لتحديد الموضع، وليس إزاحة. للاتجاه الأمامي يُستخدم WHERE id > last_id، وللاتجاه الخلفي WHERE id < first_id. فهرس B-tree يجد أول سجل بعد المؤشر في O(log n)، مما يعطي وقت استجابة مستقراً.
-- جلب 20 سجلًا بعد المؤشر '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- جلب 20 سجلًا قبل المؤشر '83' (للخلف)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
يمكن أن يكون المؤشر بسيطاً (قيمة ID) أو معقداً (مركباً من عدة حقول). المؤشرات البسيطة هي المفتاح الأساسي للسجل، مثلاً id تزايدي تلقائي أو UUID. تُستخدم المؤشرات المركبة للفرز حسب حقول غير فريدة، مثلاً (created_at, id)، حيث يضمن id التفرد عندما تكون الأختام الزمنية متطابقة.
التنسيق النموذجي لواجهة API هو مؤشر كسلسلة مشفرة بـ base64. يفك الخادم ترميز المؤشر، ويستخرج القيمة، ويبني استعلام SQL. ترميز Base64 يخفي البنية الداخلية للمؤشر عن العميل ويسمح بتغيير التنسيق دون كسر التوافق العكسي. يتلقى العميل المؤشرات في حقل endCursor من الاستجابة ويمررها كسلسلة في الطلب التالي.
يدعم التصفح بالمؤشر التنقل ثنائي الاتجاه. للحركة للأمام (next)، يُستخدم مؤشر آخر عنصر في الصفحة الحالية؛ للخلف (previous)، مؤشر العنصر الأول. المعاملان after و before في الطلب يحددان الاتجاه: after يأخذ السجلات بعد المؤشر، before يأخذ السجلات قبل المؤشر.
الاختيار بين التصفح بالمؤشر و Offset هو أحد القرارات المعمارية الرئيسية عند تصميم API. لكل طريقة نقاط قوة وضعف تحدد قابلية تطبيقها. Cursor pagination تتفوق في السيناريوهات ذات البيانات الديناميكية؛ Offset تتفوق في سيناريوهات التنقل العشوائي.
| الخاصية | Cursor | Offset |
|---|---|---|
| الاستقرار عند الإضافات | عالي (بدون تكرار) | منخفض (إزاحة الصفحات) |
| الأداء على المجموعات الكبيرة | O(log n) — مستقر | O(n) — يتدهور مع النمو |
| التنقل برقم الصفحة | لا | نعم (page=5) |
| تعقيد التنفيذ | متوسط | منخفض |
| دعم REST | cursor/before/after | page/offset |
| دعم GraphQL | معيار Relay | غير موصى به |
يقوم التصفح بـ Offset بفحص كامل للجدول حتى موضع OFFSET. عند offset=100000، تقرأ قاعدة البيانات وتتخطى 100000 صف، حتى لو كان LIMIT يساوي 20. MySQL و PostgreSQL لا يمكنهما تحسين OFFSET — هذه خاصية تنفيذ LIMIT/OFFSET في SQL. يستخدم التصفح بالمؤشر فهرس B-tree الذي يجد الموضع في O(log n).
مشكلة إضافية لـ Offset هي «تخطي» السجلات عند التصفح للخلف. إذا قام مستخدم بتحميل الصفحة 5 وفي تلك اللحظة أُضيفت سجلات جديدة، عند طلب الصفحة 6 سيرى السجل من الصفحة 5 مرة أخرى أو سيفوته الجديد. Cursor pagination تلغي هذا السيناريو تماماً: المؤشر يشير إلى مكان محدد في المجموعة، والإضافات لا تغير الموضع.
لنلق نظرة على تنفيذ التصفح بالمؤشر على الخادم (Kotlin + Spring) وعلى العميل (Android + Retrofit). يقبل الخادم المعاملات after، before، limit ويعيد قائمة بالسجلات مع المؤشرات و pageInfo. الاستجابة النموذجية تحتوي على hasNextPage و hasPreviousPage لإدارة واجهة التصفح.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
على العميل، يتم تنفيذ التصفح بالمؤشر عبر PagingSource من Paging 3، حيث المفتاح هو مؤشر (Long). يتلقى PagingSource.load LoadParams.key — مؤشر آخر سجل محمل. LoadResult.Page يعيد البيانات و nextKey — المؤشر للصفحة التالية. عندما nextKey = null، يكتمل التصفح.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource مع مفتاح المؤشر
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
في GraphQL، يتم تنفيذ التصفح بالمؤشر عبر نمط Relay Connection. لكل نوع Connection (مع pageInfo و edges) و Edge (node + cursor). يمرر الاستعلام المعاملات first، after، last، before. يعيد الخادم مصفوفة من edges مع المؤشرات و pageInfo مع hasNextPage/hasPreviousPage.
يوصى باستخدام التصفح بالمؤشر لواجهات API التي تعمل مع البيانات الديناميكية حيث تُضاف السجلات أو تُحذف بشكل متكرر. أمثلة كلاسيكية: خلاصة الأخبار في شبكة اجتماعية، رسائل الدردشة، سجل المعاملات، تعليقات المنشورات. في كل هذه السيناريوهات، الاتساق وعدم وجود تكرارات مهمان.
هناك سيناريوهات يكون فيها التصفح بـ Offset أكثر ملاءمة: لوحات الإدارة حيث تحتاج التنقل برقم الصفحة؛ البحث مع التصفح حيث قد تتغير النتائج؛ التقارير والتحليلات حيث تحتاج رابطاً ثابتاً للصفحة 5. في هذه الحالات، لا تفوق مزايا المؤشر تعقيد التنفيذ.
لا يدعم التصفح بالمؤشر «القفز» إلى صفحة عشوائية — لا يمكن للمستخدم النقر على «الصفحة 5» والذهاب إليها. هذا قيد معماري: حساب العدد الإجمالي للصفحات يتطلب استعلام COUNT منفصلاً، قد يكون مكلفاً للجداول الكبيرة. في مثل هذه الحالات، نهج هجين: مؤشر للبيانات + count للتصفح.
الأسئلة الشائعة
المؤشر هو معرف فريد للسجل يشير إلى موضع في مجموعة البيانات. يمكن أن يكون بسيطاً (ID السجل) أو مركباً (عدة حقول). يتلقى العميل مؤشر آخر سجل في الصفحة ويمرره في الطلب التالي للحصول على الدفعة التالية.
Cursor pagination غير عرضة للإزاحة عند إضافة سجلات جديدة — كل عنصر يقع بالضبط في صفحة واحدة. كما تحافظ على السرعة على الأحجام الكبيرة باستخدام الفهارس بدلاً من مسح أول n صف. Offset أبسط لكن غير مستقر للبيانات الديناميكية.
نعم، التصفح بالمؤشر غير مرتبط بـ GraphQL. يمكن تنفيذه في أي API REST بتمرير المؤشر كمعامل استعلام ?after=83&limit=20. يجب أن تحتوي الاستجابة على pageInfo مع endCursor و hasNextPage — هذا يسمح للعميل بإدارة التحميل دون معرفة البنية الداخلية للمؤشر.
ID التزايدي التلقائي هو الخيار الأمثل: يتزايد بشكل رتيب، لا يتغير، يُفهرس بكفاءة. UUID v7 (المرتب زمنياً) مناسب أيضاً. الأختام الزمنية قد تعطي تكرارات في نفس الوقت، لذا اجمعه مع ID: (created_at, id) لضمان تفرد المؤشر.
التصفح بالمؤشر لا يوفر العدد الإجمالي للصفحات — هذا قيده. إذا كنت بحاجة لمعلومات العدد الإجمالي، نفذ استعلام COUNT منفصلاً بنفس المرشحات. للجداول الكبيرة، استخدم العد التقريبي عبر EXPLAIN أو إجمالي مخبأ من التحليلات.
الملخص
سنقوم بتطوير تطبيق جوال جاهز
تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.
اقرأ أيضًا