Перевод статьи опубликованной на Eventsourcing Publications. Статья описывает некоторые из идей примененных в проекте Eventsourcing.

Если вы читали статью Фаулера или подобные источники на тему event sourcing, у вас в мозгу могла остаться вот приблизительно такая картинка:

image

Общая идея такого подхода заключается в том, что пользователь (или любая другая внешняя система) генерирует команды, мы их обрабатываем, складывая полученные события в event store и обновляя «состояние мира» в базе данных, данные из которой запрашивает пользователь.

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



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

Если честно, хотелось бы избежать таких сложностей.

За свою карьеру я разработал достаточно проектов чтоб понять несколько простых истин. Одна из этих истин — самая постоянная штука — это изменения. Фактически, нет нормального способа как узнать как будет выглядеть твое приложение и модель данных через несколько месяцев. Может разве что после того как переписано было все три раза с нуля, да и то это под вопросом.

Что если я могу отложить угадывание будущего до того момента, как я буду собственно в этом самом будущем? Так как я записываю каждое событие, я всегда могу перестроить «состояние мира», проигрывая все старые события еще раз, но, как я отметил раньше, это весьма дорогое удовольствие.

Но что если я могу просто посылать запросы на поиск событий которые подпадают под необходимые критерии, прямо во время запроса от пользователя? Например, представим что у нас есть пользователи и у них есть email адреса. Предположим, что мы создали события UserCreated и EmailChanged. Что если вместо того чтобы переигрывать все события для всех пользователей (или даже для одного!), мы просто будем искать UserCreated(id: ) и последний EmailChanged(userId: )?

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

// Pseudocode-ish User's email retrieval
public class User {
   public String email() {
     return query(equal(EmailChanged.ID, id), 
                  descending(EmailChanged.TIMESTAMP)).first().
                  email();
   }


Если алгоритм для выяснения email'а пользователя когда либо изменится, все что нам надо сделать это поменять метод User#email(). Более того, пока я не напишу сам метод User#email(), мне не нужно знать что это именно то, как я буду структурировать данные для презентации. Я просто должен записать все данные в моих событиях, по возможности не упуская ничего.

Что еще более интересно, этот подход ведет к следующему умозаключению: на самом деле, нету универсальной версии «состояния мира». Так же как мы, люди, воспринимаем мир и реальность вокруг нас по-разному, в зависимости от контекста, программы могут воспринимать реальность через разные призмы и я не вижу причин почему они должны быть ограничены в этом.

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

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

Вот, кстати, еще одно интересное последствие использования этого подхода. С растущей популярностью GraphQL как API, все больше и больше приложений оптимизируют объем передаваемых данных и количество запросов, запрашивая и получая только тот минимум который им необходим. Вместе с «ленивым» сбором данных из событий мы никогда не строим «состояния мира» на те части данных, которые не запрашиваются.

Вышеописанный подход используется в проекте Eventsourcing, в том числе в его расширении под названием Domain Protocol. У проекта также есть адаптор к GraphQL. Проект полностью открыт и лицензирован под Mozilla Public License 2.0. Недавно была создана некоммерческая организация Eventsourcing, Inc. для дальнейшего развития и поддержки проекта.
Поделиться с друзьями
-->

Комментарии (33)


  1. AigizK
    17.05.2016 06:00

    Не очень понял. Куда мы должны отправить запрос, в EventStore или StateDb?


    1. yrashk
      17.05.2016 06:02

      AigizK, запрос на чтение в «традиционной» модели отправляется в state db (часто это реляционная база даннных); в ленивой модели запрос уходит в event store (который не только журналирует события, но и индексирует их). так понятней?


      1. AigizK
        17.05.2016 07:12

        Но разделение DB же как раз позволяет настроить ES быстрым на запись, а state db на чтение. И при необходимости мы можем держать много копий state db. А вы как раз жертвуете этим преимуществом в замен актуальным данным.


