Datenpunkte

Vorkompilieren von LINQ-Abfragen

Julie Lerman

Beispielcode herunterladen.

Wenn Sie in Ihren Anwendungen mit LINQ to SQL oder LINQ to Entities arbeiten, sollten Sie unbedingt in Erwägung ziehen, alle Abfragen vorzukompilieren, die wiederholt erstellt und ausgeführt werden. Ich vertiefe mich oft in die Erledigung einer bestimmten Aufgabe und versäume, vorkompilierte Abfragen zu nutzen, bis ich mit der Entwicklungsarbeit schon ziemlich weit bin. Das ist wie bei der "Ausnahmebehandlungskrankheit", wo Entwickler erst dann versuchen, die Ausnahmebehandlung in eine Anwendung hineinzuschmuggeln, nachdem Ausnahmen aufgetreten sind.

Aber auch wenn Sie diese wichtige leistungssteigernde Technik eingesetzt haben, ist es recht wahrscheinlich, dass ihre Vorzüge nicht zum Tragen kommen. Ihnen wird vielleicht auffallen, dass der versprochene Leistungszuwachs ausbleibt, aber die Ursache (und Korrektur) hierfür entgeht Ihnen.

In diesem Artikel erkläre ich zuerst, wie Abfragen vorkompiliert werden, und dann konzentriere ich mich auf das Problem, dass die Vorteile der Vorkompilierung in Webanwendungen, Diensten und anderen Szenarien verloren gehen. Sie erfahren, wie Sie sicherstellen, dass Sie die erwarteten Leistungsvorteile bei Postbacks, kurzlebigen Dienstvorgängen und anderem Code erzielen, bei dem wichtige Instanzen den Bereich verlassen.

Vorkompilieren von Abfragen

Der Prozess der Umformung einer LINQ-Abfrage in eine relevante Speicherabfrage (beispielsweise in T-SQL, das von der Datenbank ausgeführt wird) ist ein relativ kostenintensiver Bestandteil der gesamten Abfrageausführung. Abbildung 1 zeigt, welche Schritte zur Umformung einer LINQ to Entities-Abfrage in eine Speicherabfrage erforderlich sind.


Abbildung 1 Umformen einer LINQ-Abfrage in eine relevante Speicherabfrage

Im Blogbeitrag des Entity Framework-Teams mit dem Titel "Exploring the Performance of the ADO.NET Entity Framework - Part 1" (blogs.msdn.com/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx), wird dieser Prozess in einzelne Schritte aufgeschlüsselt und gezeigt, wie viel Zeit die Ausführung der einzelnen Schritte erfordert. Beachten Sie, dass dieser Beitrag auf der Entity Framework-Version von Microsoft .NET Framework 3.5 SP1 basiert und dass der Zeitaufwand der einzelnen Schritte in der neuen Version wahrscheinlich anders verteilt ist. Nichtsdestotrotz ist die Vorkompilierung noch immer ein kostspieliger Teil der Abfrageausführung.

Durch die Vorkompilierung einer Abfrage wird des Entity Framework und LINQ to SQL ermöglicht, die Speicherabfrage wiederzuverwenden, statt sie immer wieder erneut erstellen zu müssen. Wenn eine Anwendung beispielsweise oft verschiedene Kunden aus dem Datenspeicher abruft, kann die zugehörige Abfrage möglicherweise so aussehen:

Context.Customers.Where(c=>c.CustomerID==_custID)

Angenommen, für die einzelnen Abfragen wird jeweils nur der Parameter _custID geändert, wäre es dann keine ungeheure Zeitverschwendung, diesen Ausdruck für jede Abfrage erneut in einen SQL-Befehl zu übersetzen?

Sowohl LINQ als auch SQL and Entity Framework lassen die Vorkompilierung von Abfragen zu Weil sich die Prozesse in den Frameworks etwas unterscheiden, verfügt jedes Framework über eine eigene CompiledQuery-Klasse. In LINQ to SQL wird System.Data.LINQ.CompiledQuery verwendet, in Entity Framework dagegen System.Data. Objects.CompiledQuery. Beide Varianten von CompiledQuery lassen die Übergabe von Parametern zu und beide erfordern, dass das aktuell verwendete DataContext- bzw. ObjectContext-Objekt übergeben wird. Von der Programmierung her gesehen sind beide im Grunde genommen gleich.

