Modellieren und Partitionieren von Daten in Azure Cosmos DB anhand eines praktischen Beispiels

GILT FÜR: NoSQL

Dieser Artikel veranschaulicht auf Grundlage verschiedener Azure Cosmos DB-Konzepte wie Datenmodellierung, Partitionierung und bereitgestelltem Durchsatz die Erstellung eines Datenentwurfs anhand einer praktischen Übung.

Wenn Sie normalerweise mit relationalen Datenbanken arbeiten, haben Sie sich wahrscheinlich bestimmte Gewohnheiten und Vorstellungen im Hinblick auf das Entwerfen eines Datenmodells angeeignet. Die meisten dieser bewährten Methoden lassen sich aufgrund der spezifischen Einschränkungen, aber auch der einzigartigen Vorteile von Azure Cosmos DB, nicht gut umsetzen und können dazu führen, dass Sie sich für suboptimale Lösungen entscheiden. Dieser Artikel führt Sie Schritt für Schritt durch den gesamten Modellierungsprozess für einen realen Anwendungsfall in Azure Cosmos DB – von der Elementmodellierung bis hin zur Zusammenstellung der Entitäten und Containerpartitionierung.

Laden Sie Quellcode aus der Community herunter, oder sehen Sie ihn sich an, der die Konzepte aus diesem Artikel veranschaulicht.

Wichtig

Ein Community-Mitwirkender hat dieses Codebeispiel beigesteuert, und das Azure Cosmos DB-Team unterstützt seine Wartung nicht.

Szenario

In dieser Übung betrachten wir die Domäne einer Blogplattform, auf der Benutzer die Möglichkeit haben, Beiträge zu erstellen. Benutzer können diese Beiträge auch mit „Gefällt mir“ markieren und ihnen Kommentare hinzufügen.

Tipp

Einige Wörter sind hier kursiv hervorgehoben. Sie stellen die Art von „Dingen“ dar, die von unserem Modell bearbeitet werden sollen.

Weitere Anforderungen für unsere Spezifikation:

  • Auf einer Startseite wird ein Feed der kürzlich erstellten Beiträge angezeigt.
  • Wir können alle Beiträge für einen Benutzer, alle Kommentare für einen Beitrag und alle „Gefällt mir“-Markierungen für einen Beitrag abrufen.
  • Beiträge werden mit dem Benutzernamen der Autoren sowie der Anzahl von Kommentaren und „Gefällt mir“-Markierungen zurückgegeben.
  • Kommentare und „Gefällt mir“-Markierungen werden ebenfalls mit dem Benutzernamen des Benutzers, von denen sie erstellt wurden, zurückgegeben.
  • Bei der Anzeige in Listenform muss für Beiträge nur eine gekürzte Zusammenfassung des Inhalts angezeigt werden.

Identifizieren der wichtigsten Zugriffsmuster

Zunächst strukturieren wir unsere anfängliche Spezifikation, indem wir die Zugriffsmuster der Lösung ermitteln. Beim Entwurf eines Datenmodells für Azure Cosmos DB ist es wichtig zu verstehen, welche Anforderungen unser Modell erfüllen muss, um sicherzustellen, dass es diese Anforderungen effizient erfüllt.

Um den Gesamtprozess leichter nachvollziehbar zu machen, kategorisieren wir diese verschiedenen Anfragen entweder als Befehle oder als Abfragen, wobei wir einige Vokabeln von CQRS übernehmen. In CQRTS sind Befehle Schreibanforderungen (d. h. Absichten zum Aktualisieren des Systems), und Abfragen sind schreibgeschützte Anforderungen.

Hier sehen Sie die Liste der Anforderungen, die unserer Plattform verfügbar macht:

  • [C1] : Einen Benutzer erstellen/bearbeiten
  • [Q1] : Einen Benutzer abrufen
  • [C2] : Einen Beitrag erstellen/bearbeiten
  • [Q2] : Einen Beitrag abrufen
  • [Q3] : Beiträge eines Benutzers in Kurzform auflisten
  • [C3] : Einen Kommentar erstellen
  • [Q4] : Kommentare für einen Beitrag auflisten
  • [C4] : Einen Beitrag mit „Gefällt mir“ markieren
  • [Q5] : „Gefällt mir“-Markierungen eines Beitrags auflisten
  • [Q6] : Die x neuesten Beiträge in Kurzform auflisten (Feed)

