paginate() در Laravel آن‌قدر راحت است که کمتر کسی به هزینه‌اش فکر می‌کند، تا روزی که صفحه‌های عمیق یک لیست یا یک job که کل جدول را صفحه‌به‌صفحه می‌خواند کند می‌شود. در این مقاله pagination مبتنی بر offset و cursor در Laravel را روی یک جدول یک میلیون ردیفی اندازه گرفته‌ام تا معلوم شود اختلاف از کجا می‌آید، چقدر بزرگ است و کدام روش برای کدام صفحه مناسب است.

دو روش، دو SQL متفاوت

paginate() و simplePaginate() از LIMIT ... OFFSET استفاده می‌کنند. برای صفحه ۵۰۰۰ با ۲۰ آیتم در هر صفحه:

SELECT id, total, created_at FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 99980;

دیتابیس نمی‌تواند مستقیم به ردیف ۹۹٬۹۸۱ پرش کند. باید ۹۹٬۹۸۰ ردیف قبلی را به ترتیب پیدا کند، کنار بگذارد و بعد ۲۰ ردیف بعدی را برگرداند. هزینه با عمق صفحه به‌صورت خطی بالا می‌رود.

cursorPaginate() به‌جای شماره صفحه، مقدار ستون‌های مرتب‌سازی در آخرین ردیف صفحه قبل را نگه می‌دارد و صفحه بعد را با یک شرط روی همان ستون‌ها می‌خواند. SQL واقعی را از Laravel 8.83 روی همین دیتابیس گرفته‌ام:

// صفحه اول
select `id`, `total`, `created_at` from `orders`
where `status` = ?
order by `created_at` desc, `id` desc limit 21

// صفحه دوم
select `id`, `total`, `created_at` from `orders`
where `status` = ?
  and (`created_at` < ? or (`created_at` = ? and (`id` < ?)))
order by `created_at` desc, `id` desc limit 21
-- bindings: ["paid","2026-09-30 23:35:21","2026-09-30 23:35:21",336115]

دو نکته در این خروجی مهم است. Laravel یک ردیف بیشتر از اندازه صفحه (limit 21) می‌خواند تا بفهمد صفحه بعدی وجود دارد یا نه، و شرط صفحه دوم دقیقاً همان مقایسه چندستونی است که با یک index مناسب در زمان ثابت اجرا می‌شود. به این روش keyset pagination یا seek method هم می‌گویند.

محیط و روش اندازه‌گیری

همان محیطی که در مقاله انتخاب index ترکیبی MySQL توضیح دادم: MySQL 8.4.11 در Docker روی Apple M2 Pro، buffer pool یک گیگابایتی، جدول orders با یک میلیون ردیف و ۷۰٪ سفارش‌های paid. هر حالت بعد از دو اجرای گرم‌کردن ۱۵ تا ۲۰ بار اجرا شده و میانه گزارش شده است. عدد مطلق روی سخت‌افزار شما فرق می‌کند؛ شکل منحنی مهم است.

نتیجه اول: مرتب‌سازی ساده بر اساس کلید اصلی

offsetLIMIT/OFFSETcursor (id > ?)
۰۰٫۳۰ ms۰٫۴۱ ms
۱۰٬۰۰۰۱٫۲۱ ms۰٫۴۳ ms
۱۰۰٬۰۰۰۹٫۱۷ ms۰٫۴۷ ms
۵۰۰٬۰۰۰۴۳٫۶۹ ms۰٫۴۵ ms
۹۰۰٬۰۰۰۷۸٫۶۵ ms۰٫۴۸ ms

زمان cursor در عمل ثابت ماند، اما زمان offset تقریباً متناسب با عمق رشد کرد. در صفحه اول، offset حتی کمی سریع‌تر بود؛ پس برای لیست‌های کوتاه هیچ دلیلی برای تغییر نیست.

نتیجه دوم: لیست فیلترشده و مرتب‌شده بر اساس تاریخ

لیست‌های واقعی معمولاً فیلتر و مرتب‌سازی غیر از id دارند. برای query بالا، index (status, created_at, id) ساختم و دوباره اندازه گرفتم:

offsetLIMIT/OFFSETcursorPaginate
۰۱٫۵۷ ms۱٫۶۹ ms
۱۰٬۰۰۰۹٫۵۹ ms۱٫۶۵ ms
۱۰۰٬۰۰۰۹۴٫۷۸ ms۱٫۶۵ ms
۵۰۰٬۰۰۰۴۵۷٫۴۵ ms۱٫۷۲ ms

در عمق ۵۰۰ هزار، offset حدود ۲۶۵ برابر کندتر بود. در لیستی که کاربر عادی فقط چند صفحه اول را می‌بیند شاید این مهم نباشد، اما سه جای رایج هست که دقیقاً همین عمق‌ها اتفاق می‌افتد: crawlerهایی که لینک «صفحه بعد» را تا انتها دنبال می‌کنند، اپ موبایل با infinite scroll، و jobهای export یا sync که کل جدول را صفحه‌به‌صفحه می‌خوانند.

هزینه پنهان paginate(): شمارش کل

paginate() برای نمایش «صفحه ۳ از ۲۴۰۰» یک query جداگانه COUNT(*) هم اجرا می‌کند. روی همین داده، شمارش سفارش‌های پرداخت‌شده با index مناسب حدود ۶۱ میلی‌ثانیه طول کشید؛ یعنی در صفحه اول، شمارش ۴۰ برابر خود query داده گران‌تر بود. simplePaginate() و cursorPaginate() این شمارش را انجام نمی‌دهند.

اگر رابط کاربری شما فقط دکمه «قبلی/بعدی» دارد، پرداخت هزینه COUNT(*) در هر درخواست توجیهی ندارد.

ماتریس تصمیم

نیازpaginate()simplePaginate()cursorPaginate()
نمایش تعداد کل و شماره صفحه‌هابلهنهنه
پرش مستقیم به صفحه ۴۷بلهبله (با هزینه offset)نه
صفحه‌های عمیق یا infinite scrollکندکندمناسب
پایداری هنگام درج ردیف جدیدممکن است آیتم تکراری یا جاافتاده ببینیدهمان مشکلپایدار
مرتب‌سازی روی ستون nullable یا عبارت محاسبه‌شدهبلهبلهمحدود
هزینه COUNT در هر درخواستداردنداردندارد

قاعده من در پروژه‌ها ساده است: پنل ادمین با جدول و شماره صفحه، paginate() با فیلترهایی که دامنه را کوچک نگه می‌دارند. API موبایل، فیدها، تاریخچه تراکنش‌ها و هر جایی که infinite scroll دارد، cursorPaginate(). jobهای پردازش دسته‌ای، chunkById() یا lazyById() که همان منطق keyset را برای پردازش داخلی پیاده می‌کنند.

پیاده‌سازی درست cursorPaginate

سه شرط برای اینکه cursor هم درست و هم سریع کار کند:

  1. مرتب‌سازی باید یکتا باشد. اگر فقط بر اساس created_at مرتب کنید و دو سفارش در یک ثانیه ثبت شده باشند، ممکن است یکی بین دو صفحه گم شود. همیشه یک ستون یکتا، معمولاً id، را به‌عنوان آخرین معیار مرتب‌سازی اضافه کنید.
  2. index باید با ترتیب و جهت مرتب‌سازی هم‌خوان باشد. برای where status = ? order by created_at desc, id desc، index (status, created_at, id) لازم است. بدون index، cursor هم مجبور به مرتب‌سازی کل نتیجه می‌شود.
  3. ستون‌های مرتب‌سازی nullable نباشند. مقایسه < با NULL نتیجه ندارد و ردیف‌ها حذف می‌شوند.
// Controller
public function index(Request $request)
{
    $orders = Order::query()
        ->where('status', 'paid')
        ->orderByDesc('created_at')
        ->orderByDesc('id')                 // یکتا کردن مرتب‌سازی
        ->cursorPaginate(20, ['id', 'total', 'created_at']);

    return OrderResource::collection($orders);
}

// Migration
Schema::table('orders', function (Blueprint $table) {
    $table->index(['status', 'created_at', 'id'], 'orders_status_created_id_idx');
});

پاسخ API یک next_cursor رمزگذاری‌شده با base64 برمی‌گرداند که کلاینت در درخواست بعدی به‌عنوان پارامتر cursor می‌فرستد. این مقدار رمزنگاری نشده است و قابل خواندن و دستکاری است. Laravel آن را به شرط SQL با binding تبدیل می‌کند، پس خطر تزریق ندارد، اما نباید روی محرمانه‌بودنش حساب کنید.

