Wzorzec CQRS

Azure Storage

CQRS to segregacja poleceń i odpowiedzialności zapytań, czyli wzorzec oddzielający operacje odczytu i aktualizacji magazynu danych. Implementowanie magazynu CQRS w aplikacji może zmaksymalizować wydajność, skalowalność i zabezpieczenia. Elastyczność tworzona przez migrację do magazynu CQRS umożliwia systemowi lepsze rozwijanie się w czasie i uniemożliwia aktualizowanie poleceń powodujących konflikty scalania na poziomie domeny.

Kontekst i problem

W tradycyjnych architekturach ten sam model danych jest używany do wysyłania zapytań do bazy danych i aktualizowania jej. Jest to proste i dobrze się sprawdza w przypadku podstawowych operacji CRUD. Jednak w bardziej złożonych aplikacjach ta metoda może stać się niewydolna. Na przykład po stronie odczytu aplikacja może wykonywać wiele różnych zapytań zwracających obiekty transferu danych (DTO, data transfer object) o różnych kształtach. Mapowanie obiektu może stać się skomplikowane. Po stronie zapisu model może wdrażać złożoną walidację i logikę biznesową. W efekcie możesz uzyskać zbyt skomplikowany model, który wykonuje zbyt dużo działań.

Obciążenia odczytu i zapisu są często asymetryczne, z bardzo różnymi wymaganiami dotyczącymi wydajności i skalowania.

Tradycyjna architektura CRUD

  • Często występuje niezgodność między reprezentacjami odczytu i zapisu danych, takimi jak dodatkowe kolumny lub właściwości, które muszą zostać poprawnie zaktualizowane, mimo że nie są wymagane w ramach operacji.

  • Rywalizacja o dane może wystąpić, gdy operacje są wykonywane równolegle na tym samym zestawie danych.

  • Tradycyjne podejście może mieć negatywny wpływ na wydajność ze względu na obciążenie magazynu danych i warstwy dostępu do danych oraz złożoność zapytań wymaganych do pobrania informacji.

  • Zarządzanie zabezpieczeniami i uprawnieniami może stać się złożone, ponieważ każda jednostka podlega operacjom odczytu i zapisu, które mogą uwidaczniać dane w niewłaściwym kontekście.

Rozwiązanie

Usługa CQRS oddziela odczyty i zapisy w różnych modelach, używając poleceń do aktualizowania danych i zapytań w celu odczytywania danych.

  • Polecenia powinny być oparte na zadaniach, a nie skoncentrowane na danych. ("Zarezerwuj pokój hotelowy", a nie "ustaw wartość ReservationStatus na Reserved"). Może to wymagać pewnych odpowiednich zmian w stylu interakcji użytkownika. Druga część polega na tym, aby częściej modyfikować logikę biznesową przetwarzania tych poleceń. Jedną z technik obsługujących tę funkcję jest uruchomienie niektórych reguł walidacji na kliencie jeszcze przed wysłaniem polecenia, ewentualnie wyłączenie przycisków, wyjaśniając, dlaczego w interfejsie użytkownika ("brak pozostałych pomieszczeń"). W ten sposób przyczyną niepowodzeń poleceń po stronie serwera można zawęzić do warunków wyścigu (dwóch użytkowników próbujących zarezerwować ostatni pokój), a nawet te mogą być czasami rozwiązane przy użyciu większej ilości danych i logiki (umieszczenie gościa na liście oczekujących).
  • Polecenia mogą być umieszczane w kolejce do przetwarzania asynchronicznego, a nie przetwarzane synchronicznie.
  • Zapytania nigdy nie modyfikują bazy danych. Zapytanie zwraca obiekt DTO, który nie hermetyzuje żadnej wiedzy domeny.

Modele można następnie odizolować, jak pokazano na poniższym diagramie, chociaż nie jest to bezwzględne wymaganie.

Podstawowa architektura CQRS

Posiadanie oddzielnych modeli zapytań i aktualizacji upraszcza projektowanie i implementację. Jednak jedną wadą jest to, że kod CQRS nie może być generowany automatycznie na podstawie schematu bazy danych przy użyciu mechanizmów tworzenia szkieletów, takich jak narzędzia O/RM (jednak można utworzyć dostosowanie na podstawie wygenerowanego kodu).

Aby uzyskać większą izolację, możesz fizycznie oddzielić odczyt danych od zapisu danych. W takim przypadku baza danych odczytu może używać własnego schematu danych zoptymalizowanego dla zapytań. Na przykład może ona przechowywać zmaterializowany widok danych, aby uniknąć złożonych połączeń lub złożonych mapowań obiektowo-relacyjnych. Może ona nawet używać innego typu magazynu danych. Na przykład baza danych zapisu może być relacyjna, natomiast baza danych odczytu może być bazą danych dokumentów.