        1. yrashk
          17.05.2016 07:15

          В существующей реализации, event store на самом деле (внутри) — две базы, одна — журнал, другая — индекс. Можно рассматривать их совокупность как одну event store, либо как event store + state db, где стейт — это просто индексы событий.


          1. AigizK
            17.05.2016 07:29

            Под ES подразумевал базы для записи, где сохраняете все события. Сейчас работаю над проектом, где используется подход ES+CQRS. Поэтому некое представление, как это работает, есть.
            Я не совсем понял, что вы хотите сделать:
            при переигрывание событий, чтоб заполнить StateDB, доставать только определенные события
            или
            пользовательский запрос направить на базу, где хранятся сами события и от StateDB отказаться


            1. yrashk
              17.05.2016 07:34

              Идея в том что не строится state db согласной некой модели данных, а только записываются события и они же индексируются (простые индексы по полям, или более сложные композитные индексы). вместо того чтобы записывать изменения в таблицу Users (например), и искать пользователя там, мы просто ищем события которые дают нам минимально необходимое понимание о том что произошло (в случае примера в статье, UserCreated(id) и последний EmailChanged(user: id). Ищем их не перебором, а по индексам (индексы могут быть определены как заранее, так и после записи событий).

              При этой модели, state db в классическом понимании нет, как нету и «единой» модели данных.

              Так понятней, или все еще плохо объяснил?


        1. yrashk
          17.05.2016 07:20

          мне кажется, важно вот какое замечание сделать: разделение на write side и read side было и остается, но read side представляет собой не около-конечную модель данных а «сырые индексы» и сборка моделей происходит (в большинстве случаев) в рантайме. Соответственно, нет необходимости «предугадывать» будущее.


          1. AigizK
            17.05.2016 07:32

            Т.е. ReadDb содержит инфу, скажем как быстро из WriteDB достать события UserCreated и EmailChanged и мы уже напрямую обращаемся к базе WtiteDb, так?


            1. yrashk
              17.05.2016 07:35

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


              1. AigizK
                17.05.2016 08:00

                1.Поступило событие
                2.Вы пишете его в WriteDb и получаете допустим некий ключ, по которому можно быстро получить это событие
                3. Пишите этот ключ в ReadDb
                4. Пользователь делает запрос, разбираете, выясняете какие ключи вам нужны, достаете по ним данные из WriteDb

                В итоге:
                1. пользователь так же будет получать не актуальную информацию(хотя не настолько как в традиционном варианте), потому что пока вы считываете ключи, WriteDb может измениться, а новые ключи еще не успели добавить в ReadDb
                2. изменений может быть очень много и вместо одного запроса, который бы вернул статистику изменения курса, вам нужно отправить много запросов(ведь вы предлагаете доставать отдельные события по ключу)
                3. если запросов на чтение много, то БД на запись так же будет тормозить


                1. yrashk
                  17.05.2016 08:11

                  отличный анализ!

                  по итогам:
                  1. более актуальная информация чем в традиционном варианте — уже лучше.
                  2. как я упоминал в статье, конечно есть случаи когда массовые выборки будут медленными, и так или иначе агрегировать и/или кешировать данные надо. идеального мира нет :)
                  3. не обязательно, это зависит от того как она устроена. append only дает определенные преимущества.


                  1. AigizK
                    17.05.2016 08:12

                    Тогда подробнее расскажите, как устроен у вас WriteDb


                    1. yrashk
                      17.05.2016 08:18

                      пока что ничего сильно сложного (но проект еще очень молодой). сейчас writedb (журнал) реализуется через MVStore (движок от H2), так как в моей текущей модели я разрабатываю приложения со «встроенным» хранилищем (через интерфейс можно, конечно, добавить любые другие реализации. например, более раннии инкарнации этого проекта использовали postgresql по умолчанию).

                      если разбивать файлы в которых лежат необходимые ключи (например, consistent hashing или на исторические «отрезки») и хранить на независимых устройствах (в терминах дисков ли, или сетевых устройств) то можно обеспечить сценарий в котором чтение из writedb практически независимо от записи. пока что этого ф-ционала нет, но это интересная тема!


  1. AigizK
    17.05.2016 08:17

    Есть еще один подход. Оставить традиционный способ заполнения ReadDb и натравить на него ElasticSearch.
    1. делаете запрос в ElasticSearch
    2. ElasticSearch возвращает ИД из ReadDb
    3. Достаете данные из ReadDb

    Такой подход легко масштабируется


    1. yrashk
      17.05.2016 08:20

      масштабируется — да. но это не решает проблему (которая для кого-то проблема, а для кого-то — нет) с нежеланием предугадывать будущее (соотв., строить read side модели). у меня это было задачей :)


  1. mird
    17.05.2016 09:52

    Начну с простого вопроса:
    А почему вы решили, что для хранения состояния нужна обязательно реляционная бд? Фаулер пишет буквально следующее:

    Application states can be stored either in memory or on disk. Since an application state is purely derivable from the event log, you can cache it anywhere you like. A system in use during a working day could be started at the beginning of the day from an overnight snapshot and hold the current application state in memory.

    То есть можно вообще не создавать read model и кешировать в памяти доменную модель из которой и брать нужные вещи, In-memory cache можно восстанавливать по событиям при старте приложения.
    Разделение на Read модель и доменную модель описывается паттерном CQRS, который хоть и хорошо сочетается с EventSourcing но все же отдельный паттерн, не требующий ни EventSourcing ни даже отдельных бд для чтения и записи.


    1. yrashk
      17.05.2016 10:21

      Спасибо за комментарий!

      Я не писал, что нужна обязательно реляционная база данных, выше в комментариях я написал «часто это реляционная база данных». Доменную модель и правда можно хранить где угодно. Если доменная модель в памяти, это не значит что read side нету, просто она в памяти.

      Можно прекрасно делать то что вы описываете («кешировать в памяти доменную модель из которой и брать нужные вещи, In-memory cache можно восстанавливать по событиям при старте приложения») однако это не решает ту задачу которая была передо мной — избежать планирования доменной модели («предугадывание будущего»), и дорогостоящих «переигрываний» событий.


      1. mird
        17.05.2016 11:15

        То есть вы хотите вообще не иметь никакой модели? В этом случае ни о какой консистентности данных говорить не приходится вообще. (Даже об eventually consistency.) Вам просто не на чем проверять эту консистентность. Нет, если ваша система хранит разрозненные данные, можно так делать, наверное, Но, боюсь, для того чтобы получить что-нибудь полезное из этой кучи, вам придется применять методы применяемые для работы с большими данными.


  1. lair
    17.05.2016 10:46
    +1

    Прямо скажем, идея "давайте использовать EventStore как read-model" лежит на поверхности, и как раз в варианте "давайте просто читать последнее релевантное событие" (особенно если ES так организован, что позволяет это сделать сравнительно быстро). Проблемы этого подхода начинаются в тот момент, когда вам надо-таки прочитать всю историю агрегата (например, если вам надо для пользователя достать все теги, на которые он подписан, а каждый тег вы храните отдельным событием).


    1. yrashk
      17.05.2016 11:21

      Конечно, на поверхности! Ничего сверх-нового. Мои усилия на тему чтения истории агрегата сейчас таковы: 1) оптимизация поиска (индексы используют CQengine, память/диск) 2) организация события в таком виде при котором легко запрашивать без over-fetching.


      1. lair
        17.05.2016 11:23

        Мои усилия на тему чтения истории агрегата сейчас таковы: 1) оптимизация поиска (индексы используют CQengine, память/диск)

        Индексы синхронные?


        организация события в таком виде при котором легко запрашивать без over-fetching.

        … то есть вы подстроили события (которые write-model) под то, как удобно читать (то есть read-model)?


        1. yrashk
          17.05.2016 11:31

          1. Явно выраженных гарантий по поводу индексов еще нет (но скорее всего будут), пока это факт реализации и некоторых тестов — индексы появляются по факту коммита транзакции (scope транзакций — команда), перед тем как контроль «вернется» к отправителю команды, то есть по сути идут вместе с транзакцией.
          2. Только в некотором смысле — в том плане что я сохраняю данные в событиях в таком виде в котором использование индексов для того чтобы найти эти события было настолько тривиальным насколько это возможно. Случаи, когда таки приходится делать агрегацию событий для оптимизации конечно происходят, но я стараюсь это свести к минимуму.


          1. lair
            17.05.2016 11:34

            Явно выраженных гарантий по поводу индексов еще нет (но скорее всего будут), пока это факт реализации и некоторых тестов — индексы появляются по факту коммита транзакции (scope транзакций — команда), перед тем как контроль «вернется» к отправителю команды, то есть по сути идут вместе с транзакцией.

            То есть вы пожертвовали скоростью записи ради индексов?


            Вы понимаете, что ваши индексы — это и есть ваша доменная модель?


            1. yrashk
              17.05.2016 11:36

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


              1. lair
                17.05.2016 11:39
                +1

                Индекс — это и есть попытка угадать, какие данные вам понадобятся.


                1. yrashk
                  17.05.2016 11:46

                  Да, вы правы, это можно и так рассматривать! :)

                  С моей личной практической точки зрения, это тот «низкий» порог «угадывания» который меня устроил. Мне не нужно угадывать какие будут структуры моделей, как нужно со временем менять модели и мигрировать данные, мне нужно только время от времени добавлять индексы к полям, причем совсем необязательно перед тем как события записаны — индексы можно добавить в любой момент.


                  1. lair
                    17.05.2016 11:51

                    … и каждый индекс будет просаживать производительность записи EventStore.


                    1. yrashk
                      17.05.2016 11:54

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


                      1. lair
                        17.05.2016 11:56

                        … а если вы их сделаете асинхронными, вы получите eventual consistency без возможности управления ей (или вы сразу закладываетесь на то, что данные, которые вы получили, неактуальны?)


                        1. yrashk
                          17.05.2016 18:20

                          логично, черт побери — мы плаваем между двумя трейд-оффами тут. либо быстро но eventually consistent, либо медленно. есть другие предложения?


                          1. lair
                            17.05.2016 18:38
                            +1

                            Просто если вы согласны на eventually consistent, то его надо закладывать в проект заранее, а не "мы пока не знаем, какие индексы будут".


                            1. yrashk
                              18.05.2016 02:08

                              пока что в проекте нет eventual consistency, поведение ближе к стандартном поведению многих баз данных. Есть предложения на тему как лучше всего «заложить» это в проект заранее? Я пока себе это представляю как набор требований выставляемых «поставщиком» конкретной команды — насколько ему важно гарантия проиндексированных данных, или хватит гарантии журналирования, чтобы перейти к следующим шагам быстрее.


                              1. lair
                                18.05.2016 13:19
                                +1

                                Лично я вообще воспринимаю работу с eventual consistency с другой стороны — это поведение команды на запись: она должна знать, относительно каких данных она была консистентна (а, значит, либо запрос на чтение должен отдавать идентификатор версии, либо мы тащим с собой все данные для проверки консистентности), и проверять к каким данным она применяется (естественно, уже в рамках транзакции), а дальше уже применять ту или иную стратегию сведения.


                                Для "честного" ES это в реальности делается очень просто — каждый запрос на чтение возвращает номер события, относительно которого данные прочитаны (и актуальны), каждая команда на запись проверяет, что в агрегате нет новых событий после указанного (а тривиальная стратегия сведения сводится к ошибке "данные изменились, накатите заново").