Шаблон CQRS

Хранилище Azure

CQRS означает разделение ответственности за команду и запрос, шаблон, который разделяет операции чтения и обновления для хранилища данных. Внедрив в приложение CQRS, можно максимально увеличить его производительность, масштабируемость и защиту. Гибкость, достигнутая при переходе на CQRS, позволяет системе лучше развиваться с течением времени и не мешает командам обновления вызывать конфликты слияния на уровне домена.

Контекст и проблема

В традиционных архитектурах аналогичная модель используется в базах данных для выполнения запросов и обновлений. Этот простой подход отлично подходит для базовых операций создания, чтения, обновления и удаления. Но в более сложных приложениях такой подход не всегда будет удобным. Например, при чтении данных приложение может выполнять много разных запросов и возвращать несколько объектов передачи данных разных форм. Это усложняет сопоставление объектов. Для записи данных модель может применять сложные процедуры проверки и процессы бизнес-логики. В результате вы получите слишком сложную модель, которая выполняет слишком много функций.

Рабочие нагрузки чтения и записи часто асимметричные, с очень разными требованиями к производительности и масштабированию.

Стандартная архитектура CRUD

  • Часто существует несоответствие между представлениями данных чтения и записи, например дополнительными столбцами или свойствами, которые должны быть правильно обновлены, даже если они не требуются в рамках операции.

  • Состязание с данными может возникать, когда операции выполняются параллельно с тем же набором данных.

  • Традиционный подход может негативно повлиять на производительность из-за нагрузки на хранилище данных и уровень доступа к данным, а также сложность запросов, необходимых для получения информации.

  • Управление безопасностью и разрешениями может стать сложным, так как каждая сущность подвержена операциям чтения и записи, которые могут предоставлять данные в неправильном контексте.

Решение

CQRS разделяет операции чтения и записи в разные модели, используя команды для обновления данных и запросов для чтения данных.

  • Команды должны быть основаны на задачах, а не на основе данных. ("Book hotel room", not "set ReservationStatus to Reserved"). Это может потребовать некоторых соответствующих изменений в стиле взаимодействия с пользователем. Другая часть этого заключается в том, чтобы просмотреть изменение бизнес-логики, обрабатывающей эти команды, чтобы быть успешными чаще. Один из способов, поддерживающих эту функцию, заключается в том, чтобы выполнять некоторые правила проверки на клиенте даже до отправки команды, возможно, отключая кнопки, объясняя, почему в пользовательском интерфейсе ("нет комнат слева"). Таким образом, причина сбоев команд на стороне сервера может быть сужена до условий гонки (два пользователя пытаются забронировать последнюю комнату), и даже те, которые иногда можно решить с некоторыми дополнительными данными и логикой (помещая гостя в список ожидания).
  • Команды могут размещаться в очереди для асинхронной обработки, а не синхронно обрабатываться.
  • Запросы никогда не изменяют данные в базе данных. Запрос возвращает объект передачи данных, который не содержит сведения о предметной области.

Затем модели можно изолировать, как показано на следующей схеме, хотя это не является абсолютным требованием.

Базовая архитектура CQRS

Наличие отдельных моделей запросов и обновлений упрощает проектирование и реализацию. Однако одним из недостатков является то, что код CQRS не может быть автоматически создан из схемы базы данных с помощью механизмов формирования шаблонов, таких как средства O/RM (однако вы сможете создать настройку на основе созданного кода).

Для дополнительной изоляции можно физически разделить данные для чтения и данные для записи. В этом случае в базе данных для чтения можно оптимизировать схему данных так, чтобы максимально эффективно выполнять запросы. Например, в ней можно хранить материализованные представления данных, чтобы не использовать сложные операции соединения или сложные объектно-реляционные сопоставления. Вы можете даже применить другой тип хранилища данных. Например, база данных для записи останется реляционной, а для чтения вы примените базу данных документов.

Если используются отдельные базы данных чтения и записи, они должны храниться в синхронизации. Обычно это достигается путем публикации события модели записи при каждом обновлении базы данных. Дополнительные сведения об использовании событий см. в стиле архитектуры на основе событий. Так как брокеры сообщений и базы данных обычно не могут быть зачислены в одну распределенную транзакцию, могут возникнуть проблемы при обеспечении согласованности при обновлении базы данных и событиях публикации. Дополнительные сведения см. в руководстве по обработке идемпотентных сообщений.

Архитектура CQRS с раздельными хранилищами для чтения и записи

В качестве хранилища для чтения можно использовать реплику хранилища для записи с доступом на чтение. Более того, это могут быть хранилища с совершенно разными структурами данных. Использование нескольких реплика только для чтения может повысить производительность запросов, особенно в распределенных сценариях, где реплика только для чтения находятся рядом с экземплярами приложения.

Разделение хранилищ для чтения и записи позволяет правильно масштабировать каждое из них в соответствии с нагрузкой. Традиционно нагрузка на хранилища для чтения намного выше, чем на хранилища для записи.

Некоторые реализации CQRS используют шаблон источников событий. В этом шаблоне состояние приложения сохраняется как последовательность событий. Каждое событие обозначает некоторый набор изменений в данных. Текущее состояние воссоздается при последовательном воспроизведении событий. В контексте CQRS одним из преимуществ событий является то, что те же события можно использовать для уведомления других компонентов, в частности, для уведомления модели чтения. Модель чтения на основе событий поддерживает моментальный снимок текущего состояния, повышая эффективность запросов. Но учтите, что шаблон источников событий усложняет архитектуру.

Преимущества CQRS включают:

  • Независимое масштабирование. CQRS позволяет раздельно масштабировать рабочие нагрузки чтения и записи, снижая риск конфликтов блокировки.
  • Оптимизация схем данных. Для процессов чтения можно применить схему, оптимизированную для запросов, а для процессов записи — другую схему, оптимизированную для обновлений.
  • Безопасность. Так будет проще назначить для выполнения операций записи данных только допустимые сущности домена.
  • Четкое разделение зон ответственности. Разделение процессов чтения и записи позволяет получить более гибкие и простые в обслуживании. Большая часть сложной бизнес-логики переместится в модель записи. Это в некоторой степени упростит модель чтения.
  • Более простые запросы. Сохраняя в базе данных для чтения материализованное представление данных, вы предотвратите использование приложением сложных соединений в запросах.

Проблемы и рекомендации по реализации

Ниже приведены некоторые проблемы реализации этого шаблона:

  • Сложность. Основная идея CQRS очень проста. Но ее реализация может привести к усложнению проекта приложения, особенно если добавить шаблон источников событий.

  • Обмен сообщениями. Сама по себе модель CQRS не требует обмена сообщениями, но сообщения часто применяются для обработки команд и публикации событий обновления. Это означает, что приложению придется обрабатывать сбои и дубликаты при передаче сообщений. Ознакомьтесь с рекомендациями по очередям приоритетов для работы с командами с различными приоритетами.

  • Итоговая согласованность. Если вы разделите базы данных для чтения и записи, в базе данных для чтения могут оставаться устаревшие данные. Хранилище моделей чтения должно быть обновлено, чтобы отразить изменения в хранилище моделей записи, и может быть трудно определить, когда пользователь выпустил запрос на основе устаревших данных чтения.

Когда следует использовать шаблон CQRS

Рассмотрим CQRS для следующих сценариев:

  • Домены совместной работы, в которых многие пользователи получают доступ к тем же данным параллельно. CQRS позволяет определять команды с достаточной степенью детализации, чтобы свести к минимуму конфликт слияния на уровне домена, и конфликты, которые возникают, могут быть объединены командой.

  • В пользовательских интерфейсах на основе задач, где пользователи выполняют сложный процесс в виде последовательности шагов или применяется сложная модель предметной области. Модель записи содержит полный стек обработки команд с бизнес-логикой, проверкой входных данных и бизнес-проверкой. Модель записи может рассматривать набор связанных объектов как единую единицу для изменений данных (агрегирования в терминологии DDD) и гарантировать, что эти объекты всегда находятся в согласованном состоянии. Модель чтения не имеет бизнес-логики или стека проверки и просто возвращает DTO для использования в модели представления. Для моделей чтения и записи реализована итоговая согласованность.

  • Сценарии, в которых производительность операций чтения данных должна быть точно настроена отдельно от производительности операций записи данных, особенно если количество операций чтения гораздо больше количества операций записи. В этом сценарии можно масштабировать модель чтения, но запустить модель записи только в нескольких экземплярах. Ограничение числа экземпляров модели записи помогает снизить риск конфликтов слияния.

  • В сценариях, где отдельная команда разработчиков работает над сложной моделью предметной области в рамках модели записи, позволяя другой команде направить усилия на модель чтения и пользовательские интерфейсы.

  • Если ожидается, что система будет со временем развиваться с разделением моделей, или в ней будут часто меняться бизнес-правила.

  • При интеграции с другими системами, особенно с применением модели источников событий, что позволит при временном сбое одной из подсистем сохранить доступность остальных.

Этот шаблон не рекомендуется использовать в следующих случаях:

  • Домен или бизнес-правила просты.

  • Достаточно простых операций пользовательского интерфейса и доступа к данным в стиле CRUD.

Попробуйте применить подход CQRS в ограниченных разделах системы, где эффект от него будет заметным.

Проектирование рабочей нагрузки

Архитектор должен оценить, как шаблон CQRS можно использовать в проектировании рабочей нагрузки для решения целей и принципов, описанных в основных принципах Платформы Azure Well-Architected Framework. Например:

Принцип Как этот шаблон поддерживает цели основных компонентов
Эффективность производительности помогает рабочей нагрузке эффективно соответствовать требованиям путем оптимизации масштабирования, данных, кода. Разделение операций чтения и записи в высокоуровневых рабочих нагрузках чтения и записи обеспечивает целевую производительность и оптимизацию масштабирования для конкретной цели каждой операции.

- Pe:05 Масштабирование и секционирование
- Производительность данных PE:08

Как и любое решение по проектированию, рассмотрите любые компромиссы по целям других столпов, которые могут быть представлены с этим шаблоном.

Шаблон источника событий и CQRS

Шаблон CQRS часто используется в сочетании с шаблоном источников событий. Системы на основе CQRS используют разные модели данных для чтения и записи, каждая из которых оптимизируется для выполнения соответствующих задач, а данные могут размещаться в разных физических хранилищах. Используемая вместе с шаблоном источников событий, модель записи сохраняет данные о событиях и является официальным источником данных. Модель чтения в системах на базе CQRS обычно предоставляет материализованные представления данных, часто с высоким уровнем денормализации. Эти представления создаются с учетом конкретных интерфейсов приложения и требований к отображению, что позволяет максимизировать производительность интерфейсов и запросов.

Если в качестве хранилища для записи используется поток событий, а не данные на определенный момент времени, можно избежать конфликтов при обновлении агрегатов, максимально повысить производительность и масштабируемость. Также эти события позволят в асинхронном режиме создавать материализованные представления данных для заполнения хранилища для чтения.

Так как официальным источником информации являются хранилища событий, в случае изменения системы или модели чтения можно просто удалить все материализованные представления и заново воспроизвести предыдущие события, чтобы создать новое представление текущего состояния. Материализованные представления по сути выполняют роль устойчивого кэша данных с доступом только на чтение.

Если вы используете шаблоны CQRS и источников событий, учитывайте следующее:

  • Как в любой системе с разделением хранилищ для записи и чтения, здесь возможна только итоговая согласованность. Между созданием события и обновлением данных в хранилище всегда будет некоторая задержка.

  • Этот шаблон повышает сложность системы, так как в нем нужно создать код для запуска и обработки событий, а также постоянно собирать или обновлять все представления и объекты, необходимые для обработки запросов в модели чтения. Сложность совместного применения шаблонов источников событий и CQRS затруднит реализацию системы и потребует нового подхода к разработке системы. Но зато источники событий упрощают моделирование предметной области, а также позволяют легко изменять представления и создавать новые, сохраняя все изменения в данных.

  • Может потребоваться значительное время и большой объем ресурсов для создания материализованных представлений, которые используются в модели чтения или в проекциях, так как этот процесс включает воспроизведение и обработку всех событий для конкретных сущностей или коллекций сущностей. Это особенно важно при обработке данных за длительные периоды времени, ведь нужно полностью проверить все связанные события. Устраните это путем реализации моментальных снимков данных с запланированными интервалами, например общее количество определенного действия, которое произошло, или текущее состояние сущности.

Пример шаблона CQRS

Следующий фрагмент кода взят из реализации подхода CQRS, в рамках которой применены разные определения для моделей чтения и записи. Интерфейсы модели не налагают ограничения на функции хранилищ данных, и их можно изменять или конфигурировать независимо друг от друга, так как эти интерфейсы разделены.

В следующем коде представлено определение модели чтения.

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

Эта система позволяет пользователям оценивать товары. Для этого в коде приложения реализована команда RateProduct, как показано ниже.

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

Система использует класс ProductsCommandHandler для обработки команд, отправляемых приложением. Обычно клиенты отправляют команды в предметную область через систему обмена сообщениями, например очередь. Обработчик команд принимает эти команды и вызывает методы интерфейса предметной области. Для каждой команды выбирается такая степень детализации, которая позволяет снизить риск конфликта запросов. В следующем коде представлена упрощенная реализация класса 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)
  {
    ...
  }
}

Следующие шаги

При реализации этого шаблона будут полезны следующие шаблоны и рекомендации:

Записи блога Мартина Фаулера:

  • Шаблон источников событий. Описание применения источников событий совместно с шаблоном CQRS для упрощения задач в сложных предметных областях при одновременном повышении производительности, масштабируемости и скорости реагирования. Кроме того, рассмотрено предоставление поддержки согласованности для транзакционных данных с сохранением полного аудиторского следа и истории, которые позволяют выполнять компенсацию действий.

  • Materialized View Pattern (Шаблон материализованного представления). Модель чтения в реализации CQRS может содержать или самостоятельно создавать материализованные представления данных из модели записи.

  • Презентация более эффективного CQRS с помощью асинхронных шаблонов взаимодействия с пользователем