城东有家百年老店“聚宝斋”,掌柜换了三代,账房先生却始终守着一本流水账。规矩很特别:绝不涂改。记错了,就补一条“冲正”;每笔收支,都要写明日期、事由、经手人。月底对账时,先生把流水账从头翻到尾,算出当天余额。有一回伙计碰翻墨汁污了两页,先生不慌不忙,拿出上月底的“小汇总本”,从那里继续往后翻,很快恢复了账目。 老店的记账方式,正是本文主角——Event Sourcing(事件溯源)。核心思想朴素:不保存“现在余额是多少”,而是保存“每一步发生了什么”。 当前状态只是历史事件的累计结果,事件本身才是唯一事实。为了加速恢复,先生备了“小汇总本”(快照);为了给掌柜看不同角度的账,先生又整理了“利润表”“欠款表”这类专门账册(投影)。这套思路应用于高并发消息与异步系统,能构建可审计、可回溯、高可用的核心业务。

岗位关联

掌握 Event Sourcing,就能在高并发订单、金融结算、AI 行为日志等场景中,设计出可随时回溯任意时点状态的系统,获得审计级可信度与故障自愈能力,是资深架构师的核心武器。

目录
  1. 可视化演示
  2. 核心概念
  3. 事件存储设计
  4. 溯源与重放
  5. 快照优化
  6. 投影与查询
  7. 架构优势
  8. 挑战与对策
  9. 实战案例
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

观察不可变事件按序追加到事件存储,通过重放事件重建状态,以及快照优化降低重放开销的过程,可切换三种模式。

本节配有交互动效(进入视口后自动加载,约 3KB)。

事件追加:append-only 每次状态变更记录为不可变事件,无法修改 事件按时间顺序追加到事件存储 状态 = Σ(事件序列) 天然支持审计与时间旅行。

核心概念

学徒第一天进账房,师傅教他的第一句话是:“记错不要紧,要紧的是不许改账。” 学徒想不通,明明擦掉重写更快,何必多写一条?师傅指着门外说:“要是哪天掌柜问起上个月的哪笔账,你擦掉了,拿什么给他看?” 学徒这才知道,这家老店的账本,每一页都只能往后面添,从不能往回改。

这正对应两种数据思维。传统 CRUD(增删改查,即 Create/Read/Update/Delete)像黑板,每次更新都擦掉旧值写新值,查询时只有一个“最新结果”;Event Sourcing(事件溯源)则像流水账,把每次变化(事件)追加到账本末尾,永不抹去。事件就是“已经发生的事实”,一旦写入便不可变。系统不断追加事件,形成一条长长的事件流(Event Stream),当前状态不单独保存,而是随时可以由事件流推导——这个过程叫状态重建(Rebuild)。

在工程上,事件是一个结构化记录,描述“什么时间、哪笔交易、发生了哪种变化”。把从初始状态到当前状态的全部事件依次累加,就能得到当前状态,这可以用一个简洁的式子表达:

所以学徒终于明白:擦掉重写,其实是在和时间对抗;而流水账,是和事实同行。每一条记录都不撒谎,每一个状态都有来路。哪怕掌柜问起半年前的一笔旧账,他也能翻出来,一笔笔讲清楚来龙去脉。这就是事件溯源最朴素的底气——事实一旦被记住,就永远不会消失。

Snow=S0+∑i=1nΔEi\displaystyle S_{\text{now}} = S_0 + \sum_{i=1}^{n} \Delta E_i
  1. 当前状态 S_now等号左边表示业务当下的累计结果,比如账户余额。
    Snow\displaystyle S_{\text{now}}
  2. 初始状态 S_0业务开始时的基线,比如开户存入的第一笔本金。
    S0\displaystyle S_0
  3. 求和符号 Σ把每个事件带来的变化量逐一累加起来,相当于把流水账逐页相加。
    ∑i=1n\displaystyle \sum_{i=1}^{n}
  4. 事件增量 ΔE_i第 i 个事件带来的变化量,比如“存入 100 元”就是 +100。
    ΔEi\displaystyle \Delta E_i

