Jedno pozornie niewinne ToList() potrafi zmienić szybkie zapytanie SQL w operację, która pobiera z bazy tysiące niepotrzebnych rekordów. Rozstrzygnięcie dylematu IEnumerable vs IQueryable pomaga zrozumieć, gdzie faktycznie wykonuje się kod LINQ, kiedy filtr trafia do SQL i jak uniknąć problemów z wydajnością w aplikacjach .NET.
Najważniejsze różnice między IEnumerable i IQueryable w praktyce
- IEnumerable wykonuje operatory LINQ w pamięci aplikacji.
- IQueryable buduje wyrażenie, które dostawca, na przykład EF Core, może przetłumaczyć na SQL.
- ToList(), First() i podobne metody materializują zapytanie i kończą jego dalsze składanie po stronie bazy.
- AsEnumerable() świadomie przenosi kolejne operacje do pamięci.
- Najlepszy wybór zależy od miejsca wykonania filtrów, sortowania i projekcji, a nie od samej nazwy interfejsu.
Najkrótsza odpowiedź brzmi prosto, ale ma ważne konsekwencje
IEnumerable opisuje sekwencję elementów, po której aplikacja może iterować. Gdy używam na niej metod LINQ, takich jak Where, Select czy OrderBy, operacje wykonują się zwykle w pamięci procesu .NET.
IQueryable reprezentuje zapytanie, które może zostać zinterpretowane przez zewnętrznego dostawcę. W przypadku Entity Framework Core dostawca analizuje drzewo wyrażeń i tłumaczy je na zapytanie SQL wykonywane w bazie.
Najważniejsza różnica nie polega więc na tym, że jeden interfejs jest „lepszy”. Chodzi o to, gdzie wykonywana jest praca. Dla małej kolekcji w pamięci nie ma to większego znaczenia. Dla tabeli z milionem rekordów może zdecydować o czasie odpowiedzi, zużyciu pamięci i obciążeniu serwera.
IEnumerable pracuje na danych dostępnych w pamięci
Najłatwiej zobaczyć działanie IEnumerable na zwykłej kolekcji:
var products = new List
{
new() { Name = "Laptop", Price = 4500 },
new() { Name = "Mysz", Price = 120 }
};
IEnumerable result =
products.Where(p => p.Price > 500); W tym przykładzie filtr jest wykonywany przez kod aplikacji. Lista już znajduje się w pamięci, więc nie ma żadnego SQL ani zewnętrznego dostawcy, który mógłby zoptymalizować zapytanie. To właśnie klasyczny scenariusz dla LINQ to Objects.
W praktyce podobny efekt pojawia się po wcześniejszym pobraniu danych z EF Core:
var products = await db.Products.ToListAsync();
var expensiveProducts = products
.Where(p => p.Price > 500)
.OrderByDescending(p => p.Price)
.ToList();Pierwsze wywołanie pobiera całą tabelę lub cały wcześniejszy zakres zapytania. Dopiero później aplikacja filtruje i sortuje wynik. Przy kilkunastu rekordach nie będzie to problemem, ale przy dużej tabeli oznacza niepotrzebny transfer danych i większe zużycie RAM.
IQueryable pozwala przenieść logikę do SQL
Jeżeli źródłem danych jest DbSet, mogę budować zapytanie bez natychmiastowego pobierania rekordów:
var query = db.Products
.Where(p => p.Price > 500)
.OrderByDescending(p => p.Price)
.Select(p => new
{
p.Name,
p.Price
})
.Take(20);
var products = await query.ToListAsync();Do momentu wywołania ToListAsync() powyższy kod tworzy opis zapytania. EF Core może przełożyć go na SQL zawierający filtr, sortowanie, wybór konkretnych kolumn i limit wyników. W efekcie baza zwraca tylko 20 potrzebnych rekordów i wybrane pola.
To opóźnione wykonanie nazywa się deferred execution. Samo zdefiniowanie zapytania nie musi jeszcze powodować połączenia z bazą. Zapytanie wykonuje się dopiero przy materializacji, iteracji albo wywołaniu metody zwracającej pojedynczy wynik, na przykład FirstAsync() lub AnyAsync().
Nie oznacza to jednak, że każde wyrażenie C# da się zamienić na SQL. Dostawca musi rozumieć użyte operatory i metody. Jeżeli w filtrze wywołam własną metodę, EF Core może zgłosić wyjątek albo wymusić wykonanie fragmentu po stronie klienta, zależnie od miejsca i rodzaju wyrażenia.
Porównanie zachowania obu interfejsów
| Kryterium | IEnumerable | IQueryable |
|---|---|---|
| Miejsce wykonania | Pamięć aplikacji | Baza danych lub inny dostawca |
| Rodzaj delegata | Func |
Expression |
| Źródło danych | Listy, tablice, kolekcje | EF Core, LINQ to SQL i inni dostawcy |
| Moment wykonania | Podczas iteracji lub operacji LINQ | Przy materializacji albo iteracji |
| Optymalizacja SQL | Nie dotyczy | Możliwa po stronie dostawcy |
| Ryzyko | Duże zużycie pamięci przy dużych kolekcjach | Nieprzewidywalne zapytania lub trudna translacja |
IQueryable dziedziczy po IEnumerable, dlatego oba typy wyglądają podobnie w kodzie. To podobieństwo bywa mylące. Wywołanie tej samej metody LINQ może prowadzić do innego miejsca wykonania, zależnie od statycznego typu źródła.
Największą różnicę widać po ToList i AsEnumerable
Przedwczesna materializacja
Ten kod najpierw pobiera dane z bazy, a dopiero potem wykonuje filtr:
var users = await db.Users.ToListAsync();
var activeUsers = users
.Where(u => u.IsActive)
.Take(50)
.ToList();Lepsza wersja przesuwa wszystkie operacje możliwe do wykonania w SQL przed materializację:
var activeUsers = await db.Users
.Where(u => u.IsActive)
.Take(50)
.ToListAsync();Różnica jest szczególnie duża, gdy filtr redukuje wynik z setek tysięcy rekordów do kilkudziesięciu. Moja praktyczna zasada jest prosta: ToList wywołuję dopiero wtedy, gdy naprawdę potrzebuję kolekcji w pamięci.
Świadome przejście do LINQ to Objects
AsEnumerable() nie jest błędem. Czasem jest potrzebne, gdy część logiki nie ma odpowiednika w SQL:
var products = db.Products
.Where(p => p.IsPublished)
.AsEnumerable()
.Where(p => CalculateScore(p) > 10);W tym przykładzie pierwszy filtr może trafić do bazy, a drugi wykona się już w aplikacji. To rozsądny kompromis, o ile wcześniejszy warunek ogranicza liczbę rekordów. Przeniesienie do pamięci przed filtrem selektywnym może natomiast szybko stać się wąskim gardłem.
Przeczytaj również: MySQL subquery w praktyce - IN, EXISTS i wydajne zapytania
Przypadkowa zmiana operatorów LINQ
Problem może pojawić się także wtedy, gdy zapytanie zostanie przekazane jako IEnumerable:
IEnumerable products = db.Products;
var result = products
.Where(p => p.Price > 500)
.ToList(); Od tego momentu kompilator wybiera operatory z klasy Enumerable, a nie Queryable. W efekcie filtr może zostać wykonany w pamięci. Sam typ zmiennej potrafi więc wpłynąć na zachowanie kodu, nawet jeśli obiekt pod spodem pochodzi z EF Core.
Typowe błędy przy zapytaniach do bazy
Najczęstszy błąd to traktowanie IQueryable jak zwykłej kolekcji i wielokrotne wykonywanie tego samego zapytania:
var query = db.Orders.Where(o => o.Status == OrderStatus.Open);
var count = await query.CountAsync();
var first = await query.FirstOrDefaultAsync();W tym przypadku baza może otrzymać dwa osobne zapytania. To może być poprawne, ale przy kosztownych filtrach lub dużym ruchu warto świadomie zdecydować, czy oba wyniki są potrzebne. Wielokrotna enumeracja niezmaterializowanego zapytania często oznacza dodatkowe połączenia z bazą.
Drugi błąd to pobieranie całych encji, gdy ekran potrzebuje trzech pól. Projekcja przez Select ogranicza liczbę kolumn i często upraszcza działanie pamięci śledzenia zmian:
var summaries = await db.Orders
.Where(o => o.CreatedAt >= startDate)
.Select(o => new OrderSummary
{
Id = o.Id,
Total = o.Total,
CustomerName = o.Customer.Name
})
.ToListAsync();Przy zapytaniach tylko do odczytu warto rozważyć także AsNoTracking(). Nie przyspieszy każdej operacji w zauważalny sposób, ale ogranicza narzut śledzenia encji, gdy aplikacja nie zamierza ich modyfikować.
Trzeci problem to przekonanie, że IQueryable zawsze zapewni lepszą wydajność. Złożone zapytanie z wieloma złączeniami, sortowaniem i funkcjami może wygenerować trudny SQL. Dlatego w krytycznych miejscach sprawdzam rzeczywiste zapytanie przez ToQueryString() oraz analizuję plan wykonania w bazie.
Kiedy wybrać IEnumerable, a kiedy IQueryable
Po IEnumerable sięgam, gdy dane są już w pamięci, kolekcja jest niewielka albo filtr wymaga kodu, którego dostawca nie potrafi przetłumaczyć. Dobrze pasuje też do metod, które mają działać niezależnie od konkretnego źródła danych.
IQueryable wybieram przy budowaniu zapytań do relacyjnej bazy, szczególnie gdy użytkownik może filtrować, sortować i stronicować wynik. W takim scenariuszu jak najwięcej pracy powinno zostać wykonane po stronie SQL, zanim dane opuszczą serwer bazy.
Nie wystawiam jednak bezrefleksyjnie IQueryable z każdej warstwy aplikacji. Przekazanie go przez cały system daje odbiorcy dużą kontrolę, ale może też ujawnić szczegóły dostępu do danych, utrudnić testowanie i spowodować zapytania trudne do przewidzenia. W wielu projektach lepiej zamknąć budowanie zapytania w warstwie dostępu do danych i zwracać gotowy model albo zmaterializowaną kolekcję.
-
Mała kolekcja w pamięci - wybierz
IEnumerable. -
Duża tabela w SQL - filtruj i sortuj na
IQueryable. -
Stronicowanie - użyj
SkipiTakeprzed materializacją. - Własna logika C# - przenieś ją do pamięci dopiero po ograniczeniu danych.
-
Granica warstwy aplikacji - zwróć konkretny model zamiast przypadkowo ujawniać
IQueryable.
Decyzję warto podejmować na podstawie miejsca wykonania
Najlepszym testem nie jest samo sprawdzenie typu zmiennej, lecz odpowiedź na trzy pytania. Ile danych pobieram? Które operacje wykona baza, a które aplikacja? Czy zapytanie wykonuje się dokładnie tyle razy, ile zakładam?
Jeżeli potrafię odpowiedzieć na te pytania i obejrzeć wygenerowany SQL, wybór interfejsu przestaje być kwestią przyzwyczajeń. IEnumerable jest świetne dla danych lokalnych, a IQueryable dla zapytań, które powinny zostać zoptymalizowane przez dostawcę, jednak oba narzędzia mają sens tylko wtedy, gdy świadomie kontroluję moment materializacji.
Na koniec rozdzieliłbym jeszcze IQueryable od IAsyncEnumerable. To drugie służy przede wszystkim do asynchronicznego, strumieniowego odczytu elementów i nie oznacza automatycznie, że filtr zostanie przetłumaczony na SQL. W aplikacjach opartych na EF Core najważniejsze pozostaje to, co dzieje się przed ToListAsync() i gdzie znajduje się granica między bazą a pamięcią aplikacji.
