GraphQL Demo (3/8) – Dataloader und Batching

Der Dataloader stellt Batching und Caching zur Verfügung. Entwickelt wurde er von Facebook. Wir haben ihn für das Batching eingesetzt, so werden nun verschiedene Anfragen, die mit den Chatnachrichten laden, zu einer Anfrage zusammenführt, die dann an die Datenbank gestellt wird. Die geladenen Daten werden im Dataloader gecacht und können später ohne Datenbankzugriff geladen werden.

Lizenzierung

Der Dataloader darf kostenfrei verwendet werden, sofern man die Copyright-Notiz von Facebook beibehält und für Facebook durch die Verwendung keinerlei Schaden entsteht. Genaueres hier.

Wie man den Dataloader einsetzt

Wir haben unsere Beispielanwendung in TypeScript geschrieben. Demnach sind auch folgende Codefragmente in TypeScript gehalten.

  1. Vorausetzungen: Der Dataloader setzt derzeit eine JavaScript-Umgebung voraus, welche die Features von globalen ES6-Promises- und Map-Klassen unterstützt.
  2. Den Dataloader im Projekt installieren:

    npm install –save dataloader

  1. Dataloader in der Klasse verfügbar machen:

    let DataLoader = require (‚dataloader‘);

  1. Jeder Batch benötigt einen eigenen Dataloader. Ein Dataloader bekommt als Argument eine Funktion, die die entsprechenden Schlüssel über die Batch-Funktion auf die Ergebnisse mappt:

    let messageLoaderForUsers = new DataLoader((ids:any) =>
        batchMessagesUser(ids));

  1. Der Kern liegt in der Batch-Funktion. Sie bekommt mehrere IDs und lädt diese alle auf einmal aus einer Datenbank oder anderen Datenquelle. Sie gibt ein Dictionary zurück, welches als Schlüssel die ID und als Value die geladenen Daten liefert:

    async function batchMessagesUser(ids: number[]) {
            //Messeges from the given authors are filtered
            let messages = await Message.findAll({
    attributes: [‚id‘, ‚authorId‘],
    where: {authorId: { $in: ids }}
    });

    //messages are grouped by author
    const groupedMessages = _.groupBy(messages, „authorId“);

    //We return an array with the IDs as key and the message as value
    return ids.map(key => groupedMessages[key] || []);
    }

  1. Wenn die Daten nun abgefragt werden sollen, muss nur noch die Load-Funktion vom Dataloader aufgerufen werden, welche ein Promise zurückgibt:

    messageLoaderUser.load(authorID)

Herausforderungen beim Dataloader

Der Dataloader stellt nativ keine Unterstützung zur Pagnation zur Verfügung. In einem solchen Fall werden bei jeder Anfrage alle Tabelleneinträge zurückgegeben und die ganze Datenbank abgefragt.
Um dieses Problem zu lösen, gibt es zwei Möglichkeiten. Zum einen könnte man ein verschachteltes rekursives Union-All-SQL-Statement bauen, das die angefragten Daten bis zum übergebenen Limit zurück gibt und der Map-Funktion übergibt. Dazu muss aber der batch-Funktion die Pagnation übergeben werden und für jeden Offset und jedes Limit der Pagnation existiert dann ein eigener Batch.
Die andere Möglichkeit wäre zunächst alle IDs und Fremdschlüssel über den Dataloader aus der Datenbank laden, auf diese dann die Pagnation ansetzt und anschließend die Datensätze zu den entsprechenden IDs sich über den Dataloader holt (1-1 Beziehung). Das reduziert die aus der Datenbank zu ladenden Daten, im Vergleich dazu, dass alle Felder zu allen Datensätzen geladen werden. Jedoch werden immer noch von allen Einträgen die IDs und Fremdschlüssel geladen, was bei großen Tabellen problematisch wird.

//Loading MessagesInfos (id and authorId) from DataLoader
let messageInfos = await messageLoaderForUsers.load(this.getId());
if (!offset) {offset = 0;}

//Pagination and extracting the Ids of the messageInfos
messageInfos = messageInfos.slice(offset, (offset + limit));
let messageIds = messageInfos.map((message: any) => message.id);

//Loading the full messages
let results = await messageLoader.loadMany(messageIds);
results = results.map((result: any) => result[0]);

return results;

Für jeden Anfragetyp muss ein eigener Dataloader mit entsprechender Batch-Funktion implementiert werden. Zusätzlich muss auch für jede Anfrage, oder zumindest für jeden User, ein eigener Dataloader erstellt werden, um Zugriffe über die Caches der Dataloader auf die Daten anderer Nutzer zu verhindern. Das Problem kann aber durch den Einsatz von entsprechenden Factory-Methoden sehr einfach gelöst werden.

Zusammenfassung:

Insgesamt lässt sich sagen, dass der Dataloader vor allem beim Batchen von einzelnen Fremdtypen bzw. 1:1-Beziehungen viel bringt (bis zu Faktor 20 schneller). Bei Listen bzw. 1-n-Beziehungen war der Performance-Vorteil mit eineinhalb bis doppelter Durchsatzrate eher mäßig. Dies würde aber höher ausfallen, wenn Datenbank und Server nicht auf der selben Maschine laufen würden und die RTT (Round Trip Time) höher wäre.
Der Dataloader vermindert zusätzlich noch die im Cache der Apollo Engine zu speichernde Datenmenge, da er nur die Attribute zurückgibt, die angefordert werden und nicht der ganze Datensatz im Cache gespeichert wird. Das hat allerdings den Nachteil, dass bei einer neuen Anfrage auf den gleichen Datensatz mit anderen Attributen kein Cache-Eintrag mehr vorhanden ist, da die Attribute nicht im Cache liegen.
Problematisch ist aufgrund von Sicherheit, Zugriffskontrolle auch, dass der Dataloader Daten für immer speichert und das kontextunabhängig. Die Kontextunabhänigkeit führt zusätzlich dazu, dass kontextabhängige Daten zu einem späteren Zeitpunkt abhängig vom Kontext, der zur Zeit der Speicherung geherrscht hat, zurückgegeben wird.

Anhang – Testergebnisse

Wir haben mit unserem Server Loadtesting durchgeführt. Dazu haben wir mit dem Tool JMeter unterschiedlich viele Wiederholungen der selben Anfragen an unseren Server geschickt.
Folgende Aspekte haben wir dabei beachtet:

  • Query: Die Query, die an den Server gestellt wurde
  • Komplexität Die maximale Anzahl der Knoten, die zurückgegeben werden können
  • Threads: Die Anzahl der parallelen Anfragen an den Server
  • Wdhs: Die Anzahl, der Wiederholungen der parallelen Threads
  • Abgeschlossene Proben: Die Anzahl der Anfragen, die insgesamt während dem Test an den Server geschickt wurden
  • Durchsatz: Die Anzahl der bearbeiteten Anfragen pro Sekunde
  • Caching: Gibt an, ob Caching im Server aktiviert war
  • Dataloader: Gibt an, ob der Dataloader aktiv war
  • Production Mode: Gibt an, ob der Server im Production Mode gelaufen ist.

In der folgenden Tabelle haben wir unsere Testergebnisse dokumentiert:
LoadTest – QraphQL

 

One thought on “GraphQL Demo (3/8) – Dataloader und Batching”

Kommentar verfassen

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

%d Bloggern gefällt das: