从事件重建状态
将事件折叠为当前状态。
从事件重建状态 是 CoddyKit 上的免费 SQL Academy 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 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,根据不可变事件日志重建当前状态和历史状态。
关键要点:
- 状态通过在
SUM中使用带正负号的CASE表达式折叠(聚合)事件得出。 - 添加时间戳筛选条件,就能免费获得时间点查询能力。
- 窗口函数可以在每个事件之后生成累计状态。
- 快照表会物化折叠后的结果以提升性能;增量更新只应用新事件。
- 模拟的时态表会保存带有效期范围的预先折叠状态行,从而快速查询历史记录。
- 当您需要将派生状态与其他表连接时,CTE 可以让重建查询保持清晰易读。
这些模式是事件溯源数据库设计和便于审计的数据库设计的基础。
常见问题解答
「从事件重建状态」课时是免费的吗?
是的 — 「从事件重建状态」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 SQL Academy 课程的其余内容,请升级到 CoddyKit PRO。 SQL Academy 课程共包含 4 节课。
「从事件重建状态」这节课中我会学到什么?
将事件折叠为当前状态。 你通过在浏览器中直接运行的动手代码来练习 SQL Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 SQL Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 SQL Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「从事件重建状态」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 SQL Academy 课中编写并运行代码吗?
能。每节 SQL Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 为什么要保留历史记录
- 仅追加事件表
- 时态行与版本化行
- 从事件重建状态