SQL Academy · Lekcja

Odtwarzanie stanu ze zdarzeń

Składaj zdarzenia w bieżący stan

Lekcja 4 z 413 kroki

Odtwarzanie stanu ze zdarzeń to bezpłatna lekcja SQL Academy na CoddyKit. To lekcja 4 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej SQL Academy, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs SQL Academy zawiera 4 lekcji w sumie.

Co oznacza odbudowywanie stanu?

W event sourcingu dane są przechowywane jako niezmienny dziennik zdarzeń, a nie jako modyfikowalne wiersze. Aby poznać bieżący stan dowolnego elementu, trzeba ponownie odtworzyć te zdarzenia i złożyć je w jeden wynik.

Nazywa się to odbudowywaniem stanu na podstawie zdarzeń. Proszę pomyśleć o koncie bankowym: zamiast przechowywać saldo, przechowuje się każdą wpłatę i wypłatę. Saldo jest zawsze sumą wszystkich tych zdarzeń.

Prosta tabela zdarzeń

Zacznijmy od utworzenia minimalnego dziennika zdarzeń dla systemu kont bankowych. Każdy wiersz reprezentuje coś, co się wydarzyło — wpłatę lub wypłatę — wraz z kwotą i znacznikiem czasu.

Ta tabela nigdy nie jest aktualizowana ani usuwana. Nowe fakty są zawsze dopisywane jako nowe wiersze.

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');

Agregowanie zdarzeń do salda

Aby odtworzyć bieżące saldo, agregujemy wszystkie zdarzenia. Wpłaty zwiększają saldo, a wypłaty je zmniejszają. Wyrażenie CASE pozwala nadać każdemu typowi zdarzenia właściwy znak przed zsumowaniem.

To pojedyncze zapytanie pokazuje bieżący stan wyprowadzony wyłącznie z historycznego dziennika zdarzeń.

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;

Stan w konkretnym momencie

Jedną z najpotężniejszych zalet event sourcingu jest możliwość odtworzenia stanu w dowolnym momencie. Wystarczy dodać filtr WHERE created_at <= :target_time przed agregowaniem.

Dzięki temu można wykonywać zapytania umożliwiające podróż w czasie bez konieczności wprowadzania dodatkowych zmian w schemacie — historia już znajduje się w dzienniku zdarzeń.

-- 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;

Saldo narastające z funkcjami okna

Zamiast jednej sumy możemy obliczyć saldo narastające — saldo po każdym zdarzeniu. Funkcja okna SUM(...) OVER (ORDER BY ...) oblicza sumę skumulowaną w miarę dodawania zdarzeń w kolejności chronologicznej.

Jest to niezwykle przydatne w ścieżkach audytu i podczas debugowania zmian stanu.

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;

Materializowanie stanu w tabeli migawek

Odtwarzanie wszystkich zdarzeń przy każdym zapytaniu może stać się kosztowne wraz ze wzrostem dziennika. Częstą optymalizacją jest materializowanie bieżącego stanu w tabeli migawek oraz jej okresowe lub wykonywane na żądanie odtwarzanie.

Snapshot przechowuje zagregowany wynik, a zapytania odczytują go zamiast za każdym razem odtwarzać cały dziennik.

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;

Przyrostowe aktualizacje migawki

Gdy pojawiają się nowe zdarzenia, nie trzeba odtwarzać całej historii. Jeśli w migawce zapisano ostatni przetworzony event_id, można zastosować tylko deltę — zdarzenia, które pojawiły się po utworzeniu migawki.

Ten przyrostowy wzorzec zapewnia szybkie odświeżanie migawek nawet w przypadku dużych dzienników.

-- 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;

Tabele temporalne i wersjonowanie systemowe

Standard SQL:2011 wprowadził systemowo wersjonowane tabele temporalne, które są utrzymywane przez samą bazę danych. Każdy wiersz automatycznie otrzymuje kolumny valid_from i valid_to zarządzane przez silnik bazy danych.

PostgreSQL nie obsługuje tego natywnie, ale można to emulować. Inne bazy danych, takie jak MariaDB i SQL Server, obsługują bezpośrednio konstrukcję 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');