事件存储设计

师傅的第二课是记账格式。“光记数字不行,还得记清楚这笔钱是哪来的。日期、事由、经手人、凭证号,一个都不能少。” 学徒发现,正是这些要素,让每一笔账在几十年后依然说得清来龙去脉。如果只记一个“收到 100 元”,第二年再翻,谁都说不清这钱来自哪里。

事件也需要类似的结构化字段。一个事件通常包含:事件 ID、事件类型(如“存款”或“取款”)、载荷(金额、账户等业务数据)、版本号(事件结构的模式版本)和时间戳。事件类型就是账目的“事由”标签,载荷相当于“金额和经手人”。这样设计,每个事件都自包含、可独立解读。

存储设计上,事件要落到专门的“事件存储”(Event Store)中,本质是 append-only 的日志结构:只允许顺序追加,不允许随机修改。为了支撑高并发写,底层常选择追加性能优异的存储引擎;同时要让不同时期的代码都能读懂老事件,需要做好序列化兼容与模式演进(Schema Evolution),这就像账房规定“数字一律用大写、日期一律用干支”,后人才能看懂旧账。

师傅合上账本时会说:“格式定好,十年后翻开都认得。” 事件存储的版本管理,就是让系统在十次重构之后,依然读得懂最早的那条记录。对高并发系统来说,多花一点心思设计事件格式,能省下未来无数排查问题的夜晚。

Event=⟨id,type,payload,version,timestamp⟩\displaystyle \text{Event} = \langle \text{id}, \text{type}, \text{payload}, \text{version}, \text{timestamp} \rangle
  1. id 事件编号全局唯一标识,对应账本上的“凭证号”。
    id\displaystyle \text{id}
  2. type 事件类型说明“发生了什么”,比如存款、取款、转账。
    type\displaystyle \text{type}
  3. payload 载荷携带业务细节,比如金额、账户、对手方。
    payload\displaystyle \text{payload}
  4. version 版本号标识事件结构的模式版本,用于兼容旧数据。
    version\displaystyle \text{version}
  5. timestamp 时间戳记录事件发生时刻,让事件排序可复现。
    timestamp\displaystyle \text{timestamp}

溯源与重放

账房里有个口头规矩:掌柜说“记一笔”,伙计先喊一嗓子,师傅才落笔。喊声不等于账目——喊声可以被拒绝(比如掌柜说记错了、余额不足),落笔的才算数。师傅敲敲账本:“喊了不算,写了才算。” 这个微小的区别,正是事件溯源的心脏。

在系统里,“喊一嗓子”就是命令(Command)——表示“想要发生的事”,携带用户意图,可能被业务规则校验拒绝;“落笔”就是事件(Event)——表示“已经发生的事”,是不可否认的事实。命令通过校验后,被转成对应的事件写入事件流。写入之后,系统把事件“应用”(apply)到内存中的状态对象上,得到一个更新的状态。整个过程天然支持重放(Replay):从初始状态出发,把事件流再次左折叠(fold)一遍,就能重建任意时刻的状态。

因为事件是事实,重放不会改变事件本身,这个性质叫“无副作用”:无论重放十次还是一百次,只要事件相同,得到的状态就相同。用公式表达,第 t 个时刻的状态就是从初始状态开始,对事件序列做一次折叠:

这就像月底对账:账房先生从去年年初的“结余”开始,一页一页往后加,把每个动作重演一遍。重放不会让已经发生的交易消失,只会让账目越来越清楚。对系统而言,“重放”不是为了怀旧,而是为了在需要时,找回任意时刻的真相。

