Prestanda
EF Core gör det lätt att skriva kod som ser helt oskyldig ut och genererar hundra databasanrop i bakgrunden. Ingen av teknikerna här är avancerad — det är mest att veta vilken av två nästan identiska rader kod som kostar dig 100x prestandan. Mät alltid innan du optimerar; en gissning om var flaskhalsen sitter är ofta fel gissning.
När du läst detta ska du kunna
- Förklara varför N+1 uppstår och lösa det med
Include - Välja mellan
AsNoTracking, projektion och full entitet beroende på syfte - Undvika att en
Include-kedja exploderar i antal rader - Hitta flaskhalsen innan du optimerar den
N+1 — det vanligaste problemet i hela EF Core
var teachers = db.Teachers.ToList(); // 1 queryforeach (var teacher in teachers){ var students = teacher.Students.ToList(); // 1 query PER lärare}// 100 lärare → 101 databasanrop för något som borde vara ettDet här uppstår oftast via lazy loading — navigation properties markerade virtual som laddar sig själva första gången de accessas. Det ser bekvämt ut i koden och är exakt varför det är en fälla: varje access i en loop blir ett nytt anrop, tyst.
var teachers = db.Teachers .Include(t => t.Students) .ToList();// 1 query, oavsett hur många lärare det finnsInclude laddar relationen i samma anrop som huvudfrågan. Det är eager loading, och det ska vara ditt förstahandsval. Behöver du data villkorat — “ladda studenterna bara om användaren klickar på detaljvyn” — använd explicit loading istället för lazy loading:
var teacher = db.Teachers.Find(1);
if (showStudents){ db.Entry(teacher).Collection(t => t.Students).Load(); // en medveten, synlig extra query}Skillnaden mot lazy loading är att den extra queryn syns i koden, på en rad du valt att skriva — inte gömd bakom en property-access i en loop.
AsNoTracking för läsning
Change trackern kostar minne och CPU för varje rad den håller koll på — och den behöver bara hålla koll på rader du tänker ändra.
var report = db.Students .AsNoTracking() .ToList();Använd den för rapporter, GET-endpoints och statistik — allt där du bara läser. Hoppa över den när du faktiskt ska anropa SaveChanges() på resultatet; utan tracking vet EF Core inte vad som ändrats.
Projicera bara det du behöver
var full = db.Students.ToList(); // hela entiteten, alla kolumner
var projected = db.Students .Select(s => new { s.Name, s.Email }) .ToList(); // bara två kolumner över nätetJu bredare en tabell är, desto mer skiljer de två åt. Behöver anroparen bara namn och e-post, skicka inte hela raden.
Filtrera innan du inkluderar
// Laddar ALLA lärare med ALLA studenter, filtrerar sen i C#var teachers = db.Teachers .Include(t => t.Students) .Where(t => t.City == "Stockholm") .ToList();
// Filtrerar i databasen innan relationen laddasvar teachers = db.Teachers .Where(t => t.City == "Stockholm") .Include(t => t.Students) .ToList();Ordningen spelar ingen roll för SQL-motorn i det här fallet — men vanan att filtrera tidigt hindrar dig från att av misstag inkludera en relation innan du inser att du bara behövde en bråkdel av raderna.
Split queries — när Include exploderar
var students = db.Students .Include(s => s.Courses) .Include(s => s.Projects) .Include(s => s.Enrollments) .ToList();Tre Include på samma nivå ger EF Core en enda SQL-join, och en join mellan tabeller med 10 rader var kan ge 10 × 10 × 10 = 1000 rader tillbaka — en cartesian explosion, för data du bad om en gång men får multiplicerad. AsSplitQuery() löser det genom att köra en separat query per Include istället för en gigantisk join:
var students = db.Students .Include(s => s.Courses) .Include(s => s.Projects) .Include(s => s.Enrollments) .AsSplitQuery() .ToList();Priset: flera queries är inte längre atomiska mot varandra. Ändras data mellan dem, kan du i teorin se en inkonsekvent snapshot. För read-heavy rapporter är det sällan ett problem — för data som ändras ofta under hög belastning, tänk igenom det.
Paginering
var page = db.Students .OrderBy(s => s.Name) // sortera INNAN Skip/Take, annars är sidan odefinierad .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToList();En sida utan OrderBy kan i teorin returnera olika ordning mellan anrop — databasen garanterar ingen stabil ordning utan en explicit sortering.
Compiled queries för högfrekventa anrop
private static readonly Func<SchoolContext, int, Student?> GetStudentById = EF.CompileQuery((SchoolContext db, int id) => db.Students.Include(s => s.Courses).FirstOrDefault(s => s.Id == id));
var student = GetStudentById(db, 1);EF Core cachar normalt redan frågeplaner, men för en query som körs tusentals gånger per sekund sparar en förkompilerad version den sista biten omkostnad. Investera bara i det när profileringen faktiskt pekar hit — det är en mikrooptimering, inte ett förstahandsval.
Batch- och bulkoperationer
// Ett SaveChanges per rad — N databas-roundtripsforeach (var student in students){ db.Students.Add(student); db.SaveChanges();}
// Ett SaveChanges för hela batchen — 1 roundtripdb.Students.AddRange(students);db.SaveChanges();För verkligt stora volymer (tiotusentals rader) är även AddRange + SaveChanges fortfarande begränsat av att EF Core bygger individuella INSERT-satser. Paketet EFCore.BulkExtensions (dotnet add package EFCore.BulkExtensions) ger BulkInsert/BulkUpdate/BulkDelete som går direkt mot databasens bulk-API:er — betydligt snabbare vid den skalan, men ett tillägg du tar in när volymen faktiskt kräver det.
Async — se även LINQ-frågor
LINQ-frågor mot EF Core går igenom async/await som standardval. Värt att upprepa specifikt här: i ASP.NET Core blockerar ett synkront databasanrop tråden som annars kunde hantera ett annat request. Det är inte bara “lite snabbare” — det är skillnaden mellan en app som skalar och en som kör slut på trådar under belastning.
Anslutningspooling
builder.Services.AddDbContext<SchoolContext>(options => options.UseSqlServer(connectionString));Connection pooling är på som standard — anslutningar återanvänds automatiskt istället för att öppnas och stängas per request. Under ovanligt hög samtidighet kan default-gränsen (100 anslutningar) bli en flaskhals; höj den i anslutningssträngen (Max Pool Size=200) om profileringen visar att det är just det som stryper dig.
Cachning för läsintensiv data
public async Task<List<Student>> GetActiveStudentsAsync(){ const string cacheKey = "active_students";
if (!_cache.TryGetValue(cacheKey, out List<Student>? students)) { students = await _db.Students .Where(s => s.IsActive) .AsNoTracking() .ToListAsync();
_cache.Set(cacheKey, students, TimeSpan.FromMinutes(10)); }
return students!;}Bra för data som läses ofta men ändras sällan. Sätt en rimlig TTL — cachad data som aldrig blir gammal är bara en bugg som väntar på att upptäckas.
Rå SQL för komplex aggregering
var stats = db.Database.SqlQueryRaw<StudentStats>(@" SELECT s.City, COUNT(*) AS StudentCount, AVG(s.Age) AS AverageAge FROM Students s GROUP BY s.City HAVING COUNT(*) > 10").ToList();Vissa aggregeringar är genuint tydligare och snabbare i handskriven SQL än i LINQ. Det är inte ett misslyckande att gå dit — EF Core är ett verktyg för de 90 % av frågorna som är rakt fram, inte ett religiöst åtagande.
Index
protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<Student>().HasIndex(s => s.Email).IsUnique();
modelBuilder.Entity<Student>().HasIndex(s => new { s.City, s.Age }); // sammansatt index
modelBuilder.Entity<Student>() .HasIndex(s => s.Name) .HasFilter("[IsActive] = 1"); // filtrerat index}Indexera kolumner som förekommer i WHERE, ORDER BY eller JOIN — inte varje kolumn i tabellen. Ett index som aldrig används kostar skrivprestanda utan att ge någon läsvinst tillbaka.
Databasvyer för återanvänd komplex logik
migrationBuilder.Sql(@" CREATE VIEW vw_ActiveStudents AS SELECT s.Id, s.Name, s.Email, COUNT(e.CourseId) AS CourseCount FROM Students s LEFT JOIN Enrollments e ON s.Id = e.StudentId WHERE s.IsActive = 1 GROUP BY s.Id, s.Name, s.Email");
[Keyless]public class ActiveStudentView{ public int Id { get; set; } public string Name { get; set; } = string.Empty; public int CourseCount { get; set; }}
modelBuilder.Entity<ActiveStudentView>().ToView("vw_ActiveStudents");
var active = db.Set<ActiveStudentView>().ToList();En vy är rätt val när flera delar av appen behöver samma komplexa join/aggregering — logiken hamnar på ett ställe (databasen), inte upprepad i flera LINQ-frågor.
Hitta flaskhalsen
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);visar varje SQL-sats EF Core genererar — ofta räcker det för att se ett N+1-problem direkt i konsolen. Vill du märka en specifik fråga i loggarna när flera liknande queries körs samtidigt:
var students = db.Students .TagWith("GetActiveStudents from StudentService") .Where(s => s.IsActive) .ToList();och för att mäta exakt en körning:
var sw = Stopwatch.StartNew();var students = await db.Students.ToListAsync();sw.Stop();Console.WriteLine($"Query tog: {sw.ElapsedMilliseconds}ms");För systematisk mätning över tid, ta in BenchmarkDotNet snarare än att lita på enstaka Stopwatch-körningar — en enskild mätning säger inte mycket om variansen mellan körningar.
Vanliga fallgropar
Lazy loading som standard — bekvämt att skriva, men varje access blir ett dolt databasanrop. Föredra Include eller explicit loading.
Tracking på queries du bara läser — AsNoTracking() är gratis prestanda när du redan vet att du inte ska spara ändringar.
SaveChanges() inuti en loop — samla ändringarna med AddRange/RemoveRange och spara en gång.
Ingen paginering på potentiellt stora dataset — “det är bara några hundra rader idag” håller inte i produktion om ett år.
Obligatorisk dad-joke
Varför gick EF Core till gymmet?
Den ville bygga bättre index.