Zu diesem Zeitpunkt haben wir uns noch keine Gedanken darüber gemacht, was die einzelnen Entitäten (Benutzer, Beitrag usw.) im Einzelnen enthalten. Dieser Schritt gehört in der Regel zu den ersten, die beim Entwerfen für einen relationalen Speicher angegangen werden müssen. Wir beginnen mit diesem Schritt am Anfang, da wir herausfinden müssen, wie diese Entitäten in Bezug auf Tabellen, Spalten, Fremdschlüssel usw. übersetzt werden. Bei einer Dokumentdatenbank, die beim Schreiben kein Schema erzwingt, ist es wesentlich weniger problematisch.

Die Zugriffsmuster von Beginn an zu identifizieren, ist vor allem deswegen wichtig, weil diese Liste von Anforderungen unsere Testsammlung sein wird. Jedes Mal, wenn wir das Datenmodell durchlaufen, gehen wir jede der Anforderungen durch und überprüfen ihre Leistung und Skalierbarkeit. Wir berechnen die in den einzelnen Modellen verbrauchten Anforderungseinheiten und optimieren sie. Alle diese Modelle verwenden die Standardindizierungsrichtlinie, und Sie können sie überschreiben, indem Sie bestimmte Eigenschaften indizieren, was auch RU-Verbrauch und Latenz weiter verbessern kann.

Version 1: Die erste Version

Wir beginnen mit zwei Containern: users und posts.

Container „users“

In diesem Container werden nur Benutzerelemente gespeichert:

{
    "id": "<user-id>",
    "username": "<username>"
}

Wir partitionieren diesen Container nach id, was bedeutet, dass jede logische Partition innerhalb des Containers nur ein Element enthält.

Container „posts“

Dieser Container hostet Entitäten wie z. B. Beiträge, Kommentare und „Gefällt mir“-Markierungen:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "title": "<post-title>",
    "content": "<post-content>",
    "creationDate": "<post-creation-date>"
}

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "creationDate": "<like-creation-date>"
}

Wir partitionieren diesen Container nach postId, was bedeutet, dass jede logische Partition innerhalb des Containers einen Beitrag, alle Kommentare zu diesem Beitrag und alle „Gefällt mir“-Markierungen des Beitrags enthält.

Wir haben für die in diesem Container gespeicherten Elemente eine type-Eigenschaft hinzugefügt, um zwischen den drei Typen von Entitäten zu unterscheiden, die der Container hostet.

Zudem haben wir uns aus den folgenden Gründen entschieden, auf zugehörige Daten zu verweisen, anstatt sie einzubetten (Details zu diesen Konzepten finden Sie in diesem Abschnitt):

  • Es gibt keine Obergrenze für die Anzahl von Beiträgen, die ein Benutzer erstellen kann.
  • Beiträge können beliebig lang sein.
  • Es gibt keine Obergrenze für die Anzahl von Kommentaren und „Gefällt mir“-Markierungen eines Beitrags.
  • Es soll möglich sein, einem Beitrag einen Kommentar hinzuzufügen oder ihn mit „Gefällt mir“ zu markieren, ohne den Beitrag selbst zu aktualisieren.

Wie gut funktioniert das Modell?

Der nächste Schritt besteht darin, die Leistung und Skalierbarkeit der ersten Version zu bewerten. Für jede der zuvor identifizierten Anforderungen messen wir die Wartezeit und die von der Anforderung verbrauchte Anzahl von Anforderungseinheiten (Request Unit, RU). Diese Messung wird für ein Dummydataset ausgeführt, das 100.000 Benutzer mit fünf bis 50 Beiträgen pro Benutzer und bis zu 25 Kommentare und 100 „Gefällt mir“-Markierungen pro Beitrag enthält.

