6.2 事件溯源:以事实为真相


6.2 事件溯源:以事实为真相

本节摘要:事件溯源(Event Sourcing)把聚合的真相从"当前状态"换成"发生过的事件流":状态不再是被存储的东西,而是事件回放算出来的东西。本节从财务审计那个"说不出金额来路"的问题讲起,给出事件存储与回放的完整代码,交代快照与事件版本演进两项配套技术,最后把代价清单摊开——事件溯源是一笔重投资,不是炫技选项。

状态是压缩,压缩不可逆

财务负责人的难题值得先翻译成技术语言。审计问的是:"315 那笔订单,实付金额 245 元,这个数是怎么来的?"状态型数据库只能回答"当前 payable 字段是 245"——至于它是从小计 295 减了券 50、还是从小计 300 减了满减 55,数据库不记得了。当前状态是对历史的有损压缩:每次 save 都用新值覆盖旧值,过程信息在覆盖中蒸发。日常业务不需要过程,所以平时无感;审计、对账、争议仲裁、问题复盘要的全是过程。

事件溯源的反转就在这里:不存压缩结果,存压缩前的原始事实。下单、用券、支付、退款,每件事一条事件记录,只追加、永不更新;需要状态时,把事件从头回放一遍算出来。状态从"被存储的真相"降级为"事件流的缓存"——丢得起、可重建,事件流才是唯一真相。这就是它与"记个操作日志"的本质区别:日志是旁路备忘,改状态、记日志是两个动作,可能漏记、可能不同步;事件溯源里两者是同一个动作——改状态这件事本身,就是追加一条事件,3.3 的"变更与事实同源"在这里走到了尽头:不是同源,是一体。

存与放:核心代码

// 事件存储:只追加,不更新,每流内序号严格递增 public class EventStore { public long append(EventStreamId streamId, long expectedVersion, List<DomainEvent> newEvents) { long current = dao.headVersion(streamId); // 乐观锁:预期版本不符即拒绝 if (current != expectedVersion) { throw new ConcurrencyException(streamId, expectedVersion, current); } long version = current; for (DomainEvent e : newEvents) { dao.insert(streamId, ++version, e); // 事实一旦写入,永远不改 } return version; } public List<DomainEvent> read(EventStreamId streamId) { return dao.readAllInOrder(streamId); // 按序号读全流 } } // 聚合重建:状态 = 事件的折叠 public class OrderRebuilder { public TradeOrder rebuild(EventStreamId id) { TradeOrder order = TradeOrder.empty(id); // 空白聚合:不产事件 for (DomainEvent e : store.read(id)) { order.apply(e); // 每条事实推进一步状态 } return order; } } // 聚合侧:业务方法产出事件,apply 折叠事件——两条路必须共用同一套状态推进 public class TradeOrder { public void pay(PaymentReceipt receipt) { // 写路径:判断后产出事实 if (status != TradeStatus.WAIT_PAYMENT) throw new IllegalStateException(); raise(new OrderPaid(id.toString(), payable(), receipt.paidAt())); } void apply(OrderPaid e) { // 读路径:事实直接推进状态 this.status = TradeStatus.PAID; this.paidAt = e.paidAt(); } }

payapply 的分工是事件溯源的心脏:业务方法负责"判断 + 产出事实",apply 方法负责"无条件接受事实"。业务校验只出现在前者——事件是已发生的事,回放时不容许再被拒绝。两条路推进的是同一套字段,分叉就意味着"能写下的事实回放不出来"或"回放出的状态和当初不一样",那是最难查的一类事故。

事件流与状态重建

事件流与状态重建

两项配套:快照与版本演进

快照解决回放成本。流越长回放越慢,于是每隔 N 条事件存一次状态快照,重建从最近快照起步、只追后续事件(图中 v3 的角色)。快照是纯缓存,丢失即重放,不承载真相——给快照加业务含义是常见歧途,它会变成第二套真相。

事件版本演进解决"事件自己也会过时"。规则改了,老事件还是老语义,直接回放会用今天的逻辑误读昨天的事实。工程惯例是给事件带版本号,读取时经升格器(Upcaster)把老格式翻译成当前格式——翻译的是格式与语义,不改写事实本身;实在无法翻译的老事件,保留原样并让 apply 分版本处理。这件事必须在第一个事件写入前就想清楚,因为事件流不可变,补课成本极高。

代价清单与适用判断

投资前的账本要摊开。成本一:全套基础设施——事件存储、升格器、快照、投影(6.1 的读侧在这里从可选项变成必需品,因为事件流没法直接按页面查询)。成本二:事件设计有洁癖要求——事件是永久档案,命名、字段、粒度的每个失误都会流传后世;改一个字段名要升格器护驾。成本三:认知成本——团队要建立"事实不可变"的思维,调试、测试、排障的肌肉记忆都要重练。成本四:敏感信息进档——事件流里的手机号、地址将永久存在,脱敏只能靠加密或视图层过滤,存储层永远洗不掉。

适用判断一条主判据、两条辅判据。主判据:过程本身是业务资产吗? 财务凭证、保单批单、交易流水——过程就是产品的一部分,值得。辅判据一:需要时间旅行能力(按任意历史时点复算状态)。辅判据二:写侧需要自然的事件发布语义,事件溯源让领域事件的持久化自动完成。三条都不沾的聚合——比如购物车、地址簿——用常规状态存储就好,事件溯源伺候的是"以过程为价值"的聚合,不是所有聚合。青柚商城最终只给支付凭证与退款两个聚合上了事件溯源,订单主聚合保持状态型——混搭不是妥协,是判据的胜利。

带走的判断

  • 当前状态是有损压缩,审计与争议要的是过程;事件溯源把真相换成事件流,状态降级为可重建的缓存。
  • 业务方法产事实、apply 折叠事实,校验只在前者;两路推进同一套字段,分叉即事故。
  • 快照是缓存不是第二真相;事件版本演进要在写第一个事件前设计,老事实只翻译格式不改写内容。
  • 主判据是"过程是否业务资产"——事件溯源重投资,伺候少数值得的聚合,混搭是正解。

真相问题解决,第四块骨头最磨人:那四百万迁不完的存量单。6.3 绞杀者与防腐层。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U