Izmjerili smo pet najčešćih grešaka u EF Coreu: koliko svaka zaista košta
N+1, cijeli entiteti, tracking, upiti u petlji i filtriranje u memoriji: izmjerili smo svaku EF Core 10 grešku uz popravak. Do 9× sporije i 700× više memorije.
Vlado Pandžić · Osnivač · Senior .NET arhitekt
Objavljeno · 8 min čitanja
Sadržaj
- Kako smo mjerili
- Rezultati
- 1. Filtriranje nakon ToList(): 9× sporije, 700× više memorije
- 2. Cijeli entiteti za listu: 2,4× sporije, 12× više memorije
- 3. N+1: ovdje 1,9× sporije, preko mreže puno gore
- 4. Upit u petlji: 1,8× sporije, i isti problem s mrežom
- 5. Tracking liste koju nitko ne mijenja: 1,5× sporije
- Zašto su ovi brojevi donja granica
- Kako ih pronaći u svojoj aplikaciji
- Pitanja koja najčešće čujemo
- Kako mi radimo
Svaki .NET developer čuo je savjete: izbjegavajte N+1, koristite AsNoTracking, ne učitavajte cijele entitete. Ono što obično nedostaje je broj. Koliko je greška zapravo sporija? Dovoljno da bude važno ili je to nešto što ćete popraviti jednog dana?
Zato smo izmjerili. Pet grešaka koje stalno iznova viđamo u EF Core aplikacijama, svaka uz svoj popravak, na istim podacima i sa standardnim alatom za benchmarking.
Kratki odgovor: najgora, filtriranje nakon ToList(), bila je 9 puta sporija i trošila 700 puta više memorije. Najblaža, tracking liste koja se samo čita, i dalje je koštala 46 % više vremena. A dvije greške koje izgledaju najbezazlenije, N+1 i upiti u petlji, na pravom poslužitelju baze postaju puno gore nego što pokazuju naši brojevi.
Kako smo mjerili
- EF Core 10 na .NET-u 10, sa SQLiteom kao datotekom baze na disku.
- Podaci: 1.000 kupaca, 10.000 narudžbi s poljem napomene od 2 KB i 50.000 stavki narudžbi. Četvrtina narudžbi je otvorena.
- Alat: BenchmarkDotNet 0.15.8, s 3 iteracije zagrijavanja i 12 mjerenih iteracija po slučaju, uz bilježenje alokacija memorije.
- Svaki slučaj otvara novi
DbContext, kao što bi to napravio web zahtjev. - Računalo: laptop s procesorom Intel Core Ultra 7 255H.
Jedno važno ograničenje: SQLite radi unutar procesa aplikacije, pa je svaki upit poziv funkcije, a ne put preko mreže. Na SQL Serveru ili Azure SQL-u svaki dodatni upit košta i jedan mrežni round trip. Na to se vraćamo niže, jer mijenja sliku za dvije od pet grešaka.
Rezultati
| Greška | Vrijeme | Popravak | Vrijeme | Sporije | Memorija |
|---|---|---|---|---|---|
Filtriranje nakon ToList(), u memoriji |
91,4 ms | Filtriranje u bazi | 10,0 ms | 9,1× | 43,6 MB naspram 0,06 MB |
| Cijeli entiteti za listu | 22,5 ms | Projekcija na potrebne stupce | 9,6 ms | 2,4× | 12,7 MB naspram 1,0 MB |
| N+1: stavke učitane po narudžbi | 3,5 ms | Jedan upit s Include |
1,9 ms | 1,9× | 1,8 MB naspram 0,8 MB |
| Jedan upit po kupcu u petlji | 19,3 ms | Jedan upit za sve kupce | 10,9 ms | 1,8× | 2,6 MB naspram 0,3 MB |
| Lista samo za čitanje s trackingom | 27,1 ms | AsNoTracking |
18,6 ms | 1,5× | 13,3 MB naspram 10,9 MB |
Sad redom, jedna po jedna, s kodom.
1. Filtriranje nakon ToList(): 9× sporije, 700× više memorije
Klasika: ToList() na krivom mjestu, i filter se izvršava u C#-u umjesto u SQL-u.
var open = db.Orders.ToList()
.Where(o => o.Status == "Open" && o.Total > 500)
.Count();
Baza pošalje svih 10.000 narudžbi, s napomenama od 2 KB, a aplikacija većinu baci. Popravak prepušta posao bazi:
var open = db.Orders.Count(o => o.Status == "Open" && o.Total > 500);
Greška je za jedan poziv alocirala 43,6 MB, a popravak 62 KB. Na web poslužitelju ta razlika znači da garbage collector naporno radi pod svakim zahtjevom, i raste zajedno s tablicom. Obično se uvuče kroz metodu koja vraća List<T> ili IEnumerable<T> umjesto IQueryable<T>, pa se filter doda tek nakon što su podaci već stigli.
2. Cijeli entiteti za listu: 2,4× sporije, 12× više memorije
Ekranu s listom trebaju četiri stupca. Kod učitava kompletne entitete, uključujući veliko tekstualno polje koje nitko ne prikazuje:
var rows = db.Orders.AsNoTracking()
.Include(o => o.Customer)
.Where(o => o.Status == "Open")
.ToList()
.Select(o => new OrderRow(o.Id, o.Customer.Name, o.Date, o.Total));
Projekcija od baze traži samo ono što ekranu treba:
var rows = db.Orders
.Where(o => o.Status == "Open")
.Select(o => new OrderRow(o.Id, o.Customer.Name, o.Date, o.Total))
.ToList();
Od 12,7 MB postao je 1 MB. Što je tablica šira, a svaka se tablica s godinama širi, to je razlika veća. Projekcije se uz to nikad ne prate, pa besplatno dobivaju i korist iz zadnje točke.
3. N+1: ovdje 1,9× sporije, preko mreže puno gore
Jedan upit za narudžbe, pa još po jedan za stavke svake narudžbe:
var orders = db.Orders.OrderBy(o => o.Id).Take(100).ToList();
foreach (var order in orders)
total += db.OrderLines.Count(l => l.OrderId == order.Id);
To je 101 upit. S Include je jedan:
var orders = db.Orders.OrderBy(o => o.Id).Take(100)
.Include(o => o.Lines)
.ToList();
U našem testu razlika je bila 1,6 ms. Zvuči bezopasno, ali SQLite nije imao mrežu. Na SQL Serveru u istom podatkovnom centru jedan round trip obično traje negdje od pola milisekunde do par milisekundi. Sto dodatnih round tripova tada svakoj stranici koja ovo radi dodaje otprilike 50 do 200 ms, a stranica koja danas ima 100 redaka sljedeće će godine imati 1.000.
4. Upit u petlji: 1,8× sporije, i isti problem s mrežom
Isti obrazac u drugačijem obliku: petlja po kupcima, jedan upit za svakoga.
foreach (var id in customerIds)
count += db.Orders.Count(o => o.CustomerId == id && o.Status == "Open");
Jedan upit obavi isti posao:
var count = db.Orders.Count(o => customerIds.Contains(o.CustomerId) && o.Status == "Open");
Lokalno je 200 upita naspram jednoga koštalo 1,8× više vremena i 7,7× više memorije. Preko mreže tih 200 round tripova dodaje istih 0,1 do 0,4 sekunde kao kod N+1. Ovakve petlje skrivaju se u importima, pozadinskim poslovima i izvještajima, i zato noćne obrade s vremenom narastu s minuta na sate.
5. Tracking liste koju nitko ne mijenja: 1,5× sporije
EF Core po defaultu čuva kopiju svakog učitanog entiteta kako bi prepoznao promjene. Za ekran koji podatke samo prikazuje, to je bačen posao:
var orders = db.Orders.Where(o => o.Status == "Open").ToList();
var orders = db.Orders.AsNoTracking().Where(o => o.Status == "Open").ToList();
46 % sporije i 2,4 MB više memorije, pri svakom učitavanju liste. Ovo je najblaža od pet grešaka i najlakša za popravak: jedan poziv, ili QueryTrackingBehavior.NoTracking kao default za kontekst koji služi samo za čitanje.
Zašto su ovi brojevi donja granica
Dvije od pet grešaka, N+1 i upiti u petlji, uglavnom se svode na broj round tripova. Naš postav potpuno je uklonio mrežu, pa pokazuje samo trošak unutar aplikacije. Gruba orijentacija za pravi poslužitelj:
| Dodatni upiti po stranici | Uz 0,5 ms po round tripu | Uz 2 ms po round tripu |
|---|---|---|
| 10 | +5 ms | +20 ms |
| 100 | +50 ms | +200 ms |
| 1.000 | +0,5 s | +2 s |
Ostale tri greške, filtriranje u memoriji, cijeli entiteti i tracking, tiču se količine podataka koja putuje i memorije koju zauzima. I njih mreža pogoršava, jer svaki nepotrebni megabajt mora proći kroz nju.
Kako ih pronaći u svojoj aplikaciji
- Pogledajte SQL. U razvoju uključite EF Core logiranje s
LogTo(Console.WriteLine)ili na upitu pozoviteToQueryString(), pa prebrojite upite po stranici. - Tražite
ToList()iAsEnumerable()iza kojih slijediWhere,CountiliFirst, te metode u repozitoriju koje vraćajuList<T>. - Tražite petlje u kojima je upit, uključujući one skrivene u lazy-loaded navigacijskim svojstvima.
- Izmjerite najsporije ekrane prije i poslije popravka, kao što smo mi napravili ovdje. Važan je onaj popravak koji pomakne broj.
Cijeli popis uzroka, uključujući one koje ovdje nismo mjerili, poput indeksa koji nedostaju i paginacije koja je sve sporija, nalazi se u našem članku o tome zašto je EF Core spor.
Pitanja koja najčešće čujemo
Je li sam EF Core spor?
Prema našem iskustvu nije. U svih pet slučajeva popravak je i dalje bio EF Core, samo drugačije korišten. Većina sporih EF Core aplikacija spora je zbog načina na koji su upiti napisani, a ne zbog same biblioteke.
Trebamo li umjesto toga koristiti Dapper ili čisti SQL?
Za nekoliko jako opterećenih upita to može imati smisla. Za sve ostalo, gornji popravci donose većinu dobitka, a zadržavate jedan način pristupa podacima. Prepisivanje podatkovnog sloja rijetko je prvi korak.
Vrijede li ovi rezultati na SQL Serveru?
Smjer vrijedi, a kod N+1 i petlji razlike su zbog mreže još veće. Točne milisekunde ovise o vašem poslužitelju, podacima i indeksima. Zato se isplati izmjeriti vlastite najsporije ekrane.
Još smo na EF6. Vrijedi li ovo i za nas?
Iste greške postoje i u EF6, a popravci su slični. Prelazak na EF Core zaseban je projekt, o kojem pišemo u članku o migraciji s EF6 na EF Core.
Za aplikacije koje su još na EF6 izmjerili smo iste vrste upita na EF6 i EF Core 10 jedne uz druge, u članku EF6 ili EF Core 10.
Kako mi radimo
Upravo ovo radimo. ProCoding je .NET studio iz Splita, a performanse EF Corea područje su na kojem provodimo velik dio vremena: pronalazimo upite koji najviše koštaju, popravljamo ih uz mjerenja prije i poslije te pazimo da podatkovni sloj prati rast podataka. Samostalno, s vašim timom ili kao freelance .NET developer unutar vašeg tima. Prvi korak je besplatan razgovor od 30 minuta.
Izvori
- BenchmarkDotNet, alat za benchmarking koji smo koristili
- Efficient querying, Microsoft Learn
- Tracking vs. no-tracking queries, Microsoft Learn
- Simple logging in EF Core, Microsoft Learn
- Naša mjerenja, listopad 2026., opisana gore
Ovaj članak služi općem informiranju i nije pravni, porezni, financijski ni drugi stručni savjet. Scenariji, primjeri i izračuni su ilustrativni. Uvjeti korištenja i odricanje od odgovornosti.