Cursor Pagination — روش صفحهبندی دادهها است که از یک کursor منحصربهفرد برای پیمایش در مجموعه مرتب شده رکوردها استفاده میکند. بر اساس GraphQL Specification (2025)، صفحهبندی کursor یک استاندارد توصیهشده برای APIهایی است که با دادههای پویا کار میکنند. Cursor Pagination معایب اصلی رویکرد Offset را برطرف میکند: ناپایداری هنگام درج و کاهش عملکرد در جابجاییهای بزرگ.
نکات اصلی
Cursor Pagination (صفحهبندی کursor) — روش صفحهبندی که در آن سرور همراه با دادهها یک نشانگر ویژه — کursor — برمیگرداند. کلاینت از این کursor در درخواست بعدی برای دریافت بخش بعدی رکوردها استفاده میکند. کursor شناسه منحصربهفرد آخرین عنصر صفحه فعلی است.
برخلاف Offset-pagination که در آن کلاینت میگوید «صفحه 5 از 20 رکورد را به من بده»، صفحهبندی کursor متفاوت کار میکند: «20 رکورد بعد از رکورد با ID = 83 را به من بده». سرور کوئری با شرط WHERE id > 83 و LIMIT 20 اجرا میکند. این رویکرد تضمین میکند که هر رکورد صرفنظر از درجها دقیقاً در یک صفحه قرار میگیرد.
مفهوم صفحهبندی کursor به لطف مشخصات Relay Connection (GraphQL) گسترش یافته است که cursor-based pagination را به استانداردی برای APIهای مدرن تبدیل کرده است. Relay قالب پاسخ را مشخص میکند: edges (آرایه رکوردها با کursor)، pageInfo (hasNextPage، hasPreviousPage، startCursor، endCursor).
Cursor-pagination یک تکنیک جدید نیست — مدتها قبل از ظهور وب در پایگاههای داده استفاده میشد. در SQL به آن keyset pagination یا seek method میگویند. این روش پس از انتشار مشخصات Relay در سال 2015 در APIها محبوب شد، که قالب کursor را به عنوان رشته base64 شده برای یکپارچگی انتقال از طریق HTTP رسمی کرد.
اصل اساسی صفحهبندی کursor — کوئری از شرط WHERE روی فیلد ایندکس شده برای موقعیتیابی استفاده میکند، نه از جابجایی. برای جهت جلو از WHERE id > last_id، برای جهت عقب از WHERE id < first_id استفاده میشود. ایندکس B-tree امکان یافتن اولین رکورد بعد از کursor را در O(log n) فراهم میکند که زمان پاسخ پایدار را تضمین میکند.
-- دریافت ۲۰ رکورد بعد از نشانگر '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- دریافت ۲۰ رکورد قبل از نشانگر '83' (به عقب)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
کursor میتواند ساده (مقدار ID) یا پیچیده (ترکیبی از چند فیلد) باشد. کursorهای ساده کلید اصلی رکورد هستند، مثلاً id خودافزایششونده یا UUID. کursorهای پیچیده برای مرتبسازی بر اساس فیلدهای غیر یکتا استفاده میشوند، مثلاً (created_at, id)، که در آن id یکتایی را در زمانهای یکسان تضمین میکند.
قالب معمول API — کursor به صورت رشته base64 شده. سرور کursor را دیکد میکند، مقدار را استخراج و کوئری SQL میسازد. کدگذاری Base64 ساختار داخلی کursor را از کلاینت پنهان میکند و امکان تغییر قالب بدون از دست دادن سازگاری معکوس را فراهم میکند. کلاینت کursorها را در فیلد endCursor پاسخ دریافت کرده و به عنوان رشته در درخواست بعدی ارسال میکند.
صفحهبندی کursor از پیمایش دوطرفه پشتیبانی میکند. برای حرکت به جلو (next) از کursor آخرین عنصر صفحه فعلی، برای عقب (previous) از کursor اولین عنصر استفاده میشود. پارامترهای after و before در درخواست جهت را مشخص میکنند: after رکوردهای بعد از کursor را میگیرد، before — قبل از کursor.
انتخاب بین صفحهبندی کursor و Offset-pagination یکی از سوالات معماری کلیدی در طراحی API است. هر روش نقاط قوت و ضعفی دارد. Cursor-pagination در سناریوهای دادههای پویا برنده است، Offset — در سناریوهای پیمایش دلخواه.
| ویژگی | Cursor | Offset |
|---|---|---|
| پایداری هنگام درج | بالا (بدون تکراری) | پایین (جابجایی صفحات) |
| عملکرد در مجموعههای بزرگ | O(log n) — پایدار | O(n) — با رشد کاهش مییابد |
| پیمایش بر اساس شماره صفحه | خیر | بله (page=5) |
| پیچیدگی پیادهسازی | متوسط | کم |
| پشتیبانی در REST | cursor/before/after | page/offset |
| پشتیبانی در GraphQL | استاندارد Relay | توصیه نمیشود |
Offset-pagination اسکن کامل جدول را تا موقعیت OFFSET انجام میدهد. در offset=100000، پایگاه داده 100000 سطر را میخواند و رد میکند، حتی اگر LIMIT برابر 20 باشد. MySQL و PostgreSQL نمیتوانند OFFSET را بهینه کنند — این ویژگی پیادهسازی LIMIT/OFFSET در SQL است. Cursor-pagination از ایندکس B-tree استفاده میکند که موقعیت را در O(log n) پیدا میکند.
مشکل اضافی Offset — «افتادگی» رکوردها هنگام صفحهبندی به عقب. اگر کاربر صفحه 5 را بارگذاری کرده و در این لحظه رکوردهای جدید اضافه شوند، هنگام درخواست صفحه 6، رکورد صفحه 5 را دوباره میبیند یا رکوردهای جدید را از دست میدهد. Cursor-pagination این سناریو را کاملاً حذف میکند: کursor به مکان مشخصی در مجموعه اشاره میکند و درجها موقعیت را تغییر نمیدهند.
پیادهسازی صفحهبندی کursor را در بکاند (Kotlin + Spring) و در کلاینت (Android + Retrofit) بررسی میکنیم. سرور پارامترهای after, before, limit را دریافت کرده و لیست رکوردها را با کursor و pageInfo برمیگرداند. پاسخ معمولی شامل hasNextPage و hasPreviousPage برای مدیریت UI صفحهبندی است.
@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
)
)
}
در سمت کلاینت، صفحهبندی کursor از طریق PagingSource از Paging 3 پیادهسازی میشود، که در آن کلید کursor (Long) است. PagingSource.load LoadParams.key — کursor آخرین رکورد بارگذاری شده را دریافت میکند. LoadResult.Page دادهها و nextKey — کursor برای صفحه بعدی را برمیگرداند. وقتی nextKey = null — صفحهبندی کامل شده است.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource با کلید کursor
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، صفحهبندی کursor از طریق الگوی Connection Relay پیادهسازی میشود. هر نوع دارای Connection (با pageInfo و edges) و Edge (node + cursor) است. درخواست پارامترهای first، after، last، before را ارسال میکند. سرور برمیگرداند آرایه edges با کursor و pageInfo با hasNextPage/hasPreviousPage.
Cursor-pagination برای APIهایی که با دادههای پویا کار میکنند توصیه میشود، جایی که رکوردها اغلب اضافه یا حذف میشوند. مثالهای کلاسیک: فید خبری در شبکه اجتماعی، پیامهای چت، تاریخچه تراکنشها، نظرات پست. در همه این سناریوها، سازگاری و عدم وجود تکراری مهم است.
سناریوهایی وجود دارد که Offset-pagination راحتتر است: پنلهای مدیریتی که نیاز به پیمایش بر اساس شماره صفحه دارند؛ جستجو با صفحهبندی که نتایج ممکن است تغییر کنند؛ گزارشها و تحلیل که نیاز به لینک ثابت به صفحه 5 است. در این موارد مزایای کursor بر پیچیدگی پیادهسازی برتری ندارد.
صفحهبندی کursor از «پرش» به صفحه دلخواه پشتیبانی نمیکند — کاربر نمیتواند روی «صفحه 5» کلیک کرده و به آن برود. این محدودیت معماری است: برای محاسبه تعداد کل صفحات یک کوئری COUNT جداگانه لازم است که ممکن است برای جداول بزرگ هزینهبر باشد. در چنین مواردی رویکرد ترکیبی: cursor برای دادهها + count برای صفحهبندی.
سوالات متداول
کursor — شناسه منحصربهفرد رکورد است که موقعیت را در مجموعه داده نشان میدهد. میتواند ساده (ID رکورد) یا پیچیده (چند فیلد) باشد. کلاینت کursor آخرین رکورد صفحه را دریافت کرده و در درخواست بعدی برای دریافت بخش بعدی ارسال میکند.
Cursor-pagination هنگام اضافه شدن رکوردهای جدید دچار جابجایی نمیشود — هر عنصر دقیقاً در یک صفحه قرار میگیرد. همچنین سرعت را در حجمهای بالا با استفاده از ایندکسها به جای اسکن n سطر اول حفظ میکند. Offset سادهتر است اما برای دادههای پویا ناپایدار است.
بله، صفحهبندی کursor به GraphQL وابسته نیست. میتوان آن را در هر REST API با ارسال کursor به عنوان پارامتر کوئری ?after=83&limit=20 پیادهسازی کرد. پاسخ باید شامل pageInfo با endCursor و hasNextPage باشد — این به کلاینت امکان میدهد بدون دانستن ساختار داخلی کursor بارگذاری را مدیریت کند.
ID خودافزایششونده — انتخاب بهینه: به طور یکنواخت افزایش مییابد، تغییر نمیکند، به طور مؤثر ایندکس میشود. UUID v7 (مرتب شده بر اساس زمان) نیز مناسب است. Timestamp ممکن است در زمان یکسان تکراری ایجاد کند، بنابراین با ID ترکیب میشود: (created_at, id) برای تضمین یکتایی کursor.
صفحهبندی کursor تعداد کل صفحات را ارائه نمیدهد — این محدودیت آن است. اگر اطلاعاتی درباره total نیاز است، یک کوئری COUNT جداگانه با همان فیلترها اجرا کنید. برای جداول بزرگ از شمارش تقریبی از طریق EXPLAIN یا total کش شده از تحلیل استفاده کنید.
خلاصه
ما یک اپلیکیشن موبایل به صورت کلید در دست توسعه خواهیم داد
IT Sectr از سال 2017 برنامههای iOS و Android را برای استارتاپها و کسبوکارها ایجاد میکند. ما به شما مشاوره میدهیم و بهترین راهحل را پیشنهاد خواهیم کرد.
همچنین بخوانید