Odpytywanie historii temporalnej

Po utworzeniu emulowanej tabeli temporalnej można sprawdzić, jakie było saldo w dowolnym momencie w przeszłości, filtrując dane według zakresu obowiązywania. Wiersz, którego zakres zawiera docelowy znacznik czasu, przedstawia stan z tego momentu.

Ten wzorzec oddziela logikę zapytań od odtwarzania zdarzeń — tabela historii stanu jest już wstępnie zagregowana.

-- 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';

Event sourcing z wieloma encjami

Rzeczywiste systemy śledzą jednocześnie zdarzenia dotyczące wielu encji. Wspólny dziennik zdarzeń z kolumnami entity_id i entity_type pozwala odtworzyć stan dowolnego obiektu z jednej tabeli.

W tym przykładzie śledzimy ruchy magazynowe dotyczące wielu produktów. Odtworzenie bieżącego stanu zapasów dla każdego produktu ponownie sprowadza się do agregacji według grup.

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;

Używanie CTE dla przejrzystości

Zapytania odtwarzające stan mogą stać się złożone. Umieszczenie kroku agregowania w obiekcie CTE poprawia czytelność i pozwala przejrzyście połączyć odtworzony stan z innymi tabelami.

W tym przykładzie odtwarzamy salda kont, a następnie łączymy je z tabelą referencyjną kont, aby uwzględnić nazwiska właścicieli w wynikach.

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;

Sprawdzenie wiedzy

Sprawdźmy, jak dobrze rozumieją Państwo odtwarzanie stanu ze zdarzeń w SQL.

Podsumowanie lekcji

W tej lekcji nauczyli się Państwo odtwarzać bieżący i historyczny stan z niezmiennego dziennika zdarzeń za pomocą SQL.

Najważniejsze informacje:

  • Stan powstaje przez agregowanie (składanie) zdarzeń za pomocą wyrażenia CASE określającego znak wewnątrz SUM.
  • Dodanie filtra czasowego zapewnia bezpłatnie zapytania dotyczące konkretnego momentu.
  • Funkcje okna tworzą stan narastający po każdym zdarzeniu.
  • Tabele migawek materializują zagregowany wynik na potrzeby wydajności, a przyrostowe aktualizacje uwzględniają tylko nowe zdarzenia.
  • Emulowane tabele temporalne przechowują wstępnie zagregowane wiersze stanu z zakresami obowiązywania, co umożliwia szybkie wyszukiwanie danych historycznych.
  • CTE zapewniają czytelność zapytań odtwarzających stan, gdy trzeba połączyć wyprowadzony stan z innymi tabelami.

Wzorce te stanowią podstawę projektowania baz danych opartych na event sourcingu i ułatwiających audyt.

Bezpłatny start

Ucz się SQL dzięki korepetycjom AI — za darmo

Pisz i uruchamiaj kod w przeglądarce, otrzymuj natychmiastową pomoc od korepetytora AI dostępnego 24/7 i kontynuuj naukę w sieci lub w aplikacji.

Kursy
46
Lekcje
183

Często zadawane pytania

Czy lekcja „Odtwarzanie stanu ze zdarzeń” jest bezpłatna?

Tak — pełny tekst „Odtwarzanie stanu ze zdarzeń” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu SQL Academy, przejdź na CoddyKit PRO. Kurs SQL Academy zawiera 4 lekcji w sumie.

Co nauczysz się w „Odtwarzanie stanu ze zdarzeń”?

Składaj zdarzenia w bieżący stan Ćwiczysz SQL Academy z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć SQL Academy?

Nie wymagamy żadnego doświadczenia. SQL Academy w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 4 z 4.

Ile czasu zajmuje lekcja „Odtwarzanie stanu ze zdarzeń”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji SQL Academy?

Tak. Każda lekcja SQL Academy zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Dlaczego warto przechowywać historię
  2. Tabele zdarzeń tylko do dopisywania
  3. Wiersze czasowe i wersjonowane
  4. Odtwarzanie stanu ze zdarzeń
← Powrót do SQL Academy