Jeśli są używane oddzielne bazy danych odczytu i zapisu, muszą być zsynchronizowane. Zazwyczaj jest to realizowane przez opublikowanie zdarzenia przez model zapisu za każdym razem, gdy aktualizuje bazę danych. Aby uzyskać więcej informacji na temat używania zdarzeń, zobacz Styl architektury opartej na zdarzeniach. Ponieważ brokerzy komunikatów i bazy danych zwykle nie mogą być wymienione w jednej transakcji rozproszonej, podczas aktualizowania bazy danych i zdarzeń publikowania mogą występować wyzwania związane z zagwarantowaniem spójności. Aby uzyskać więcej informacji, zobacz wskazówki dotyczące przetwarzania komunikatów idempotentnych.

Architektura CQRS z oddzielnymi magazynami odczytu i zapisu

Magazyn odczytu może być repliką tylko do odczytu magazynu zapisu lub magazyny odczytu i zapisu mogą mieć całkowicie inną strukturę. Użycie wielu replik tylko do odczytu może zwiększyć wydajność zapytań, szczególnie w scenariuszach rozproszonych, w których repliki tylko do odczytu znajdują się w pobliżu wystąpień aplikacji.

Rozdzielenie magazynów odczytu i zapisu zapewnia każdemu z nich możliwość odpowiedniego skalowania w celu dopasowania do obciążenia. Na przykład magazyny odczytu są zazwyczaj znacznie bardziej obciążone niż magazyny zapisu.

Niektóre implementacje podejścia CQRS używają wzorca określania źródła zdarzeń. Za pomocą tego wzorca stan aplikacji jest przechowywany jako sekwencja zdarzeń. Każde zdarzenie reprezentuje zestaw zmian danych. Bieżący stan jest tworzony przez ponowne odtwarzanie zdarzeń. W kontekście CQRS jedną zaletą określania źródła zdarzeń jest to, że te same zdarzenia mogą służyć do powiadamiania innych składników — w szczególności do powiadamiania modelu odczytu. Model odczytu używa zdarzeń do tworzenia migawki bieżącego stanu, co jest bardziej wydajne dla zapytań. Jednak określanie źródła zdarzeń zwiększa złożoność projektu.

Zalety CQRS obejmują:

  • Niezależne skalowanie. Podejście CQRS umożliwia niezależne skalowanie obciążeń odczytu oraz zapisu i może skutkować mniejszą liczbą blokad rywalizacji.
  • Zoptymalizowane schematy danych. Strona odczytu może używać schematu zoptymalizowanego dla zapytań, a strona zapisu — schematu zoptymalizowanego dla aktualizacji.
  • Zabezpieczenia. Łatwiej jest zapewnić możliwość wykonywania operacji zapisu względem danych tylko przez właściwe jednostki domeny.
  • Separacja problemów. Oddzielenie stron odczytu i zapisu może doprowadzić do modeli, które są łatwiejsze w obsłudze i bardziej elastyczne. Większość złożonej logiki biznesowej przechodzi do modelu zapisu. Model odczytu może być stosunkowo prosty.
  • Prostsze zapytania. Zapisując zmaterializowany widok w bazie danych odczytu, aplikacja może uniknąć złożonych połączeń podczas wykonywania zapytania.

Problemy i zagadnienia dotyczące implementacji

Niektóre wyzwania związane z implementacją tego wzorca obejmują:

  • Złożoność. Podstawowa koncepcja CQRS jest prosta. Jednak może ona prowadzić do bardziej złożonego projektu aplikacji zwłaszcza wtedy, gdy zawiera wzorzec określania źródła zdarzeń.

  • Obsługa komunikatów. Chociaż podejście CQRS nie wymaga obsługi komunikatów, jest ona często używana do przetwarzania poleceń i publikowania zdarzeń aktualizacji. W takim przypadku aplikacja musi obsługiwać błędy komunikatów lub zduplikowane komunikaty. Zapoznaj się ze wskazówkami dotyczącymi kolejek priorytetowych , aby radzić sobie z poleceniami o różnych priorytetach.

  • Spójność ostateczna. Jeśli oddzielisz bazy danych odczytu i zapisu, dane odczytu mogą być nieaktualne. Magazyn modeli odczytu musi zostać zaktualizowany w celu odzwierciedlenia zmian w magazynie modeli zapisu i może być trudne do wykrycia, kiedy użytkownik wystawił żądanie na podstawie nieaktualnych danych odczytu.

