LINQ و Collections
کوئری LINQ تا وقتی کسی نتیجه را نخواهد اجرا نمیشود. روی IQueryable کوئری به SQL ترجمه میشود و در دیتابیس اجرا میشود، روی IEnumerable در حافظه برنامه. collection را بر اساس کاری انتخاب کن که بیشتر با آن انجام میدهی.
نویسنده: bezzad
مشکل: صفحهای که یک میلیون ردیف میخواند
فروشگاه ما یک میلیون محصول دارد. صفحه «محصولات یک دسته» کند است و حافظه سرور بالا میرود. کد این است:
public IEnumerable<Product> GetAll() => db.Products;
// somewhere else
var phones = repository.GetAll()
.Where(p => p.CategoryId == categoryId)
.ToList();
کد درست به نظر میرسد. شرط Where هست. پس چرا کل جدول خوانده میشود؟ جواب در دو ایده است: اجرای تأخیری و فرق IEnumerable با IQueryable.
اجرای تأخیری (Deferred Execution)
یک کوئری LINQ مثل دستور پخت است، نه خود غذا. وقتی Where و Select را مینویسی، فقط دستور را مینویسی. هیچ کاری انجام نمیشود.
کوئری فقط وقتی اجرا میشود که کسی نتیجه را بخواهد:
- حلقه foreach روی آن.
- متدهایی مثل ToList، ToArray، ToDictionary.
- متدهایی که یک مقدار برمیگردانند، مثل Count، First، Any، Sum.
var prices = new List<decimal> { 900, 5 };
var cheap = prices.Where(p => p < 100); // nothing runs here
prices.Add(8);
Console.WriteLine(cheap.Count()); // 2: the query runs now and sees the new price
خطر: اجرای چندباره
چون کوئری فقط یک دستور است، هر بار که نتیجه را بخواهی، دوباره از اول اجرا میشود.
var newProducts = db.Products.Where(p => p.CreatedAt > lastWeek);
if (newProducts.Any()) // query 1 to the database
{
foreach (var p in newProducts) // query 2 to the database
Notify(p);
}
اینجا دو بار به دیتابیس میرویم. اگر بین این دو، محصولی اضافه شود، نتیجه دو کوئری هم با هم فرق دارد. راه درست: یک بار با ToListAsync نتیجه را بگیر و بعد با همان لیست کار کن.
IEnumerable یا IQueryable؟
این دو اینترفیس شبیه هم هستند، ولی فرق بزرگی دارند:
- نوع IEnumerable کوئری را با کد C# اجرا میکند. شرط Where یک متد معمولی است. داده باید اول در حافظه برنامه باشد.
- نوع IQueryable کوئری را به شکل یک درخت (Expression Tree) نگه میدارد. EF Core این درخت را میخواند و آن را به SQL ترجمه میکند. شرط در خود دیتابیس اجرا میشود.
حالا کد اول درس را دوباره ببین. متد GetAll نوع IEnumerable برمیگرداند. پس قدم به قدم:
- متد Where که بعد از آن صدا زده میشود، Where مخصوص IEnumerable است، نه IQueryable.
- این Where نمیتواند به SQL ترجمه شود. پس EF Core کوئری بدون شرط میفرستد.
- یک میلیون ردیف از شبکه میآید و یک میلیون شیء در حافظه ساخته میشود.
- بعد، در حافظه، همه به جز چند صد ردیف دور ریخته میشوند.
همین اتفاق با متد AsEnumerable هم میافتد. هر چیزی بعد از آن، در حافظه اجرا میشود.
کد درست
شرط و انتخاب ستونها را قبل از اجرای کوئری بنویس. فقط ستونهایی را بخوان که لازم داری:
public sealed record ProductRow(int Id, string Name, decimal Price);
public Task<List<ProductRow>> GetByCategoryAsync(int categoryId, CancellationToken ct) =>
db.Products
.Where(p => p.CategoryId == categoryId)
.OrderBy(p => p.Price)
.Select(p => new ProductRow(p.Id, p.Name, p.Price))
.Take(50)
.ToListAsync(ct);
انتخاب collection درست
حالا داده در حافظه است. کدام collection را انتخاب کنیم؟ سؤال اصلی این است: بیشتر چه کاری با آن انجام میدهی؟
| نوع | کار اصلی | جستجو | مثال در فروشگاه |
|---|---|---|---|
| List | ترتیب مهم است، دسترسی با شماره | یکییکی (کند برای داده زیاد) | ردیفهای سبد خرید |
| Dictionary | پیدا کردن با کلید | تقریباً یک قدم | محصول با شناسه |
| HashSet | فقط «هست یا نیست؟»، بدون تکرار | تقریباً یک قدم | شناسه محصولات تمامشده |
| Queue | اول آمده، اول میرود | ندارد | صف سفارشها برای پردازش |
| SortedDictionary | کلیدها همیشه مرتب | سریع، ولی کندتر از Dictionary | قیمتها به ترتیب |
| FrozenDictionary | یک بار ساخته میشود، فقط خوانده میشود | خیلی سریع | تنظیمات و جدول هزینه ارسال |
| ConcurrentDictionary | چند thread با هم مینویسند | تقریباً یک قدم | کش در یک Singleton |
یک مثال واقعی. میخواهیم از ۱۰ هزار ردیف سفارش، آنهایی را پیدا کنیم که کالایشان تمام شده است:
// Slow: for each line, Contains checks the whole list
List<int> outOfStock = await LoadOutOfStockIdsAsync(ct);
var blocked = lines.Where(l => outOfStock.Contains(l.ProductId)).ToList();
// Fast: one hash lookup for each line
HashSet<int> outOfStockSet = [.. await LoadOutOfStockIdsAsync(ct)];
var blocked2 = lines.Where(l => outOfStockSet.Contains(l.ProductId)).ToList();
با ۱۰ هزار ردیف و ۱۰ هزار شناسه، نسخه اول تا صد میلیون مقایسه دارد. نسخه دوم حدود ۱۰ هزار.
نوع برگشتی متدها
- برای ورودی متد، کلیترین نوع را بگیر. اگر فقط روی داده حلقه میزنی، IEnumerable کافی است.
- برای خروجی متد، نوع فقطخواندنی برگردان. مثل IReadOnlyList یا IReadOnlyCollection. صدازننده میداند داده کامل در حافظه است و میتواند تعدادش را بدون اجرای دوباره بگیرد.
- هیچ وقت از Repository نوع IEnumerable روی یک DbSet برنگردان. همان مشکل اول درس.
چند متد مفید
- متد Any به جای مقایسه Count با صفر. متد Any با اولین مورد متوقف میشود. روی دیتابیس هم کوئری سبکتری میسازد.
- متد ToDictionary یا ToLookup برای جستجوی زیاد. اگر در یک حلقه بارها با Where یا First دنبال یک مورد میگردی، اول یک Dictionary بساز.
- متد CountBy از .NET 9. شمردن بر اساس یک کلید، بدون ساختن گروه کامل.
// How many orders does each customer have?
foreach (var (customerId, count) in orders.CountBy(o => o.CustomerId))
Console.WriteLine($"{customerId}: {count}");
قانونهای مهم
- کوئری LINQ یک دستور است، نه نتیجه. تا کسی نتیجه را نخواهد، اجرا نمیشود.
- نتیجه را یک بار بگیر. اگر بیش از یک بار از نتیجه استفاده میکنی، اول ToList.
- روی دیتابیس، شرط، Select و Take را قبل از اجرا بنویس. تا همه اینها در SQL باشند.
- مرز IQueryable را بشناس. بعد از AsEnumerable یا بعد از برگرداندن IEnumerable، همه چیز در حافظه است.
- برای جستجوی زیاد، List نه. از Dictionary یا HashSet استفاده کن.
- در کد پرفشار، LINQ هزینه دارد. هر lambda و هر مرحله allocation کوچکی دارد. در کد عادی مهم نیست. در کد پرفشار اندازه بگیر.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| برگرداندن IEnumerable از روی DbSet | کل جدول به حافظه میآید. | شرط روی IQueryable، بعد اجرا. |
| صدا زدن AsEnumerable قبل از Where | فیلتر در حافظه، نه در دیتابیس. | اول Where، بعد اجرا. |
| استفاده چندباره از یک کوئری | چند کوئری به دیتابیس، شاید با نتیجههای متفاوت. | یک بار ToList. |
| متد Contains روی List بزرگ در حلقه | زمان به توان دو رشد میکند. | HashSet یا Dictionary. |
| مقایسه Count با صفر به جای Any | شمردن همه، فقط برای یک بله یا نه. | متد Any. |
| خواندن همه ستونها برای یک لیست ساده | داده اضافه روی شبکه و در حافظه. | انتخاب ستونها با Select. |
خلاصه در شش خط
- کوئری LINQ تا وقتی کسی نتیجه را نخواهد اجرا نمیشود.
- هر بار که نتیجه را بخواهی، کوئری از اول اجرا میشود.
- روی IQueryable، کوئری به SQL ترجمه میشود. روی IEnumerable، در حافظه اجرا میشود.
- شرط و انتخاب ستونها را قبل از اجرا و روی IQueryable بنویس.
- برای جستجو با کلید یا «هست یا نیست؟»، Dictionary و HashSet را انتخاب کن، نه List.
- خروجی متدها را از نوع فقطخواندنی برگردان.