[C1]: Einen Benutzer erstellen/bearbeiten

Diese Anforderung ist einfach zu implementieren, da lediglich ein Element im Container users erstellt oder aktualisiert wird. Die Anforderungen verteilen sich dank des Partitionsschlüssels id gut auf alle Partitionen.

Diagramm des Schreibens eines einzelnen Elements in den Container des Benutzers.

Latenz Verbrauchte RUs Leistung
7 ms 5.71 RU

[Q1]: Einen Benutzer abrufen

Zum Abrufen eines Benutzers wird das entsprechende Element aus dem Container users gelesen.

Diagramm des Abrufens eines einzelnen Elements aus dem Container des Benutzers.

Latenz Verbrauchte RUs Leistung
2 ms 1 RU

[C2]: Einen Beitrag erstellen/bearbeiten

Ähnlich wie bei [C1] muss bei dieser Anforderung nur in den Container posts geschrieben werden.

Diagramm des Schreibens eines einzelnen Beitragselements in den Beitragscontainer.

Latenz Verbrauchte RUs Leistung
9 ms 8.76 RU

[Q2]: Einen Beitrag abrufen

Zunächst rufen wir das entsprechende Dokument aus dem Container posts ab. Aber das ist noch nicht genug, gemäß unserer Spezifikation müssen wir auch den Benutzernamen des Autors des Beitrags, die Anzahl der Kommentare und die Anzahl der „Gefällt mir“-Markierungen für den Beitrag aggregieren. Für die aufgeführten Aggregationen müssen 3 weitere SQL-Abfragen ausgegeben werden.

Diagramm des Abrufens eines Beitrags und Aggregieren zusätzlicher Daten.

Jede der weiteren Abfragen filtert nach dem Partitionsschlüssel des jeweiligen Containers. Dies ist genau das erwünschte Verhalten, da wir die Leistung und Skalierbarkeit maximieren wollen. Im Endeffekt müssen jedoch zum Zurückgeben eines einzelnen Beitrags vier Vorgänge ausgeführt werden. Dies werden wir in einer der nächsten Iterationen verbessern.

Latenz Verbrauchte RUs Leistung
9 ms 19.54 RU

[Q3]: Beiträge eines Benutzers in Kurzform auflisten

Zunächst müssen wir die gewünschten Beiträge mit einer SQL-Abfrage abrufen, die die entsprechenden Beiträge für den jeweiligen Benutzer abruft. Wir müssen aber auch weitere Abfragen ausführen, um den Benutzernamen des Autors und die Anzahl von Kommentaren und „Gefällt mir“-Markierungen zu aggregieren.

Diagramm des Abrufens aller Beiträge für einen Benutzer und Aggregieren ihrer zusätzlichen Daten.

Diese Implementierung hat viele Nachteile:

  • Die Abfragen zum Aggregieren der Anzahl von Kommentaren und „Gefällt mir“-Markierungen müssen für jeden von der ersten Abfrage zurückgegebenen Beitrag ausgeführt werden.
  • Die Hauptabfrage filtert nicht nach dem Partitionsschlüssel des Containers posts, was zu einer Auffächerung und einem Partitionsscan im Container führt.
Latenz Verbrauchte RUs Leistung
130 ms 619.41 RU

[C3]: Einen Kommentar erstellen

Ein Kommentar wird erstellt, indem das entsprechende Element in den Container posts geschrieben wird.

Diagramm des Schreibens eines einzelnen Kommentarelements in den Beitragscontainer.

Latenz Verbrauchte RUs Leistung
7 ms 8.57 RU

[Q4]: Kommentare eines Beitrags auflisten

Wir beginnen mit einer Abfrage, die alle Kommentare für den jeweiligen Beitrag abruft. Zudem müssen wir auch hier die Benutzernamen separat für jeden Kommentar aggregieren.

Diagramm des Abrufens aller Kommentare für einen Beitrag und Aggregieren ihrer zusätzlichen Daten.

Obwohl die Hauptabfrage nach dem Partitionsschlüssel des Containers filtert, beeinträchtig das separate Aggregieren der Benutzernamen die Gesamtleistung. Dies werden wir später verbessern.