Kiedy należy używać wzorca CQRS

Rozważ użycie usługi CQRS w następujących scenariuszach:

  • Domeny współpracy, w których wielu użytkowników uzyskuje dostęp do tych samych danych równolegle. Usługa CQRS umożliwia definiowanie poleceń z wystarczającą szczegółowością, aby zminimalizować konflikty scalania na poziomie domeny, a konflikty, które występują, można scalić za pomocą polecenia .

  • Oparte na zadaniach interfejsy użytkownika, gdzie użytkownicy są prowadzeni przez złożony proces w szeregu kroków lub za pomocą złożonych modeli domeny. Model zapisu ma pełny stos przetwarzania poleceń z logiką biznesową, walidacją danych wejściowych i weryfikacją biznesową. Model zapisu może traktować zestaw skojarzonych obiektów jako pojedynczą jednostkę zmian danych (agregację w terminologii DDD) i upewnić się, że te obiekty są zawsze w stanie spójnym. Model odczytu nie ma logiki biznesowej ani stosu weryfikacji i po prostu zwraca obiekt DTO do użycia w modelu widoku. Model odczytu jest ostatecznie spójny z modelem zapisu.

  • Scenariusze, w których wydajność odczytów danych musi być dostrojona oddzielnie od wydajności zapisów danych, zwłaszcza gdy liczba odczytów jest znacznie większa niż liczba zapisów. W tym scenariuszu można skalować model odczytu w poziomie, ale uruchamiać model zapisu w kilku wystąpieniach. Mała liczba wystąpień modelu zapisu pomaga też w zminimalizowaniu występowania konfliktów scalania.

  • Scenariusze, w których jeden zespół deweloperów może skupić się na złożonym modelu domeny będącym częścią modelu zapisu, a inny zespół może skupić się na modelu odczytu i interfejsach użytkownika.

  • Scenariusze, w których system ma podlegać ewolucji w czasie i może zawierać wiele wersji modelu lub w których reguły biznesowe są regularnie zmieniane.

  • Integracja z innymi systemami, zwłaszcza w połączeniu z określaniem źródła zdarzeń, gdzie błąd czasowy jednego podsystemu nie powinien wpływać na dostępność innych.

Ten wzorzec nie jest zalecany, gdy:

  • Domena lub reguły biznesowe są proste.

  • Wystarczy prosty interfejs użytkownika w stylu CRUD i operacje dostępu do danych.

Rozważ stosowanie podejścia CQRS do ograniczonych sekcji systemu, gdzie będzie ono najbardziej przydatne.

Projekt obciążenia

Architekt powinien ocenić, w jaki sposób wzorzec CQRS może być używany w projekcie obciążenia, aby sprostać celom i zasadom opisanym w filarach platformy Azure Well-Architected Framework. Na przykład:

Filar Jak ten wzorzec obsługuje cele filaru
Wydajność pomagawydajnie sprostać zapotrzebowaniu dzięki optymalizacjom skalowania, danych, kodu. Rozdzielenie operacji odczytu i zapisu w obciążeniach odczytu do zapisu umożliwia ukierunkowaną wydajność i optymalizacje skalowania dla konkretnego celu każdej operacji.

- PE:05 Skalowanie i partycjonowanie
- PE:08 Wydajność danych

Podobnie jak w przypadku każdej decyzji projektowej, należy rozważyć wszelkie kompromisy w stosunku do celów innych filarów, które mogą zostać wprowadzone przy użyciu tego wzorca.

Określanie źródła zdarzeń i wzorzec CQRS

Wzorzec CQRS jest często używany wraz ze wzorcem określania źródła zdarzeń. Systemy oparte na wzorcu CQRS używają oddzielnych modeli odczytu i zapisu danych, a każdy z nich jest dostosowany do odpowiednich zadań i często znajduje się w fizycznie oddzielonych magazynach. W przypadku użycia ze wzorcem określania źródła zdarzeń magazynem zdarzeń jest model zapisu i jest to oficjalne źródło informacji. Model odczytu systemu opartego na podejściu CQRS udostępnia zmaterializowane widoki danych zwykle jako widoki w wysokim stopniu zdenormalizowane. Widoki te są dostosowane do interfejsów i wyświetlają wymagania dotyczące aplikacji, które pomagają w zmaksymalizowaniu wydajności zarówno wyświetlania, jak i zapytań.

Używanie strumienia zdarzeń jako magazynu zapisu, a nie rzeczywistych danych w punkcie w czasie, pozwala uniknąć konfliktów aktualizacji dla jednej wartości zagregowanej oraz maksymalizuje wydajność i skalowalność. Zdarzenia mogą służyć do asynchronicznego generowania zmaterializowanych widoków danych, które są używane do zapełnienia magazynu odczytu.