مشکلات رایج هنگام مهاجرت از paginate

  • فرانت‌اند به total و last_page وابسته است. پیش از تغییر، قرارداد API را بررسی کنید. اگر لازم است، تغییر را در نسخه جدید API عرضه کنید؛ در مقاله طراحی REST API در Laravel درباره نسخه‌بندی نوشته‌ام.
  • مرتب‌سازی پویا به انتخاب کاربر. اگر کاربر می‌تواند بر اساس هر ستونی مرتب کند، برای هر ترکیب پرکاربرد index لازم دارید. ترکیب‌های مجاز را محدود کنید.
  • مرتب‌سازی روی رابطه یا ستون محاسبه‌شده. cursor روی ستون‌هایی که در نتیجه نهایی نیستند کار نمی‌کند. ستون را denormalize کنید یا برای آن لیست از offset استفاده کنید.
  • SEO برای لیست‌های عمومی. برای صفحه‌های دسته‌بندی که باید ایندکس شوند، URL پایدار با شماره صفحه هنوز ساده‌تر است. cursor برای API و رابط‌های تعاملی مناسب‌تر است.

اگر نمی‌توانید از offset دست بکشید

گاهی رابط کاربری واقعاً پرش به صفحه دلخواه را لازم دارد. دو کار هزینه را کم می‌کند: عمق مجاز را محدود کنید (کمتر کسی به صفحه ۵۰۰۰ نیاز دارد و فیلتر بهتری لازم دارد)، و از «deferred join» استفاده کنید تا offset فقط روی index پیمایش شود و ردیف‌های کامل فقط برای ۲۰ شناسه نهایی خوانده شوند:

SELECT o.id, o.total, o.created_at
FROM orders o
JOIN (
    SELECT id FROM orders
    WHERE status = 'paid'
    ORDER BY created_at DESC, id DESC
    LIMIT 20 OFFSET 99980
) page USING (id)
ORDER BY o.created_at DESC, o.id DESC;

روی همین داده، deferred join زمان offset صد هزار را از ۹۴٫۴ به ۱۳٫۸ میلی‌ثانیه و offset پانصد هزار را از ۴۴۸٫۹ به ۶۶٫۳ میلی‌ثانیه رساند؛ حدود هفت برابر بهتر. علتش این است که در query ساده، MySQL برای هر ردیفی که رد می‌کند هم ردیف کامل را از جدول می‌خواند، اما زیرquery فقط روی index پیمایش می‌کند. با این حال هزینه همچنان با عمق رشد می‌کند و ۶۶ میلی‌ثانیه هنوز ۳۸ برابر cursor است. برای صفحه‌های واقعاً عمیق، cursor همچنان راه‌حل اصلی است.

جمع‌بندی

offset برای صفحه‌های اول و پنل‌هایی که شماره صفحه لازم دارند انتخاب درستی است. وقتی لیست بلند است، از API موبایل خوانده می‌شود یا job کل داده را پیمایش می‌کند، cursorPaginate() با یک index هم‌خوان زمان پاسخ را مستقل از عمق نگه می‌دارد. در آزمایش من، این تفاوت در عمق ۵۰۰ هزار، ۴۵۷ میلی‌ثانیه در برابر ۱٫۷ میلی‌ثانیه بود.

اگر API یا پنل شما با رشد داده کند شده و می‌خواهید پیش از بازنویسی، گلوگاه‌ها اندازه‌گیری و اولویت‌بندی شوند، از طریق خدمت بهینه‌سازی سرعت و ممیزی فنی Laravel با من در تماس باشید.

پرسش‌های متداول

آیا cursorPaginate برای صفحه‌های دسته‌بندی سایت که باید ایندکس شوند مناسب است؟

معمولاً نه. برای صفحه‌های عمومی SEO، URL پایدار با شماره صفحه ساده‌تر است؛ cursor برای API، infinite scroll و پردازش داخلی مناسب‌تر است.

برای پردازش کل جدول در یک job کدام روش بهتر است؟

از chunkById یا lazyById استفاده کنید که مثل cursor بر اساس شناسه پیش می‌روند و با عمق کند نمی‌شوند.