Latenz Verbrauchte RUs Leistung
23 ms 27.72 RU

[C4]: Einen Beitrag mit „Gefällt mir“ markieren

Wie bei [C3] erstellen wir das entsprechende Element im Container posts.

Diagramm des Schreibens eines einzelnen (gefällt mir) Elements in den Beitragscontainer.

Latenz Verbrauchte RUs Leistung
6 ms 7.05 RU

[Q5]: „Gefällt mir“-Markierungen eines Beitrags auflisten

Wie bei [Q4] fragen wir die „Gefällt mir“-Markierungen für den Beitrag ab, und anschließend aggregieren wir die zugehörigen Benutzernamen.

Diagramm des Abrufens aller „Gefällt mir“-Markierungen für einen Beitrag und Aggregieren ihrer zusätzlichen Daten.

Latenz Verbrauchte RUs Leistung
59 ms 58.92 RU

[Q6]: Die „x“ neuesten Beiträge in Kurzform auflisten (Feed)

Wir rufen die neuesten Beiträge ab, indem wir den Container posts absteigend nach Erstellungsdatum sortiert abfragen, und anschließend aggregieren wir die Benutzernamen sowie die Anzahl von Kommentaren und „Gefällt mir“-Markierungen für jeden der Beiträge.

Diagramm des Abrufens der neuesten Beiträge und Aggregieren ihrer zusätzlichen Daten.

Auch hier filtert unsere erste Abfrage nicht nach dem Partitionsschlüssel des Containers posts, wodurch ein kostspieliges Fan-out ausgelöst wird. Dieses ist noch schwerwiegender, da wir auf ein viel größeres Resultsset zielen und die Ergebnisse mit einer ORDER BY-Klausel sortieren, wodurch es in Bezug auf die Anforderungseinheiten noch teurer wird.

Latenz Verbrauchte RUs Leistung
306 ms 2063.54 RU

Überlegungen zur Leistung von Version 1

Wenn wir uns die Leistungsprobleme im vorherigen Abschnitt ansehen, können wir zwei primäre Arten von Problemen erkennen:

  • Manche Anforderungen erfordern mehrere Abfragen, um alle Daten zu erfassen, die zurückgegeben werden müssen.
  • Manche Abfragen filtern nicht nach dem Partitionsschlüssel der jeweiligen Container, was zu einer Auffächerung führt und die Skalierbarkeit beeinträchtigt.

Diese Probleme werden wir nun beheben. Beginnen wir mit dem ersten Problem.

Version 2: Verwenden der Denormalisierung zum Optimieren der Leseabfragen

Der Grund, warum wir einigen Fällen weitere Anforderungen ausstellen müssen, ist, dass die Ergebnisse der ersten Anforderung nicht alle Daten enthalten, die wir zurückgeben müssen. Die Denormalisierung von Daten löst diese Art von Problem im gesamten Dataset, wenn mit einem nicht relationalen Datenspeicher wie Azure Cosmos DB gearbeitet wird.

In unserem Beispiel ändern wir die Beitragselemente, indem wir den Benutzernamen des Beitragsautors, die Anzahl von Kommentaren und die Anzahl von „Gefällt mir“-Markierungen hinzufügen:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

Außerdem ändern wir die Kommentar- und „Gefällt mir“-Markierungselemente, indem wir den Benutzernamen des Benutzers hinzufügen, von denen sie erstellt wurden:

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "userUsername": "<comment-author-username>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "userUsername": "<liker-username>",
    "creationDate": "<like-creation-date>"
}

Denormalisieren der Anzahl von Kommentaren und „Gefällt mir“-Markierungen

Wir möchten erreichen, dass bei jeder Hinzufügung eines Kommentars oder einer „Gefällt mir“-Markierung die Anzahl commentCount oder likeCount im zugehörigen Beitrag erhöht wird. Da postId unseren posts-Container partitioniert, befinden sich das neue Element (Kommentar oder „Gefällt mir“-Markierung) und der dazugehörige Beitrag in der gleichen logischen Partition. Wir können daher eine gespeicherte Prozedur zum Ausführen des Vorgangs verwenden.

Wenn Sie einen Kommentar ([C3]) erstellen, wird nicht einfach ein neues Element in den posts-Container eingefügt, sondern die folgende gespeicherte Prozedur für diesen Container aufgerufen:

function createComment(postId, comment) {
  var collection = getContext().getCollection();

  collection.readDocument(
    `${collection.getAltLink()}/docs/${postId}`,
    function (err, post) {
      if (err) throw err;

      post.commentCount++;
      collection.replaceDocument(
        post._self,
        post,
        function (err) {
          if (err) throw err;

          comment.postId = postId;
          collection.createDocument(
            collection.getSelfLink(),
            comment
          );
        }
      );
    })
}

Diese gespeicherte Prozedur akzeptiert die ID des Beitrags und den Text des neuen Kommentars als Parameter und führt anschließend die folgenden Aktionen aus:

  • Abrufen des Beitrags
  • Erhöhen der Anzahl von commentCount
  • Ersetzen des Beitrags
  • Hinzufügen des neuen Kommentars

Da gespeicherte Prozeduren als unteilbare Transaktionen ausgeführt werden, sind der Wert von commentCount und die tatsächliche Anzahl von Kommentaren immer synchron.

Beim Hinzufügen neuer „Gefällt mir“-Markierungen rufen wir eine ähnliche gespeicherte Prozedur auf, um likeCount zu erhöhen.

Denormalisieren von Benutzernamen

Benutzernamen erfordern eine andere Herangehensweise, da sich Benutzer nicht nur in unterschiedlichen Partitionen, sondern auch in einem anderen Container befinden. Wenn wir Daten partitions- und containerübergreifend denormalisieren müssen, können wir den Änderungsfeed des Quellcontainers verwenden.

In unserem Beispiel verwenden wir den Änderungsfeed des Containers users, um auf jede von Benutzern durchgeführte Aktualisierung des Benutzernamens zu reagieren. Wenn ein Benutzer seinen Benutzernamen aktualisiert, verteilen wir die Änderung, indem wir eine weitere gespeicherte Prozedur für den Container posts aufrufen:

Diagramm der Denormalisierung von Benutzernamen in die Beitragscontainer.

function updateUsernames(userId, username) {
  var collection = getContext().getCollection();
  
  collection.queryDocuments(
    collection.getSelfLink(),
    `SELECT * FROM p WHERE p.userId = '${userId}'`,
    function (err, results) {
      if (err) throw err;

      for (var i in results) {
        var doc = results[i];
        doc.userUsername = username;

        collection.upsertDocument(
          collection.getSelfLink(),
          doc);
      }
    });
}

Diese gespeicherte Prozedur akzeptiert die ID des Benutzers und seinen neuen Benutzernamen als Parameter und führt anschließend die folgenden Aktionen aus:

  • Abrufen aller Elemente, die mit der userId übereinstimmen (Beiträge, Kommentare oder „Gefällt mir“-Markierungen)
  • Folgende Vorgänge für jedes dieser Elemente:
    • Ersetzen von userUsername
    • Ersetzen des Elements

Wichtig

Dieser Vorgang ist kostspielig, da die gespeicherte Prozedur für jede Partition des Containers posts ausgeführt werden muss. Wir gehen davon aus, dass die meisten Benutzer bei der Registrierung einen geeigneten Benutzernamen auswählen, den sie niemals ändern, und diese Aktualisierung daher nur sehr selten ausgeführt wird.

Welche Leistungsverbesserungen bietet Version 2?

Lassen Sie uns über einige der Leistungssteigerungen von V2 sprechen.

[Q2]: Einen Beitrag abrufen

Da wir die Denormalisierung hinzugefügt haben, müssen wir zum Verarbeiten dieser Anforderung nur ein einziges Element abrufen.

Diagramm des Abrufens eines einzelnen Elements aus dem denormalisierten Beitragscontainer.

