Entity Framework Core و کارایی
کتابخانه EF Core کد LINQ را به SQL ترجمه میکند. اگر ندانی چه SQL ای ساخته میشود، به راحتی دهها کوئری اضافه میفرستی یا کل جدول را به حافظه میآوری. یاد بگیر کوئری کجا اجرا میشود، Tracking چه هزینهای دارد و Migration را چطور بیخطر اجرا کنی.
نویسنده: bezzad
مشکل: صفحه سفارشها کند است
در فروشگاه اینترنتی ما، پنل مدیریت یک صفحه دارد: «۵۰ سفارش آخر، با نام مشتری». جدولها کوچکاند، ولی صفحه حدود ۲ ثانیه طول میکشد. کد ساده و تمیز به نظر میرسد:
var orders = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.ToListAsync(ct);
return orders.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name));
مشکل در کد C# دیده نمیشود. مشکل در SQL ای است که EF Core میسازد. برای همین اولین مهارت در EF Core این است: بدانی هر خط کد چه کوئریای به دیتابیس میفرستد.
کتابخانه EF Core چطور کار میکند؟
کلاس DbContext در واقع سه کار انجام میدهد:
- ترجمه. کد LINQ را به SQL تبدیل میکند.
- ساختن شیء. ردیفهای جواب را به شیءهای C# تبدیل میکند.
- ردیابی تغییر (Change Tracking). از هر شیئی که خوانده، یک کپی نگه میدارد. موقع ذخیره، شیء را با کپی مقایسه میکند و فقط ستونهای عوضشده را UPDATE میکند.
یک نکته مهم از همین شکل: تا وقتی نوع کوئری IQueryable است، کار در دیتابیس انجام میشود. وقتی با ToListAsync یا AsEnumerable آن را به حافظه بیاوری، هر Where و Select بعدی در حافظه برنامه اجرا میشود.
// Bad: every order comes into memory, then C# filters them
var bigOrders = db.Orders.AsEnumerable().Where(o => o.Total > 1_000_000).ToList();
// Good: the filter becomes a WHERE clause in SQL
var bigOrders2 = await db.Orders.Where(o => o.Total > 1_000_000).ToListAsync(ct);
برای دیدن SQL واقعی، لاگ EF Core را روشن کن. در محیط توسعه این کافی است:
builder.Services.AddDbContext<ShopDb>(o => o
.UseSqlServer(builder.Configuration.GetConnectionString("Shop"))
.LogTo(Console.WriteLine, LogLevel.Information));
مشکل N+1
برگردیم به صفحه سفارشها. در این پروژه Lazy Loading روشن است. یعنی EF Core مشتری هر سفارش را همان لحظهای میخواند که کد برای اولین بار به آن دست میزند. قدم به قدم:
- متد ToListAsync یک کوئری میفرستد: ۵۰ سفارش، بدون مشتری.
- بعد Select در حافظه اجرا میشود و برای هر سفارش نام مشتری را میخواهد.
- هر بار، Lazy Loading یک کوئری جدا برای همان یک مشتری میفرستد.
- پس ۱ کوئری برای سفارشها و ۵۰ کوئری برای مشتریها. به این N+1 میگویند.
- هر کوئری یک رفت و برگشت شبکه دارد. ۵۱ رفت و برگشت پشت سر هم، صفحه را کند میکند.
همین مشکل بدون Lazy Loading هم پیش میآید. کافی است برنامهنویس داخل یک حلقه foreach برای هر سفارش یک کوئری بزند.
راه حل: Include یا Projection
با Include، مشتری با یک JOIN در همان کوئری اول میآید. ولی راه بهتر برای صفحهای که فقط نمایش میدهد، Projection است. یعنی Select را قبل از ToListAsync بنویسی:
public sealed record OrderRow(int Id, decimal Total, string CustomerName);
var rows = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name))
.ToListAsync(ct);
چرا Projection بهتر است؟
- خود EF Core از روی Select، یک JOIN میسازد.
- فقط سه ستون لازم خوانده میشود، نه همه ستونهای دو جدول.
- شیء OrderRow یک Entity نیست. پس Change Tracker و Lazy Loading اصلاً درگیر نمیشوند.
دکمهها را بزن و تعداد کوئریها را ببین:
صفحه، ۲۰ سفارش آخر را با نام مشتری نشان میدهد. فرض میکنیم هر رفت و برگشت به دیتابیس ۵ میلیثانیه است.
ردیابی تغییر و AsNoTracking
وقتی میخواهی چیزی را تغییر بدهی، Tracking کمک بزرگی است. شیء را بخوان، عوضش کن و ذخیره کن:
var order = await db.Orders.FirstAsync(o => o.Id == orderId, ct);
order.ShippingAddress = newAddress;
await db.SaveChangesAsync(ct); // UPDATE only the changed column
ولی وقتی فقط میخوانی، این کپیها هزینه بیفایده است. برای هر ردیف، حافظه و زمان مقایسه میگیرد. پس برای کوئری فقطخواندنی، متد AsNoTracking را اضافه کن، یا مستقیم Projection بنویس.
یک نکته دیگر: متد SaveChangesAsync همه تغییرها را در یک تراکنش ذخیره میکند. پس برای یک ذخیره ساده، خودت تراکنش باز نکن. DbContext خودش یک Unit of Work است.
تغییر گروهی بدون خواندن
اگر بخواهی همه سفارشهای پرداختنشده قدیمی را «منقضی» کنی، لازم نیست اول همه را بخوانی. متد ExecuteUpdateAsync مستقیم یک دستور UPDATE میفرستد:
await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Expired), ct);
این دستور از Change Tracker رد نمیشود. یعنی اگر یکی از همین سفارشها قبلاً در همین DbContext خوانده شده باشد، شیء داخل حافظه عوض نمیشود.
داده خیلی زیاد: تکهتکه بخوان
مدیر فروشگاه میخواهد از ۲ میلیون تراکنش خروجی بگیرد. اگر همه را با ToListAsync بخوانی، ۲ میلیون شیء همزمان در حافظه است و Pod ممکن است با کمبود حافظه بمیرد. به جای آن، ردیفها را یکییکی بخوان و بنویس:
await foreach (var tx in db.Transactions.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
await writer.WriteRowAsync(tx, ct);
}
در این حالت هر ردیف خوانده میشود، نوشته میشود و از حافظه میرود. AsNoTracking هم اینجا لازم است. وگرنه Change Tracker همه شیءها را نگه میدارد و فایده تکهتکه خواندن از بین میرود.
چرخه عمر DbContext
- هر درخواست، یک DbContext. متد AddDbContext آن را Scoped ثبت میکند. این درست است.
- یک DbContext برای چند Thread امن نیست. اگر دو کوئری را روی یک DbContext با Task.WhenAll همزمان اجرا کنی، EF Core خطای InvalidOperationException میدهد. برای کار موازی، از هر کار یک DbContext جدا بساز (مثلاً با IDbContextFactory).
- کلاس DbContext را Singleton نکن. ردیاب تغییرهای آن مدام بزرگتر میشود و درخواستهای مختلف داده هم را میبینند.
تغییر ساختار دیتابیس بدون Downtime
حالا میخواهیم ستون FullName مشتری را به دو ستون FirstName و LastName تبدیل کنیم. سرویس ۴ Pod دارد و Deploy به صورت Rolling است. یعنی چند دقیقه کد قدیم و جدید با هم کار میکنند.
اگر یک Migration بنویسی که ستونهای جدید را بسازد و ستون قدیمی را همان لحظه حذف کند:
- سه Pod قدیمی هنوز ستون FullName را میخوانند و خطای «ستون وجود ندارد» میگیرند.
- اگر نسخه جدید باگ داشته باشد، Rollback هم ممکن نیست. چون کد قدیم به ستونی نیاز دارد که دیگر نیست.
راه حل الگوی Expand and Contract است. اول چیز جدید را اضافه کن، مدتی با هر دو کار کن، و در آخر قدیمی را حذف کن.
چند قانون برای Migration در Production:
- مایگریشن را جدا از بالا آمدن برنامه اجرا کن. اگر هر ۴ Pod موقع شروع متد Migrate را صدا بزنند، ممکن است همزمان اجرا شوند. بهتر است یک قدم جدا در Pipeline داشته باشی. با دستور ساخت اسکریپت SQL یا Migration Bundle این کار ساده است:
dotnet ef migrations script --idempotent -o migrate.sql
dotnet ef migrations bundle -o efbundle
- پر کردن داده قدیمی را دستهدسته انجام بده. یک UPDATE روی میلیونها ردیف، جدول را مدت طولانی قفل میکند.
- اسکریپت Migration را قبل از اجرا بخوان. گاهی EF Core برای یک تغییر نام، ستون را حذف و دوباره میسازد و داده از بین میرود.
آیا Repository روی EF Core لازم است؟
کلاس DbContext خودش Unit of Work است و هر DbSet شبیه یک Repository است. پس یک Repository عمومی (با Add و Update و GetAll برای همه جدولها) معمولاً فقط یک لایه اضافه است. بدتر اینکه گاهی Include و Projection را پنهان میکند و دوباره به N+1 میرسیم.
ولی یک Repository مخصوص، مثلاً برای Aggregate سفارش، جاهایی مفید است. وقتی کوئریهای تکراری باید یک جا باشند، یا وقتی میخواهی یک نقطه برای اضافه کردن کش (با الگوی Decorator) داشته باشی.
قانونهای مهم
- همیشه SQL ساختهشده را ببین. با لاگ یا با متد ToQueryString.
- برای نمایش، Projection بنویس. فقط ستونهای لازم را بخوان.
- برای خواندن، AsNoTracking. Tracking فقط وقتی لازم است که میخواهی ذخیره کنی.
- فیلتر را قبل از ToListAsync بنویس. تا در SQL اجرا شود، نه در حافظه.
- مراقب Lazy Loading باش. خیلی از تیمها آن را خاموش نگه میدارند تا N+1 پنهان نماند.
- مایگریشن سازگار با نسخه قبلی. هیچ وقت ستونی را در همان Deploy حذف نکن که کد قدیم هنوز میخواندش.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| خواندن رابطه داخل حلقه یا با Lazy Loading | مشکل N+1 و صدها کوئری | Projection یا Include |
| فراخوانی AsEnumerable یا ToList قبل از Where | کل جدول به حافظه میآید | فیلتر قبل از اجرای کوئری |
| Tracking برای صفحه فقطخواندنی | حافظه و زمان بیشتر | AsNoTracking یا Projection |
| خواندن ۲ میلیون ردیف با ToListAsync | کمبود حافظه و مرگ Pod | خواندن تکهتکه با AsAsyncEnumerable |
| یک DbContext برای چند کار موازی | خطای InvalidOperationException | یک DbContext برای هر کار |
| حذف ستون در همان Deploy | خطای ۵۰۰ و Rollback ناممکن | الگوی Expand and Contract |
| اجرای Migrate هنگام شروع همه Pod ها | اجرای همزمان و خطا | یک قدم جدا در Pipeline |
چه وقت EF Core؟
مناسب
- بیشتر برنامههای تجاری: ثبت، ویرایش و خواندن داده.
- جایی که Change Tracking و Migration وقت تیم را ذخیره میکند.
- کوئریهای معمولی که با LINQ خوانا میمانند.
با احتیاط
- گزارشهای خیلی پیچیده. گاهی SQL دستی (با متد SqlQuery یا Dapper) خواناتر و سریعتر است.
- کارهای گروهی روی میلیونها ردیف. از ExecuteUpdateAsync یا ابزار مخصوص دیتابیس استفاده کن.
- وقتی تیم SQL ساختهشده را هیچ وقت نگاه نمیکند.
خلاصه در شش خط
- کتابخانه EF Core کد LINQ را به SQL ترجمه میکند. همیشه آن SQL را ببین.
- تا قبل از ToListAsync، کوئری در دیتابیس اجرا میشود. بعد از آن، در حافظه.
- مشکل N+1 یعنی یک کوئری برای هر ردیف. با Projection یا Include حلش کن.
- برای خواندن، AsNoTracking. برای داده خیلی زیاد، خواندن تکهتکه.
- هر درخواست یک DbContext. آن را بین Thread ها مشترک نکن.
- مایگریشن را با الگوی Expand and Contract و جدا از شروع برنامه اجرا کن.