St=fold(apply,S0,[E1,E2,…,Et])\displaystyle S_t = \text{fold}\bigl(\text{apply}, S_0, [E_1, E_2, \dots, E_t]\bigr)
  1. fold 折叠把序列中的元素逐个累积成一个结果,相当于“逐页翻账本”。
    fold\displaystyle \text{fold}
  2. apply 应用函数把一个事件作用到当前状态上,比如把“取款 50 元”作用到余额上。
    apply\displaystyle \text{apply}
  3. S_0 起始状态折叠的初始累积值,也就是开户时的余额。
    S0\displaystyle S_0
  4. 事件序列 [E1..Et]从第 1 个到第 t 个事件的完整列表,按时间排序。
    [E1,E2,…,Et]\displaystyle [E_1, E_2, \dots, E_t]

快照优化

流水账记了十几年,对一次账要翻半天。师傅想了个办法:每个月月底,把截至当天的余额抄到一张“小汇总页”上,夹在账本里。下回对账,先看汇总页,再从那里继续往后翻。学徒恍然大悟:“原来不用每次都从第一页开始。” 师傅笑了:“账越厚,越要靠汇总页指路。”

这就是快照(Snapshot)的朴素原型。快照是“某个时点的状态备份”,记录了事件流在第 k 个事件之后的状态。有了快照,重建状态时就不必从 S₀ 重放全部事件,只需加载最近一次快照,再重放快照之后的新事件。快照本身也是不可变的,通常周期性生成,可以存储在独立的存储介质中,以便快速加载。

快照的关键是触发策略:可以按事件数量(每 1000 条)、按时间(每小时)、按业务周期(每日结算)触发。快照越新,恢复越快,但生成成本也越高;快照越旧,恢复越慢,但生成频率低、开销小。架构师需要在这两者之间做出权衡。恢复流程也一目了然:先加载快照状态 S_k,再顺序应用 k 之后的事件:

师傅给学徒总结:“汇总页是路上的驿站,不用从城门口重新出发。” 快照就是事件溯源世界里的驿站,让最遥远的起点变得不再可怕。在高并发系统里,快照策略设计得好不好,直接决定了故障恢复是“秒级”还是“小时级”。

St=fold(apply,Sk,[Ek+1,…,Et])\displaystyle S_t = \text{fold}\bigl(\text{apply}, S_k, [E_{k+1}, \dots, E_t]\bigr)
  1. S_k 快照状态第 k 个事件之后的已保存余额,相当于“小汇总页”。
    Sk\displaystyle S_k
  2. E_{k+1} 第一个后续事件快照之后的下一笔交易,恢复工作从这里开始重放。
    Ek+1\displaystyle E_{k+1}
  3. 重放区间 [E_{k+1}, E_t]只需处理快照之后的事件,而不是全部历史。
    [Ek+1,…,Et]\displaystyle [E_{k+1}, \dots, E_t]

投影与查询

掌柜经常要看不同的账:今天看“本月利润”,明天看“哪些客户还欠钱”。每月都翻一遍流水账太慢,师傅便提前把流水账整理成一本本专门账册:利润表、应收款表。这些账册不改变事实,只是换个角度组织事实。学徒问:“这不就是重新抄一遍吗?” 师傅说:“抄,但要抄得让掌柜一眼看得懂。”

在事件溯源中,这种“从事件流派生出的查询友好的视图”叫作投影(Projection)。投影消费事件流,按业务需要构造出专门的读模型(Read Model),比如“用户账户汇总”“订单状态表”“本月收入统计”。查询时直接读投影,不用重放所有事件,读性能大幅提升。投影既可以同步订阅事件(事件写入后立即更新),也可以异步批处理(定时批量刷新)。

当投影数据损坏,或者产品需要新增一个查询视角时,可以从头(或从某个快照)重新投影(Rebuild)。由于异步刷新,投影与事件源之间是最终一致性(Eventual Consistency)——事件刚写入时,投影可能还没更新,但经过极短时间后最终一致。用公式表示,投影就是一个把事件流转换为视图的函数:

