بحث Pagination در ASP.NET Core: Offset در برابر Cursor 📄🆚🚀
صفحه بندی برای مدیریت کارآمد مجموعه دادههای بزرگ، حیاتی است. با اینکه offset pagination به طور گسترده استفاده میشود و کار را راه میاندازد، cursor-based pagination مزایای جالبی برای سناریوهای خاص ارائه میدهد.
این روش به ویژه برای فیدهای real-time، اینترفیسهای infinite scroll، و APIهایی که عملکرد در مقیاس بالا در آنها اهمیت دارد، ارزشمند است - مانند تایملاینهای شبکههای اجتماعی، لاگهای فعالیت، یا جریانهای رویداد که کاربران به طور مکرر در صفحات مجموعه دادههای بزرگ جابجا میشوند.
بیایید هر دو رویکرد را با استفاده از یک جدول ساده UserNotes بررسی کرده و ببینیم چگونه با یک میلیون رکورد عمل میکنند.
اسکیمای دیتابیس 💾
من یک جدول ساده برای نمایش تکنیکهای pagination ایجاد کردم. این جدول با ۱,۰۰۰,۰۰۰ رکورد برای اهداف تست پر شده است، که باید برای نشان دادن تفاوت عملکرد بین offset و cursor pagination کافی باشد.
CREATE TABLE user_notes (
id uuid NOT NULL,
user_id uuid NOT NULL,
note character varying(500),
date date NOT NULL,
CONSTRAINT pk_user_notes PRIMARY KEY (id)
);
و این هم کلاس #C که انتیتی UserNote را نشان میدهد:
public class UserNote
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? Note { get; set; }
public DateOnly Date { get; set; }
}
Offset Pagination: رویکرد سنتی 🐢
ما در Offset pagination از عملیات Skip و Take استفاده میکنیم. ما تعداد معینی از ردیفها را رد میکنیم (skip) و تعداد ثابتی از ردیفها را برمیداریم (take). اینها معمولاً به OFFSET و LIMIT در کوئریهای SQL ترجمه میشوند.
app.MapGet("/offset", async (
AppDbContext dbContext,
int page = 1,
int pageSize = 10,
CancellationToken cancellationToken = default) =>
{
// ... (بررسی ورودیها) ...
var query = dbContext.UserNotes
.OrderByDescending(x => x.Date)
.ThenByDescending(x => x.Id);
// Offset pagination معمولاً تعداد کل آیتمها را میشمارد
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
// رد کردن و برداشتن تعداد مورد نیاز از آیتمها
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return Results.Ok(new { /* ... نتایج ... */ });
});SQL تولید شده:
-- این کوئری اول ارسال میشود
SELECT count(*)::int FROM user_notes AS u;
-- و سپس کوئری اصلی دادهها
SELECT u.id, u.date, u.note, u.user_id
FROM user_notes AS u
ORDER BY u.date DESC, u.id DESC
LIMIT @pageSize OFFSET @offset;
محدودیتهای Offset Pagination: ❌
• عملکرد با افزایش آفست کاهش مییابد زیرا دیتابیس باید تمام ردیفهای قبل از آفست را اسکن و دور بریزد.
• ریسک از دست دادن یا تکرار آیتمها وقتی دادهها بین صفحات تغییر میکنند.
• نتایج متناقض با آپدیتهای همزمان.
Cursor-Based Pagination: یک رویکرد سریعتر 🚀
و حالا Cursor pagination از یک نقطه مرجع (cursor) برای واکشی مجموعه بعدی نتایج استفاده میکند. این نقطه مرجع معمولاً یک شناسه منحصر به فرد یا ترکیبی از فیلدهاست که ترتیب مرتبسازی را تعریف میکند.
app.MapGet("/cursor", async (
AppDbContext dbContext,
DateOnly? date = null,
Guid? lastId = null,
int limit = 10,
CancellationToken cancellationToken = default) =>
{
// ... (بررسی ورودیها) ...
var query = dbContext.UserNotes.AsQueryable();
if (date != null && lastId != null)
{
// از cursor برای واکشی مجموعه بعدی نتایج استفاده میکنیم
query = query.Where(x => x.Date < date || (x.Date == date && x.Id <= lastId));
}
// آیتمها را واکشی کرده و مشخص میکنیم آیا آیتمهای بیشتری وجود دارد یا نه
var items = await query
.OrderByDescending(x => x.Date)
.ThenByDescending(x => x.Id)
.Take(limit + 1)
.ToListAsync(cancellationToken);
// ... (منطق برای استخراج cursor بعدی) ...
return Results.Ok(new { /* ... نتایج ... */ });
});