عنوان:

‫بهبود کارایی صفحه‌بندی اطلاعات در SQL Server با استفاده از Entity Framework Core


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۱۰/۰۲ ۱۰:۱۵
آدرس: www.dntips.ir
در جایی که اپلیکیشن‌های وب و APIها با حجم عظیمی از داده‌ها سروکار دارند، صفحه‌بندی (Pagination) یکی از ابزارهای کلیدی برای مدیریت نمایش نتایج است. تصور کنید کاربران سایت شما بخواهند به صفحه‌های انتهایی یک لیست طولانی دسترسی پیدا کنند. اگر روش صفحه‌بندی شما کارآمد نباشد، این کار می‌تواند به یک تجربه‌ی کند و ناامیدکننده تبدیل شود. مقاله اصلی که در اینجا ارائه شده، به بررسی مشکلات عملکردی صفحه‌بندی در SQL Server می‌پردازد و راه‌حل‌هایی مانند استفاده از OFFSET-FETCH، ROW_NUMBER و صفحه‌بندی مبتنی بر کلید (Keyset Pagination) را پیشنهاد می‌کند. هدف این مقاله، تطبیق این مفاهیم به محیط .NET با تمرکز بر Entity Framework Core (EF Core) است. در ادامه کلاس‌های مدل، کوئری‌های LINQ، و تنظیمات خاص EF Core، مانند ایندکس‌گذاری را پوشش می‌دهیم تا بتوانید این راه‌حل‌ها را مستقیماً در پروژه‌های خود پیاده سازی کنیم.

توضیح روش‌ها و پیاده‌سازی در .NET و EF Core
ابتدا بیایید مدل‌های داده‌ها را تعریف کنیم. فرض کنید با یک جدول ساده‌ی کاربران (Users) کار می‌کنیم که شامل فیلدهایی مانند شناسه کاربر (UserID)، تاریخ ایجاد (CreationDate) و سایر اطلاعات است. این مدل بر اساس مثال‌های مقاله اصلی طراحی شده، اما برای سادگی، فیلدهای غیرضروری را محدود کرده‌ایم.

تعریف کلاس‌های مدل و تنظیمات EF Core
در EF Core، مدل‌ها را با استفاده از کلاس‌های زیر تعریف می‌کنیم. برای پشتیبانی از ایندکس‌ها، از متد OnModelCreating در کلاس DbContext استفاده می‌شود. این کار به SQL Server کمک می‌کند تا کوئری‌ها را سریع‌تر اجرا کند، به ویژه در صفحه‌بندی که مرتب‌سازی (Sorting) نقش کلیدی دارد.
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

public class User
{
    [Key]
    public int UserID { get; set; }
    public DateTime CreationDate { get; set; }
    public string UserName { get; set; } // برای مثال‌های مرتب‌سازی اضافی
    // سایر فیلدها...
}

public class UserPaginationRowNumber // برای روش پیش‌محاسبه‌شده
{
    [Key]
    public int UserID { get; set; }
    public int RowNumberByCreationDate { get; set; } // شماره ردیف بر اساس تاریخ ایجاد
    // می‌توانید ستون‌های دیگری برای مرتب‌سازی‌های مختلف اضافه کنید، مانند RowNumberByUserName
}

public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; }
    public DbSet<UserPaginationRowNumber> UserPaginationRowNumbers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ایندکس برای مرتب‌سازی بر اساس تاریخ ایجاد و شناسه (برای شکستن تساوی‌ها)
        modelBuilder.Entity<User>()
            .HasIndex(u => new { u.CreationDate, u.UserID })
            .HasDatabaseName("IX_CreationDate");

        // ایندکس منحصربه‌فرد برای جدول پیش‌محاسبه‌شده
        modelBuilder.Entity<UserPaginationRowNumber>()
            .HasIndex(r => r.RowNumberByCreationDate)
            .IsUnique()
            .HasDatabaseName("IX_RowNumberByCreationDate");
    }
}
این تنظیمات اطمینان می‌دهند که SQL Server از ایندکس‌ها برای کوئری‌های مرتب‌سازی استفاده کند.

نکته: در EF Core، همیشه از AsNoTracking() برای کوئری‌های فقط‌خواندنی استفاده کنید تا عملکرد بهبود یابد، زیرا ردیابی تغییرات (Change Tracking) غیرضروری است.