师傅管这些专门账册叫“掌柜的眼镜”——账没变,但看账的人有了不同的视角。投影让事件流既能写得好,也能读得快。理解投影,就理解了 Event Sourcing 系统中“写一份事实,服务多种查询”的优雅之处。

V=project([E1,E2,…,Et])\displaystyle V = \text{project}\bigl([E_1, E_2, \dots, E_t]\bigr)
  1. V 投影结果一个查询友好的读模型,比如“本月收入汇总表”。
    V\displaystyle V
  2. project 投影函数把事件流转换成一个或多个视图,必要时可由事件流重建。
    project\displaystyle \text{project}
  3. 事件流输入投影的唯一输入源,事件不变则投影可重复生成。
    [E1,E2,…,Et]\displaystyle [E_1, E_2, \dots, E_t]

架构优势

有一年,聚宝斋遭了贼,柜上登记簿被翻得乱七八糟。掌柜慌了,师傅却从流水账里一笔笔算出了所有账目,还顺藤摸瓜找出了账款去向。掌柜问:“这账本怎么这么厉害?” 师傅答:“因为它记的是每一步,而不是最后一眼。” 这正是事件溯源最迷人的地方。

事件溯源的第一个优势是审计追踪(Audit Trail):每一件事都有完整凭证,谁能改、何时改、改了什么,全部有据可查,天然满足金融与合规要求。第二个优势是时点恢复(Point-in-time Recovery):想回到任何一天,只需把事件流重放到那一天。第三个优势是领域建模(Domain Modeling):状态机的每一步转移都有明确事件,特别适合落地领域驱动设计(DDD),聚合根可以把业务规则表达得清晰自然。

在高并发场景下,事件流以追加写为主,写入可以横向扩展,避开“同一行数据多次更新”的锁竞争;重放与投影还可以并行分摊到多台机器。这种读写分离、追加优先的架构,让事件溯源在高吞吐写密集场景中拥有独特的弹性。从成本角度看,全量重放与快照加增量恢复的代价差异可以用复杂度表示:

师傅的流水账之所以值钱,不是因为它写得漂亮,而是因为它“记得住每一个事实”。事件溯源让系统拥有同样的记忆力和抗毁性——这对追求高可用、强审计的架构师来说,是无法拒绝的理由。

Treplay=O(n×c),Tsnapshot=O(s+k×c)\displaystyle T_{\text{replay}} = O(n \times c), \qquad T_{\text{snapshot}} = O(s + k \times c)
  1. T_replay 全量重放耗时与事件总数 n 成正比,n 越大越慢。
    Treplay=O(n×c)\displaystyle T_{\text{replay}} = O(n \times c)
  2. c 单事件应用代价处理一个事件所需的平均时间,比如一次余额累加。
    c\displaystyle c
  3. T_snapshot 快照恢复耗时加载快照耗时 s 加上快照后 k 个事件的重放耗时。
    Tsnapshot=O(s+k×c)\displaystyle T_{\text{snapshot}} = O(s + k \times c)
  4. 对比结论当 k 远小于 n 时,快照恢复显著胜出。
    k≪n\displaystyle k \ll n

挑战与对策

流水账也有麻烦:记账格式变过三次,早年“大洋”和今天的“元”怎么换算?遇到坏账是划掉还是补记?师傅的经验是:格式可以变,但旧账不能废,要有一套“换算表”。学徒把这些换算规则抄成小册子,每次看旧账前都要翻一翻。

事件溯源最大的挑战之一是事件演化(Event Evolution)。业务规则变了,旧事件的结构和新代码不一致。对策包括:事件带版本号,应用层按版本做兼容读取;用 upcast(读取时转换)机制,在新代码读取旧事件时将其升级为新结构;保留历史版本的解析逻辑。最重要的是:绝不能改写历史事件。另一个挑战是最终一致性与运维复杂度:投影有短暂延迟,不适配强实时聚合查询;事件存储需要额外的生命周期管理,包括保留策略、归档、压缩和清理。

