Sono anni che non scrivo sul blog, ma voglio interrompere il digiuno per parlare di un argomento che mi sta sempre a cuore: Entity Framework e più in particolare Entity Framework Core. Il titolo del post è un sunto di diverse discussioni che ho avuto negli ultimi 6 mesi. L'ultima risale a stamattina quindi è bella fresca. L'essenza è sempre la stessa: bellissimo EF, utilissimo, comodissimo, ma è lento e quindi devo usare direttamente ADO.NET o Dapper. Una volta eseguita l'analisi del codice, si è SEMPRE finito per scoprire che il problema non è EF, ma come lo si usa. Qui voglio racchiudere alcune esperienze che ho avuto e che sono secondo me simboliche.
Esperienza 1
Mi contatta il cliente e lamenta un'estrema lentezza dell'applicazione. A parte rari casi di hot path ricorsivi nel codice o di lentezza di altri sistemi, in questi casi il problema è sempre il colloquio con il database. In questo specifico caso, le scritture erano molto veloci, ma molte query impiegavano diversi secondi. Una volta iniziata l'analisi del codice abbiamo scoperto alcune cose interessanti. Ad esempio le query estremamente lente erano quelle con molte Include che quindi tiravano giù un enorme quantità di dati. Faccio un esempio.di una cosa realmente vista mascherando ovviamente i nomi reali.Context.People .Include(c => c.Children) .ThenInclude(c => c.Friends) .ThenInclude(c => c.Mother) .Include(c => c.Children) .ThenInclude(c => c.Friends) .ThenInclude(c => c.Father) .Include(c => c.Addresses) .ThenInclude(c => c.City) .Include(c => c.Addresses) .ThenInclude(c => c.Country) .Include(c => c.Addresses) .ThenInclude(c => c.Region) .Include(c => c.Documents) .Include(c => c.Mother) .Include(c => c.Father) .Include(c => c.Friends) .Include(c => c.Children)
Ignoriamo il senso dei dati concentriamoci sulla quantità di relazioni. Per default, EF cerca di recuperare tutti i dati eseguendo una unica query e dato che molte di queste sono 1:n, la quantità di dati che verrà restituita è enorme visto che molti dati saranno duplicati. Questo passaggio di dati enormi (di cui molti inutili perchè duplicati), unito alla lentezza nell'eseguire una query così complessa, rendeva l'applicazione visibilmente lenta. In questo caso la situazione era ancora più complessa perchè si stava cercando di migrare da .NET 2.2 a .NET 6 e con .NET 2.2 questi problemi di lentezza non si verificavano. Quello che gli sviluppatori non sapevano è che da EF Core 2.2 a EF Core 3 il motore che genera SQL partendo LINQ è completamente cambiato tornando ad assomigliare a quello di EF6 che risolve tutte le query in un colpo solo, mentre quello di EF Core fino alla 2.2 spezzetta le Include con relazioni 1:n in più comandi SQL. Con EF Core 3.1, il team ha effettuato un aggiornamento al motore di generazione del codice SQL che ci permette di specificare come trattare le Include 1:n: il metodo LINQ AsSplitQuery. Se usiamo questo metodo, EF assume lo stesso comportamento delle versioni fino alla 2.2 e quindi spezzettando le query queste saranno molto veloci e molti dati duplicati non saranno restituiti. Risultato, siamo passati da circa 20 secondi a meno di 2 semplicemente invocando AsSplitQuery nella query.
Context.People .AsSplitQuery() .Include(c => c.Children) .ThenInclude(c => c.Friends) .ThenInclude(c => c.Mother) .Include(c => c.Children) .ThenInclude(c => c.Friends) .ThenInclude(c => c.Father) .Include(c => c.Addresses) .ThenInclude(c => c.City) .Include(c => c.Addresses) .ThenInclude(c => c.Country) .Include(c => c.Addresses) .ThenInclude(c => c.Region) .Include(c => c.Documents) .Include(c => c.Mother) .Include(c => c.Father) .Include(c => c.Friends) .Include(c => c.Children)
Ma non finisce qui. Un'altra cosa che il cliente non sapeva è che già da qualche versione le Include possono essere filtrate. Nell query non servivano tutti gli amici, ma solo gli ultimi 10. Aggiungendo una Where nella Include siamo riusciti a diminuire ulteriormente la quantità di dati recuperati portando i tempi di esecuzione sotto il secondo.
Context.People .AsSplitQuery() .Include(c => c.Children) .ThenInclude(c => c.Friends.OrderBy(f => f.Date).Take(10)) .ThenInclude(c => c.Mother) .Include(c => c.Children) .ThenInclude(c => c.Friends) .ThenInclude(c => c.Father) .Include(c => c.Addresses) .ThenInclude(c => c.City) .Include(c => c.Addresses) .ThenInclude(c => c.Country) .Include(c => c.Addresses) .ThenInclude(c => c.Region) .Include(c => c.Documents) .Include(c => c.Mother) .Include(c => c.Father) .Include(c => c.Friends.OrderBy(f => f.Date).Take(10)) .Include(c => c.Children)
Esperienza 2
Un cliente lamenta un'eccessiva lentezza generale del sistema e identifica la soluzione nell'utilizzare un hint di Sql Server: WITH(NOLOCK). Il problema è che il codice SQL viene generato da EF e il cliente PENSA che EF sia limitato perchè non da modo di toccarlo: evidentemente il concetto di interceptor non era mai stato affrontato. La sfida era creare un interceptor che prendesse la stringa SQL generata da EF e aggiungesse dopo ogni "FROM [tabella] as [alias]" la clausola WITH(NOLOCK). All'inizio è stata usata una libreria interna che trasformava il codice SQL in token modificabili, ma la libreria era limitata e quindi si è optato per le RegEx. Una volta implementato il meccanismo, le performance del db erano decisamente migliorate, ma eseguire ogni volta il parsing del codice SQL peggiorava le prestazioni della macchina web. Per ovviare abbiamo aggiunto un meccanismo di cache che usa il codice SQL di EF come chiave e il codice SQL modificato come valore. Purtroppo il codice è di proprietà del cliente e non posso mostrarlo qui.Esperienza 3
Dopo aver fatto tantissime prove ed esperimenti, un cliente lamenta che EF sia troppo lento in certe operazioni "massive" e mi mostra due pezzi di codice dove per aggiornare 200 record EF impiega 45 secondi mentre ADO.NET ne impiega 9 (va detto che lo sviluppatore usava la propria macchina come client e Sql Azure come db, quindi molta lentezza era data dalla latenza di rete). Il primo pensiero è sempre quello, stampiamo i comandi SQL in console così vediamo cosa genera. Con mia sorpresa i comandi erano pressoché identici e quindi era chiaro che il problema fosse EF.Cominciando ad analizzare il codice si vede che il cliente ha implementato il repository pattern dove a ogni operazione viene effettuato il SaveChanges. Andando ulteriormente a verificare, scopriamo che il change tracker aveva 13k entity in memoria. La combinazione di questi due fattori è esplosiva.
Conclusioni
Per inserire un commento, devi avere un account.
Fai il login e torna a questa pagina, oppure registrati alla nostra community.
- Cosa penso di ASP.NET vNext, il 3 settembre 2014 alle 09:00
- E così AngularJS e DurandalJS convergono..., il 7 maggio 2014 alle 11:51
- Usare fiddler per simulare le risposte da un servizio, il 28 ottobre 2013 alle 08:00
- Tip: cosa fare quando Entity Framework Code-First Migrations smette di funzionare, il 18 gennaio 2013 alle 11:04
- Visual Studio 11 beta: le novità di Entity Framework 5.0 e WCF 4.5, il 2 marzo 2012 alle 23:08