Bearbeiten

Freigeben über


CQRS-Muster

Azure Storage

Command Query Responsibility Segregation (CQRS) ist ein Entwurfsmuster, das Lese- und Schreibvorgänge für einen Datenspeicher in separate Datenmodelle trennt. Auf diese Weise kann jedes Modell unabhängig optimiert werden und die Leistung, Skalierbarkeit und Sicherheit einer Anwendung verbessern.

Kontext und Problem

In herkömmlichen Architekturen wird häufig ein einzelnes Datenmodell für Lese- und Schreibvorgänge verwendet. Dieser Ansatz ist unkompliziert und eignet sich gut für grundlegende CRUD-Vorgänge (siehe Abbildung 1).

Diagramm, das eine herkömmliche CRUD-Architektur zeigt.
Abbildung 1. Eine herkömmliche CRUD-Architektur.

Da Anwendungen jedoch wachsen, wird das Optimieren von Lese- und Schreibvorgängen für ein einzelnes Datenmodell immer schwieriger. Lese- und Schreibvorgänge weisen häufig unterschiedliche Leistungs- und Skalierungsanforderungen auf. Eine herkömmliche CRUD-Architektur berücksichtigt diese Asymmetrie nicht. Es führt zu mehreren Herausforderungen:

  • Datenkonflikt: Die Lese- und Schreibdarstellungen von Daten unterscheiden sich häufig. Einige Felder, die während Aktualisierungen erforderlich sind, sind möglicherweise bei Lesevorgängen nicht erforderlich.

  • Sperrverknügung: Parallele Vorgänge auf demselben Datensatz können zu Sperrverknügungen führen.

  • Leistungsprobleme: Der herkömmliche Ansatz kann sich negativ auf die Leistung auswirken, da die Datenspeicher- und Datenzugriffsebene geladen wird, und die Komplexität der Abfragen, die zum Abrufen von Informationen erforderlich sind.

  • Sicherheitsbedenken: Verwalten der Sicherheit wird schwierig, wenn Entitäten Lese- und Schreibvorgängen unterliegen. Diese Überlappung kann Daten in unbeabsichtigten Kontexten verfügbar machen.

Die Kombination dieser Verantwortlichkeiten kann zu einem überkomplizierten Modell führen, das zu viel zu tun versucht.

Lösung

Verwenden Sie das CQRS-Muster, um Schreibvorgänge (Befehle) von Lesevorgängen (Abfragen) zu trennen. Befehle sind für das Aktualisieren von Daten verantwortlich. Abfragen sind für das Abrufen von Daten verantwortlich.

Verstehen von Befehlen. Befehle sollten bestimmte Geschäftsaufgaben anstelle von Aktualisierungen auf niedriger Ebene darstellen. Verwenden Sie z. B. in einer Hotelbuchungs-App "Hotelzimmer buchen" anstelle von "Reservierungsstatus auf Reserviert festlegen". Dieser Ansatz spiegelt die Absicht hinter Benutzeraktionen besser wider und richtet Befehle an Geschäftsprozesse aus. Um sicherzustellen, dass Befehle erfolgreich sind, müssen Sie möglicherweise den Benutzerinteraktionsfluss, die serverseitige Logik verfeinern und asynchrone Verarbeitung in Betracht ziehen.

Bereich der Verfeinerung Empfehlung
Clientseitige Überprüfung Überprüfen Sie bestimmte Bedingungen, bevor Sie den Befehl senden, um offensichtliche Fehler zu verhindern. Wenn beispielsweise keine Räume verfügbar sind, deaktivieren Sie die Schaltfläche "Buch", und geben Sie eine klare, benutzerfreundliche Nachricht in der Benutzeroberfläche an, in der erläutert wird, warum die Buchung nicht möglich ist. Durch dieses Setup werden unnötige Serveranforderungen reduziert und benutzern sofortiges Feedback bereitgestellt, wodurch die Benutzererfahrung verbessert wird.
Serverseitige Logik Verbessern Sie die Geschäftslogik, um Edgefälle und Fehler ordnungsgemäß zu behandeln. Um z. B. die Racebedingungen zu beheben (mehrere Benutzer, die versuchen, den letzten verfügbaren Raum zu buchen), können Sie Benutzer zu einer Warteliste hinzufügen oder alternative Optionen vorschlagen.
Asynchronbetrieb Sie können auch asynchron Prozessbefehle , indem Sie sie in einer Warteschlange platzieren, anstatt sie synchron zu behandeln.

Grundlegendes zu Abfragen. Abfragen ändern niemals Daten. Stattdessen geben sie Data Transfer Objects (DTOs) zurück, die die erforderlichen Daten in einem praktischen Format ohne Domänenlogik darstellen. Diese klare Trennung von Bedenken vereinfacht die Gestaltung und Implementierung des Systems.

Grundlegendes zur Trennung von Lese- und Schreibmodellen

Durch das Trennen des Lesemodells vom Schreibmodell wird das Systemdesign und die Implementierung vereinfacht, indem unterschiedliche Bedenken für Datenschreib- und Lesevorgänge behoben werden. Diese Trennung verbessert Klarheit, Skalierbarkeit und Leistung, führt aber einige Kompromisse ein. Beispielsweise können Gerüsttools wie O/RM-Frameworks nicht automatisch CQRS-Code aus einem Datenbankschema generieren, sodass benutzerdefinierte Logik zum Überbrücken der Lücke erforderlich ist.

In den folgenden Abschnitten werden zwei primäre Ansätze zum Implementieren der Trennung von Lese- und Schreibmodellen in CQRS erläutert. Jeder Ansatz bietet einzigartige Vorteile und Herausforderungen, z. B. Synchronisierung und Konsistenzverwaltung.

Trennung von Modellen in einem einzigen Datenspeicher

Dieser Ansatz stellt die grundlegende Ebene von CQRS dar, bei der sowohl die Lese- als auch schreibmodelle eine einzelne zugrunde liegende Datenbank gemeinsam nutzen, aber unterschiedliche Logik für ihre Vorgänge beibehalten. Durch die Definition separater Bedenken verbessert diese Strategie die Einfachheit und bietet vorteile bei Skalierbarkeit und Leistung für typische Anwendungsfälle. Mit einer einfachen CQRS-Architektur können Sie das Schreibmodell aus dem Lesemodell unter Verwendung eines freigegebenen Datenspeichers ableiten (siehe Abbildung 2).

Diagramm, das eine grundlegende CQRS-Architektur zeigt.
Abbildung 2. Eine grundlegende CQRS-Architektur mit einem einzigen Datenspeicher.

Dieser Ansatz verbessert Klarheit, Leistung und Skalierbarkeit, indem unterschiedliche Modelle für die Behandlung von Schreib- und Lesebedenken definiert werden:

  • Schreibmodell: entwickelt, um Befehle zu verarbeiten, die Daten aktualisieren oder beibehalten. Sie umfasst Validierung, Domänenlogik und stellt die Datenkonsistenz durch Optimierung der Transaktionsintegrität und Geschäftsprozesse sicher.

  • Lesemodell: entwickelt, um Abfragen zum Abrufen von Daten zu verarbeiten. Sie konzentriert sich auf das Generieren von DTOs (Data Transfer Objects) oder Projektionen, die für die Präsentationsebene optimiert sind. Sie verbessert die Abfrageleistung und Reaktionsfähigkeit, indem Domänenlogik vermieden wird.

Physische Trennung von Modellen in separaten Datenspeichern

Eine komplexere CQRS-Implementierung verwendet unterschiedliche Datenspeicher für die Lese- und Schreibmodelle. Die Trennung der Lese- und Schreibdatenspeicher ermöglicht es Ihnen, die einzelnen zu skalieren, um der Last zu entsprechen. Außerdem können Sie für jeden Datenspeicher eine andere Speichertechnologie verwenden. Sie können eine Dokumentdatenbank für den Lesedatenspeicher und eine relationale Datenbank für den Schreibdatenspeicher verwenden (siehe Abbildung 3).

Diagramm, das eine CQRS-Architektur mit separaten Lese- und Schreibdatenspeichern zeigt.
Abbildung 3. Eine CQRS-Architektur mit separaten Lese- und Schreibdatenspeichern.

Die Synchronisierung separater Datenspeicher: Wenn Sie separate Speicher verwenden, müssen Sie sicherstellen, dass beide synchronisiert bleiben. Ein gängiges Muster besteht darin, dass das Schreibmodell Ereignisse veröffentlicht, wenn sie die Datenbank aktualisiert, mit der das Lesemodell seine Daten aktualisiert. Weitere Informationen zum Verwenden von Ereignissen finden Sie unter ereignisgesteuerten Architekturstil. In der Regel können Sie jedoch keine Nachrichtenbroker und Datenbanken in einer einzelnen verteilten Transaktion auflisten. Es kann also Herausforderungen geben, die Konsistenz beim Aktualisieren der Datenbank- und Veröffentlichungsereignisse zu gewährleisten. Weitere Informationen finden Sie unter idempotenten Nachrichtenverarbeitung.

Datenspeicher lesen: Der Lesedatenspeicher kann ein eigenes Datenschema verwenden, das für Abfragen optimiert ist. Beispielsweise kann eine materialisierte Ansicht der Daten gespeichert werden, um komplexe Verknüpfungen oder O/RM-Zuordnungen zu vermeiden. Der Lesespeicher kann ein schreibgeschütztes Replikat des Schreibspeichers sein oder eine andere Struktur aufweisen. Die Bereitstellung mehrerer schreibgeschützter Replikate kann die Leistung verbessern, indem die Latenz reduziert und die Verfügbarkeit erhöht wird, insbesondere in verteilten Szenarien.

Vorteile von CQRS

  • Unabhängige Skalierung. Mit CQRS können die Lese- und Schreibmodelle unabhängig skaliert werden, wodurch die Sperrverknügung minimiert und die Systemleistung beim Laden verbessert werden kann.

  • Optimierte Datenschemas: Lesevorgänge können ein schema verwenden, das für Abfragen optimiert ist. Schreibvorgänge verwenden ein schema, das für Updates optimiert ist.

  • Sicherheit: Durch trennen von Lese- und Schreibvorgängen können Sie sicherstellen, dass nur die entsprechenden Domänenentitäten oder Vorgänge über die Berechtigung zum Ausführen von Schreibaktionen für die Daten verfügen.

  • Trennung von Zuständigkeiten: Das Aufteilen der Lese- und Schreibaufgaben führt zu saubereren, besser verwendbaren Modellen. Die Schreibseite behandelt in der Regel komplexe Geschäftslogik, während die Leseseite einfach bleiben und sich auf die Abfrageeffizienz konzentrieren kann.

  • Einfachere Abfragen: Wenn Sie eine materialisierte Ansicht in der Lesedatenbank speichern, kann die Anwendung beim Abfragen komplexe Verknüpfungen vermeiden.

Probleme und Überlegungen bei der Implementierung

Bei der Implementierung dieses Musters gibt es u.a. folgende Herausforderungen:

  • Erhöhte Komplexität. Während das Kernkonzept von CQRS einfach ist, kann es erhebliche Komplexität in das Anwendungsdesign bringen, insbesondere in Kombination mit dem Event Sourcing-Muster.

  • Messaging-Herausforderungen. Obwohl Messaging keine Anforderung für CQRS ist, verwenden Sie es häufig zum Verarbeiten von Befehlen und Veröffentlichen von Updateereignissen. Wenn Messaging beteiligt ist, muss das System potenzielle Probleme wie Nachrichtenfehler, Duplikate und Wiederholungen berücksichtigen. In den Anleitungen zu Prioritätswarteschlangen finden Sie Strategien zum Behandeln von Befehlen mit unterschiedlichen Prioritäten.

  • Letztliche Konsistenz: Wenn die Lese- und Schreibdatenbanken getrennt sind, spiegeln die Lesedaten möglicherweise nicht die neuesten Änderungen sofort wider, was zu veralteten Daten führt. Es kann schwierig sein, sicherzustellen, dass der Lesemodellspeicher up-to-Datum mit Änderungen im Schreibmodellspeicher bleibt. Darüber hinaus erfordert das Erkennen und Behandeln von Szenarien, in denen ein Benutzer auf veraltete Daten agiert, sorgfältig zu berücksichtigen.

Wann das Muster „CQRS“ verwendet werden sollte:

Das CQRS-Muster ist in Szenarien hilfreich, die eine klare Trennung zwischen Datenänderungen (Befehlen) und Datenabfragen (Lesevorgänge) erfordern. Erwägen Sie die Verwendung von CQRS in den folgenden Situationen:

  • Kollaborative Domänen: In Umgebungen, in denen mehrere Benutzer gleichzeitig auf dieselben Daten zugreifen und diese ändern, hilft CQRS, Konflikte beim Zusammenführen zu reduzieren. Befehle können genügend Granularität enthalten, um Konflikte zu vermeiden, und das System kann alle, die innerhalb der Befehlslogik auftreten, auflösen.

  • Aufgabenbasierte Benutzeroberflächen: Anwendungen, die Benutzer durch komplexe Prozesse als Eine Reihe von Schritten oder mit komplexen Domänenmodellen leiten, profitieren von CQRS.

    • Das Schreibmodell verfügt über einen vollständigen Befehlsverarbeitungsstapel mit Geschäftslogik, Eingabeüberprüfung und Geschäftsvalidierung. Das Schreibmodell kann eine Gruppe zugeordneter Objekte als einzelne Einheit für Datenänderungen behandeln, die als Aggregat in der domänengesteuerten Entwurfsterminologie bekannt sind. Das Schreibmodell kann auch sicherstellen, dass sich diese Objekte immer in einem konsistenten Zustand befinden.

    • Das Lesemodell weist keine Geschäftslogik oder keinen Validierungsstapel auf. Es gibt einen DTO für die Verwendung in einem Ansichtsmodell zurück. Das Lesemodell ist letztlich konsistent mit dem Schreibmodell.

  • Leistungsoptimierung: Systeme, bei denen die Leistung von Datenlesevorgängen getrennt von der Leistung von Datenschreibvorgängen optimiert werden muss, insbesondere wenn die Anzahl der Lesevorgänge größer ist als die Anzahl der Schreibvorgänge, profitieren Sie von CQRS. Das Lesemodell wird horizontal skaliert, um große Abfragevolumes zu verarbeiten, während das Schreibmodell auf weniger Instanzen ausgeführt wird, um Zusammenführungskonflikte zu minimieren und Konsistenz aufrechtzuerhalten.

  • Trennung von Entwicklungsbedenken: CQRS ermöglicht Es Teams, unabhängig zu arbeiten. Ein Team konzentriert sich auf die Implementierung der komplexen Geschäftslogik im Schreibmodell, während ein anderes die Lesemodell- und Benutzeroberflächenkomponenten entwickelt.

  • Sich entwickelnde Systeme: CQRS unterstützt Systeme, die sich im Laufe der Zeit entwickeln. Es bietet Platz für neue Modellversionen, häufige Änderungen an Geschäftsregeln oder andere Änderungen, ohne dass sich dies auf vorhandene Funktionen auswirkt.

  • Systemintegration: Systeme, die in andere Subsysteme integriert werden, insbesondere diejenigen, die Event Sourcing verwenden, bleiben auch dann verfügbar, wenn ein Subsystem vorübergehend fehlschlägt. CQRS isoliert Fehler und verhindert, dass eine einzelne Komponente das gesamte System beeinträchtigt.

Wann nicht CQRS verwendet werden soll

Vermeiden Sie CQRS in den folgenden Situationen:

  • Die Domäne oder die Geschäftsregeln sind einfach.

  • Eine einfache Benutzeroberfläche im CRUD-Stil und Datenzugriffsvorgänge reichen aus.

Workloadentwurf

Ein Architekt sollte auswerten, wie das CQRS-Muster im Design ihrer Workload verwendet wird, um die Ziele und Prinzipien zu berücksichtigen, die in den Azure Well-Architected Framework-Säulenbehandelt werden. Zum Beispiel:

Säule So unterstützt dieses Muster die Säulenziele
Die Leistungseffizienz hilft Ihrer Workload, Anforderungen effizient durch Optimierungen in Skalierung, Daten und Code zu erfüllen. Die Trennung von Lese- und Schreibvorgängen bei hohen Lese-Schreib-Workloads ermöglicht gezielte Leistungs- und Skalierungsoptimierungen für den spezifischen Zweck jedes Vorgangs.

- PE:05 Skalierung und Partitionierung
- PE:08 Datenleistung

