Warum dein Entity Framework langsam ist Teil 2

In Teil 1 haben wir uns den generellen Aufbau angeschaut und mit IQueryable, IEnumerable und ToList die Basis gelegt. Nun wollen wir uns anschauen, wie wir die Abfrage tatsächlich schneller bekommen.

Weniger Abfragen: Include

Mit ToList haben wir bereits die Customer Tabelle in den Speicher bekommen. Unsere verschachtelten foreach-Schleifen sorgen nun allerdings für Lazy Loading, d.h. es wird mit jeder Verschachtelungstiefe erneut eine Anfrage an die Datenbank gesendet. Es werden also in eienr Abfrage alle Customer Objekte geladen. In der nächsten wird zu einem Customer alle SalesOrderHeader Objekte geladen. Zum Schluss werden dann noch zu jedem SalesOrderHeader alle SalesOrderDetail Objekte geladen. Daraus resultieren die bisherigen 880 SQL SELECT Statements. Mit Include können wir diese Referenzen in einer Abfrage laden.

var customers = context.Customer
    .Include(c => c.SalesOrderHeader.Select(h => h.SalesOrderDetail.Select(d => d.Product)))
    .ToList();

Sum is 353.504,37
Used 1 SQL SELECT Statements
Get took 3275ms
Output took 1ms

Wir haben nun also mit einer Abfrage alle Tabellen auf einmal geladen. Dies kann nützlich sein (besonders für UPDATE Statements), hat aber einen gewissen Performance Impact, welcher um so schlimmer wird, je mehr verschachtelte Tabellen geladen werden. Das liegt daran, dass das generierte SQL ein JOIN über diese Tabellen ist. Haben wir nun 1 Customer mit 2 SalesOrderHeader mit jeweils 3 SalesOrderDetail, werden 1 * 2 * 3 = 6 Zeilen aus der Datenbank geladen. Jede dieser Zeilen hat für den Customer die gleichen Informationen und auch die SalesOrderHeader sind doppelt vertreten. Man kann sich das so vorstellen:
[Customer][SalesOrderHeader1][SalesOrderDetail1][Product1]
[Customer][SalesOrderHeader1][SalesOrderDetail2][Product2]
[Customer][SalesOrderHeader1][SalesOrderDetail3][Product3]
[Customer][SalesOrderHeader2][SalesOrderDetail4][Product4]
[Customer][SalesOrderHeader2][SalesOrderDetail5][Product5]
[Customer][SalesOrderHeader2][SalesOrderDetail6][Product6]

Join in Memory: Mehrere Abfragen

Um das Problem mit den redundanten Daten zu umgehen, machen wir einfach mehrere Abfragen, jeweils eine pro Tabelle, die wir benötigen.

var customers = context.Customer.ToDictionary(c => c.CustomerID);
var headers = context.SalesOrderHeader.ToDictionary(h => h.SalesOrderID);
var details = context.SalesOrderDetail.ToDictionary(d => d.SalesOrderDetailID);
var products = context.Product.ToDictionary(p => p.ProductID);
RelationshipFixup(customers, headers, details, products);
private static void RelationshipFixup(IDictionary<int, Customer> customers, IDictionary<int, SalesOrderHeader> headers, IDictionary<int, SalesOrderDetail> details, IDictionary<int, Product> products)
{
    foreach (var header in headers.Values)
    {
        header.Customer = customers[header.CustomerID];
        header.Customer.SalesOrderHeader.Add(header);
    }

    foreach (var detail in details.Values)
    {
        detail.SalesOrderHeader = headers[detail.SalesOrderID];
        detail.SalesOrderHeader.SalesOrderDetail.Add(detail);

        detail.Product = products[detail.ProductID];
        detail.Product.SalesOrderDetail.Add(detail);
    }
}

Sum is 353.504,37
Used 1025 SQL SELECT Statements
Get took 10563ms
Output took 40859ms

Huch, da ist aber etwas gewaltig schief gelaufen. Das Problem ist, dass wir die Tabellen alle auf demselben context Objekt holen. Standardmäßig ist Change Tracking und Proxy Creation im Entity Framework 6.x angeschaltet (in 7 ist es standardmäßig ausgeschaltet). Dies kostet umso mehr Performance, je mehr Daten in einem Context geladen werden.

Diesmal korrekt: Mehrere Abfragen

Um das Problem zu beheben, sorgen wir dafür, dass Change Tracking und Proxy creation ausgeschaltet werden. Wenn man dies tut, muss man sich im klaren darüber sein, dass man auch Lazy Loading ausschaltet. Hat man früher mit Lazy Loading gearbeitet, kann es nun zu Fehlern im Programm kommen, da nun alles explizit geladen werden muss. Dadurch erhält man allerdings die volle Kontrolle über sein Programm zurück;

IDictionary<int, Customer> customers;
using (var context = GetContextForGet(counter))
{
    customers = context.Customer.ToDictionary(c => c.CustomerID);
    var headers = context.SalesOrderHeader.ToDictionary(h => h.SalesOrderID);
    var details = context.SalesOrderDetail.ToDictionary(d => d.SalesOrderDetailID);
    var products = context.Product.ToDictionary(p => p.ProductID);

    RelationshipFixup(customers, headers, details, products);
}
private static EfPerformanceContext GetContextForGet()
{
    var context = new EfPerformanceContext();

    context.Configuration.AutoDetectChangesEnabled = false;
    context.Configuration.ProxyCreationEnabled = false;

    return context;
}

Sum is 353.504,37
Used 4 SQL SELECT Statements
Get took 1305ms
Output took 0ms

Wir sehen, dass zwar mehr SQL Select Abfragen, als bei Include verwendet wurden, aber der Abfragen insgesamt schneller waren. Um unsere Entity Framework Abfragen jetzt noch schneller zu machen, werden wir im nächsten Teil die Abfragen parallelisieren.

2 Gedanken zu „Warum dein Entity Framework langsam ist Teil 2

  1. Pingback: Warum dein Entity Framework langsam ist Teil 3 - Cocktails and Code

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.