روش‌های صفحه‌بندی و کوئری‌های LINQ معادل
حالا به سراغ روش‌های اصلی می‌رویم. هر روش را با توضیح عملکرد، مزایا/معایب، و کد LINQ توصیف می‌کنم. فرض کنید می‌خواهیم ۱۰ رکورد از صفحه N را بگیریم (pageSize = 10, pageNumber = N).
1. صفحه‌بندی ساده با OFFSET-FETCH (روش پایه)
این روش معادل Skip و Take در LINQ است و مستقیماً به OFFSET/FETCH در SQL ترجمه می‌شود. اما همان‌طور که مقاله اصلی اشاره می‌کند، با افزایش شماره صفحه، عملکرد افت می‌کند زیرا تمام ردیف‌های قبلی اسکن می‌شوند.
var pageSize = 10;
var pageNumber = 30000; // مثلاً صفحه آخر

var query = context.Users
    .OrderBy(u => u.CreationDate)
    .ThenBy(u => u.UserID)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .AsNoTracking(); // بهینه‌سازی اضافی

var results = await query.ToListAsync(); // استفاده از async برای عملکرد بهتر
عملکرد و تحلیل: این روش O(n) است، یعنی زمان اجرا، با شماره صفحه، افزایش می‌یابد. برای تعداد صفحات پایین، خوب است، اما برای تعداد صفحات بالا (مثل ۳۰ هزار) ممکن است ثانیه‌ها طول بکشد. مزیت: ساده و پشتیبانی از دسترسی تصادفی. معایب: مصرف بالای CPU و I/O.


نکته: اگر فیلترهایی مانند جستجو را اضافه می‌کنید، آن را زودتر اعمال کنید تا حجم داده کاهش یابد: context.Users.Where(u => u.UserName.Contains("search")).

2. بهبود با ایندکس‌گذاری
با ایندکس تعریف‌شده در OnModelCreating، EF Core از آن برای مرتب‌سازی استفاده می‌کند. کد همان روش قبل است، اما عملکرد برای صفحات پایین بهتر می‌شود. مقاله اصلی توضیح می‌دهد که بدون ایندکس، اسکن کامل جدول رخ می‌دهد؛ اما اینجا ایندکس تعریف شده، اسکن ایندکس (Index Scan) را فعال می‌کند.
تحلیل بیشتر: اگر مرتب‌سازی‌های متنوعی دارید (مثلا بر اساس نام کاربر)، ایندکس‌های جداگانه‌ای را اضافه کنید. اما مراقب باشید: ایندکس‌های زیاد، فضای ذخیره‌سازی را افزایش می‌دهند و عملیات نوشتاری را کند می‌کنند. در EF Core، از HasIndex برای هر ترکیب استفاده کنید.
modelBuilder.Entity<User>()
            .HasIndex(u => new { u.CreationDate, u.UserID })
            .HasDatabaseName("IX_CreationDate");



3. فیلتر زودرس و جوین دیررس (Filter Early, Join Late)
این روش کلیدها (مثل UserID) را ابتدا استخراج می‌کند، سپس داده‌ی کامل را می‌گیرد. این کار، اسکن کامل را کاهش می‌دهد.
var pageSize = 10;
var skip = (pageNumber - 1) * pageSize;

var keyQuery = context.Users
    .OrderBy(u => u.CreationDate)
    .ThenBy(u => u.UserID)
    .Select(u => u.UserID)
    .Skip(skip)
    .Take(pageSize)
    .AsNoTracking();

var keys = await keyQuery.ToListAsync();

var results = await context.Users
    .Where(u => keys.Contains(u.UserID))
    .AsNoTracking()
    .ToListAsync();
عملکرد: نزدیک به O(1)، زیرا فقط کلیدها اسکن می‌شوند (با ایندکس باریک). مقاله اصلی تأکید می‌کند که این روش، مرتب‌سازی را حذف می‌کند. مزیت: کارآمد برای صفحات بالا. معایب: هنوز از Skip استفاده می‌کند، پس کاملاً ثابت نیست. نکته‌ی اضافی: اگر لیست کلیدها طولانی باشد، از Batch در EF Core برای جلوگیری از محدودیت‌های SQL استفاده کنید.



4. صفحه‌بندی مبتنی بر کلید (Keyset Pagination یا Watermark)
این روش از آخرین مقدار دیده‌شده (مثل تاریخ ایجاد + شناسه) برای ادامه استفاده می‌کند. مناسب برای ناوبری "بعدی/قبلی" بدون دسترسی تصادفی.
var lastCreationDate = DateTime.Parse("2022-07-28"); // از صفحه قبلی
var lastUserID = 299990; // از صفحه قبلی

var keyQuery = context.Users
    .Where(u => u.CreationDate > lastCreationDate ||
                (u.CreationDate == lastCreationDate && u.UserID > lastUserID))
    .OrderBy(u => u.CreationDate)
    .ThenBy(u => u.UserID)
    .Select(u => u.UserID)
    .Take(pageSize)
    .AsNoTracking();

var keys = await keyQuery.ToListAsync();

var results = await context.Users
    .Where(u => keys.Contains(u.UserID))
    .AsNoTracking()
    .ToListAsync();
عملکرد: کاملاً O(1) با استفاده از ایندکس seek. مقاله اصلی توضیح می‌دهد که این روش، اسکن را به محدوده‌ی کوچکی، کاهش می‌دهد. مزیت: ثابت و سریع. معایب: نیاز به ردیابی مقادیر قبلی در اپلیکیشن، و عدم پشتیبانی از فیلترها. نکته: در APIها، این مقادیر را در URL یا توکن "continuation" ذخیره کنید.


5. شماره ردیف‌های پیش‌محاسبه‌شده (Precomputed Row Numbers)
بهترین روش برای عملکرد ثابت، با جدول جداگانه. ابتدا جدول را پر کنید (مثلاً با یک job دوره‌ای):
// پر کردن جدول (در یک متد جداگانه، مثلاً با Quartz.NET برای زمان‌بندی)
var users = await context.Users
    .OrderBy(u => u.CreationDate)
    .ThenBy(u => u.UserID)
    .Select((u, index) => new UserPaginationRowNumber { UserID = u.UserID, RowNumberByCreationDate = index + 1 })
    .ToListAsync();

context.UserPaginationRowNumbers.Truncate(); // یا DeleteAll
context.UserPaginationRowNumbers.AddRange(users);
await context.SaveChangesAsync();

// ایندکس منحصربه‌فرد برای جدول پیش‌محاسبه‌شده
modelBuilder.Entity<UserPaginationRowNumber>()
            .HasIndex(r => r.RowNumberByCreationDate)
            .IsUnique()
            .HasDatabaseName("IX_RowNumberByCreationDate");

سپس صفحه‌بندی:
var startRow = (pageNumber - 1) * pageSize + 1;
var endRow = startRow + pageSize - 1;

var keyQuery = context.UserPaginationRowNumbers
    .Where(r => r.RowNumberByCreationDate >= startRow && r.RowNumberByCreationDate <= endRow)
    .Select(r => r.UserID)
    .AsNoTracking();

var keys = await keyQuery.ToListAsync();

var results = await context.Users
    .Where(u => keys.Contains(u.UserID))
    .AsNoTracking()
    .ToListAsync();
عملکرد: O(1) کامل، بدون وابستگی به ایندکس اصلی. مقاله اصلی اشاره می‌کند که این روش، داده را ممکن است کمی قدیمی نگه دارد؛ برای حل، هر ۵ دقیقه refresh کنید. مزیت: پشتیبانی از مرتب‌سازی‌های مختلف با ستون‌های اضافی. معایب: فضای ذخیره‌سازی اضافی و نیاز به نگهداری. نکته اضافی: از Partition Switching در SQL برای refresh بدون downtime استفاده کنید (با EF Core، Stored Procedure بنویسید).


نتیجه‌گیری
صفحه‌بندی کارآمد در SQL Server با .NET و EF Core نه تنها عملکرد اپلیکیشن شما را بهبود می‌بخشد، بلکه تجربه‌ی کاربری را نیز ارتقا می‌دهد. روش‌های ساده مانند OFFSET-FETCH برای شروع خوب هستند، اما برای مقیاس‌پذیری، به سمت keyset یا پیش‌محاسبه‌شده حرکت کنید. انتخاب روش، بستگی به نیازهای شما دارد: اگر دسترسی تصادفی مهم است، پیش‌محاسبه‌شده ایده‌آل است؛ در غیر این صورت، keyset ساده‌تر است.