Berücksichtigen Sie wie bei jeder Designentscheidung alle Kompromisse im Hinblick auf die Ziele der anderen Säulen, die mit diesem Muster eingeführt werden könnten.

Kombinieren von Event Sourcing und CQRS

Einige Implementierungen von CQRS enthalten das Event Sourcing-Muster, das den Zustand des Systems als chronologische Serie von Ereignissen speichert. Jedes Ereignis erfasst die an den Daten vorgenommenen Änderungen zu einem bestimmten Zeitpunkt. Um den aktuellen Zustand zu ermitteln, gibt das System diese Ereignisse in der Reihenfolge wieder. In dieser Kombination:

  • Der Ereignisspeicher ist das Schreibmodell und die einzige Quelle der Wahrheit.

  • Das Lesemodell generiert materialisierte Ansichten aus diesen Ereignissen, in der Regel in einer stark denormalisierten Form. Diese Ansichten optimieren den Datenempfang, indem Strukturen auf Abfrage- und Anzeigeanforderungen zugeschnitten werden.

Vorteile der Kombination von Event Sourcing und CQRS

Dieselben Ereignisse, die das Schreibmodell aktualisieren, können als Eingaben für das Lesemodell dienen. Das Lesemodell kann dann eine Echtzeitmomentaufnahme des aktuellen Zustands erstellen. Diese Momentaufnahmen optimieren Abfragen, indem sie effiziente, vorkompilierte Ansichten der Daten bereitstellen.

Anstatt den aktuellen Zustand direkt zu speichern, verwendet das System einen Datenstrom von Ereignissen als Schreibspeicher. Dieser Ansatz reduziert Updatekonflikte bei Aggregaten und verbessert die Leistung und Skalierbarkeit. Das System kann diese Ereignisse asynchron verarbeiten, um materialisierte Ansichten für den Lesespeicher zu erstellen oder zu aktualisieren.

Da der Ereignisspeicher als einzige Quelle der Wahrheit fungiert, können Sie leicht materialisierte Ansichten neu generieren oder sich an Änderungen im Lesemodell anpassen, indem Sie historische Ereignisse wiedergeben. Im Wesentlichen funktionieren materialisierte Ansichten als dauerhafter, schreibgeschützter Cache, der für schnelle und effiziente Abfragen optimiert ist.

Überlegungen bei der Kombination von Event Sourcing und CQRS

Bevor Sie das CQRS-Muster mit dem Event Sourcing-Musterkombinieren, bewerten Sie die folgenden Überlegungen:

  • Letztendliche Konsistenz: Da die Schreib- und Lesespeicher getrennt sind, können Aktualisierungen des Lesespeichers hinter der Ereignisgenerierung zurückbleiben, was zu einer späteren Konsistenz führt.

  • Erhöhte Komplexität: Kombinieren von CQRS mit Event Sourcing erfordert einen anderen Designansatz, der eine erfolgreiche Implementierung schwieriger machen kann. Sie müssen Code schreiben, um Ereignisse zu generieren, zu verarbeiten und zu verarbeiten und Ansichten für das Lesemodell zusammenzustellen oder zu aktualisieren. Event Sourcing vereinfacht jedoch die Domänenmodellierung und ermöglicht es Ihnen, neue Ansichten einfach neu zu erstellen oder zu erstellen, indem sie den Verlauf und die Absicht aller Datenänderungen beibehalten.

  • Leistung der Ansichtsgenerierung: Das Generieren materialisierter Ansichten für das Lesemodell kann erhebliche Zeit und Ressourcen beanspruchen. Das gleiche gilt für das Projizieren von Daten durch Wiedergeben und Verarbeiten von Ereignissen für bestimmte Entitäten oder Sammlungen. Dieser Effekt erhöht sich, wenn Berechnungen das Analysieren oder Addieren von Werten über lange Zeiträume umfassen, da alle zugehörigen Ereignisse untersucht werden müssen. Implementieren Sie Momentaufnahmen der Daten in regelmäßigen Abständen. Speichern Sie z. B. regelmäßige Momentaufnahmen aggregierter Summen (die Anzahl der Vorkommen einer bestimmten Aktion) oder den aktuellen Status einer Entität. Momentaufnahmen reduzieren die Notwendigkeit, den gesamten Ereignisverlauf wiederholt zu verarbeiten, wodurch die Leistung verbessert wird.

