← 中文资源

简体中文版本

构建事件溯源的账户账本

一个完整的事件溯源账本:不可变事件、聚合重放、乐观并发、投影,以及经过验证的余额不变量。

阅读英文原文

项目概述

本项目用事件溯源实现账户账本:账户是一串不可变事件,余额由重放推导而非权威存储,任何历史状态都可重建。相比可变行,它用更多存储与代码换来一个可辩护的答案——余额为何是现在这个数。

架构与关键决策

事件按聚合逐版本追加;加载聚合即按序重放事件折叠出状态,命令先在该状态上校验再产出新事件。追加携带命令观察到的版本,若已变化则拒绝,从而无需锁即可避免丢失更新。投影订阅新事件维护查询用的余额表,并在测试中与重放比对;因为投影可重建,其错误是重建问题而非数据丢失。事件不可变,纠错以补偿事件表达,这正是历史可信、存储仅追加的原因。

实施与测试

应用一串命令断言重放后的余额;并发发起两笔合计超额的取款断言恰好一笔成功;从头重建投影断言与增量更新一致;断言违反不变式的命令被拒且不产生事件。

限制与后续

  • 无快照,重放成本随流长度增长。
  • 投影单进程、可重建但未分片。
  • 不支持跨聚合事务,转账依赖补偿事件。

代码示例

pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest
event-ledger/
├── src/aggregate.ts
├── src/append.ts
├── src/projection.ts
├── src/commands.ts
├── tests/ledger.test.ts
└── package.json
CREATE TABLE events (
  stream_id text, version int, type text, payload jsonb,
  PRIMARY KEY (stream_id, version));
let balance = 0;
for (const e of events) balance += e.type === 'deposited' ? e.amount : -e.amount;
INSERT INTO events(stream_id, version, type, payload)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING version;
await db.upsertBalance(accountId, balance);
assert.equal(projected.balance, replayBalance(events));
pnpm vitest run