4. Event sourcing и брокеры
Event Sourcing и брокеры сообщений: архитектура неизменяемых логов
«Бухгалтеры не пользуются ластиками — иначе они сели бы в тюрьму. В хорошей инженерии, как и в хорошем бухучете, история того, как вы пришли к числу, неизмеримо ценнее самого числа».
— Грег Янг
Иллюзия текущего состояния (CRUD vs Event Sourcing)
Классическая веб-разработка последних трех десятилетий заложница парадигмы CRUD (Create, Read, Update, Delete). Мы привыкли считать, что база данных обязана хранить мгновенный «слепок» текущей реальности.
Представьте банковский счет. В традиционной реляционной CRUD-модели при переводе денег разработчик выполняет деструктивную операцию:
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
В этот момент физические байты на диске перезаписываются (или помечаются мертвыми в MVCC). Старое значение баланса безвозвратно уничтожено. Если через полгода регулятор или клиент задаст вопрос: «Почему 15 марта на счете было 4 200 рублей, а не 5 000?», ответить по одной лишь таблице accounts невозможно. Приходится нагромождать костыли в виде таблиц аудита (audit_logs), триггеров и версионирования строк, которые со временем неизбежно рассинхронизируются с реальным состоянием.
Event Sourcing заимствует мудрость многовековой практики бухгалтерского учета (Double-Entry Bookkeeping). Бухгалтеры никогда не используют ластик: если совершена ошибка или произошел перевод, в гроссбух дописывается новая корректирующая проводка.
В Event Sourcing операции UPDATE и DELETE запрещены на физическом уровне. Допустима строго одна операция — INSERT (Append).
CRUD Модель (Деструктивное состояние):
[Таблица accounts: ID=42, Balance=400] <-- Прошлые значения стерты навсегда!
Event Sourcing Модель (Иммутабельный лог):
[t0: AccountCreated (0)] ──> [t1: MoneyDeposited (+500)] ──> [t2: MoneyWithdrawn (-100)]
│
Текущее состояние: SUM() = 400 ◄┘
Текущее состояние агрегата — это математическая функция свертки (Fold / Reduce) от всех событий, произошедших с момента его создания:
$$\text{CurrentState} = \sum_{i=0}^{N} \text{Apply}(\text{Event}_i)$$
Поток событий (Event Stream) для счета №42:
AccountCreated { InitBalance: 0 }MoneyDeposited { Amount: 500 }MoneyWithdrawn { Amount: 100 }
Текущий баланс (400) не хранится как статическое поле. Он вычисляется в оперативной памяти процесса в ходе быстрой процедуры восстановления — регидратации (Rehydration).
Брокер как База Данных (Идеальный Match)
Для работы Event Sourcing необходим специализированный компонент — Event Store (Хранилище событий).
К Event Store предъявляются три жестких требования:
- Строгий порядок событий (Strict Ordering): транзакции должны воспроизводиться строго в хронологическом порядке;
- Иммутабельность: история никогда не модифицируется и не сжимается задним числом (Append-only Log);
- Реактивная подписка: возможность стриминга новых событий подписчикам в реальном времени (Pub/Sub).
Именно поэтому распределенный лог Apache Kafka (а также NATS JetStream) выступает идеальным техническим фундаментом для Event Sourcing — куда более естественным, чем классические реляционные СУБД!
Вспомним устройство распределенного журнала из 1. Kafka. Архитектура и модель log based системы: Kafka спроектирована как последовательный Append-only лог сегментов на диске. Если задать параметры топика:
retention.bytes = -1(бесконечный объем);retention.ms = -1(вечное хранение),
то топик Kafka де-факто превращается в высоконадежную, персистентную СУБД. При этом использование ID агрегата (например,account_id) в качестве ключа партиционирования (Partition Key) гарантирует, что все события конкретного банковского счета попадут в одну и ту же партицию, обеспечивая абсолютный порядок чтения и записи.
Анатомия Агрегата в Go
Посмотрим, как фундаментальный паттерн Event Sourcing реализуется в чистом и идиоматичном коде на Go:
package domain
import (
"errors"
"fmt"
)
// Account — Доменный Агрегат (Aggregate Root)
// Состояние не мутируется напрямую публичными методами!
type Account struct {
ID string
Balance int64
Version int // Критично для проверки оптимистичных блокировок
}
// Event — базовый интерфейс любого доменного события
type Event interface {
EventType() string
}
// Конкретные неизменяемые события:
type AccountCreated struct {
AccountID string
}
func (e AccountCreated) EventType() string { return "AccountCreated" }
type MoneyDeposited struct {
Amount int64
}
func (e MoneyDeposited) EventType() string { return "MoneyDeposited" }
type MoneyWithdrawn struct {
Amount int64
}
func (e MoneyWithdrawn) EventType() string { return "MoneyWithdrawn" }
// Apply — чистая функция-мутатор: единственный способ изменить поля структуры.
// Внутри Apply запрещена любая логика ввода-вывода (I/O) и валидации!
func (a *Account) Apply(event Event) {
switch e := event.(type) {
case AccountCreated:
a.ID = e.AccountID
a.Balance = 0
case MoneyDeposited:
a.Balance += e.Amount
case MoneyWithdrawn:
a.Balance -= e.Amount
}
a.Version++
}
// NewAccountFromHistory восстанавливает состояние агрегата из цепочки событий (Rehydration)
func NewAccountFromHistory(events []Event) *Account {
account := &Account{}
for _, e := range events {
account.Apply(e) // O(N) проход в оперативной памяти
}
return account
}
// Withdraw реализует команду бизнес-логики.
// Метод проверяет инварианты и ГЕНЕРИРУЕТ новое событие, но НЕ меняет состояние сам!
func (a *Account) Withdraw(amount int64) (Event, error) {
if amount <= 0 {
return nil, errors.New("сумма списания должна быть строго положительной")
}
if a.Balance < amount {
return nil, fmt.Errorf("insufficient funds: доступно %d, запрошено %d", a.Balance, amount)
}
// Мы НЕ пишем a.Balance -= amount!
// Мы только фиксируем факт успешного формирования события:
return MoneyWithdrawn{Amount: amount}, nil
}
Решение проблемы Dual Write
Главный триумф Event Sourcing заключается в том, что проблема Dual Write (двойной записи) не просто решается — она перестает существовать в принципе!
Поскольку мы больше не ведем отдельную таблицу текущего состояния, в архитектуре остается ровно одна операция записи:
sequenceDiagram
autonumber
participant C as Клиент (HTTP)
participant S as Go Service
participant K as Kafka / JetStream (Event Store)
C->>S: POST /accounts/42/withdraw {"amount": 100}
S->>K: Чтение истории событий (account_id = 42)
Note over S: Регидратация: NewAccountFromHistory()<br>Баланс = 500, Версия = 2
Note over S: Проверка: Balance >= 100 (OK)
Note over S: Генерация MoneyWithdrawn{100}
S->>K: Append: MoneyWithdrawn (ОДНА АТОМАРНАЯ ЗАПИСЬ!)
Note over K: Запись в журнал, смещение Offset=3
S->>C: HTTP 200 OK {"status": "success", "new_version": 3}
Нет никакой локальной реляционной базы данных, которую требовалось бы координировать с брокером через двухфазный коммит. Брокер сообщений и есть ваша база данных. Запись всегда атомарна, последовательна и иммутабельна.
Темная сторона: Как читать данные? (Введение в CQRS)
У Event Sourcing есть обратная сторона медали, которая может стать фатальной, если не подготовить архитектуру чтения заранее.
Представьте, что отдел комплаенса просит предоставить отчет:
«Выведи список всех счетов, где текущий баланс превышает 1 000 долларов».
В обычной реляционной базе данных вы бы выполнили мгновенный запрос:
SELECT * FROM accounts WHERE balance > 1000;
В чистом Event Sourcing выполнить такой запрос невозможно!
У вас нет таблицы с колонкой balance. У вас есть только терабайтный журнал миллионов транзакций в Kafka. Чтобы найти подходящие счета, вам пришлось бы:
- Вычитать миллиарды событий из Kafka с самого первого дня существования банка;
- Сгруппировать события по миллионам пользователей;
- Регидрировать каждый агрегат в оперативной памяти и проверить баланс.
Сложность операции — $O(N)$ по объему всего лога событий в мире. Это вызовет гарантированный отказ сервиса по памяти и CPU.
По этой причине Event Sourcing практически никогда не используется в одиночку. В 100% реальных продакшен-систем он работает в неразрывной связке с паттерном CQRS (Command Query Responsibility Segregation):
flowchart TD
subgraph CommandSide ["Command Side (Запись: Event Store)"]
direction TB
CMD["Command API"] -->|"1. Валидация команды"| AGG["Aggregate: Account"]
AGG -->|"2. Append Event"| ES{"Kafka Event Store"}
end
subgraph QuerySide ["Query Side (Чтение: Проекции / Materialized Views)"]
direction TB
ES -->|"3. Асинхронный стриминг событий"| PROJ["Projection Worker (Go)"]
PROJ -->|"4. Обновление денормализованной витрины"| RDB[/"PostgreSQL View / ElasticSearch<br>(Таблица текущих балансов)"/]
RDB -->|"5. Быстрый SELECT WHERE balance > 1000"| QRY["Query API"]
end
style ES fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style RDB fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
style CMD fill:#131d33,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
style QRY fill:#131d33,stroke:#a855f7,stroke-width:2px,color:#f8fafc
Система разделяется на два независимых крыла:
- Command Side (Запись): принимает команды, валидирует бизнес-правила и дописывает новые события в неизменяемый Event Store (Kafka);
- Query Side (Чтение): фоновые консьюмеры слушают новые события из Kafka и обновляют классические оптимизированные базы данных (PostgreSQL, ClickHouse, Redis, Elasticsearch), создавая готовые для чтения витрины (Materialized Views / Projections).
Оптимизация: Snapshotting (Снапшоты)
Если банковский счет активно используется на протяжении 5 лет, для него могут накопиться десятки тысяч транзакций. Вычитывать и прогонять через Apply() 50 000 событий при каждой попытке списать 100 рублей — непозволительная трата времени и процессорных тактов.
Для решения проблемы применяется техника Snapshotting (Снимки состояния):
flowchart LR
E1["События 1..1000"] --> S1[("Snapshot v1000<br>Balance=5000")]
S1 -.->|"Базовое состояние"| R["Регидратация"]
E2["Событие 1001"] --> R
E3["Событие 1002"] --> R
R --> Final["Актуальный Агрегат<br>Balance=4900 (v1002)"]
style S1 fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
style Final fill:#0f172a,stroke:#10b981,stroke-width:2px,color:#f8fafc
- Фоновый процесс или сам обработчик раз в $N$ событий (например, каждые 1 000 событий) сериализует текущее восстановленное состояние агрегата и сохраняет его в быстрое хранилище (Redis, S3 или отдельный компактный топик Kafka).
- При поступлении новой команды сервис загружает последний снапшот (версия 1 000, баланс 5 000).
- Из Event Store запрашиваются только те события, чья версия строго больше 1 000.
- Сервис «докручивает» всего 2–3 свежих события поверх снапшота. Время отклика возвращается к константному $O(1)$!
Итог
- Event Sourcing заменяет деструктивное хранение текущего состояния на непрерывный иммутабельный журнал доменных событий.
- Apache Kafka и NATS JetStream служат естественными хранилищами событий (Event Store) благодаря модели Append-only лога с гарантией порядка внутри партиции.
- Проблема Dual Write исчезает, поскольку единственным источником правды становится брокер.
- Архитектурный компромисс: Event Sourcing делает запись тривиальной, но усложняет чтение. Для выборок и аналитики необходимы асинхронные проекции.
В следующей лекции мы досконально исследуем вторую половину этого тандема: как правильно проектировать асинхронные проекции и витрины данных в рамках паттерна 5. CQRS и брокеры.