Ponieważ magazyn zdarzeń jest oficjalnym źródłem informacji, istnieje możliwość usunięcia zmaterializowanych widoków i odtworzenia wszystkich wcześniejszych zdarzeń, aby utworzyć nową reprezentację bieżącego stanu, gdy system ewoluuje lub gdy model odczytu należy zmienić. Zmaterializowane widoki są w zasadzie trwałą pamięcią podręczną umożliwiającą tylko odczyt danych.

W razie używania podejścia CQRS w połączeniu z wzorcem określania źródła zdarzeń należy rozważyć następujące kwestie:

  • Podobnie jak w przypadku każdego systemu, gdzie magazyny zapisu i odczytu są oddzielone, systemy oparte na tym wzorcu są spójne tylko ostatecznie. Będzie istnieć pewne opóźnienie między generowanym zdarzeniem i aktualizowanym magazynem danych.

  • Wzorzec zwiększa złożoność, ponieważ musi zostać utworzony kod do inicjowania i obsługi zdarzeń oraz łączenia albo aktualizowania odpowiednich widoków lub obiektów wymaganych przez zapytania lub modele odczytu. Złożoność wzorca CQRS, gdy jest on używany ze wzorcem określania źródła zdarzeń, może utrudnić pomyślne wdrożenie i wymaga innego podejścia do projektowania systemów. Jednak określanie źródła zdarzeń może ułatwić modelowanie domeny i uprościć przebudowę widoków lub tworzenie nowych, ponieważ cel zmian w danych jest zachowany.

  • Generowanie zmaterializowanych widoków do użycia w modelu odczytu lub projekcji danych przez odtwarzanie i obsługę zdarzeń dla określonych obiektów lub kolekcji obiektów może wymagać znacznego czasu przetwarzania i wykorzystania zasobów. Dotyczy to zwłaszcza sytuacji, w których jest wymagane sumowanie lub analiza wartości w długim okresie, ponieważ może być konieczne zbadanie wszystkich skojarzonych zdarzeń. Rozwiąż ten problem, implementując migawki danych w zaplanowanych interwałach, na przykład łączną liczbę określonej akcji, która wystąpiła, lub bieżący stan jednostki.

Przykład wzorca CQRS

Poniższy kod pokazuje pewne fragmenty przykładu wdrożenia podejścia CQRS, które używają różnych definicji modeli odczytu i zapisu. Interfejsy modelu nie wymagają żadnych funkcji podstawowych magazynów danych oraz mogą ewoluować i mogą być niezależnie strojone, ponieważ te interfejsy są rozdzielone.

Poniższy kod przedstawia definicję modelu odczytu.

// 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; }
  }
}

System pozwala użytkownikom oceniać produkty. Kod aplikacji realizuje to przy użyciu polecenia RateProduct pokazanego w poniższym kodzie.

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; }
}

System używa klasy ProductsCommandHandler do obsługi poleceń wysyłanych przez aplikację. Klienci wysyłają zazwyczaj polecenia do domeny za pośrednictwem systemu obsługi komunikatów, takiego jak kolejka. Procedura obsługi poleceń akceptuje te polecenia i wywołuje metody interfejsu domeny. Stopień szczegółowości każdego polecenia pozwala zmniejszyć prawdopodobieństwo żądań powodujących konflikt. Poniższy kod przedstawia zarys klasy 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)
  {
    ...
  }
}

Następne kroki

Podczas implementowania tego wzorca są przydatne następujące wzorce i wskazówki:

  • Podstawy spójności danych. Opisuje problemy, które zazwyczaj występują z powodu spójności ostatecznej między magazynami danych odczytu i zapisu podczas używania wzorca CQRS, i sposoby rozwiązywania tych problemów.

  • Partycjonowanie danych poziomych, pionowych i funkcjonalnych. W tym artykule opisano najlepsze rozwiązania dotyczące dzielenia danych na partycje, które mogą być zarządzane i uzyskiwane oddzielnie, aby zwiększyć skalowalność, zmniejszyć rywalizację i zoptymalizować wydajność.

  • Przewodnik po wzorcach i rozwiązaniach CQRS Journey (Podróż CQRS). W szczególności wprowadzenie wzorca segregacji odpowiedzialności zapytań poleceń bada wzorzec i kiedy jest przydatny, oraz Epilogue: Wnioski zdobyte pomagają zrozumieć niektóre problemy, które pojawiają się podczas korzystania z tego wzorca.

Wpisy na blogu Martina Fowlera: