0Pricing
Node.js Backend Development Bootcamp · 课时

快照、版本控制与事件模式演进

使用快照加快状态恢复,并在不破坏历史数据的情况下演进事件模式。

快照、版本控制与事件模式演进 是 CoddyKit 上的免费 Node.js Backend Development Bootcamp 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Node.js Backend Development Bootcamp 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Rehydration Gets Slow

In Event Sourcing, the current state of an aggregate is rebuilt by replaying every event from the start of its stream. This is called rehydration.

For a fresh bank account with 12 events, replaying them is instant. But a long-lived aggregate (an account open for 5 years, a shopping cart with thousands of edits) can accumulate tens of thousands of events. Loading and folding all of them on every command becomes the bottleneck.

  • I/O cost: reading thousands of rows from the event store
  • CPU cost: folding each event through the reducer
  • Latency: every write must first rehydrate before validating the next command

The standard fix is a snapshot: a periodic, serialized copy of the aggregate state so you only replay events after it.

Rehydration as a Fold

Before optimizing, make rehydration explicit. An aggregate's state is a left fold over its events: state = events.reduce(apply, initialState).

Here is a minimal account aggregate. The apply function is a pure reducer — given the previous state and one event, it returns the next state. Notice it never mutates; it returns new objects so it stays easy to reason about and test.

function apply(state, event) {
  switch (event.type) {
    case 'AccountOpened':
      return { ...state, balance: 0, version: state.version + 1 };
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.amount, version: state.version + 1 };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.amount, version: state.version + 1 };
    default:
      return state;
  }
}

function rehydrate(events) {
  const initial = { balance: 0, version: 0 };
  return events.reduce(apply, initial);
}

const events = [
  { type: 'AccountOpened' },
  { type: 'MoneyDeposited', amount: 100 },
  { type: 'MoneyWithdrawn', amount: 30 },
];

console.log(rehydrate(events)); // { balance: 70, version: 3 }

What a Snapshot Actually Is

A snapshot is a saved tuple of (aggregateId, version, serializedState). The version is the event sequence number the state reflects — this is the critical part.

To rehydrate with a snapshot:

  • Load the latest snapshot for the aggregate
  • Seed the fold with the snapshot state instead of initialState
  • Replay only events with version > snapshot.version

Key rule: a snapshot is a derived cache, never the source of truth. The event stream remains authoritative. If a snapshot is lost or corrupted, you can always rebuild it by replaying from zero.

Rehydrating From a Snapshot

This version loads from a snapshot when one exists, then folds only the newer events. The snapshot already encodes version, so we know exactly where to resume.

In production the snapshot and the events come from your store; here they are passed in so the logic runs standalone.

function apply(state, event) {
  switch (event.type) {
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.amount, version: event.version };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.amount, version: event.version };
    default:
      return { ...state, version: event.version };
  }
}

function loadFromSnapshot(snapshot, allEvents) {
  const start = snapshot ? snapshot.state : { balance: 0, version: 0 };
  const fromVersion = snapshot ? snapshot.version : 0;
  const newer = allEvents.filter((e) => e.version > fromVersion);
  return newer.reduce(apply, start);
}

const snapshot = { version: 3, state: { balance: 70, version: 3 } };
const events = [
  { type: 'MoneyDeposited', amount: 100, version: 1 },
  { type: 'MoneyWithdrawn', amount: 30, version: 3 },
  { type: 'MoneyDeposited', amount: 50, version: 4 },
];

console.log(loadFromSnapshot(snapshot, events)); // { balance: 120, version: 4 }

When to Take a Snapshot

You don't snapshot on every event — that defeats the purpose. Common strategies:

  • Every N events: e.g. snapshot whenever version % 100 === 0. Simple and predictable.
  • By replay cost: snapshot when the gap between current version and last snapshot exceeds a threshold.
  • Async / background: a separate worker subscribes to the stream and writes snapshots off the hot path, so writes never wait on snapshotting.

Tune N empirically. Too low wastes storage and write throughput; too high means long replays. For many systems N = 50–200 is a reasonable starting point.

const SNAPSHOT_EVERY = 100;

function shouldSnapshot(currentVersion, lastSnapshotVersion) {
  // Snapshot when we've crossed another multiple of N
  const since = currentVersion - lastSnapshotVersion;
  return since >= SNAPSHOT_EVERY;
}

console.log(shouldSnapshot(100, 0));   // true
console.log(shouldSnapshot(140, 100)); // false
console.log(shouldSnapshot(205, 100)); // true

The Hidden Trap: Snapshots Are Versioned Too

Here is the subtle danger. A snapshot stores the shape of your state at the moment it was written. If you later change how state is structured (rename a field, add a required property, change a type), old snapshots become incompatible.

Imagine you stored { balance: 70 }, then refactored to { balanceCents: 7000 }. A six-month-old snapshot still has balance, and your new apply code expects balanceCents. Rehydration silently produces wrong state.

Rule: tag every snapshot with a schemaVersion. On load, if the snapshot's schema version doesn't match the current code, discard it and replay from the events (which are upcasted — covered next). Events are forever; snapshots are disposable.

Guarding Snapshot Schema Version

This guard checks the snapshot's schemaVersion before trusting it. If it's stale, we fall back to a full replay. Because events are the source of truth, falling back is always safe — it's just slower for that one load, and the next snapshot will be written in the new shape.

const CURRENT_SCHEMA = 2;

function chooseStartState(snapshot) {
  if (snapshot && snapshot.schemaVersion === CURRENT_SCHEMA) {
    return { state: snapshot.state, fromVersion: snapshot.version };
  }
  // Stale or missing snapshot: replay everything
  return { state: { balanceCents: 0, version: 0 }, fromVersion: 0 };
}

const stale = { schemaVersion: 1, version: 300, state: { balance: 70 } };
const fresh = { schemaVersion: 2, version: 300, state: { balanceCents: 7000, version: 300 } };

console.log(chooseStartState(stale).fromVersion); // 0 -> full replay
console.log(chooseStartState(fresh).fromVersion); // 300 -> fast path

Evolving Event Schemas: The Real Constraint

Events are immutable historical facts. You can never go back and edit a MoneyDeposited event from two years ago — it really happened in that shape. Yet your code must keep evolving.

So when business needs change, you cannot rewrite history. Instead you have a small set of disciplined techniques:

  • Weak schema / additive change: only ever add optional fields; never remove or repurpose existing ones.
  • Upcasting: transform old event versions into the current shape at read time.
  • New event type: when a change is too big, introduce a new event type and stop emitting the old one.

The golden rule: old events must remain readable forever. Every consumer reads through an upcaster, so the rest of your code only ever sees the latest shape.

Upcasting Old Events at Read Time

Upcasting means: as each event is read from the store, run it through a function that migrates older versions up to the current shape before it reaches apply. Each event carries an eventVersion so the upcaster knows what to do.

Example evolution: v1 deposits had a single amount in dollars. v2 splits money into currency + integer amountCents. Old v1 events get upcasted on the fly — without ever touching the stored data.

function upcast(event) {
  if (event.type === 'MoneyDeposited' && (event.eventVersion || 1) === 1) {
    return {
      ...event,
      eventVersion: 2,
      currency: 'USD',
      amountCents: Math.round(event.amount * 100),
    };
  }
  return event;
}

const stored = [
  { type: 'MoneyDeposited', amount: 100 },                         // legacy v1
  { type: 'MoneyDeposited', eventVersion: 2, currency: 'USD', amountCents: 5000 },
];

const upcasted = stored.map(upcast);
console.log(upcasted[0]); // { ...amountCents: 10000, currency: 'USD', eventVersion: 2 }
console.log(upcasted.every((e) => typeof e.amountCents === 'number')); // true

Chaining Upcasters Across Multiple Versions

Schemas evolve more than once. The clean pattern is a chain of single-step upcasters: v1→v2, v2→v3, v3→v4. An event enters at its stored version and is pushed forward step by step until it reaches the latest version. This keeps each migration small, testable, and independent.

Never write a giant v1→v4 function — you'd duplicate logic and it rots fast. Compose small steps instead.

const upcasters = {
  1: (e) => ({ ...e, eventVersion: 2, amountCents: Math.round(e.amount * 100) }),
  2: (e) => ({ ...e, eventVersion: 3, currency: e.currency || 'USD' }),
  3: (e) => ({ ...e, eventVersion: 4, source: e.source || 'unknown' }),
};

const LATEST = 4;

function upcastToLatest(event) {
  let e = { ...event, eventVersion: event.eventVersion || 1 };
  while (e.eventVersion < LATEST) {
    e = upcasters[e.eventVersion](e);
  }
  return e;
}

const legacy = { type: 'MoneyDeposited', amount: 42 };
const result = upcastToLatest(legacy);
console.log(result.eventVersion);  // 4
console.log(result.amountCents);   // 4200
console.log(result.currency);      // USD
console.log(result.source);        // unknown

Putting It Together: Safe, Fast Loading

A production rehydration path combines everything:

  • Load the latest snapshot; discard it if its schemaVersion is stale
  • Read events after the (valid) snapshot version
  • Upcast every event to the latest shape
  • Fold them onto the start state
  • Optionally write a new snapshot if the replay gap was large

This gives you O(events-since-snapshot) loads in the happy path, automatic correctness when state shape changes, and forward-compatible event reading. Snapshots make it fast; upcasting keeps it correct as the system evolves.

Operational tip: when you ship a breaking state refactor, you don't need to migrate snapshots — just bump CURRENT_SCHEMA and let the next load rebuild them lazily.

Quick Check

Test your understanding of the safe-evolution strategy.

Recap

You learned how to make Event Sourcing both fast and durable over time:

  • Rehydration is a left fold over events; long streams make it slow.
  • Snapshots store (aggregateId, version, state) so you replay only events after them — a derived cache, never the source of truth.
  • Snapshot strategy: take one every N events or by replay-gap, ideally on a background worker.
  • Tag snapshots with schemaVersion; discard and full-replay when state shape changes — no snapshot migration needed.
  • Events are immutable. Evolve safely with additive-only changes, upcasting old events to the latest shape at read time, or introducing new event types.
  • Chain single-step upcasters (v1→v2→v3) so every consumer sees only the current shape.

Snapshots give you speed; versioning and upcasting give you the freedom to evolve without ever breaking history.

常见问题解答

「快照、版本控制与事件模式演进」课时是免费的吗?

是的 — 「快照、版本控制与事件模式演进」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Node.js Backend Development Bootcamp 课程的其余内容,请升级到 CoddyKit PRO。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

「快照、版本控制与事件模式演进」这节课中我会学到什么?

使用快照加快状态恢复,并在不破坏历史数据的情况下演进事件模式。 你通过在浏览器中直接运行的动手代码来练习 Node.js Backend Development Bootcamp,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Node.js Backend Development Bootcamp 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Node.js Backend Development Bootcamp 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「快照、版本控制与事件模式演进」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Node.js Backend Development Bootcamp 课中编写并运行代码吗?

能。每节 Node.js Backend Development Bootcamp 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 事件作为事实来源与仅追加日志
  2. 聚合、命令与领域事件建模
  3. 构建读取模型与投影
  4. 快照、版本控制与事件模式演进
← 返回 Node.js Backend Development Bootcamp