Die CompiledQuery.Compile-Methode gibt einen Delegaten in der Form einer Funktion zurück, die bei Bedarf aufgerufen werden kann.

Es folgt eine einfache Abfrage, die mit der CompiledQuery-Klasse von Entity Framework kompiliert wurde. Diese Klasse ist statisch und muss daher nicht instanziiert werden.


    var _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
       ((ctx, id) =>ctx.Customers.Where(c=> c.ContactID == id).Single());

    Dim _custByID= CompiledQuery.Compile(Of SalesEntities, Integer, Customer) 
       (Function(ctx As ObjectContext, id As Integer) 
        ctx.Customers.Where(Function(c) c.CustomerID = custID).Single)

Sie können im Abfrageausdruck entweder LINQ-Methoden oder LINQ-Operatoren verwenden. Diese Abfragen werden mit LINQ-Methoden und Lambdas erstellt.

Da die Syntax etwas verwirrender als bei typischen generischen Methoden ist, erläutere ich die einzelnen Bestandteile. Ziel der Compile-Methode ist die Erstellung einer Funktion (Delegat), die zu einem späteren Zeitpunkt aufgerufen werden kann, wie in folgendem Code gezeigt wird:


    CompiledQuery.Compile<SalesEntities, int, Customer>

    CompiledQuery.Compile(Of SalesEntities, Integer, Customer)

Weil es sich um eine generische Methode handelt, muss ihr mitgeteilt werden, welche Typen als Argumente übergeben werden und welche Typen zurückgeben werden, wenn der Delegat aufgerufen wird: Für LINQ to SQL muss zumindest ein Typ von ObjectContext oder DataContext übergeben werden. Sie können ein Objekt vom Typ System.Data.Objects.ObjectContext oder einen davon abgeleiteten Typ angeben. In diesem Fall verwende ich explizit die abgeleitete Klasse SalesEntities, die zu meinem Entitätsdatenmodell gehört.

Sie können auch mehrere Argumente definieren, die unmittelbar nach dem Kontext angegeben werden müssen. In meinem Beispiel gebe ich Compile an, dass die resultierende vorkompilierte Abfrage auch einen Parameter vom Typ int bzw. Integer akzeptieren soll. Der letzte Typ beschreibt, was die Abfrage zurückgibt. In meinem Beispiel ist dies ein Customer-Objekt:


    ((ctx, id) =>ctx.Customers.Where(c => c.ContactID == id).Single())

    Function(ctx As ObjectContext, id As Integer) 
       ctx.Customers.Where(Function(c) c.CustomerID = custID).Single

Ergebnis der vorstehenden Compile-Methode ist der folgende Delegat:


    private System.Func<SalesEntities, int, Customer> _custByID

    Private _custByID As System.Func(Of SalesEntities, Integer, Customer)

Sobald die Abfrage kompiliert wurde, können Sie sie einfach immer dann aufrufen, wenn diese Abfrage ausgeführt werden soll, und übergeben dann die ObjectContext- oder DataContext-Instanz und alle anderen erforderlichen Parameter. Hier wird eine Instanz namens _commonContext und eine Variable namens _custID verwendet:

Customer cust  = _custByID.Invoke(_commonContext, _custID);

Wenn der Delegat zum ersten Mal aufgerufen wird, wird die Abfrage in die Speicherabfrage übersetzt, und diese Übersetzung zur Wiederverwendung in nachfolgenden Aufrufen von Invoke zwischengespeichert. LINQ kann die Kompilierung der Abfrage überspringen und gleich mit der Ausführung beginnen.

Sicherstellen, dass tatsächlich die vorkompilierte Abfrage verwendet wird

Es gibt ein nicht ganz offensichtliches und relativ wenig bekanntes Problem im Zusammenhang mit vorkompilierten Abfragen. Viele Entwickler unterstellen, dass die Abfrage im Anwendungsprozess zwischengespeichert wird und im Speicher verfügbar bleibt. Ich bin sicherlich davon ausgegangen, weil ich nichts fand, was auf etwas Anderes hinwies, abgesehen von einigen wenig eindrucksvollen Leistungsindikatoren. Wenn allerdings das Objekt, in dem die kompilierte Abfrage instanziiert wurde, den Geltungsbereich verlässt, dann geht auch die vorkompilierte Abfrage verloren. Da sie für jede Ausführung erneut vorkompiliert werden muss, bringt die Vorkompilierung hier keinerlei Vorteile mit sich. In der Tat ist sie sogar aufwendiger als die einfache Ausführung einer LINQ-Abfrage, weil die CLR in Bezug auf den Delegaten einige Zusatzaufgaben erledigen muss.