Latenz Verbrauchte RUs Leistung
2 ms 1 RU

[Q4]: Kommentare eines Beitrags auflisten

Auch in diesem Fall können wir die zusätzlichen Anforderungen zum Abrufen der Benutzernamen weglassen und stattdessen eine einzige Abfrage verwenden, die nach dem Partitionsschlüssel filtert.

Diagramm des Abrufens aller Kommentare für einen denormalisierten Beitrag.

Latenz Verbrauchte RUs Leistung
4 ms 7.72 RU

[Q5]: „Gefällt mir“-Markierungen eines Beitrags auflisten

Beim Auflisten der „Gefällt mir“-Markierungen haben wir die gleiche Situation.

Diagramm des Abrufens aller „Gefällt mir“-Markierungen für einen denormalisierten Beitrag.

Latenz Verbrauchte RUs Leistung
4 ms 8.92 RU

Version 3: Sicherstellen der Skalierbarkeit aller Anforderungen

Es gibt immer noch zwei Anforderungen, die wir nicht vollständig optimiert haben, wenn wir uns unsere Gesamtleistungsverbesserungen ansehen. Diese Anforderungen sind [Q3] und [Q6]. Dies sind die Anforderungen mit Abfragen, die nicht nach dem Partitionsschlüssel der Container filtern, auf die sie abzielen.

[Q3]: Beiträge eines Benutzers in Kurzform auflisten

Diese Anforderung profitiert bereits von den Verbesserungen, die wir zur Vermeidung weiterer Abfragen in Version 2 eingeführt haben.

Diagramm zeigt die Abfrage zum Auflisten von Beiträgen eines Benutzers in Kurzform.

Die verbleibende Abfrage filtert jedoch noch immer nicht nach dem Partitionsschlüssel des Containers posts.

Die Überlegungen zu dieser Situation sind einfach:

  1. Die Anforderung muss nach der userId filtern, da wir alle Beiträge für einen bestimmten Benutzer abrufen wollen.
  2. Die Leistung dieser Anforderung ist nicht gut, da sie für den Container posts ausgeführt wird, der nicht über eine userId-Partitionierung verfügt.
  3. Wir könnten das Leistungsproblem natürlich beheben, indem wir die Anforderung für einen Container ausführen, der mit userId partitioniert ist.
  4. Tatsächlich haben wir bereits einen solchen Container: den Container users.

Wir fügen daher eine zweite Denormalisierungsebene hinzu, indem wir alle Posts im Container users duplizieren. Auf diese Weise erhalten wir effektiv eine Kopie unserer Beiträge, die lediglich nach einer anderen Dimension partitioniert ist. Die Beiträge können dadurch deutlich effizienter anhand ihrer userId abgerufen werden.

Der Container users enthält jetzt zwei Arten von Elementen:

{
    "id": "<user-id>",
    "type": "user",
    "userId": "<user-id>",
    "username": "<username>"
}

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

In diesem Beispiel:

  • Wir haben ein type-Feld im Benutzerelement hinzugefügt, um Benutzer von Beiträgen zu unterscheiden,
  • Außerdem haben wir ein userId-Feld im Benutzerelement hinzugefügt, das mit dem Feld id redundant, aber erforderlich ist, da der Container users jetzt mit userId partitioniert ist (und nicht wie zuvor mit id)

Um diese Denormalisierung zu erreichen, verwenden wir wieder den Änderungsfeed. Dieses Mal reagieren wir auf den Änderungsfeed des Containers posts, um alle neuen oder aktualisierten Beiträge im Container users zu verteilen. Und da zum Auflisten von Beiträgen nicht der vollständige Inhalt zurückgegeben werden muss, können wir diese im Prozess abschneiden.

Diagramm der Denormalisierung von Beiträgen in den Container des Benutzers.

Jetzt können wir unsere Abfrage an den Container users weiterleiten und dabei nach dem Partitionsschlüssel des Containers filtern.

Diagramm des Abrufens aller Beiträge für einen denormalisierten Benutzer.

Latenz Verbrauchte RUs Leistung
4 ms 6.46 RU

