lenec ru

← все посты

Event sourcing на практике: цена входа и когда оно того не стоит

13K

Event sourcing звучит красиво в любой презентации: «храним не состояние, а историю изменений», «полный аудит из коробки», «можно восстановить любой момент». Я знаю три проекта, которые на этом красивом описании ушли в event sourcing — и потом полтора года вытаскивали себя обратно. Знаю один, где event sourcing работает уже пять лет и команда им довольна.

Разница не в том, кто умнее. Разница в том, понимали ли в момент решения настоящую цену входа. Event sourcing — это не «база данных по-другому», это инвертированная архитектура работы с состоянием. И платят за неё не разработкой, а эксплуатацией: миграциями схемы событий, отладкой, проекциями, временем на новых людей в команде.

Разберу, как это работает по сути, какие конкретно компромиссы вы примете, и в каких случаях овчинка стоит выделки.

Что такое event sourcing на самом деле

В обычной системе вы храните текущее состояние. Заказ — строка в таблице с полями status, amount, updated_at. Меняете статус — UPDATE.

В event sourcing вы храните историю событий. Заказ создан, заказ оплачен, заказ отгружен — три события. Текущее состояние — это результат свёртки всех событий слева направо. Если событий сто, состояние — это «последовательно применить сто событий к пустому объекту».

sealed class OrderEvent {
    data class Created(val id: UUID, val userId: UUID, val items: List<Item>) : OrderEvent()
    data class Paid(val id: UUID, val amount: Money, val txId: String) : OrderEvent()
    data class Shipped(val id: UUID, val trackingNumber: String) : OrderEvent()
    data class Cancelled(val id: UUID, val reason: String) : OrderEvent()
}

class Order private constructor() {
    var status: OrderStatus = OrderStatus.NEW
        private set
    var totalPaid: Money? = null
        private set
    
    fun apply(event: OrderEvent) {
        when (event) {
            is OrderEvent.Created -> { status = OrderStatus.CREATED }
            is OrderEvent.Paid -> { status = OrderStatus.PAID; totalPaid = event.amount }
            is OrderEvent.Shipped -> { status = OrderStatus.SHIPPED }
            is OrderEvent.Cancelled -> { status = OrderStatus.CANCELLED }
        }
    }
    
    companion object {
        fun rehydrate(events: List<OrderEvent>): Order = Order().apply { events.forEach { apply(it) } }
    }
}

Чтобы получить заказ — вы читаете все его события из event store и сворачиваете. Чтобы изменить — добавляете новое событие. UPDATE'ов нет, есть только APPEND.

Что вы получаете

Реальные плюсы, не маркетинговые.

Аудит без отдельной системы

Каждое изменение — событие с временем и контекстом. Вопрос «как заказ оказался в этом статусе» — это SELECT * FROM events WHERE aggregate_id = ? в хронологическом порядке. Не нужны audit-таблицы, не нужны триггеры.

Это особенно ценно для финансов, медицины, юридически значимых сценариев. Любой регулятор понимает «вот лог всех событий». В CRUD-системе для этого приходится строить отдельный пайплайн.

Time travel

«Покажите состояние заказа на 12 марта в 15:00». В event sourcing это просто: возьмите события до этого времени, сверните, получите состояние. В CRUD надо иметь отдельный механизм версионирования или snapshot'ы — отдельная боль.

Новые проекции на старых данных

Появилась задача: «нужен дашборд, который показывает среднее время от оплаты до отгрузки». В CRUD-системе вы добавляете эту метрику с этого момента, прошлые данные в неё не попали. В event sourcing — переигрываете все исторические события через новую проекцию и получаете данные с самого начала.

Естественная интеграция

События, которыми живёт ваш домен, — это и события для интеграции. Сохранили в event store — публикуете в Kafka. Получается outbox pattern «бесплатно», потому что событие уже первичный артефакт.

Что вы платите

Теперь честно про цену.

Эволюция схемы событий

Самая большая боль. Вы выпустили в прод OrderPaid(amount: Money, txId: String). Через полгода добавили поле method: PaymentMethod. У вас в event store миллион старых событий без этого поля.

Варианты:

  • Версионировать события (OrderPaidV1, OrderPaidV2) и поддерживать оба апплая.
  • Делать апкастинг: при чтении старое событие конвертируется в новую форму с дефолтами.
  • Реплейнуть всю историю в новую схему. Долго, дорого, не везде возможно.

На активном проекте у меня было 30+ типов событий, у каждого 2–4 версии. Конвертеры между ними — отдельный модуль кода с тестами. Любая правка существующего события — отдельное обсуждение.

Чтение состояния

Чтобы прочитать заказ, нужно прочитать все его события и свернуть. На заказе со ста событиями — 100 строк из БД и 100 вычислений. На активном агрегате с тысячей событий — заметная задержка на каждом запросе.

Решение — snapshots. Раз в N событий (50, 100) сохраняете текущее состояние. При чтении: загружаете снапшот, добавляете события после него. Снапшоты надо инвалидировать при изменении схемы и поддерживать процесс их создания.

