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 правда — это журнал событий, а текущее состояние — производная величина.
На диаграмме видно ключевое различие: слева — подход, где история теряется; справа — подход, где каждое изменение зафиксировано как отдельное событие, а баланс вычисляется функцией от всех событий.
Вот сравнение двух подходов в таблице:
| Характеристика | CRUD | Event Sourcing |
|---|---|---|
| Что храним | Текущее состояние (строка в таблице) | Поток неизменяемых событий |
| История изменений | Нет (если нет аудит-лога) | Полная, от создания сущности |
| Как получить состояние | SELECT balance FROM accounts | Replay всех событий от начала |
| Отладка и аудит | Нужен отдельный лог | Встроено в архитектуру |
| Производительность записи | Высокая (один UPDATE) | Высокая (append-only, нет блокировок) |
| Производительность чтения | Высокая (один SELECT) | Низкая без снапшотов |
| Сложность реализации | Низкая | Высокая |
| Исправление ошибок | UPDATE ... SET balance = правильное | Добавить компенсирующее событие |
Примечательно, что высокая производительность записи в Event Sourcing — побочный эффект: события только добавляются (append-only), без UPDATE и DELETE. Блокировок на запись нет (или они минимальны), конкурентные изменения разрешаются на уровне агрегата.
Как работает Event Sourcing: от команды до проекции
Давайте пройдём полный путь — от клиентского запроса до обновлённой проекции. Это не абстракция, а реальный flow, который вы будете проектировать в ТЗ.
Диаграмма показывает полный цикл обработки команды в Event Sourcing. Разберём шаги:
- Клиент отправляет команду (
POST /accounts/withdraw) через API Gateway. - Command Handler (часть Write Model) получает команду и загружает агрегат.
- Агрегат восстанавливает своё состояние, загружая все прошлые события из Event Store.
- Event Store возвращает поток событий:
[AccountCreated, Deposited, Withdrawn, ...]. - Агрегат применяет бизнес-логику: «Можно ли списать 500, если баланс 300?»
- Если валидация пройдена — новое событие
Withdrawn(amount=500)добавляется в Event Store. - Event Store публикует событие для подписчиков.
- Проекция (Read Model) обновляется асинхронно: пересчитывает баланс и сохраняет результат в Read DB.
- Клиент получает ответ
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При обработке нового списания агрегат:
- Загружает все события счёта из Event Store.
- Применяет их одно за другим, вычисляя текущий баланс.
- Проверяет бизнес-правило:
balance >= withdrawalAmount. - Если правило выполняется — добавляет новое событие
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, но взамен вы получаете полную прослеживаемость каждого изменения в системе.
А когда через полгода бизнес спросит: «Почему у клиента Иванова баланс именно такой?» — вы не полезете переписывать базу. Вы просто покажете цепочку событий.