[Q6]: Die „x“ neuesten Beiträge in Kurzform auflisten (Feed)

Hier haben wir es mit einer ähnlichen Situation zu tun: Auch nach dem Beseitigen der weiteren Abfragen durch das Hinzufügen der in Version 2 hinzugefügten Denormalisierung filtert die verbleibende Abfrage nicht nach dem Partitionsschlüssel des Containers:

Diagramm mit der Abfrage zum Auflisten der „x“ neuesten Beiträge in Kurzform

Gemäß dem obigen Ansatz muss die Anforderung auf eine Partition beschränkt werden, um die Leistung und Skalierbarkeit der Anforderung zu maximieren. Das Treffen nur einer einzelnen Partition ist denkbar, da wir nur eine begrenzte Anzahl von Elementen zurückgeben müssen. Zum Auffüllen der Startseite unserer Blogplattform müssen wir nur die 100 neuesten Beiträge abrufen, ohne das gesamte Dataset zu paginieren.

Zum Optimieren dieser letzten Anforderung fügen wir unserem Entwurf daher einen dritten Container hinzu, der einzig zur Verarbeitung dieser Anforderung dient. Wir denormalisieren unsere Beiträge im neuen Container feed:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

Das type-Feld partitioniert diesen Container, das immer post in unseren Elementen ist. Dadurch wird sichergestellt, dass sich alle Elemente in diesem Container in derselben Partition befinden.

Zum Erreichen der Denormalisierung müssen wir nur die Änderungsfeedpipeline verknüpfen, die wir zuvor hinzugefügt haben, um die Beiträge an den neuen Container zu verteilen. Wichtig ist hierbei, dass nur die 100 neuesten Beiträge gespeichert werden dürfen. Andernfalls kann der Inhalt des Containers die maximale Größe einer Partition überschreiten. Diese Einschränkung kann durch den Aufruf eines Post-Triggers bei jedem Hinzufügen eines Dokuments in den Container implementiert werden:

Diagramm der Denormalisierung von Beiträgen in den Feedcontainer.

Hier sehen Sie den Text des nachgestellten Triggers, der die Auflistung abschneidet:

function truncateFeed() {
  const maxDocs = 100;
  var context = getContext();
  var collection = context.getCollection();

  collection.queryDocuments(
    collection.getSelfLink(),
    "SELECT VALUE COUNT(1) FROM f",
    function (err, results) {
      if (err) throw err;

      processCountResults(results);
    });

  function processCountResults(results) {
    // + 1 because the query didn't count the newly inserted doc
    if ((results[0] + 1) > maxDocs) {
      var docsToRemove = results[0] + 1 - maxDocs;
      collection.queryDocuments(
        collection.getSelfLink(),
        `SELECT TOP ${docsToRemove} * FROM f ORDER BY f.creationDate`,
        function (err, results) {
          if (err) throw err;

          processDocsToRemove(results, 0);
        });
    }
  }

  function processDocsToRemove(results, index) {
    var doc = results[index];
    if (doc) {
      collection.deleteDocument(
        doc._self,
        function (err) {
          if (err) throw err;

          processDocsToRemove(results, index + 1);
        });
    }
  }
}

Im letzten Schritt müssen Sie die Abfrage an den neuen Container feed umleiten:

Diagramm des Abrufens der neuesten Beiträge.

Latenz Verbrauchte RUs Leistung
9 ms 16.97 RU

Zusammenfassung

Werfen wir nun einen Blick auf die Verbesserungen der Gesamtleistung und Skalierbarkeit, die wir in den verschiedenen Versionen unseres Entwurfs eingeführt haben.

