Logo
Overview

Event Sourcing: хранение состояния как цепочка событий

June 19, 2026
9 min read

Event Sourcing: хранение состояния как цепочка событий

Когда вы слышите «база данных», то, скорее всего, представляете таблицу с колонкой balance и запросом UPDATE accounts SET balance = 500 WHERE id = 123. И это работает. Работает до тех пор, пока бухгалтерия не спросит: «А откуда взялась эта цифра?» — и тогда выясняется, что предыдущее значение перезаписано, история потеряна, а ответ «в базе так записано» никого не устраивает.

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

Звучит радикально? На первый взгляд — да. Но если вы когда-нибудь проектировали системы, где важна аудируемость (финансы, страхование, учёт), вы уже чувствовали боль от того, что классический CRUD не сохраняет историю. Давайте разберёмся, как Event Sourcing решает эту проблему — и какую цену за это придётся заплатить.

Что такое Event Sourcing: не состояние, а события

Event Sourcing — паттерн, при котором состояние бизнес-сущности хранится не как текущий снимок (snapshot), а как неизменяемая последовательность событий (event stream). Чтобы получить текущее состояние, нужно «проиграть» (replay) все события с самого начала.

Аналогия простая. Представьте, что учёт ваших расходов ведётся двумя способами:

  • Способ 1 (CRUD): вы храните только итоговый баланс — «500 рублей». Если потратили 200, просто переписываете: «300 рублей». Куда делись деньги — неизвестно.
  • Способ 2 (Event Sourcing): вы записываете каждую операцию: «Пришла зарплата 1000», «Оплата ЖКХ −300», «Кофе −200». Баланс = сумма всех событий. История прозрачна. Хотите узнать, когда и на что ушли деньги, — просто читаете журнал.

Event Sourcing пришёл из мира Domain-Driven Design и плотно связан с CQRS. Грег Янг (Greg Young) популяризировал эту связку в конце 2000-х, и с тех пор паттерн стал стандартным инструментом для систем, где важна не только скорость, но и полнота истории изменений.

CRUD vs Event Sourcing: две философии хранения

Принципиальная разница — в том, что считать «правдой». В CRUD-подходе правда — это последняя запись в таблице. В Event Sourcing правда — это журнал событий, а текущее состояние — производная величина.

100%
flowchart LR
  subgraph Trad["Традиционный CRUD"]
      T1["UPDATE accounts SET balance = 500"]
      T2["Старое значение: 1000 → Новое: 500"]
      T3["Где 500 рублей? Неизвестно"]
      T1 --> T2 --> T3
  end
  
  subgraph ES["Event Sourcing"]
      E1["AccountCreated(balance=1000)"]
      E2["PaymentSent(amount=300)"]
      E3["FeeCharged(amount=200)"]
      E4["Баланс = f(события) = 1000 - 300 - 200 = 500"]
      E1 --> E4
      E2 --> E4
      E3 --> E4
  end
  
  style Trad fill:#e0e0e0,stroke:#999,color:#333
  style T1 fill:#faa,stroke:#c44,color:#fff
  style T2 fill:#faa,stroke:#c44,color:#fff
  style T3 fill:#faa,stroke:#c44,color:#fff
  style ES fill:#50c878,stroke:#3a9a5c,color:#fff
  style E1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style E2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style E3 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style E4 fill:#7b68ee,stroke:#5a4db2,color:#fff

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

Вот сравнение двух подходов в таблице:

ХарактеристикаCRUDEvent Sourcing
Что хранимТекущее состояние (строка в таблице)Поток неизменяемых событий
История измененийНет (если нет аудит-лога)Полная, от создания сущности
Как получить состояниеSELECT balance FROM accountsReplay всех событий от начала
Отладка и аудитНужен отдельный логВстроено в архитектуру
Производительность записиВысокая (один UPDATE)Высокая (append-only, нет блокировок)
Производительность чтенияВысокая (один SELECT)Низкая без снапшотов
Сложность реализацииНизкаяВысокая
Исправление ошибокUPDATE ... SET balance = правильноеДобавить компенсирующее событие

Примечательно, что высокая производительность записи в Event Sourcing — побочный эффект: события только добавляются (append-only), без UPDATE и DELETE. Блокировок на запись нет (или они минимальны), конкурентные изменения разрешаются на уровне агрегата.

Как работает Event Sourcing: от команды до проекции

Давайте пройдём полный путь — от клиентского запроса до обновлённой проекции. Это не абстракция, а реальный flow, который вы будете проектировать в ТЗ.

100%
flowchart TD
  Client["Клиент и Фронтенд"] -->|1. POST /accounts/withdraw| GW["API Gateway"]
  GW -->|2. WithdrawCommand| CH["Command Handler - Write Model"]
  CH -->|3. Загрузка событий| AGG["Агрегат Account"]
  AGG -->|4. getEvents accountId| ES["Event Store - источник правды"]
  ES -->|5. Deposited, Withdrawn, ...| AGG
  AGG -->|6. Проверка: баланс >= сумма?| VALID{Бизнес-валидация}
  VALID -->|Да| APPEND["append Withdrawn amount=500"]
  VALID -->|Нет| ERROR["Ошибка: недостаточно средств"]
  APPEND -->|7. Новое событие| ES
  ES -->|8. Публикация события| SUB["Подписчики событий"]
  SUB -->|9. Обновить проекцию баланса| PROJ["Проекция: AccountBalance - Read Model"]
  PROJ -->|10. UPSERT| RDB["Read DB - PostgreSQL"]
  CH -->|201 Created| Client
  
  style Client fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style GW fill:#f0a500,stroke:#c88400,color:#fff
  style CH fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style AGG fill:#7b68ee,stroke:#5a4db2,color:#fff
  style ES fill:#7b68ee,stroke:#5a4db2,color:#fff
  style VALID fill:#f0a500,stroke:#c88400,color:#fff
  style APPEND fill:#50c878,stroke:#3a9a5c,color:#fff
  style ERROR fill:#faa,stroke:#c44,color:#fff
  style SUB fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style PROJ fill:#50c878,stroke:#3a9a5c,color:#fff
  style RDB fill:#e0e0e0,stroke:#999,color:#333

Диаграмма показывает полный цикл обработки команды в Event Sourcing. Разберём шаги:

  1. Клиент отправляет команду (POST /accounts/withdraw) через API Gateway.
  2. Command Handler (часть Write Model) получает команду и загружает агрегат.
  3. Агрегат восстанавливает своё состояние, загружая все прошлые события из Event Store.
  4. Event Store возвращает поток событий: [AccountCreated, Deposited, Withdrawn, ...].
  5. Агрегат применяет бизнес-логику: «Можно ли списать 500, если баланс 300?»
  6. Если валидация пройдена — новое событие Withdrawn(amount=500) добавляется в Event Store.
  7. Event Store публикует событие для подписчиков.
  8. Проекция (Read Model) обновляется асинхронно: пересчитывает баланс и сохраняет результат в Read DB.
  9. Клиент получает ответ 201 Created.

Ключевой момент: Event Store — единственный источник правды. Если Read DB упадёт и потеряет данные, мы можем восстановить все проекции, проиграв события из Event Store заново. Это не серебряная пуля, но мощный инструмент для систем, где потеря данных критична.

Event Sourcing + CQRS: почему они идут рука об руку

Если вы читали пост про CQRS — разделение чтения и записи, то уже знаете: команды пишут в одну модель, запросы читают из другой. Event Sourcing идеально ложится на эту архитектуру.

Почему? В классическом CQRS у вас всё равно есть какая-то синхронизация между Write Model и Read Model. Чаще всего это «проекция» — когда изменение в Write Model триггерит обновление Read Model. Event Sourcing делает этот механизм естественным: события, записанные в Event Store, автоматически становятся источником для проекций.

Связка работает так:

  • Write side: агрегат принимает команду, генерирует события, сохраняет их в Event Store.
  • Read side: подписчики слушают новые события и обновляют денормализованные проекции — плоские таблицы, заточенные под конкретные запросы (SELECT * FROM account_balance_view WHERE id = 123).

Разделение не просто «удобное» — оно архитектурно обосновано. Write-сторона оптимизирована под консистентность и бизнес-правила, Read-сторона — под скорость и гибкость запросов. Каждая проекция может быть заточена под свой набор use-case’ов: одна для личного кабинета, другая — для отчёта регулятору.

Практический разбор: пересчёт баланса через replay событий

Допустим, мы проектируем банковскую систему. Счёт создаётся, на него приходят деньги, с него уходят деньги, банк списывает комиссию. В CRUD-мире обработка списания выглядит так:

-- Классический подход: читаем, проверяем, пишем
BEGIN;
SELECT balance FROM accounts WHERE id = 123; -- допустим, баланс 1000
-- бизнес-логика: если 1000 >= 500, то списываем
UPDATE accounts SET balance = balance - 500 WHERE id = 123;
COMMIT;

Проблема здесь не в транзакции (она есть), а в том, что через месяц никто не скажет, почему баланс изменился с 1000 на 500. Может, клиент перевёл деньги. А может, ошибка в расчёте комиссии, которую никто не заметил.

Теперь тот же сценарий в Event Sourcing. Вместо UPDATE мы добавляем события:

Событие 1: AccountCreated(accountId=123, initialBalance=1000)
Событие 2: PaymentReceived(accountId=123, amount=300)
Событие 3: FeeCharged(accountId=123, amount=50)
Событие 4: Withdrawn(accountId=123, amount=500)

Текущий баланс — это функция от всех событий:

balance = 1000 + 300 - 50 - 500 = 750

При обработке нового списания агрегат:

  1. Загружает все события счёта из Event Store.
  2. Применяет их одно за другим, вычисляя текущий баланс.
  3. Проверяет бизнес-правило: balance >= withdrawalAmount.
  4. Если правило выполняется — добавляет новое событие Withdrawn(amount=X).

Что немаловажно: если бизнес-правила меняются задним числом (например, банк решил не списывать комиссию за определённый период), вы не правите базу руками. Вы добавляете компенсирующее событие FeeReversed(accountId=123, originalFee=50), и пересчитываете баланс с учётом нового потока событий. История остаётся нетронутой — все исходные события на месте.

Event Store: хранилище, которое хранит правду

Event Store — специализированное хранилище для событий. Формально можно использовать и реляционную базу, но на практике появляются специализированные решения (EventStoreDB, Axon Server) или надстройки над PostgreSQL/Kafka.

Ключевые свойства Event Store:

  • Append-only: события только добавляются, никогда не изменяются и не удаляются.
  • Потоковая модель: события группируются в потоки (streams) — обычно по одному потоку на агрегат.
  • Хронологический порядок: события в потоке упорядочены по времени, что гарантирует детерминированный replay.
  • Снапшоты (snapshots): чтобы не проигрывать тысячи событий каждый раз, периодически сохраняется снапшот состояния агрегата. При загрузке replay идёт от последнего снапшота, а не от первого события.

Снапшоты — чисто техническая оптимизация. Они не заменяют события, а ускоряют восстановление. Если снапшот повреждён — не страшно, его можно пересчитать из событий.

Подводные камни: когда Event Sourcing создаёт проблемы

Это не серебряная пуля. Паттерн добавляет изрядную сложность, и применять его стоит осознанно (а не потому что «все микросервисы так делают» — большинство как раз не делают).

Сложность запросов. Если вам нужен отчёт «покажи всех клиентов, у которых баланс меньше 1000 и которые совершили больше трёх операций за неделю», — на голом Event Store вы такой запрос не выполните. Нужны проекции. Много проекций. Каждая — отдельный код, который надо писать и поддерживать.

Eventual Consistency. Read Model обновляется асинхронно. Клиент отправил команду, получил 201, а в личном кабинете баланс ещё старый. Для большинства бизнес-сценариев это приемлемо (латентность — доли секунды), но для некоторых — критично.

Эволюция событий. События в Event Store неизменяемы. Если через год вы решите переименовать поле amount в transactionAmount, старые события останутся со старым именем. Код должен уметь обрабатывать обе версии (upcasting). Это усложняет поддержку.

Размер Event Store. События копятся. Активный интернет-магазин может генерировать миллионы событий в день. Нужна стратегия хранения: партиционирование, архивирование старых потоков, снапшоты.

Вот сводка «когда стоит, а когда нет»:

СценарийEvent Sourcing?Почему
Банковский счёт, аудит обязателенДаПолная история, неизменяемость
Страховой полис, история изменений условийДаКаждое изменение = событие, audit log встроен
Простой блог с комментариямиНетИзбыточно, хватит CRUD
Корзина интернет-магазинаСкорее нетВысокая частота изменений, состояние краткосрочное
Система бронирования билетовДаКонкурентный доступ, нужна история всех действий
IoT-телеметрия (сенсоры, логи)Да, но как поток, не как Event SourcingСобытия — естественная модель данных

Заключение

Event Sourcing — не замена CRUD, а специализированный инструмент для систем, где история изменений так же важна, как текущее состояние. Банки, страхование, учётные системы — здесь он окупает свою сложность. Для интернет-магазина с корзиной — скорее всего, избыточен.

Если вы проектируете систему с высокими требованиями к аудиту и уже используете событийную архитектуру, присмотритесь к Event Sourcing. Связка с CQRS даёт чистую архитектуру, где модель записи оптимизирована под консистентность, а модель чтения — под перформанс запросов. Да, придётся писать проекции и мириться с eventual consistency, но взамен вы получаете полную прослеживаемость каждого изменения в системе.

А когда через полгода бизнес спросит: «Почему у клиента Иванова баланс именно такой?» — вы не полезете переписывать базу. Вы просто покажете цепочку событий.