Rico Mariani untersucht die mit der Verwendung des Delegaten verbundenen Kosten in seinem Blogbeitrag "Performance Quiz #13—Linq to SQL compiled query cost—solution" (blogs.msdn.com/ricom/archive/2008/01/14/performance-quiz-13-linq-to-sql-compiled-query-cost-solution.aspx). Die Diskussion in den Kommentaren ist gleichermaßen aufschlussreich.

Ich habe Blogberichte über das "fürchterliche Leistungsverhalten" von LINQ to Entities in Webanwendungen "sogar mit vorkompilierten Abfragen" gelesen. Das liegt daran, dass jedes Mal, wenn die Seite etwas zurücksendet, ein Kontext neu instanziiert und die Abfrage erneut vorkompiliert wird. Die vorkompilierte Abfrage wird nie wiederverwendet. Das gleiche Programm tritt überall dort auf, wo kurzlebige Kontexte verwendet werden. Das kann in Situationen sein, in denen dies zu erwarten ist, beispielsweise bei einem Web- oder WCF (Windows Communication Foundation)-Dienst, und in weniger klaren Situationen, z. B. einem Repository, das während der Ausführung einen neuen Kontext instanziiert, wenn keine Instanz bereitgestellt wird.

Der Verlust des Delegaten lässt sich vermeiden, indem Sie eine als static (Shared in VB) deklarierte Variable verwenden, um die Abfrage über verschiedene Prozesse hinweg zu speichern, und sie dann unter Verwendung des aktuell gegebenen Kontexts aufrufen.

Es folgt ein Muster, das ich erfolgreich bei Webanwendungen, WCF-Diensten und Repositories verwendet habe, bei denen das ObjectContext-Objekt häufig den Gültigkeitsbereich verlässt und der Delegat innerhalb des gesamten Anwendungsprozesses verfügbar sein sollte. Sie müssen einen statischen Delegaten im Konstruktor der Klasse deklarieren, in der Abfragen aufgerufen werden. Hier deklariere ich einen Delegaten, der der kompilierten Abfrage entspricht, die ich zuvor erstellt habe:


    static Func<ObjectContext, int, Customer> _custByID;