V1 V2 V3
[C1] 7 ms / 5.71 RU 7 ms / 5.71 RU 7 ms / 5.71 RU
[Q1] 2 ms / 1 RU 2 ms / 1 RU 2 ms / 1 RU
[C2] 9 ms / 8.76 RU 9 ms / 8.76 RU 9 ms / 8.76 RU
[Q2] 9 ms / 19.54 RU 2 ms / 1 RU 2 ms / 1 RU
[Q3] 130 ms / 619.41 RU 28 ms / 201.54 RU 4 ms / 6.46 RU
[C3] 7 ms / 8.57 RU 7 ms / 15.27 RU 7 ms / 15.27 RU
[Q4] 23 ms / 27.72 RU 4 ms / 7.72 RU 4 ms / 7.72 RU
[C4] 6 ms / 7.05 RU 7 ms / 14.67 RU 7 ms / 14.67 RU
[Q5] 59 ms / 58.92 RU 4 ms / 8.92 RU 4 ms / 8.92 RU
[Q6] 306 ms / 2063.54 RU 83 ms / 532.33 RU 9 ms / 16.97 RU

Wir haben ein leseintensives Szenario optimiert

Möglicherweise haben Sie bemerkt, dass wir uns auf die Verbesserung der Leistung von Leseanforderungen (Abfragen) konzentriert und dafür Abstriche bei den Schreibanforderungen (Befehle) in Kauf genommen haben. In vielen Fällen lösen Schreibvorgänge jetzt eine nachfolgende Denormalisierung über Änderungsfeeds aus, wodurch diese Vorgänge rechenintensiver werden und ihre Materialisierung mehr Zeit in Anspruch nimmt.

Wir rechtfertigen diesen Fokus auf die Leseleistung damit, dass eine Blogplattform (wie die meisten sozialen Apps) leseintensiv ist. Eine leseintensive Arbeitsauslastung deutet darauf hin, dass die Anzahl der Leseanfragen in der Regel um Größenordnungen höher ist als die Anzahl der Schreibanfragen, die bedient werden müssen. Deshalb ist es sinnvoll, den Kostenaufwand für die Ausführung von Schreibanforderungen zu erhöhen, um im Gegenzug die Kosten von Leseanforderungen zu reduzieren und die Leistung dieser Anforderungen zu verbessern.

Sehen wir uns dazu die größte Optimierung an, die wir erzielt haben: Für [Q6] haben wir die Anzahl verbrauchter RUs von 2000 auf nur 17 RUs reduziert. Wir haben dies durch die Denormalisierung von Beiträgen erreicht, mit Kosten von etwa 10 RUs pro Element. Da wir deutlich mehr Feedanforderungen als Anforderungen zum Erstellen oder Aktualisieren von Beiträgen verarbeiten würden, sind diese Kosten für die Denormalisierung angesichts der Gesamteinsparungen vernachlässigbar.

Denormalisierung kann inkrementell angewendet werden.

Um die in diesem Artikel behandelten Verbesserungen der Skalierbarkeit zu erreichen, müssen Daten im Dataset denormalisiert und dupliziert werden. Anzumerken ist hierbei, dass diese Optimierungen nicht sofort umgesetzt werden müssen. Abfragen, die nach Partitionsschlüsseln filtern, haben für große Datenmengen eine bessere Leistung, aber partitionsübergreifende Abfragen können akzeptabel sein, wenn sie nur selten oder nur für eine begrenzte Menge an Daten aufgerufen werden. Wenn Sie nur einen Prototyp bauen, oder ein Produkt mit einer engen und kontrollierten Benutzerbasis starten, können Sie sich diese Verbesserungen wahrscheinlich für später aufheben. Es ist dann wichtig, die Leistung Ihres Modells zu überwachen, damit Sie entscheiden können, ob und wann die Verbesserungen vorgenommen werden sollen.

Im Änderungsfeed, den wir zum Verteilen der Aktualisierungen an andere Container verwenden, werden diese Aktualisierungen dauerhaft gespeichert. Dank dieser Persistenz können alle Aktualisierungen seit der Erstellung des Containers angefordert und das Bootstrapping der denormalisierten Ansichten als ein einmaliger Aufholvorgang durchgeführt werden, selbst dann, wenn in Ihrem System bereits viele Daten vorhanden sind.

Nächste Schritte

Nach dieser Einführung in die praktische Datenmodellierung und -partitionierung sollten Sie sich die folgenden Artikel ansehen, um die behandelten Konzepte zu wiederholen: