Levelwise
فارسی
داده و ذخیره‌سازی

Entity Framework Core و کارایی

کتابخانه EF Core کد LINQ را به SQL ترجمه می‌کند. اگر ندانی چه SQL ای ساخته می‌شود، به راحتی ده‌ها کوئری اضافه می‌فرستی یا کل جدول را به حافظه می‌آوری. یاد بگیر کوئری کجا اجرا می‌شود، Tracking چه هزینه‌ای دارد و Migration را چطور بی‌خطر اجرا کنی.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال فروشگاه اینترنتیکد C# و .NET 10

نویسنده: 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 در واقع سه کار انجام می‌دهد:

  1. ترجمه. کد LINQ را به SQL تبدیل می‌کند.
  2. ساختن شیء. ردیف‌های جواب را به شیء‌های C# تبدیل می‌کند.
  3. ردیابی تغییر (Change Tracking). از هر شیئی که خوانده، یک کپی نگه می‌دارد. موقع ذخیره، شیء را با کپی مقایسه می‌کند و فقط ستون‌های عوض‌شده را UPDATE می‌کند.
۱. کد سی‌شارپdb.Orders.Where(...)EF Core۲. ترجمه به اس‌کیو‌ال۳. دیتابیسSELECT ... FROMWHERE ...۴. شیء‌های برنامهساخته از ردیف‌هاتا اینجا فقط یک نقشه است. هنوز کوئری‌ای نرفته.اینجا اجرا می‌شودToListAsyncChange Trackerیک کپی از مقدار اولبرای ذخیره بعدیAsNoTrackingبا این متد، این کپی ساخته نمی‌شود.برای صفحه‌ای که فقط می‌خواند، سبک‌تر است.
تا قبل از متدهایی مثل ToListAsync یا FirstAsync، کوئری فقط یک نقشه است و چیزی به دیتابیس نمی‌رود.

یک نکته مهم از همین شکل: تا وقتی نوع کوئری 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 مشتری هر سفارش را همان لحظه‌ای می‌خواند که کد برای اولین بار به آن دست می‌زند. قدم به قدم:

  1. متد ToListAsync یک کوئری می‌فرستد: ۵۰ سفارش، بدون مشتری.
  2. بعد Select در حافظه اجرا می‌شود و برای هر سفارش نام مشتری را می‌خواهد.
  3. هر بار، Lazy Loading یک کوئری جدا برای همان یک مشتری می‌فرستد.
  4. پس ۱ کوئری برای سفارش‌ها و ۵۰ کوئری برای مشتری‌ها. به این N+1 می‌گویند.
  5. هر کوئری یک رفت و برگشت شبکه دارد. ۵۱ رفت و برگشت پشت سر هم، صفحه را کند می‌کند.
Lazy LoadingبرنامهدیتابیسOrdersمشتری سفارش ۱مشتری سفارش ۲...۱ + ۵۰ = ۵۱ کوئری۵۱ رفت و برگشت شبکه، پشت سر همSelect / IncludeبرنامهدیتابیسJOINSELECT o.Id, o.Total, c.NameFROM Orders o JOIN Customers cفقط ۱ کوئریفقط ستون‌های لازم می‌آیند
مشکل تعداد ردیف‌ها نیست. مشکل تعداد رفت و برگشت‌ها است.

همین مشکل بدون 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 بهتر است؟

  1. خود EF Core از روی Select، یک JOIN می‌سازد.
  2. فقط سه ستون لازم خوانده می‌شود، نه همه ستون‌های دو جدول.
  3. شیء OrderRow یک Entity نیست. پس Change Tracker و Lazy Loading اصلاً درگیر نمی‌شوند.

دکمه‌ها را بزن و تعداد کوئری‌ها را ببین:

مثال زنده: این صفحه چند کوئری می‌فرستد؟

صفحه، ۲۰ سفارش آخر را با نام مشتری نشان می‌دهد. فرض می‌کنیم هر رفت و برگشت به دیتابیس ۵ میلی‌ثانیه است.