``` VB

    Shared _custByID As Func(Of ObjectContext, Integer, Customer)

Die Abfrage kann an verschiedenen Stellen im Code kompiliert werden. Sie kann in einem Klassenkonstruktor oder unmittelbar vor ihrem Aufruf kompiliert werden. Folgendes Beispiel enthält eine Methode, die eine Abfrage ausführen und ein Custormer-Objekt zurückgeben soll:

public static Customer GetCustomer( int ID)
    {
      //test for an existing instance of the compiled query
      if (_custByID == null)
      {
        _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
         ((ctx, id) => ctx.Customers.Where(c => c.CustomerID == id).Single());
      }
      return _custByID.Invoke(_context, ID);
    }

Diese Methode verwendet die kompilierte Abfrage. Zuerst wird die Abfrage während der Ausführung kompiliert, allerdings nur bei Bedarf, und ob dies erforderlich ist, wird ermittelt, indem geprüft wird, ob die Abfrage bereits instanziiert wurde. Wenn die Abfrage im Klassenkonstruktor kompiliert wird, müssen Sie den gleichen Test durchführen, um sicherzustellen, dass nur, wenn notwendig, Ressourcen zum Kompilieren verwendet werden.

Weil der Delegat _custByID als static deklariert wurde, bleibt er selbst dann im Arbeitsspeicher, wenn die Klasse, in der er enthalten ist, den Geltungsbereich verlässt. Solange sich der Anwendungsprozess selbst im Geltungsbereich befindet, ist der Delegat daher verfügbar. Er wird nicht ungültig, und daher wird die Kompilierung übersprungen.

Vorkompilierte Abfragen und Projektionen

Es sind noch einige andere Stolpersteine zu beachten, die einfacher zu erkennen sind. Der erste Stolperstein hat mit Projektionen zu tun, ist aber nicht dem Problem des ungewollten Neukompilierens vorkompilierter Abfragen eigen. Wenn in einer Abfrage Spalten projiziert und nicht bestimmte Typen zurückgegeben werden, ist das Ergebnis immer ein anonymer Typ.

In der Definition der Abfrage kann der Rückgabetyp nicht angegeben werden, weil es keine Möglichkeit gibt, "Typ eines anonymen Typs" auszudrücken. Dasselbe Problem stellt sich, wenn die Abfrage in einer Methode ausgeführt werden soll, die das Ergebnis zurückgeben soll, weil auch hier nicht angegeben werden kann, was von der Methode zurückgegeben wird. Entwickler, die mit LINQ arbeiten, machen häufig mit der letztgenannten Einschränkung Bekanntschaft.

Angesichts der Tatsache, dass ein anonymer Typ nur temporär während der Ausführung verwendet wird und nicht zur Wiederverwendung vorgesehen ist, dann ist diese Einschränkung zwar frustrierend, aber sinnvoll. Anonyme Typen sollen nicht von Methode zu Methode weitergegeben werden.

Sie müssen für die vorkompilierte Abfrage einen Typ definieren, der der Projektion entspricht. Beachten Sie, dass Sie in Entity Framework einen Klasse, keine Struktur verwenden müssen, da LINQ to Entities keine Projektionen in Typen zulässt, die keinen Konstruktor haben. LINQ to SQL lässt Strukturen als Projektionsziel zu. Für Entity Framework können Sie daher nur eine Klasse, aber für LINQ to SQL können Sie sowohl eine Klasse als auch eine Struktur verwenden, um die Beschränkungen im Zusammenhang mit anonymen Typen zu vermeiden.

Vorkompilierte Abfragen und Vorabruf in LINQ to SQL

Ein weiteres potenzielles Problem mit vorkompilierten Abfragen betrifft den Vorabruf bzw. das Eager Loading (eifrige Laden), es tritt aber nur bei LINQ to SQL auf. Im Entity Framework wird die Include-Methode zum Eager Loading verwendet, sodass nur eine Abfrage in der Datenbank ausgeführt wird. Weil Include Bestandteil einer Abfrage sein kann, z. B. context.Customer.Include("Orders"), ist dies hier kein Problem. Bei LINQ to SQL, wird das Eager Loading allerdings im DataContext und nicht in der Abfrage definiert.

DataContext.LoadOptions verfügt über eine LoadWith-Methode, mit der Sie angeben können, welche zugehörigen Daten zusammen mit einer bestimmten Entität vorzeitig geladen werden sollen.

Sie können LoadWith so definieren, dass die Bestellungen (engl. Orders) von Kunden (engl. Customers), die abgefragt werden, geladen werden:

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)

Dann könnten Sie eine Regel hinzufügen, die besagt, dass die Detaildaten zu allen geladenen Bestellungen geladen werden sollen:

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions.LoadWith<Order>(o =>o.Details)

Sie können LoadOptions direkt für die DataContext-Instanz definieren oder eine DataLoadOptions-Klasse erstellen, LoadWith-Regeln in diesem Objekt definieren und es dann dem Kontext anfügen:

DataLoadOptions _myLoadOptions = new DataLoadOptions();
_myLoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions= myLoadOptions

Es gibt allerdings Vorbehalte gegenüber dem allgemeinen Einsatz von LoadOptions und der DataLoadOptions-Klasse. Wenn Sie beispielsweise DataLoadOptions definieren, dem DataContext anfügen und eine Abfrage mit diesem DataContext ausführen, dann können Sie anschließend keinen neuen Satz von DataLoadOptions anfügen. Es lässt sich noch viel mehr zu den verschiedenen Ladeoptionen und den damit verbundenen Nachteilen sagen, wir wollen uns jedoch ein grundlegendes Muster für die Anwendung einiger LoadOptions auf eine vorkompilierte Abfrage ansehen.

Das Besondere an dem Muster ist, dass Sie DataLoadOptions vorab definieren können, ohne diese einem bestimmten Kontext zuzuordnen.

Deklarieren Sie in den Klassendeklarationen, in denen die statischen Funktionsvariablen zum Speichern der vorkompilierten Abfragen deklariert werden, eine neue Variable vom Typ DataLoadOptions. Es ist wichtig, dass auch diese Variable als static deklariert wird, damit sie zusammen mit den Delegaten verfügbar bleibt:

static DataLoadOptions Load_Customer_Orders_Details = new DataLoadOptions();

Dann können Sie in der Methode, in der die Abfrage kompiliert und aufgerufen wird, die LoadOptions zusammen mit dem Delegaten definieren (siehe Abbildung 2). Diese Methode ist in .NET Framework 3.5 und .NET Framework 4 gültig.

Abbildung 2 Definieren von LoadOptions in Verbindung mit einem Delegaten

public Customer GetRandomCustomerWithOrders()
    {
      if (Load_Customer_Orders_Details == null)
      {
        Load_Customer_Orders_Details = new DataLoadOptions();
        Load_Customer_Orders_Details.LoadWith<Customer>(c => c.Orders);
        Load_Customer_Orders_Details.LoadWith<Order>(o => o.Details);
      }
      if (_context.LoadOptions == null)
      {
        _context.LoadOptions = Load_Customer_Orders_Details;
      }
      if (_CustWithOrders == null)
      {
        _CustWithOrders = CompiledQuery.Compile<DataClasses1DataContext, Customer>
               (ctx => ctx.Customers.Where(c => c.Orders.Any()).FirstOrDefault());
      }
      return _CustWithOrders.Invoke(_context);
    }

Da die DataLoadOptions als static deklariert wurden, werden sie nur bei Bedarf definiert. Abhängig von der Logik der betreffenden Klasse, kann der DataContext neu sein oder nicht neu sein. Wenn es sich um einen Kontext handelt, der wiederverwendet wird, dann verfügt er über die zuvor zugewiesenen LoadOptions. Andernfalls müssen Sie sie zuweisen. Sie können diese Abfrage jetzt wiederholt aufrufen und trotzdem die Vorteile den Vorabruf von LINQ to SQL nutzen.

Das Vorkompilieren im Blick behalten

Im Rahmen der LINQ-Abfrageausführung ist die Abfragekompilierung ein kostspieliger Teil des Prozesses. Jedes Mal, wenn Sie einer auf LINQ to SQL oder Entity Framework basierenden Anwendung LINQ-Abfragelogik hinzufügen, sollten Sie in Erwägung ziehen, diese Abfragen vorzukompilieren und wiederzuverwenden. Denken Sie aber nicht, dass Sie damit fertig sind. Wie Sie gesehen haben, gibt es Szenarien, in denen vorkompilierte Abfragen keinen Nutzen bringen. Verwenden Sir einen Profiler, z. B. SQL Profiler oder eines der Profiling-Tools von Hibernating Rhinos, zu denen L2SProf (l2sprof.com/.com) und EFProf (efprof.com) gehören. Möglicherweise müssen Sie einige der hier gezeigten Muster nutzen, um sicherzustellen, dass vorkompilierte Abfragen die versprochenen Leistungsvorteile bringen.

Danny Simmons vom Microsoft Entity Framework-Team erklärt in seinem Blogbeitrag, wie Zusammenführungsoptionen beim Vorkompilieren von Abfragen gesteuert werden und führt einige andere Fehlerquellen an, vor denen Sie sich vorsehen müssen. Sie finden diesen Beitrag unter blogs.msdn.com/dsimmons/archive/2010/01/12/ef-merge-options-and-compiled-queries.aspx.

 

Julie Lerman ist als Microsoft MVP, .NET-Mentor und Unternehmensberaterin tätig und wohnt in den Bergen von Vermont. Sie hält bei User Groups und Konferenzen in der ganzen Welt Vorträge zum Thema Datenzugriff und anderen Microsoft .NET-Themen. Lerman führt einen Blogs unter thedatafarm.com/blog und ist Autorin des hoch gelobten Titels "Programming Entity Framework" (O’Reilly Media, 2009). Sie können sie auf Twitter unter Twitter.com/julielermanvt erreichen.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels:  Danny Simmons