Паттерны

Паттерны

Сага (Saga)

Источник

https://microservices.io/patterns/data/saga.html

Контекст

В системе используется паттерн "Одна база данных на сервис" (Database per Service). Некоторые бизнес-транзакции, однако, охватывают несколько сервисов, так что нужен механизм между-сервисных транзакций. Мы не можем использовать локальные ACID транзакции из-за коммуникаций между сервисами

Решение

Реализовать каждую бизнес-транзакцию как сагу. Сага - последовательность локальных транзакций. Каждая локальная транзакция обновляет БД и публикует сообщение или событие, чтобы вызвать следующую локальную транзакцию саги. Если локальная транзакция фейлится из-за ограничений бизнес-правил, то сага выполняет серию компенсирующих транзакций для отмены уже сделанных изменений.

attachments/Pasted image 20221016172040.png

Есть два способа координирования саг:

  • Хореография (Choreography) - каждая локальная транзакция публикует доменные события, которые запускают локальные транзакции в других сервисах.
  • Оркестрация (Orchestration) - оркестратор (объект) говорит участникам какие локальные транзакции надо выполнить.

Пример с Хореографией

attachments/Pasted image 20221016172609.png

E-commerce приложение создаёт заказ используя сагу, которая включает следующие шаги:

  1. Сервис Заказов получает POST /orders реквест и создаёт Заказ в состоянии PENDING.
  2. Потом он отправляет событие "Заказ Создан"
  3. Сервис Покупателя получает его и пытается зарезервировать средства
  4. Затем он отправляет событие с исходом
  5. Сервис Заказов получает событие и одобряет или отклоняет Заказ

Пример с Оркестрацией

attachments/Pasted image 20221016215704.png

  1. Сервис Заказов получает POST /orders реквест и создаёт оркестратор саги "Создание Заказа"
  2. Оркестратор создаёт Заказ в состоянии PENDING
  3. Он отправляет команду "Зарезервировать средства" Сервису Покупателей
  4. Сервис Покупателей пытается зарезервировать средства
  5. Он отправляет назад сообщение с ответом о результате команды
  6. Оркестратор одобряет или отклоняет Заказ

Resulting context

This pattern has the following benefits:

It enables an application to maintain data consistency across multiple services without using distributed transactions
This solution has the following drawbacks:

The programming model is more complex. For example, a developer must design compensating transactions that explicitly undo changes made earlier in a saga.
There are also the following issues to address:

In order to be reliable, a service must atomically update its database and publish a message/event. It cannot use the traditional mechanism of a distributed transaction that spans the database and the message broker. Instead, it must use one of the patterns listed below.

A client that initiates the saga, which an asynchronous flow, using a synchronous request (e.g. HTTP POST /orders) needs to be able to determine its outcome. There are several options, each with different trade-offs:

The service sends back a response once the saga completes, e.g. once it receives an OrderApproved or OrderRejected event.
The service sends back a response (e.g. containing the orderID) after initiating the saga and the client periodically polls (e.g. GET /orders/{orderID}) to determine the outcome
The service sends back a response (e.g. containing the orderID) after initiating the saga, and then sends an event (e.g. websocket, web hook, etc) to the client once the saga completes.

The Database per Service pattern creates the need for this pattern
The following patterns are ways to atomically update state and publish messages/events:
Event sourcing
Transactional Outbox
A choreography-based saga can publish events using Aggregates and Domain Events

Transactional Outbox

Источники

  • Pattern: Transactional outbox
  • Microservices 101: Transactional Outbox and Inbox

Контекст

Типичное действие для сервиса - обновить БД и послать события. Причем это нужно сделать атомарно, чтобы избежать неконсистентности и багов.

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

Проблема

Как надёжно/атомарно обновить БД и послать события?

Ограничения

  • Нет распределенных транзакций
  • Если транзакция проходит, то событие должно быть послано. И наоборот, если произошел откат транзакции, то событие не должно быть послано
  • События должны быть доставлены в брокер в порядке их отправления из сервиса

Решение

attachments/Pasted image 20221019123225.png

Сервис с реляционной БД, должен записывать события в таблицу исходящих сообщений (Outbox) в одной транзакции с обновлением БД.

Сервис с NoSQL БД добавляет события в атрибут обновляемой записи (документа).

Отдельный Message Relay процесс публикует события, записанные в БД, в брокер сообщений.

Итоги

Плюсы использования паттерна:

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

Минусы использования паттерна:

  • Message Relay может послать одно и то же событие больше одного раза. Например, если сервис упал после публикации события, но до обновления его статуса в БД.
    А значит получатели события должны быть идемподентны
  • Много шаблонного кода.

Message Relay

Источники

  • Pattern: Transaction log tailing
  • Pattern: Polling publisher

Проблема

Как публиковать сообщение из таблицы Outbox базы данных в брокер?

Решение

Polling publisher

attachments/Pasted image 20221019143157.png

Доставать сообщения из таблицы Outbox с помощью фонового процесса, который поллит таблицу

  • Находим неотправленные сообщения
  • Публикуем их в брокер
  • Помечаем их в БД как отправленные

Плюсы решения:

  • Работает с любой SQL базой

Минусы решения:

  • Может быть сложно слать события в нужном порядке (?)
  • Работает не со всеми NoSQL базами

Возможные сложности:

  • Поллинг может создать значительную нагрузку на БД, так как запросы должны быть частыми
    А если уменьшить интервал, то это увеличить задержку доставки сообщений.
    Еще можно увеличить размер группы сообщений в запросе к БД, но тогда больше вероятность, что одно из сообщений не доставится и тогда всю группу нельзя будет пометить как отправленную

  • Возможны проблемы с производительностью при слишком большом потоке событий.
    Производительность можно улучшить распараллеливанием. Чтобы разные потоки не брали одинаковые сообщения, можно помечать взятые элементы в БД как занятые:

    SELECT ... FOR UPDATE SKIP LOCKED
    
  • Из-за большого потока событий таблица можно слишком разрастись.
    Чтобы этого избежать, можно запустить другой фоновый процесс, который будет удалять старые отправленные события

Пример: Eventuate Tram framework реализует поллинг

Transaction log tailing

attachments/Pasted image 20221019111018.png
Отслеживать лог транзакций БД и публиковать каждое событие из таблицы Outbox в брокер сообщений

Механизм отслеживания индивидуален для БД:

  • MySQL binlog
  • Postgres WAL
  • AWS DynamoDB table streams

Плюсы решения:

  • Гарантированная точность

Минусы решения:

  • Требует конкретного решения для каждой БД
  • Сложно избежать дублирования публикации событий (?)

Пример: Eventuate Tram framework реализует отслеживание лога

Event sourcing

Источники

  • Pattern: Event sourcing

Контекст

Такой же что у Transactional Outbox

Проблема

Такая же что у Transactional Outbox

Ограничения

Такие же что у Transactional Outbox

Решение

attachments/Pasted image 20221021201224.png

Event sourcing сохраняет состояние бизнес сущности (например, Заказ или Клиент) как последовательность событий, меняющих состояние. Когда состояние меняется, новое событие добавляется в список событий. Так так сохранение событие - одна операция, то она сама по себе атомарна. Приложение восстанавливает состояние воспроизводя последовательно все события.

Приложения сохраняют события в хранилище событий (БД событий). У хранилища есть API добавления и получения событий. Оно так же выступает в роли брокера сообщений и предоставляет API, которое позволяет сервисам подписываться на события.

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

Итоги

Event sourcing имеет несколько преимуществ:

  • Оно решает одну из ключевых проблем в реализации событийной архитектуры и делает возможным надежную публикацию событий в любой момент изменения состояния
  • Сохраняет события вместо доменных сущностей, что в основном помогает избежать объектно-реляционного разрыва (object‑relational impedance mismatch problem)
  • Предоставляет 100% надежный лог аудита изменений сущности
  • Делает возможным реализацию временных запросов (temporal queries), которые определяют состояние сущности в любой момент времени

Event sourcing имеет несколько недостатков:

  • Сложная и дорогая реконструкция состояния

Event store

Источники

  • https://softwaremill.com/implementing-event-sourcing-using-a-relational-database/
  • https://softwaremill.com/mqperf/

Проблема

Для реализации паттерна Event Sourcing нужно хранилище, которое одновременно должно надёжно хранить данные о событиях и иметь гарантированную доставку at least once.

Реализации

Postgres

У постгреса нет встроенных механизмов для подписки на события.

Поэтому нужны доп. действия для этого:

Можно создать таблицу с очередью неотправленных событий.

CREATE TABLE event_queue ( 
  id SERIAL PRIMARY KEY, 
  event_id INT NOT NULL REFERENCES events(id),
  next_delivery TIMESTAMPTZ NOT NULL
);

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

Отдельный поток будет считывать записи из этой таблицы, публиковать события и удалять запись из таблицы event_queue.

Считывать можно следующим способом:

SELECT id, content FROM jobs WHERE next_delivery <= $now FOR UPDATE SKIP LOCKED LIMIT n;

UPDATE jobs SET next_delivery = $nextDelivery;

То есть, одновременно со считыванием ставим lock с помощью next_delivery. Это позволит скрыть событие от других экземпляров и процессов на определенное время N.

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

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

Считывать можно несколькими способами:

  1. Поллинг таблицы и считывание записей пачкой
  2. Механизм listen/notify