Selbst-optimierende ORMs – Give me the free lunch!

Einst wurde O/R Mapping als Vietnamkrieg der Informatik bezeichnet. Es mag sein, dass dieser Vergleich bei näherer Betrachtung hinkt, er verdeutlicht jedoch auf jeden Fall, dass O/R Mapping ein heftig umstrittenes Feld ist:

„Although it may seem trite to say it, Object/Relational Mapping is the Vietnam of Computer Science. It represents a quagmire which starts well, gets more complicated as time passes, and before long entraps its users in a commitment that has no clear demarcation point, no clear win conditions, and no clear exit strategy.“

Ich möchte gar nicht so sehr auf den Vietnamkrieg-Vergleich als solches eingehen. Es sollte nur zeigen, was es für ein heißes Eisen war und immer noch ist. Vereinfachend kann das Pro und Kontra auf das Abwägen von Performance und Produktivität reduziert werden. Mangelnde Performance ist ein häufig geäußerter Kritikpunkt an O/R Mapping. ORMs sind nicht per se inperformant, aber ein Tuning erfordert Fingerspitzengefühl und Expertenkenntnisse. Die Mächtigkeit von objektorientiertem Design zu ermöglichen, ist dagegen das große Plus des O/R Mappings.

Es wird oft geäußert, dass zwischen dem Objektparadigma und dem relationalen Paradigma ein Impedience Mismatch existiert. Tabellen sind zwar nicht eins zu eins auf Klassen abbildbar, aber aus meiner Sicht ist dies keine große Kluft, sondern nur ein kleiner Spalt, der mittels O/R Mapping ja ausreichend überbrückt wird. Das viel größere Problem ist aus meiner Sicht ein ganz anderes. Es hat auch gar nichts mit Datenbanken oder dem relationalen Paradigma zu tun, sondern ist ganz einfach ein Grundproblem der Objektorientierung:

Objektorientierung und Verteilung harmonieren nicht!

Dies ist ein anerkanntes „Naturgesetz“, das von namenhaften Autoren beschrieben wurde:

  • Patterns of Enterprise Application Architecture: Fowler’s erstes Gesetz des verteilten Objektorientierten Designs: “Don’t distribute your objects.“ – Dieses Gesetz wurde zwar nicht direkt für O/R Mapping postuliert, jedoch hat Lazy Loading das gleiche Performance-Verhalten wie “verteilte Objekte”
  • The Law of Leaky Abstractions – Joel Spolsky beschreibt unter anderem, dass die Abstrahierung von Hardware wie z.B. Netzwerken unerwartete Auswirkungen haben kann

Und auch CORBA ist gescheitert, weil das Unmögliche eben doch nicht immer möglich ist.

Warum Verteilung den O/R Mappern so viele Schwierigkeiten bereitet, lässt sich in ein paar Zeilen Pseudocode verdeutlichen:

<table>
  @foreach(var customer in database.Customers)
  {
    <tr>
      <td>@customer.Name</td>
      <td>
        @foreach(var phoneNumber in customer.PhoneNumbers.Where(p => p.IsGermanMobileNumberPattern())
        {
          <div>@phoneNumber.Value</div>
        }
      </td>
    <tr>
   }
</table>

In diesem primitiven Code sind viele typische Fallstricke des O/R Mappings versteckt.

Zunächst sieht man zwei verschachtelte foreach-Anweisungen. Ein primitives O/R Mapping wird für jeden Kunden SQL-Commands an die Datenbank schicken, um die Telefonnummern zu laden. Dieses Phänomen ist als Cripple Loading bekannt. Gute O/R Mapper erlauben (mittlerweile) situatives und selektives Eager Loading, um dieses Problem zu umgehen, d.h. beim Laden der Daten können auf eine produktive Art und Weise Beziehungen angegeben werden, die mitgeladen werden sollen.

Ein weiteres oft beschriebenes Problem ist das unnötige Laden von Fat-Content. Ein O/R Mapper lädt standardmäßig immer alle Felder eines Objektes. Es gibt jedoch sehr komplexe Objekte mit hunderten von Feldern wie z.B. einen Personalstammsatz. Vor allem wenn einzelne Felder Langtexte, Bilder oder Ähnliches enthalten, können Performance-Einbußen entstehen.

Aber das Problem ist hier noch nicht zu Ende. Das selektive Eager Loading ist ein manuelles Performance-Tuning. Was ist, wenn jedoch außer einfachen „Daten-Eigenschaften“ Methoden aufgerufen werden, wie z.B. die Prüfung „IsGermanMobileNumberPattern“ im Pseudocode-Beispiel?  Hier kann kaum Optimierung stattfinden, denn durch Information Hiding und Vererbung weiß man per Definition der Objektorientierung nicht, was sich hinter dem Aufruf einer Methode versteckt. Wird da ein Webservice aufgerufen oder nur eine Regex-Prüfung ausgeführt?

Das relationale Paradigma funktioniert perfekt mit verteilten Umgebungen

Relationale Datenbanksysteme funktionieren dagegen schon seit Urzeiten perfekt als verteilte Systeme mit Datenbankservern und mit Clients. Aber sie bieten eben keine Funktionalität wie Information Hiding oder Substituierbarkeit.

Passend zum obigen Beispiel könnte eine SQL-Abfrage sehr performant die Daten zurückliefern:

SELECT Id, Name FROM Customer;

SELECT PhoneNumber.CustomerId, PhoneNumber.Value FROM Customer
JOIN PhoneNumber ON PhoneNumber.CustomerId = Customer.Id;

Aber was ist, wenn nun die Client-Anwendung Code hinzufügt, z.B. um eine bestimmte Icon-Regel auszuführen, die für bestimmte Kundengruppen ein Icon ermittelt?

Dementsprechend muss das SQL geändert werden. Dies kann als wartungsunfreundlich bezeichnet werden, da häufige Änderungen am Datenzugriff nötig sind. Außerdem kann es unter Umständen gar nicht so einfach sein, die zu ladenden Daten festzulegen, wenn ein gutes objektorientiertes Design mit Information Hiding und  Wiederverwendung erstellt wird. Alle potentiellen Konsumenten der Daten müssen analysiert werden.

Daraus kann man schließen, dass es in vielen Anwendungen ökonomisch vernünftig ist, auf Performance-Optimierungen zu verzichten und stattdessen einfacheren, wartbaren und Objekt-orientierten Code zu schreiben.

Können wir nicht beides haben? Give me the Free Lunch!

Nun kommen wir zum eigentlichen Thema dieses Artikels (war die Einleitung vielleicht etwas lang geraten?!). Es wäre wirklich paradiesisch ein System zu haben, welches einfach und wartbar zu programmieren ist, jedoch gleichzeitig eine gute Performance besitzt.

Warum auf den Free Lunch verzichten, bei der Hardware haben wir auch lange Zeit ständig Verbesserungen erfahren, ohne dafür mehr zu bezahlen oder anders programmieren zu müssen.

Nun ist die große Preisfrage, wie dies funktionieren könnte. Die Zielstellung ist einfach. Eine Engine müsste herausfinden, in welcher Situation welche Felder und Beziehungen zu einem Objekt geladen werden sollen.

Ich denke, dass dies prinzipiell auf zwei Arten passieren kann:

  1. statische Codeanalyse
  2. statistisches Lernen

Puh, beides nicht gerade einfach. Ich bin der Meinung, dass Objektdatenbanken (war es evtl. Objectivity DB von Versant?) durchaus bereits in der Vergangenheit einen statistischen Ansatz verfolgt haben.

Ich habe auch den statistischen Ansatz favorisiert, da mich die statische Codeanalyse bei der Programmierung schlicht überfordern würde. Mein kleines Proof of Concept belegt dabei eindeutig: YES, you can have free lunch!

Das System hat folgendes Design: 

Das Grundprinzip soll dabei sein, dass während der Laufzeit aufgezeichnet wird, wann welche Properties zugegriffen werden. Auf diese Kenntnisse wird dann beim Laden von Entities bzw. beim Ausführen von Datenbankqueries zurückgegriffen. Diese Statistik global für jede Entity zu führen wäre recht sinnlos, denn ein Property wird ja in vielen verschiedenen Kontexten verwendet. Um das Proof of Contept nicht zu kompliziert zu gestalten, wird ein DestinationCall eingeführt, welcher typischerweise eine Programm-Funktion darstellt, wie z.B. „Zeige Kundenliste an“, „Bearbeite Kunde“ oder „führe Kreditberechnung durch“. Für jeden DestinationCall werden dann die zugegriffenen Properties aufgezeichnet, daraus werden LoadAdvices generiert. Der QueryExecutor, d.h. eine absolute Kernkomponente eines O/R Mappers, muss dann auf die LoadAdvices zugreifen und damit die Optimierung umsetzen.

Eine Voraussetzung für das Laden ist ein Konzept zum effizienten Umgang mit partiell geladenen Entities. Dies wird durch SparseObjects und ein damit verbundenes Typsystem abgebildet. Diese sind an die aus WPF bekannten Dependency Properties angelehnt. Man muss also auf jeden Fall auf POCOs verzichten und seine Entities auf eine spezielle Art und Weise implementieren.

Das Proof of Concept beinhaltet die API und ein Konsolenprogramm, welches einige Beispiele ausgibt:

Download Beispiel und Source Code

Das Proof of Concept hat derzeit einige Beschränkungen:

  • Es werden lediglich primitive Properties unterstützt, d.h. keine Kind-Objektgraphen / Beziehungen. Das Recording müsste dafür sicher nicht viel verändert werden, jedoch der QueryExecutor und das Typsystem, weswegen ich vorerst darauf verzichtet habe
  • Der DestinationCall-Kontext muss derzeit als Feld gesetzt werden. Es wäre eleganter dies mit PostSharp aspektorientiert zu implementieren und damit Methoden wie z.B. einen EventHandler zu attributieren
  • Der DestinationCall-Kontext müsste eher ein Stack als ein einzelnes Feld sein
  • Die Objekte unterstützen kein Lazy-Loading. Im Falle eines falschen Load-Advices könnten keine Daten nachgeladen werden. Das Lazy-Loading selbst kann intelligenter sein, als das heutiger O/R Mapper. Ein einzelnes Lazy-Loading, könnte zu einem Batch-Loading führen, z.B. falls eine Property in einer foreach-Anweisung zugegriffen wird
  • Die LoadAdvices werden nicht aus einer Datenbank geladen, sondern nur per API oder aus dem Recording der Laufzeit

Wer Lust hat, diese Unzulänglichkeiten zu beheben, kann sich gerne bei mir melden!

Da das Recording einen Overhead für die Laufzeit bedeutet, sollte es für ein reales Programm nicht ständig ausgeführt werden. Nachdem eine Lernphase abgeschlossen ist, sollte das Recording deaktiviert werden. Dann ist der Overhead nur noch sehr gering, denn die Implementierung ist für Inlining optimiert, sodass der Overhead wirklich nur wenige MSIL-Instruktionen beinhaltet. Diese Lernphase muss auch überhaupt nicht beim Endnutzer stattfinden, sondern könnte auch während der Entwicklung oder in einem Testlabor „nebenbei“ stattfinden. Die aufgezeichneten LoadAdvices könnten dann als Teil des Programms (in einer Datenbank) ausgeliefert werden.

Die API nutzen

Die Implementierung ist zwar nur ein Prototyp, aber man kann aber auf jeden Fall damit rumspielen. Dazu muss eine Entitätsklasse definiert werden, welche Metadaten über Typ und Properties definiert. Wem das sehr ähnlich zu den Dependency Properties von WPF erscheint, der hat dies absolut richtig erkannt, lediglich die Initialisierung erfolgt etwas anders, da zusätzlich zum Property auch noch ein EntityType definiert werden muss und EntityType eine Eigenschaft mit den dazugehörigen Properties haben soll:

Natürlich schreit dies gerade zu nach Codegeneration!

Fazit

Es ist wirklich ein Beweis erbracht, dass ein statistischer Ansatz funktionieren könnte, allerdings müsste das System tief in einen O/R Mapper integriert werden und kann nur schwer einem vorhanden modular hinzugefügt werden. Sprich: man müsste seinen eigenen O/R Mapper implementieren oder ein Open-Source-System wie NHibernate forken.

Das Prinzip des DestinationCalls ist sicher nicht das Ende der Weisheit, eine weiterführende Engine, die Kosten für Eager Loading and Cache Misses analysiert und darauf basierend Entscheidungen trifft, wäre natürlich viel ausgeklügelter, aber ich denke, dass selbst das einfache DestinationCall Paradigma bereits in vielen Anwendungsteilen enorme Performance-Verbesserungen erzielen kann. Dadurch, dass der Entwickler den DestinationCall immer manuell benennen muss, z.B. durch das Setzen eines Attributes, ist auch ein Schutz vor fehlgeleiteter Optimierung gegeben.

Aber sicher wäre noch viel Arbeit zu tun, um diese Funktionalität in einen O/R Mapper zu integrieren.