CREATE TABLE event_store (
    aggregate_id   UUID NOT NULL,
    aggregate_type TEXT NOT NULL,
    version        BIGINT NOT NULL,
    event_type     TEXT NOT NULL,
    event_version  INT NOT NULL,
    payload        JSONB NOT NULL,
    metadata       JSONB NOT NULL DEFAULT '{}'::jsonb,
    occurred_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (aggregate_id, version)
);

CREATE TABLE snapshots (
    aggregate_id UUID PRIMARY KEY,
    version      BIGINT NOT NULL,
    state        JSONB NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Проекции и eventual consistency

В CRUD-системе вы пишете в одну таблицу и читаете оттуда же. В event sourcing вы пишете события, но читать их в исходной форме непрактично. Поэтому появляются проекции — материализованные представления для конкретных запросов.

«Список заказов пользователя» — отдельная проекция, обновляется по событиям. «Заказы по статусам» — другая. И обе — eventually consistent: после OrderPaid проходит время, прежде чем проекция обновится.

Плата: каждый запрос к данным — вопрос «по какой проекции». Появилась новая страница UI — нужна новая проекция. Изменилась логика — переигрываем события в проекцию, ждём.

Сложность отладки

В CRUD: «посмотри значение поля в БД» — за 5 секунд видишь состояние. В event sourcing: посмотри события, сверни в голове или в коде, посмотри, что получилось. На сложных агрегатах с десятками событий это занимает время.

Инструменты помогают (event store browser, debug-эндпоинты для рехидратации), но всё равно дороже, чем посмотреть в таблицу.

Команда должна понимать

Event sourcing требует другого мышления. «Не меняем состояние, добавляем событие». «Не делаем делиты, делаем компенсирующие события». «Не редактируем прошлое, версионируем настоящее».

Новый человек в команде первый месяц задаёт одни и те же вопросы: «почему я не могу обновить поле напрямую», «как мне удалить запись», «куда дёргать UPDATE». Если у вас высокая ротация — это постоянный налог.

Когда event sourcing окупается

Из моего опыта — три сценария.

Финансы и регуляторика. Если вы обязаны хранить полную историю изменений по требованию закона или внешнего аудита, event sourcing решает это естественным образом. Альтернатива — отдельная audit-инфраструктура, которая почти такая же сложная.

Сложный домен с множеством состояний. Workflow-системы, страхование, кредиты — там, где сущность проходит десятки переходов и каждый переход важен. История переходов — суть бизнеса.

Много читателей с разными потребностями. Если на одни и те же данные нужны 5 разных проекций (отчёты, аналитика, поиск, дашборды клиентов), event sourcing с CQRS даёт каждому читателю свою оптимизированную проекцию без компромиссов в сторону монолитной БД.

Когда не стоит

Список симптомов «не сюда».

  • CRUD-домен. Профили пользователей, настройки, каталог товаров. Тут нужно «есть значение, поменяй на новое». Event sourcing — лишний слой.
  • Маленький проект. До 5–10 разработчиков event sourcing — это перевес инфраструктуры над бизнес-кодом.
  • Команда не имеет опыта с этим подходом. Внедрить event sourcing на фронте обучения — это год боли. Лучше начать с outbox/CQRS-light и расти.
  • Нет реальных требований к истории. «На всякий случай» — плохой повод. Цена эксплуатации не оправдывается «может пригодится».

Гибридный подход

Чаще всего я выбираю не «всё в event sourcing» и не «весь CRUD», а гибрид.

Один-два ключевых агрегата в event sourcing — там, где история критична (заказы, платежи). Остальные сервисы — обычный CRUD с outbox для интеграции. Это даёт пользу event sourcing там, где она нужна, и не нагружает остальную систему.

Стоит признать: гибрид сложнее объяснить новому человеку. Часть кода работает «по-старому», часть — «по-новому». Но это меньшее зло, чем event sourcing везде или нигде.

Подводные камни

Несколько вещей, которые меня кусали.

Race conditions при записи. Два процесса параллельно прочитали состояние агрегата на версии 10, оба добавляют событие версии 11. Один проиграет. Нужна оптимистическая блокировка: при записи проверяем, что версия 11 ещё свободна. Это уникальный индекс по (aggregate_id, version).

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

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

GDPR и удаление данных. «Забудьте обо мне» — нельзя в event store, потому что события иммутабельны. Решения: шифровать персональные данные ключом пользователя и удалять ключ; держать персоналии в отдельном CRUD-хранилище, в событиях — только id.

Что запомнить

Event sourcing — не «лучший способ хранения», а архитектурный выбор с большой ценой эксплуатации. Он даёт реальную пользу в доменах с историей, аудитом и множеством читателей. Он стоит дорого в простых CRUD-сценариях.

Если задумываетесь о внедрении: оцените, сколько у вас людей, которые понимают подход, какова сложность вашего домена, нужна ли история по факту или «вдруг пригодится». На любом сомнительном ответе стартуйте с гибрида или с outbox + CQRS-light. Event sourcing легко добавить позже на конкретный агрегат, тяжело откатить с целого продукта.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.