تعداد کوئری۰
زمان شبکه (تقریبی)۰ میلی‌ثانیه
ستون‌های خوانده‌شده-
یک روش را انتخاب کن.
    مراقب Include های زیاد باش. اگر یک سفارش را با ردیف‌های سفارش و پرداخت‌هایش با هم Include کنی، JOIN ها ردیف‌ها را ضرب می‌کنند. سفارشی با ۱۰ ردیف و ۳ پرداخت، ۳۰ ردیف برمی‌گرداند. به این Cartesian Explosion می‌گویند. متد AsSplitQuery هر Include مجموعه‌ای را در یک کوئری جدا می‌فرستد و این ضرب را از بین می‌برد.

    ردیابی تغییر و 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

    1. هر درخواست، یک DbContext. متد AddDbContext آن را Scoped ثبت می‌کند. این درست است.
    2. یک DbContext برای چند Thread امن نیست. اگر دو کوئری را روی یک DbContext با Task.WhenAll همزمان اجرا کنی، EF Core خطای InvalidOperationException می‌دهد. برای کار موازی، از هر کار یک DbContext جدا بساز (مثلاً با IDbContextFactory).
    3. کلاس DbContext را Singleton نکن. ردیاب تغییرهای آن مدام بزرگ‌تر می‌شود و درخواست‌های مختلف داده هم را می‌بینند.

    تغییر ساختار دیتابیس بدون Downtime

    حالا می‌خواهیم ستون FullName مشتری را به دو ستون FirstName و LastName تبدیل کنیم. سرویس ۴ Pod دارد و Deploy به صورت Rolling است. یعنی چند دقیقه کد قدیم و جدید با هم کار می‌کنند.

    اگر یک Migration بنویسی که ستون‌های جدید را بسازد و ستون قدیمی را همان لحظه حذف کند:

    1. سه Pod قدیمی هنوز ستون FullName را می‌خوانند و خطای «ستون وجود ندارد» می‌گیرند.
    2. اگر نسخه جدید باگ داشته باشد، Rollback هم ممکن نیست. چون کد قدیم به ستونی نیاز دارد که دیگر نیست.

    راه حل الگوی Expand and Contract است. اول چیز جدید را اضافه کن، مدتی با هر دو کار کن، و در آخر قدیمی را حذف کن.

    ۱. گسترشستون‌های جدید را اضافه کنخالی‌پذیر و بدون حذفFullName+ FirstName, LastName۲. دوره گذارکد در هر دو جا می‌نویسدداده قدیمی دسته‌دسته پر می‌شودFullNameFirstName, LastName۳. جمع کردنچند روز بعد، با اطمینانستون قدیمی را حذف کن- FullNameFirstName, LastNameدر هر مرحله، نسخه قبلی کد هنوز با دیتابیس کار می‌کند. پس برگشت به عقب ممکن است.

    چند قانون برای Migration در Production:

    1. مایگریشن را جدا از بالا آمدن برنامه اجرا کن. اگر هر ۴ Pod موقع شروع متد Migrate را صدا بزنند، ممکن است همزمان اجرا شوند. بهتر است یک قدم جدا در Pipeline داشته باشی. با دستور ساخت اسکریپت SQL یا Migration Bundle این کار ساده است:
    dotnet ef migrations script --idempotent -o migrate.sql
    dotnet ef migrations bundle -o efbundle
    1. پر کردن داده قدیمی را دسته‌دسته انجام بده. یک UPDATE روی میلیون‌ها ردیف، جدول را مدت طولانی قفل می‌کند.
    2. اسکریپت Migration را قبل از اجرا بخوان. گاهی EF Core برای یک تغییر نام، ستون را حذف و دوباره می‌سازد و داده از بین می‌رود.

    آیا Repository روی EF Core لازم است؟

    کلاس DbContext خودش Unit of Work است و هر DbSet شبیه یک Repository است. پس یک Repository عمومی (با Add و Update و GetAll برای همه جدول‌ها) معمولاً فقط یک لایه اضافه است. بدتر اینکه گاهی Include و Projection را پنهان می‌کند و دوباره به N+1 می‌رسیم.

    ولی یک Repository مخصوص، مثلاً برای Aggregate سفارش، جاهایی مفید است. وقتی کوئری‌های تکراری باید یک جا باشند، یا وقتی می‌خواهی یک نقطه برای اضافه کردن کش (با الگوی Decorator) داشته باشی.

    قانون‌های مهم

    1. همیشه SQL ساخته‌شده را ببین. با لاگ یا با متد ToQueryString.
    2. برای نمایش، Projection بنویس. فقط ستون‌های لازم را بخوان.
    3. برای خواندن، AsNoTracking. Tracking فقط وقتی لازم است که می‌خواهی ذخیره کنی.
    4. فیلتر را قبل از ToListAsync بنویس. تا در SQL اجرا شود، نه در حافظه.
    5. مراقب Lazy Loading باش. خیلی از تیم‌ها آن را خاموش نگه می‌دارند تا N+1 پنهان نماند.
    6. مایگریشن سازگار با نسخه قبلی. هیچ وقت ستونی را در همان 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 ساخته‌شده را هیچ وقت نگاه نمی‌کند.

    خلاصه در شش خط

    1. کتابخانه EF Core کد LINQ را به SQL ترجمه می‌کند. همیشه آن SQL را ببین.
    2. تا قبل از ToListAsync، کوئری در دیتابیس اجرا می‌شود. بعد از آن، در حافظه.
    3. مشکل N+1 یعنی یک کوئری برای هر ردیف. با Projection یا Include حلش کن.
    4. برای خواندن، AsNoTracking. برای داده خیلی زیاد، خواندن تکه‌تکه.
    5. هر درخواست یک DbContext. آن را بین Thread ها مشترک نکن.
    6. مایگریشن را با الگوی Expand and Contract و جدا از شروع برنامه اجرا کن.