Восстановление состояния из событий
Сворачивайте события в текущее состояние
«Восстановление состояния из событий» — бесплатный урок SQL Academy на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения SQL Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс SQL Academy содержит 4 уроков всего.
Что означает восстановление состояния
При построении состояния из событий данные хранятся как неизменяемый журнал событий, а не как изменяемые строки. Чтобы узнать текущее состояние чего-либо, необходимо воспроизвести эти события и свести их в единый результат.
Это называется восстановлением состояния из событий. Представьте банковский счёт: вместо хранения баланса Вы сохраняете каждое пополнение и снятие средств. Баланс всегда равен сумме всех этих событий.
Простая таблица событий
Начнём с создания минимального журнала событий для системы банковских счетов. Каждая строка представляет то, что произошло, — пополнение или снятие средств, — а также сумму и отметку времени.
Эта таблица никогда не обновляется и не удаляется. Новые факты всегда добавляются в виде новых строк.
CREATE TABLE account_events (
event_id SERIAL PRIMARY KEY,
account_id INT NOT NULL,
event_type VARCHAR(20) NOT NULL, -- 'deposit' or 'withdrawal'
amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
(1, 'deposit', 1000.00, '2024-01-01 09:00:00+00'),
(1, 'deposit', 500.00, '2024-01-03 14:00:00+00'),
(1, 'withdrawal', 200.00, '2024-01-05 10:00:00+00'),
(1, 'deposit', 300.00, '2024-01-07 11:00:00+00'),
(1, 'withdrawal', 150.00, '2024-01-09 16:00:00+00');Свёртка событий в баланс
Чтобы восстановить текущий баланс, мы суммируем все события. Пополнения увеличивают баланс, а снятия уменьшают его. Выражение CASE позволяет учитывать каждый тип события с правильным знаком перед суммированием.
Этот единственный запрос возвращает текущее состояние, полностью полученное из исторического журнала событий.
SELECT
account_id,
SUM(
CASE event_type
WHEN 'deposit' THEN amount
WHEN 'withdrawal' THEN -amount
ELSE 0
END
) AS current_balance
FROM account_events
WHERE account_id = 1
GROUP BY account_id;Состояние на определённый момент
Одна из самых мощных особенностей событийной модели — возможность восстановить состояние на любой момент времени. Просто добавьте фильтр WHERE created_at <= :target_time перед агрегацией.
Так Вы получаете запрос для путешествия во времени без каких-либо дополнительных изменений схемы — вся история уже находится в журнале событий.
-- What was the balance at the end of January 5th?
SELECT
account_id,
SUM(
CASE event_type
WHEN 'deposit' THEN amount
WHEN 'withdrawal' THEN -amount
ELSE 0
END
) AS balance_at_snapshot
FROM account_events
WHERE account_id = 1
AND created_at <= '2024-01-05 23:59:59+00'
GROUP BY account_id;Накопительный баланс с оконными функциями
Вместо одного общего итога можно вычислить накопительный баланс — баланс после каждого события. Оконная функция SUM(...) OVER (ORDER BY ...) вычисляет накопительную сумму по мере добавления событий в хронологическом порядке.
Это особенно полезно для аудиторских журналов и отладки переходов между состояниями.
SELECT
event_id,
created_at,
event_type,
amount,
SUM(
CASE event_type
WHEN 'deposit' THEN amount
WHEN 'withdrawal' THEN -amount
ELSE 0
END
) OVER (PARTITION BY account_id ORDER BY created_at, event_id)
AS running_balance
FROM account_events
WHERE account_id = 1
ORDER BY created_at, event_id;Сохранение состояния в таблице снимков
Повторное воспроизведение всех событий при каждом запросе может стать затратным по мере роста журнала. Распространённая оптимизация — сохранить текущее состояние в таблице снимков и периодически или по требованию пересобирать его.
Снимок хранит свёрнутый результат; запросы читают данные из снимка, а не воспроизводят весь журнал при каждом обращении.
CREATE TABLE account_snapshots (
account_id INT PRIMARY KEY,
current_balance NUMERIC(12, 2) NOT NULL,
as_of_event_id INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Populate / refresh the snapshot from the event log
INSERT INTO account_snapshots (account_id, current_balance, as_of_event_id, updated_at)
SELECT
account_id,
SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END),
MAX(event_id),
NOW()
FROM account_events
GROUP BY account_id
ON CONFLICT (account_id) DO UPDATE
SET current_balance = EXCLUDED.current_balance,
as_of_event_id = EXCLUDED.as_of_event_id,
updated_at = EXCLUDED.updated_at;Инкрементальное обновление снимков
Когда появляются новые события, не нужно воспроизводить всю историю. Если в снимке записан идентификатор последнего обработанного события event_id, можно применить только изменения — события, появившиеся после создания снимка.
Этот инкрементальный подход позволяет быстро обновлять снимки даже для больших журналов.
-- Apply only new events since the last snapshot
UPDATE account_snapshots AS snap
SET
current_balance = snap.current_balance + delta.net,
as_of_event_id = delta.max_event_id,
updated_at = NOW()
FROM (
SELECT
ae.account_id,
SUM(CASE ae.event_type WHEN 'deposit' THEN ae.amount WHEN 'withdrawal' THEN -ae.amount ELSE 0 END) AS net,
MAX(ae.event_id) AS max_event_id
FROM account_events ae
JOIN account_snapshots s ON s.account_id = ae.account_id
WHERE ae.event_id > s.as_of_event_id
GROUP BY ae.account_id
) AS delta
WHERE snap.account_id = delta.account_id;Временные таблицы и системное версионирование
В стандарте SQL:2011 появились временные таблицы с системным версионированием, которые поддерживаются самой базой данных. Для каждой строки механизм автоматически создаёт столбцы valid_from и valid_to и управляет ими.
PostgreSQL не поддерживает эту возможность изначально, но её можно эмулировать. Другие базы данных, например MariaDB и сервер SQL, напрямую поддерживают конструкцию WITH SYSTEM VERSIONING.
-- Emulating a temporal table in PostgreSQL
CREATE TABLE account_state_history (
account_id INT NOT NULL,
current_balance NUMERIC(12, 2) NOT NULL,
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ NOT NULL DEFAULT 'infinity'
);
-- Insert initial state
INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1000.00, '2024-01-01 09:00:00+00');
-- On update: close old row, insert new row
UPDATE account_state_history
SET valid_to = '2024-01-03 14:00:00+00'
WHERE account_id = 1 AND valid_to = 'infinity';
INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1500.00, '2024-01-03 14:00:00+00');Запрос исторических данных во времени
Создав эмулированную временную таблицу, можно узнать, каким был баланс в любой момент прошлого, отфильтровав данные по диапазону действия. Строка, диапазон которой содержит целевую временную метку, показывает состояние на этот момент.
Этот подход отделяет логику запросов от воспроизведения событий — таблица истории состояний уже содержит свёрнутые данные.
-- What was the account balance on January 4th?
SELECT
account_id,
current_balance,
valid_from,
valid_to
FROM account_state_history
WHERE account_id = 1
AND valid_from <= '2024-01-04 00:00:00+00'
AND valid_to > '2024-01-04 00:00:00+00';Событийная модель с несколькими сущностями
В реальных системах одновременно отслеживаются события для множества сущностей. Общий журнал событий со столбцами entity_id и entity_type позволяет восстановить состояние любого объекта из одной таблицы.
Здесь мы отслеживаем перемещения запасов для нескольких товаров. Восстановление текущего остатка для каждого товара снова сводится к групповой агрегации.
CREATE TABLE inventory_events (
event_id SERIAL PRIMARY KEY,
product_id INT NOT NULL,
event_type VARCHAR(20) NOT NULL, -- 'received', 'shipped', 'adjusted'
quantity INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO inventory_events (product_id, event_type, quantity, created_at) VALUES
(101, 'received', 200, '2024-03-01 08:00:00+00'),
(101, 'shipped', 50, '2024-03-02 12:00:00+00'),
(101, 'shipped', 30, '2024-03-04 15:00:00+00'),
(102, 'received', 150, '2024-03-01 08:00:00+00'),
(102, 'adjusted', -10, '2024-03-03 09:00:00+00');
-- Rebuild current stock for all products
SELECT
product_id,
SUM(CASE event_type WHEN 'received' THEN quantity WHEN 'shipped' THEN -quantity ELSE quantity END) AS stock_on_hand
FROM inventory_events
GROUP BY product_id
ORDER BY product_id;Использование CTE для ясности
Запросы для восстановления состояния могут стать сложными. Если обернуть этап свёртки в CTE, запрос станет понятнее, а восстановленное состояние можно будет аккуратно объединить с другими таблицами.
Здесь мы восстанавливаем балансы счетов, а затем объединяем их со справочной таблицей счетов, чтобы добавить имена владельцев в результат.
CREATE TABLE accounts (
account_id INT PRIMARY KEY,
owner_name VARCHAR(100) NOT NULL
);
INSERT INTO accounts (account_id, owner_name) VALUES
(1, 'Alice'),
(2, 'Bob');
INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
(2, 'deposit', 2000.00, '2024-01-02 10:00:00+00'),
(2, 'withdrawal', 400.00, '2024-01-06 11:00:00+00');
WITH rebuilt_balances AS (
SELECT
account_id,
SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END) AS balance
FROM account_events
GROUP BY account_id
)
SELECT
a.account_id,
a.owner_name,
rb.balance
FROM accounts a
JOIN rebuilt_balances rb USING (account_id)
ORDER BY a.account_id;Проверка знаний
Проверьте, насколько хорошо Вы поняли восстановление состояния из событий в SQL.
Итоги урока
В этом уроке Вы узнали, как с помощью SQL восстанавливать текущее и историческое состояние из неизменяемого журнала событий.
Главные выводы:
- Состояние получают, сворачивая (агрегируя) события с помощью выражения
CASEсо знаками внутриSUM. - Фильтр по временной метке позволяет бесплатно выполнять запросы для получения состояния на конкретный момент времени.
- Оконные функции формируют текущее состояние после каждого события.
- Таблицы снимков сохраняют свёрнутый результат для повышения производительности; инкрементальные обновления применяют только новые события.
- Эмулированные временные таблицы хранят строки уже свёрнутого состояния с диапазонами действия, что ускоряет поиск исторических данных.
- CTE делают запросы для восстановления состояния понятнее, когда нужно объединить полученное состояние с другими таблицами.
Эти подходы лежат в основе структур баз данных на основе событий и удобных для аудита.
Часто задаваемые вопросы
Урок «Восстановление состояния из событий» бесплатный?
Да — полный текст урока «Восстановление состояния из событий» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс SQL Academy, подпишись на CoddyKit PRO. Курс SQL Academy содержит 4 уроков всего.
Чему я научусь в уроке «Восстановление состояния из событий»?
Сворачивайте события в текущее состояние Ты практикуешь SQL Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать SQL Academy?
Предыдущий опыт не требуется. SQL Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.
Сколько времени занимает урок «Восстановление состояния из событий»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке SQL Academy?
Да. Каждый урок SQL Academy включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Зачем хранить историю
- Таблицы событий только для добавления
- Временные строки и строки с версиями
- Восстановление состояния из событий