团队还需要从“覆盖式思维”切换到“追加式思维”,这是最隐蔽的学习曲线。很多工程师刚接触时,总想把错误事件改掉,这与事件溯源的根基相悖。事件兼容读取可以用一个分段函数表达:

师傅常说:“规矩改了,老账的算法不能丢。” 事件演化管理的核心,就是让系统在演进中始终读得懂历史。能在十年后还说得清每一笔账的来龙去脉,这才是“可溯源”的真正含义。

Et′=upcastv(Et)={Et,v=version(Et)transform(Et),otherwise\displaystyle E'_t = \text{upcast}_v(E_t) = \begin{cases} E_t, & v = \text{version}(E_t) \\ \text{transform}(E_t), & \text{otherwise} \end{cases}
  1. upcast_v 升级函数根据目标版本 v 将旧事件转换为新结构。
    upcastv\displaystyle \text{upcast}_v
  2. E_t 原始事件历史中真实发生过的事件,始终保持不可变。
    Et\displaystyle E_t
  3. 版本匹配判断若事件版本已符合要求,直接使用;否则触发转换。
    v=version(Et)\displaystyle v = \text{version}(E_t)
  4. transform 转换器负责字段映射,比如把“大洋”换算成“元”。
    transform(Et)\displaystyle \text{transform}(E_t)

实战案例

聚宝斋的故事还没完。有一年新来的伙计差点把“应付款”当成“应收款”入账,账目全乱了。师傅靠流水账和汇总页,半天内恢复得一清二楚。这个场景,正是今天很多线上系统的日常——数据出错了,能不能快速找回真相?

以电商订单为例:订单状态从“已创建”到“已支付”到“已发货”,每一步都是一个事件。如果使用覆盖式存储,状态被误更新后很难追责;改用事件溯源后,每次状态迁移都落为事件。客服定位“订单为什么卡住”时,直接回放该订单的事件流即可。金融系统的转账、支付更是如此——交易流水本身就是事件流,审计、对账、冲正都依托事件展开。

在高可用设计上,事件流天然适合多副本复制。主副本接受追加写,从副本同步事件并异步构建投影;主库故障时,从副本加载最近快照并重放事件,即可快速恢复服务。这套以事件溯源为基础的高可用与故障复盘框架,让恢复时间变得可预估、可控。故障恢复时间(RTO,即 Recovery Time Objective)可以粗略估算为:

回到账房,师傅把账本递给新伙计:“记住,这笔账今天写下来,明天、明年、几十年后,都还能说得清。” 事件溯源给系统的,正是这种跨越时间的确定性。无论是金融账务、电商订单还是 AI 日志,只要事实被完整记录,系统就永远有路可退。

RTO=Tload+(Nsince×Tapply)\displaystyle \text{RTO} = T_{\text{load}} + (N_{\text{since}} \times T_{\text{apply}})
  1. T_load 快照加载时间从存储读取最近快照的耗时。
    Tload\displaystyle T_{\text{load}}
  2. N_since 快照后事件数故障发生时,快照之后新增的事件数量。
    Nsince\displaystyle N_{\text{since}}
  3. T_apply 单事件应用耗时重建状态时处理单个事件的平均耗时。
    Tapply\displaystyle T_{\text{apply}}
  4. 总恢复耗时加载快照的时间加上增量重放所有未处理事件的时间。
    RTO=Tload+(Nsince×Tapply)\displaystyle \text{RTO} = T_{\text{load}} + (N_{\text{since}} \times T_{\text{apply}})

AI 应用场景

金融反欺诈:从事件流重建资金网络

阶段:特征工程与数据管道

解决的问题:反欺诈模型需要“某笔资金在 3 分钟内经过多少账户”这类时序证据。传统覆盖式存储只留下最终余额,无法回答“钱刚从哪来”。事件溯源保留下每一笔转账事件,使模型可任意回放时间窗口,重建资金流转图谱,就像账房先生能随时翻出任意一天的流水来还原资金去向。

具体案例:某支付平台把所有转账写成 TransferInitiated、TransferCompleted 事件,风控系统定时投影出“多层转账圈”特征,输入图神经网络识别洗钱团伙,准确率提升约 21%。

推荐系统:用户行为流的投影画像

阶段:数据处理与模型训练

解决的问题:推荐模型依赖用户完整行为序列,但行为流水线经常需要重跑。事件溯源把点击、曝光、播放、购买都存为不可变事件,用户画像变成可随时重建的投影,训练与线上服务共用一致的读模型。

具体案例:某音乐 App 将每次播放、快进、收藏写成事件,每日凌晨从事件流投影出“用户偏好矩阵”,召回模型直接读取。既省去解析多份日志的麻烦,又能在画像异常后一键重建。

LLM Agent 会话审计:重放每一轮对话

阶段:模型服务与合规审计

解决的问题:大模型客服出错时,需要精确重放“用户说了什么、模型答了什么、调用了哪些工具”。传统日志零散易丢,事件溯源把每轮交互建模为 UserMessageReceived、ToolInvoked、ReplyGenerated 等事件,完整记录决策链路。

具体案例:某银行客服 Agent 上线后,一次误答复引发客诉。工程师通过事件流重放该会话,定位到工具返回字段解析错误,并基于事件流补充了回归用例,全程 40 分钟完成复盘。

Python 演示

python76 行
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""Event Sourcing 极简演示: 银行账户流水账"""from dataclasses import dataclassfrom typing import List, Dict  @dataclassclass Event:    kind: str    amount: float    version: int = 1  class Account:    def __init__(self) -> None:        self.events: List[Event] = []     def apply(self, event: Event) -> None:        self.events.append(event)     def _apply_to(self, e: Event, total: float) -> float:        if e.kind == "deposit":            total += e.amount        elif e.kind == "withdraw":            total -= e.amount        return total     def balance(self) -> float:        """重放全部事件, 重建当前余额"""        total = 0.0        for e in self.events:            total = self._apply_to(e, total)        return total     def replay_from_snapshot(self, snapshot_balance: float, start_index: int) -> float:        """从快照余额 + 后续事件重建状态"""        total = snapshot_balance        for e in self.events[start_index:]:            total = self._apply_to(e, total)        return total     def projection_by_kind(self) -> Dict[str, float]:        """投影: 按事件类型汇总, 构建读模型"""        proj: Dict[str, float] = {}        for e in self.events:            proj[e.kind] = proj.get(e.kind, 0.0) + e.amount        return proj  def main() -> None:    events = [        Event("deposit", 100.0),        Event("withdraw", 30.0),        Event("deposit", 200.0),        Event("withdraw", 50.0),        Event("deposit", 50.0),    ]    account = Account()    for e in events:        account.apply(e)     total = account.balance()    print("重放全部事件后余额 =", total)     snapshot_index = 2    snapshot_balance = account.replay_from_snapshot(0.0, 0)    restored = account.replay_from_snapshot(snapshot_balance, snapshot_index)    print("从第", snapshot_index, "个事件后的快照恢复, 余额 =", restored)     proj = account.projection_by_kind()    print("投影(按事件类型汇总) =", proj)  if __name__ == "__main__":    main()
目的:用不到 60 行实现一个事件溯源账户系统,演示事件追加、全量重放、快照恢复与投影四个核心机制。
  1. self.events: List[Event] = []事件存储的本质就是一个只追加的列表,没有删除与修改入口。
  2. account.apply(e)写入事件的唯一方式,每一步都记录事实,而不是覆盖余额。
  3. total = self._apply_to(e, total)这就是重放:把所有事件逐个“应用”到状态上,折叠出当前余额。
  4. snapshot_balance = account.replay_from_snapshot(0.0, 0)先生成快照基准,模拟账房先生的“小汇总页”。
  5. proj[e.kind] = proj.get(e.kind, 0.0) + e.amount投影:从事件流派生按类型汇总的读模型,查询时无需重放。

常见误区

误区我觉得事件溯源就是多打日志,把每次操作 print 出来保存,以后排查问题还能翻出来看看,和 log 没本质区别。

正解日志是给排查用的辅助信息,事件流是业务的事实源。事件是结构化、不可变、可回放的领域事实,有类型、载荷、版本约束;而普通日志格式随意、可能截断、不保证顺序和完整性。就像流水账是正式账册,伙计随手写的便条只是便条,两者地位完全不同。

为何常见:二者都有“追加写”的外形,非常容易混淆,很多入门文章也喜欢拿日志打比方。

误区发现某个事件金额写错了,直接 UPDATE 改掉或者 DELETE 删除,让账目“看起来正确”不就行了?

正解事件不可变是根基,修改历史等于伪造事实。要修复错误,应新增“补偿事件”,比如记一笔冲正。新事件进入事件流尾部,保留因果与审计链。否则已有的投影、下游订阅、合规审计全部错乱,系统的可信度瞬间归零。

为何常见:CRUD 的覆盖写心智根深蒂固,习惯“改了就是对了”,很难接受“错了也要保留错误痕迹”。

误区为了查询快,我把对象的完整最新状态也放到事件里,每条事件都带一份全量快照,这样重放一次就能拿结果。

正解事件表达“发生了什么变化”,而不是“结果是什么”。若把最新状态塞进事件,就丢失了每次变化的因果链,无法回答“这一刻到底发生了什么”。结果应由重放推导;全量状态应放到快照或投影中,不该占用事件语义。

为何常见:“事件”和“状态”两个词概念重叠,直觉上容易把事件当成“保存状态的另一种格式”。

误区每次查询都要把所有事件重放一遍,事件多了肯定卡死,生产环境根本没法用,性能不如传统数据库。

正解写路径是顺序追加,吞吐往往很高;读路径用快照、投影、索引优化,重放不必全量。未加优化就全量重放当然慢,但那是工程方案问题,不是模式缺陷。账房先生也不会每次对账都从建店第一天翻起,他手里有“小汇总页”。

为何常见:只用最朴素的全量重放来估算性能,忽略了快照与投影这两大优化手段。

误区听教程说事件溯源通常和 CQRS 一起用,于是我决定先买一套 Event Store 中间件,把 CQRS 拆好再动手。

正解ES 是建模与存储策略,CQRS 是读写分离的架构风格,二者可以独立演进。初期用一张追加表加消息订阅也能跑通 ES;是否引入专用 Event Store 产品取决于规模与团队能力。先理解事实源,再谈框架。

为何常见:教程常把 ES、CQRS、Event Store 捆绑讲解,造成“缺一不可”的错觉。

面试题

Q1. Event Sourcing 的核心心法是什么?

单选题 · easy

  • A.定期备份业务数据
  • B.用事件序列表达状态变迁← 正确答案
  • C.仅把最终状态存入内存
  • D.依赖分布式事务保证

答案:B。事件溯源的核心心法是用不可变事件序列表达状态变迁,就像账房先生只记流水不动余额。定期备份是快照思路,只存最终状态是 CRUD 思路,分布式事务又是另一个领域,都不是事件溯源的出发点。

Q2. 关于事件的不可变性,下列哪项正确?

单选题 · easy

  • A.出错时允许删除事件
  • B.事件可以覆盖和修改
  • C.事件只能追加且永不修改← 正确答案
  • D.事件需要定期压缩清理

答案:C。事件一旦写入便是历史事实,只能追加、不能修改删除。删除事件好比撕掉账页,会让后续重放和审计失真;“定期压缩”可以淘汰旧快照,但不能动事件本身。

Q3. 快照的价值体现在哪些方面?(多选)

多选题 · mid

  • A.减少事件重放的工作量← 正确答案
  • B.加速系统状态的快速恢复← 正确答案
  • C.取代事件流成为唯一事实源
  • D.丢弃历史事件与审计信息

答案:AB。快照是“小汇总页”,作用是减少重放量、加速恢复;它只是中间产物,不能取代事件流这一事实源,也不能借快照丢弃审计所需的历史事件。

Q4. 关于“命令”与“事件”的关系,正确的是?

单选题 · mid

  • A.命令是意图,事件是事实← 正确答案
  • B.命令与事件完全相同
  • C.事件先于命令产生
  • D.命令用于查询读模型

答案:A。命令代表“想要发生的事”,会被校验拒绝;事件代表“已经发生的事”。就像伙计喊“记一笔”是命令,师傅落笔才是事实;命令不可能先于事件产生,也不用于查询读模型。

Q5. 投影(Projection)主要负责什么?

单选题 · easy

  • A.持久化原始事件
  • B.生成查询友好的读模型← 正确答案
  • C.修改历史事件内容
  • D.负责执行命令处理

答案:B。投影从事件流派生查询友好的读模型,相当于从流水账整理出利润表。保存原始事件是事件存储的工作,投影不修改历史,也不处理命令,职责非常单一。

Q6. 事件存储设计应关注哪些方面?(多选)

多选题 · mid

  • A.序列化格式的兼容演进← 正确答案
  • B.事件类型的版本化管理← 正确答案
  • C.把最终状态冗余进事件
  • D.支持追加式高并发写入← 正确答案

答案:ABD。事件存储要保证新老代码都能读旧事件,所以序列化兼容和版本管理必须设计好;事件本质是 append-only,需支持高并发追加写。把最终状态冗余进事件会掩盖事件语义,是典型的不良设计。

Q7. 从快照 S_k 重建状态,时间开销近似为?

单选题 · hard

  • A.重放全部历史事件
  • B.加载快照加重放后续事件← 正确答案
  • C.快照数量乘总事件数
  • D.仅需从磁盘加载快照

答案:B。从快照恢复时,只需要读入快照状态再重放 k 之后的少量事件,时间约为“加载快照 + 后续事件数 × 单事件耗时”。这正是快照优化的核心价值:不用重放全部历史。

Q8. 以下哪种场景最不适合使用事件溯源?

单选题 · mid

  • A.需要完整审计轨迹的场景
  • B.领域模型复杂的业务场景
  • C.需要强实时聚合且极少审计← 正确答案
  • D.需要高并发消息回放的场景

答案:C。事件溯源天然适合审计、复杂领域建模、消息回放;但如果业务要求极强实时聚合、几乎不需要审计,覆盖式存储加投影反而更简单,事件溯源的重放成本与最终一致性会成为负担。

Q9. 应对事件演化(Event Evolution)的合理策略有哪些?(多选)

多选题 · hard

  • A.提升事件版本并兼容读取← 正确答案
  • B.通过 upcast 实现读取时转换← 正确答案
  • C.直接改写已发生的旧事件
  • D.记录 schema 迁移脚本← 正确答案

答案:ABD。事件演化讲究“旧账不能废,算法不能丢”:用版本号标记、在读取时 upcast 转换、准备迁移脚本都是正路;改写历史事件会破坏事件流的时间线一致性与审计价值。

Q10. 事件溯源与 CQRS 的关系是?

单选题 · mid

  • A.必须与应用强绑定使用
  • B.可独立使用且常搭配← 正确答案
  • C.两者完全相互排斥
  • D.CQRS 可替代事件溯源

答案:B。事件溯源是写入侧的建模方式,CQRS 是读写分离的架构风格,二者经常搭配但可以独立演进。它们既不是绑定关系,也不互相排斥或替代,选型应看业务需要。