Beispiel für ein CQRS-Muster

Der folgende Code zeigt einige Auszüge aus einem Beispiel einer CQRS-Implementierung, in der unterschiedliche Definitionen für das Lese- und das Schreibmodell verwendet werden. Die Modellschnittstellen legen keine Features der zugrundeliegenden Datenspeicher fest und können unabhängig voneinander weiterentwickelt und angepasst werden, da diese Schnittstellen getrennt sind.

Der folgende Code zeigt die Definition des Lesemodells.

// Query interface
namespace ReadModel
{
  public interface ProductsDao
  {
    ProductDisplay FindById(int productId);
    ICollection<ProductDisplay> FindByName(string name);
    ICollection<ProductInventory> FindOutOfStockProducts();
    ICollection<ProductDisplay> FindRelatedProducts(int productId);
  }

  public class ProductDisplay
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal UnitPrice { get; set; }
    public bool IsOutOfStock { get; set; }
    public double UserRating { get; set; }
  }

  public class ProductInventory
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CurrentStock { get; set; }
  }
}

Das System ermöglicht den Benutzern, Produkte zu bewerten. Hierfür wird im Anwendungscode der Befehl RateProduct verwendet, wie im folgenden Code zu sehen ist.

public interface ICommand
{
  Guid Id { get; }
}

public class RateProduct : ICommand
{
  public RateProduct()
  {
    this.Id = Guid.NewGuid();
  }
  public Guid Id { get; set; }
  public int ProductId { get; set; }
  public int Rating { get; set; }
  public int UserId {get; set; }
}

Das System verwendet die Klasse ProductsCommandHandler, um die von der Anwendung gesendeten Befehle zu verarbeiten. Clients senden Befehle üblicherweise über ein Messagingsystem wie eine Warteschlange an die Domäne. Der Befehlshandler akzeptiert diese Befehle und ruft Methoden der Domänenschnittstelle auf. Die Granularität der einzelnen Befehle ist darauf ausgelegt, die Wahrscheinlichkeit von in Konflikt stehenden Anforderungen zu verringern. Der folgende Code zeigt eine Gliederung der Klasse ProductsCommandHandler.

public class ProductsCommandHandler :
    ICommandHandler<AddNewProduct>,
    ICommandHandler<RateProduct>,
    ICommandHandler<AddToInventory>,
    ICommandHandler<ConfirmItemShipped>,
    ICommandHandler<UpdateStockFromInventoryRecount>
{
  private readonly IRepository<Product> repository;

  public ProductsCommandHandler (IRepository<Product> repository)
  {
    this.repository = repository;
  }

  void Handle (AddNewProduct command)
  {
    ...
  }

  void Handle (RateProduct command)
  {
    var product = repository.Find(command.ProductId);
    if (product != null)
    {
      product.RateProduct(command.UserId, command.Rating);
      repository.Save(product);
    }
  }

  void Handle (AddToInventory command)
  {
    ...
  }

  void Handle (ConfirmItemsShipped command)
  {
    ...
  }

  void Handle (UpdateStockFromInventoryRecount command)
  {
    ...
  }
}

Nächste Schritte

Die folgenden Muster und Anweisungen könnten für die Implementierung dieses Musters relevant sein:

  • Muster für Ereignisherkunftsermittlung. Beschreibt die Verwendung von Event Sourcing mit dem CQRS-Muster. Es zeigt Ihnen, wie Sie Aufgaben in komplexen Domänen vereinfachen und gleichzeitig die Leistung, Skalierbarkeit und Reaktionsfähigkeit verbessern. Außerdem wird erläutert, wie Konsistenz für Transaktionsdaten bereitgestellt wird und gleichzeitig vollständige Überwachungspfade und Verlaufsprotokolle beibehalten werden, die Ausgleichsaktionen ermöglichen können.

  • Muster „Materialisierte Sichten“: Das Lesemodell einer CQRS-Implementierung kann materialisierte Sichten der Daten des Schreibmodells enthalten. Das Modell kann alternativ auch zur Generierung materialisierter Sichten verwendet werden.