چالش بهینهسازی کارایی در Entity Framework Core: کاهش زمان پاسخ از ۴۸۲۰ به ۲۰ میلیثانیه
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۲۳ ۰۸:۵۵
آدرس: www.dntips.ir
«پایگاهداده شما کند نیست؛ نگاشتکننده رابطهای-شیء (ORM) شما در حال برقراری مکالمهای با آن است که هیچکس درخواست نکرده بود.»
چکیده: ابزار نگاشت شیء به رابطه مایکروسافت (Entity Framework Core)، یکی از محبوبترین بسترهای دسترسی به داده در بومسازگان داتنت به شمار میرود. با این وجود، عدم درک ساختار ترجمه عبارتهای LINQ به عبارات SQL، مکانیسمهای رهگیری تغییرات (Change Tracking) و نحوه بارگذاری مجموعهها، میتواند به افول شدید کارایی در محیطهای عملیاتی منجر شود. در این مقاله، به بررسی عملی و مرحلهبهمرحله بهینهسازی یک کوئری واقعی میپردازیم که زمان اجرای آن به کمک ابزار سنجش استاندارد BenchmarkDotNet بر بستر پایگاهداده SQL Server، از ۴,۸۲۰ میلیثانیه به ۲۰ میلیثانیه (حدود ۲۴۰ برابر بهبود) کاهش یافته است. این مقاله تکنیکهای متعددی نظیر حذف سربار N+1، بهرهگیری از سناریوهای No-Tracking، نگاشت دقیق مشخصهها (Projections)، کوئریهای از پیش کامپایلشده (Compiled Queries)، تفکیک کوئریها (Split Queries) و نگاشت پایش شاخصها را تحلیل کرده و در پایان یک درخت تصمیمگیری جامع برای معماران و توسعهدهندگان داتنت ارائه میدهد.
async/await یا حتی تعویض کامل فریمورک با ابزارهایی مانند Dapper میروند.// اجرای فاجعهبار: زمان تقریبی ۴۸۲۰ میلیثانیه
public async Task<List<AuthorDto>> GetAuthorsWithBooksAsync()
{
var authors = await _context.Authors.ToListAsync();
foreach (var author in authors)
{
var books = await _context.Books
.Where(b => b.AuthorId == author.Id)
.ToListAsync();
author.Books = books;
}
return authors.Select(a => new AuthorDto
{
Id = a.Id,
Name = a.Name,
Books = a.Books.Select(b => new BookDto
{
Id = b.Id,
Title = b.Title,
PublishedDate = b.PublishedDate
}).ToList()
}).ToList();
}foreach جهت واکشی فرزندان.Authors و Books وارد حافظه رم میشوند، صرفاً برای اینکه بعداً به DTO تبدیل گردند.Include، موتور تولید عبارات EF Core کوئری را به یک عبارت ساختیافته حاوی LEFT JOIN ترجمه کرده و رابطه را در پایگاهداده با یکبار رفتوبرگشت حل میکند.// تبدیل ۱۰۱ رفتوبرگشت به یک رفتوبرگشت شبکه
var authors = await _context.Authors
.Include(a => a.Books)
.ToListAsync();AsNoTrackingChangeTracker ثبت میکند تا در صورت تغییر مشخصهها، در هنگام فراخوانی SaveChanges متدهای UPDATE صادر شوند. در سناریوهای خواندنی، این مکانیزم هدررفت خالص پردازنده و رم است.var authors = await _context.Authors
.Include(a => a.Books)
.AsNoTracking()
.ToListAsync();AsNoTracking تخصیص حافظه را بیش از ۳۵ تا ۵۰ درصد کاهش داده و زمان تاخیر را تا نصف پایین میآورد.نکته معماری: در سرویسهای مبتنی بر معماری تمیز (Clean Architecture) یا CQRS، پیشنهاد میشود در کلاس کانتکست رفتار پیشفرض را روی No-Tracking تنظیم کنید و صرفاً در مسیرهای نیازمند نوشتن، از AsTracking() بهره ببرید:protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}Select مستقیماً به کلاسهای حامل داده (DTO) تصویرسازی (Project) انجام میدهید:SELECT * به SELECT Id, Name, ... محدود میشود.var authors = await _context.Authors
.Select(a => new AuthorSummaryDto
{
Id = a.Id,
Name = a.Name,
BookCount = a.Books.Count,
LatestBook = a.Books
.OrderByDescending(b => b.PublishedDate)
.Select(b => b.Title)
.FirstOrDefault()
})
.ToListAsync();AsSplitQueryInclude روی چند زیرمجموعه (Collections) بهطور همزمان، منجر به بروز پدیده انفجار دکارتی (Cartesian Explosion) میشود. اگر نویسندهای ۵۰ کتاب و ۱۰ جایزه داشته باشد، پیوند JOIN سطرهای تکراری تولید کرده و ۵۰۰ سطر به اپلیکیشن بازمیگرداند (بهجای ۶۰ سطر واقعی).AsSplitQuery() که از EF Core 5 معرفی شد، برای هر مجموعه یک عبارت SQL مجزا صادر کرده و نتایج را در حافظه ترکیب میکند:var authors = await _context.Authors
.Include(a => a.Books)
.Include(a => a.Awards)
.AsSplitQuery()
.AsNoTracking()
.ToListAsync();ON یا WHERE مربوط به JOIN در سطح دیتابیس اعمال میشود:var authors = await _context.Authors
.Include(a => a.Books.Where(b => b.IsPublished))
.AsNoTracking()
.ToListAsync();EF.CompileAsyncQuery ساختار را به یک Delegate استاتیک تبدیل میکند:private static readonly Func<AppDbContext, int, Task<AuthorSummaryDto?>> GetAuthorByIdCompiled =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Authors
.AsNoTracking()
.Where(a => a.Id == id)
.Select(a => new AuthorSummaryDto
{
Id = a.Id,
Name = a.Name
})
.FirstOrDefault());
public async Task<AuthorSummaryDto?> GetAuthorByIdAsync(int id)
{
return await GetAuthorByIdCompiled(_context, id);
}JOIN) و مرتبسازی قرار دارند، باید دارای شاخصهای پوششدهنده باشند:protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ایندکس کلید خارجی جهت تسریع در عملیات JOIN
modelBuilder.Entity<Book>()
.HasIndex(b => b.AuthorId)
.HasDatabaseName("IX_Books_AuthorId");
// ایندکس ترکیبی بر اساس نرخ انتخابپذیری (Selectivity)
modelBuilder.Entity<Book>()
.HasIndex(b => new { b.IsPublished, b.PublishedDate })
.HasDatabaseName("IX_Books_Published_Filter");
modelBuilder.Entity<Author>()
.HasIndex(a => a.Name)
.HasDatabaseName("IX_Authors_Name");
}اصل چیدمان ایندکسهای ترکیبی (Composite Indexes): همواره ستونی را که انتخابپذیری (Selectivity) بالاتری دارد و بخش عمده سطرها را در گام اول حذف میکند، در سمت چپ ایندکس قرار دهید.
Skip(x).Take(y) در رکوردهای میلیونی باعث افت کارایی در صفحات انتهایی میشود، زیرا دیتابیس تمام سطرهای قبلی را پویش و سپس دور میریزد (Offset Penalty). بهرهگیری از Keyset Pagination (مانند Where(a => a.Id > lastSeenId).Take(pageSize)) زمان واکشی را بدون توجه به شماره صفحه، ثابت نگه میدارد.Include عمیق داریم، استفاده از روش واکشی با عملگر Contains یا ایجاد یک Dictionary بر بستر حافظه، رفتوآمدهای شبکه را بهطور چشمگیری تجمیع میکند.ExecuteUpdateAsync و ExecuteDeleteAsync)SaveChanges نیست. با متدهای ExecuteUpdateAsync و ExecuteDeleteAsync، دستور مستقیم UPDATE/DELETE در سطح پایگاهداده اجرا میشود که تا چندین برابر سرعت عملیات انبوه را افزایش میدهد.public async Task<List<AuthorSummaryDto>> GetAuthorsWithBooksOptimizedAsync(
int pageSize,
DateTime? lastSeenDate,
CancellationToken ct = default)
{
return await _context.Authors
.AsNoTracking() // گام ۲: حذف رهگیری تغییرات
.Where(a => !lastSeenDate.HasValue || a.CreatedAt > lastSeenDate)
.OrderByDescending(a => a.CreatedAt)
.Select(a => new AuthorSummaryDto // گام ۳: گزینش محدود و بهینه ستونها
{
Id = a.Id,
Name = a.Name,
BookCount = a.Books.Count(b => b.IsPublished), // محاسبه بدون لود کل ردیفها
LatestBookTitle = a.Books
.Where(b => b.IsPublished)
.OrderByDescending(b => b.PublishedDate)
.Select(b => b.Title)
.FirstOrDefault(),
TotalPageCount = a.Books
.Where(b => b.IsPublished)
.Sum(b => (int?)b.PageCount) ?? 0
})
.Take(pageSize) // کنترل حجم بسته داده در SQL
.ToListAsync(ct);
}| حالت اجرا | زمان پاسخ میانگین | نسبت تغییر (Speedup) | تخصیص حافظه (Allocated RAM) |
| کوئری اولیه (N+1 + Tracking) | ۴,۸۲۰ میلیثانیه | ۱.۰x (پایه) | ~۴۸ مگابایت |
اصلاح با Include (یک کوئری) | ۳۱۰ میلیثانیه | ۱۵.۵x | ~۱۴ مگابایت |
اعمال AsNoTracking | ۱۹۰ میلیثانیه | ۲۵.۳x | ~۸.۵ مگابایت |
گزینش انتخابی (Select) | ۴۸ میلیثانیه | ۱۰۰.۴x | ~۱.۲ مگابایت |
کوئری کامپایلشده (CompileQuery) | ۲۲ میلیثانیه | ۲۱۹.۰x | ~۴۵۰ کیلوبایت |
| اعمال ایندکسها و صفحهبندی نهایی | ۲۰ میلیثانیه | ۲۴۱.۰x | ~۲۸۰ کیلوبایت |