0Pricing
SQL Academy · Урок

Восстановление состояния из событий

Сворачивайте события в текущее состояние

«Восстановление состояния из событий» — бесплатный урок 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 — локальная установка не требуется.

Все уроки этого курса

  1. Зачем хранить историю
  2. Таблицы событий только для добавления
  3. Временные строки и строки с версиями
  4. Восстановление состояния из событий
← Назад к SQL Academy