Data engineering
Event sourcing: append-only лог как источник истины
Клиент оспаривает списание: «Я никогда не ставил годовой план». Поддержка открывает строку — plan: annual — и разводит руками. Но таблица accounts хранит только текущее значение; UPDATE, который его выставил, затёр то, что было до этого, а логи приложения ротировались две недели назад. Никто не может сказать, какой план был 14-го, кто его менял и не сделал ли это глючный webhook. В event-sourced системе это запрос на тридцать секунд: проиграть поток до этого момента и прочитать ответ. Разница не в том, что лог лучше — а в том, что лог и есть база данных.
Состояние — это свёртка событий слева
Обычная CRUD-таблица хранит последний снимок и уничтожает историю на каждом UPDATE. Event sourcing переворачивает это: append-only хранилище событий держит упорядоченную неизменяемую последовательность фактов — AccountOpened, PlanChanged, CardDeclined — а текущее состояние это просто то, что получается свёрткой редьюсера по ним с самого начала. state = events.reduce(apply, initial). События — источник истины; «текущее состояние» — производный кэш, который можно выбросить и пересчитать в любой момент.
Из этой одной инверсии берётся вся польза и вся цена. Ты получаешь полный аудит-трейл бесплатно (лог и есть аудит). Получаешь темпоральные запросы — состояние на любой прошлый момент, свёрткой только событий до этого timestamp. Получаешь отладку реплеем: копируешь продакшен-поток в staging, прогоняешь его через новый код и смотришь, как баг детерминированно воспроизводится. Ничто из этого не прикручено сбоку; всё выпадает из того, что данные никогда не выбрасываются.
Ограничение append-only несущее. Ты никогда не делаешь UPDATE или DELETE события. Ошибка исправляется добавлением компенсирующего события (PlanCorrected), так же как бухгалтер никогда не стирает запись в книге — он проводит сторнирующую. Поэтому хранилища событий оптимизированы под одно: быстрые append и быстрое последовательное чтение потока.
Event sourcing — это не «Kafka» и не change log
Это различие сеньоры путают чаще всего. Лог change-data-capture или обычный топик Kafka записывает события, но само по себе это не event sourcing. Определяющее свойство — лог это авторитетный источник истины, и состояние восстанавливается из него, а не побочный канал изменений, эмитированный после того, как база данных уже зафиксировала истину где-то ещё.
Kafka может служить хранилищем событий, но с острыми оговорками. Компакция лога — флагманская фича Kafka — оставляет только последнее значение на ключ, что прямо уничтожает историю, на которой держится event sourcing; нужно использовать топики на retention с log.retention.ms = -1 (бесконечный), а не компактируемые. Оптимистичная конкурентность на агрегат («append только если я на версии N») не имеет чистого примитива в Kafka, тогда как специализированное хранилище вроде EventStoreDB делает expectedVersion first-class условием append. Частый продакшен-паттерн: держать сырой топик событий вечно как источник, а производное текущее состояние публиковать в отдельный компактируемый топик для read-моделей — компактируемый топик это проекция, никогда не истина.
| Свойство | CRUD-таблица | Change log / CDC | Event sourcing |
|---|---|---|---|
| Источник истины | Текущая строка | База, за которой он следит | Сам лог событий |
| История | Теряется на UPDATE | Часто ограничена по времени | Полная, навсегда |
| Состояние на прошлый момент | Невозможно | Сложно / частично | Свёртка до timestamp |
| Построить новый view | Скрипты бэкфилла | Ограничено retention | Реплей всего лога |
CQRS: проекции — это одноразовые read-модели
Нельзя обслуживать запрос вроде «покажи дашборд», сворачивая весь лог на каждый запрос — это было бы разорительно медленно. Поэтому event sourcing почти всегда идёт в паре с CQRS: сторона записи добавляет события; сторона чтения гоняет проекции, которые потребляют поток и материализуют под задачу read-модели (SQL-таблицу, индекс Elasticsearch, денормализованный кэш). Каждая проекция — крошечная программа: на каждое событие обнови свою таблицу. Поскольку проекции производные, они одноразовые — дропни таблицу, проиграй лог, получи её назад. Нужен совершенно новый view через полгода? Напиши проекцию и прогони через неё историю; данные были там всегда.
Два свойства делают это безопасным в продакшене. Первое: проекции должны быть идемпотентными: одно и то же событие может быть доставлено больше одного раза (ретраи, доставка at-least-once), поэтому применить его дважды должно равняться применить один раз. Стандартный механизм — отслеживать последнюю обработанную версию события на поток и игнорировать любое событие, чья версия <= последней виденной. Второе: сторона чтения eventually consistent: между добавлением события и тем, как проекция догонит, есть реальный лаг. EventStoreDB и похожие хранилища документируют этот лаг read-модели явно — read-модель «сходится к корректному состоянию со временем», она не гарантированно актуальна в любой момент.
Почему это работает
Этот лаг — проблема UX, а не только инфраструктуры. Пользователь жмёт «Сохранить», ты добавляешь событие, потом редиректишь на список, отрендеренный из проекции, — которая не догнала, так что его изменения там нет. Наивный фикс (поллить, пока не появится) протекает архитектурой наружу к пользователю. Настоящий фикс — вернуть новое состояние оптимистично из результата команды, либо для этого одного экрана читать со стороны записи (read-your-own-writes), а проекция пусть устаканится за кулисами.
Сложные части: версионирование, GDPR и цена реплея
Вот где event sourcing зарабатывает свою репутацию. Версионирование схемы неизбежно и постоянно: ты никогда не сможешь удалить старую форму события, потому что старые события, записанные в этой форме, живут в логе вечно и обязаны оставаться воспроизводимыми. Когда OrderPlaced обзаводится полем, у каждого исторического OrderPlaced его нет. Стандартный ответ — upcasting: цепочка функций-трансформаций, которые поднимают старую форму события к текущей при десериализации, так что остальной код видит только последнюю версию. Апкастеры накапливаются; это код, который ты несёшь бесконечно.
GDPR-«право быть забытым» сталкивается лоб в лоб с неизменяемым логом. Ты юридически обязан стереть персональные данные пользователя, но лог append-only, и ты не можешь переписать историю, не сломав каждый последующий реплей и гарантию аудита. Доминирующая техника — crypto-shredding: шифровать PII каждого пользователя ключом на пользователя, хранимым вне лога; чтобы «забыть» его, выбрось ключ, сделав шифротекст невосстановимым навсегда, при этом структура события остаётся целой. Заметь острую оговорку, которую сеньор обязан подсветить: регуляторы всё ещё могут считать неудаляемый зашифрованный PII персональными данными, так что crypto-shredding — прагматичная мера, а не гарантированный юридический выигрыш; подключай юристов.
Наконец, цена реплея неограничена, если ничего не делать. Свёртка миллионов событий, чтобы загрузить один агрегат, становится медленной; финансовая система, проигрывающая терабайты тиков цен, может тратить минуты на пересборку. Фикс — snapshotting: периодически сохранять свёрнутое состояние на версии N, затем при загрузке читать снапшот и проигрывать только события после N. Снапшоты — чистая оптимизация производительности и грабли: если снапшот разошёлся с событиями (поменялась логика, потерялась запись посреди потока), он молча отдаёт неверное состояние и маскирует баг, потому что реплей его никогда не пересчитывает. Защищающиеся команды считают чексумму снапшота и пересобирают при несовпадении.
Пользователь требует GDPR-стирания. Его PII вшит в сотни неизменяемых событий в append-only хранилище. Выбери подход, который сеньор реально отгружает.
Загрузка одного агрегата теперь сворачивает 4 миллиона событий и длится слишком долго. Какой фикс сеньора?
Почему проекция, строящая read-модель, обязана быть идемпотентной?
Расставь жизненный цикл записи в event-sourced + CQRS системе:
- 1 Приходит команда; загрузи агрегат свёрткой его событий (или снапшот + хвост)
- 2 Проверь команду относительно текущего свёрнутого состояния
- 3 Добавь получившееся событие(я) в поток с проверкой ожидаемой версии
- 4 Проекции потребляют новое событие асинхронно и обновляют read-модели
- 5 Запросы читают (eventually consistent) read-модель, а не сырой лог
- 01Коллега говорит: «мы уже публикуем события в Kafka, значит мы event-sourced». Объясни, почему это может быть неправдой и что реально сделало бы это event sourcing.
- 02Пройди по тому, почему версионирование схемы в event sourcing постоянно, и как upcasting справляется с этим без переписывания истории.
Event sourcing хранит неизменяемый append-only поток событий, меняющих состояние, как источник истины и выводит текущее состояние свёрткой этого лога слева; «текущее состояние» — одноразовый кэш, который можно пересчитать когда угодно. Эта одна инверсия покупает полный аудит-трейл, темпоральные запросы (состояние на любой прошлый момент) и детерминированную отладку реплеем — ничего не прикручено сбоку, всё выпадает из того, что данные никогда не уничтожаются. Это отлично от change log на CDC или обычного топика Kafka, где истину держит база где-то ещё; в настоящем event sourcing авторитетен лог, а Kafka годится, только если избегать компакции и держать бесконечный retention. CQRS встаёт в пару естественно: проекции потребляют поток в построенные под задачу одноразовые read-модели, которые обязаны быть идемпотентными и лишь eventually consistent, поэтому чтения отстают от стороны записи. Сложные части постоянны: ты никогда не сможешь удалить старую форму события, поэтому изменения схемы решаются upcasting; GDPR-стирание против неизменяемого лога решается crypto-shredding (с реальной юридической оговоркой); а неограниченная цена реплея ограничивается snapshotting, который сам становится граблями, если снапшот хоть раз разойдётся с событиями, которые он претендует обобщать.