Entity Framework è lento! mmmmh, probabilmente sei tu che lo stai usando male!

di Stefano Mostarda, in .NET,

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)


Volendo, si poteva ulteriormente ottimizzare il risultato eliminando le Include e utilizzando la clausola Select di LINQ per estrarre solo i campi necessari, ma per via di meccanismi di mapping e altre logiche dell'applicazione, questo avrebbe comportato la rivisitazione di troppe parti di codice e il cliente era già più che soddisfatto delle nuove prestazioni.

Morale: non è EF ad essere lento, sei tu che lo stai usando male.

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.

Morale: non è EF ad essere limitato, sei tu che non lo conosci.

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. Ogni volta che facciamo una query, EF memorizza nel change tracker un oggetto (detto entry) per ogni entity recuperata. Quest'oggetto contiene un riferimento all'entity, il suo stato e ai suoi valori letti dal database. Quando invochiamo SaveChanges, EF implicitamente esegue il metodo DetectChanges. Questo metodo scorre tutte le entry nel change tracker in stato Unchanged e per ognuna va a verificare se qualche proprietà dell'entity collegata è diverso dal valore letto originariamente dal database. In caso positivo, l'entity viene marcata come Modified e successivamente verrà fatta l'update sul database dei campi modificati. Fare questo confronto per 13k oggetti non è proprio velocissimo, ma se hai 200 entity da persistere e per ognuna chiami SaveChanges, la situazione peggiora perchè devi fare il confronto 13k * 200 volte.Senza andare a toccare troppo il codice abbiamo fatto una prova disabilitando il DetectChanges implicito (in qusto caso non serviva averlo) tramite la proprietà ChangeTracker.AutoDetectChangesEnabled a false. Cambiando solo quest'opzione, siamo passati dai 46 secondi 10.
Siccome nella vita si può sempre migliorare, ho anche suggerito di non invocare il SaveChanges per ogni entity da aggiornare, ma di invocarlo solo alla fine così EF può raggruppare più update in un unico comando SQL e ottimizzare il colloquio con il database rendendolo meno "chatty". Il risultato è stato un passaggio da 10 a 2 secondi (latenza di rete inclusa, non oso pensare quanto sarà veloce una volta che girerà tutto su Azure).

Morale: non è EF ad essere lento, sei tu che lo stai usando male.

Conclusioni

Entity Framework NON è lento! Sei tu che lo stai usando male!

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese