暗无天日

=============>DarkSun的个人博客

读:Event Sourcing——让你的数据库记住每一次变更

CRUD 的问题:数据库会"失忆"

Chris May 在 Talk Python To Me 播客第 548 期 里举了一个例子:假设一个购物车,用户加了三件商品,又退掉一件,最后结账。在传统的 CRUD(Create-Read-Update-Delete)模式下,数据库里最终只剩一行数据:购物车 ID、用户 ID、状态 = 已结账。

两个用户都买了三件商品,结账后他们的数据库行一模一样。谁先加的哪件、谁中途改过主意、谁的购物路径更曲折,这些信息 CRUD 不会主动保留。除非你额外建一张历史表,否则数据库只记得"现在是什么样",不记得"怎么变成这样的"。

这对多数应用没问题。但在需要审计追踪、需要回答"过去发生了什么"、或者需要从历史数据中挖掘新洞察的场景里,CRUD 的这种"失忆"就成了硬伤。金融交易、合规审计、用户行为分析,这些场景都需要完整的历史记录。

Event Sourcing 的核心:不存状态,存事件

Event Sourcing(事件溯源)的思路是:数据库只记录每次状态变更的事件,不保存当前状态。

购物车的例子换成 Event Sourcing:

事件1: ShoppingCartCreated(cart_id=1, user_id=42)
事件2: ItemAdded(cart_id=1, item="键盘", price=299)
事件3: ItemAdded(cart_id=1, item="鼠标", price=99)
事件4: ItemRemoved(cart_id=1, item="鼠标", price=99)
事件5: CheckoutCompleted(cart_id=1, total=299)

当前状态(购物车里有一把键盘,已结账,总价 299)靠重放这些事件推导出来,不单独存储。就像 Git 不存文件的最终状态,存的是一系列 diff(变更记录),靠 base + diffs 重建出当前文件。

数据库变成了一个只追加(append-only)的事件日志,每条记录都是不可变的。没有 UPDATE,没有 DELETE,只有 INSERT。如果你对 Clojure 的不可变数据结构有所了解,这里是同一个思路:数据不可变,"修改"只是产生新版本,所有历史都保留。只不过 Event Sourcing 把这种思路从内存中的数据结构延伸到了持久化层。这带来了几个实际变化:

  • 完整的审计追踪 :每个状态变化都有记录,不需要额外建一张"历史表"
  • 时间旅行 :可以回溯到任意时间点的系统状态
  • 事后分析 :可以回答当初没想到要问的问题

播客里 Chris May 举了个实际的例子:上线三天后,另一个团队需要从他的服务拉数据到 BigQuery 做分析。他不需要额外开发数据导出功能,因为所有历史事件都在,直接回灌了几个月的数据。对方的反应是"震惊",因为他们以为只会拿到三天的数据。

你的表里有没有"状态"字段?

如果你的表里有一个 status 列,说明同一行数据会在不同状态间切换,每个状态可能有不同的业务规则。比如订单只能从"待付款"变成"已付款",不能直接跳到"已完成"。这些"谁能变成谁"的约束就是状态转换规则。纯 CRUD 不管这些,你必须在业务代码里自己判断,而 Event Sourcing 天然为状态转换建模。

以下场景适合 Event Sourcing:

  1. 需要审计追踪 :金融交易、医疗记录、法律合规(PCI、HIPAA、GDPR),要求每一步操作都有据可查
  2. 需要从历史数据中发现新洞察 :业务方可能提出你当初没想到的问题
  3. 状态转换有复杂规则 :订单从"待付款"到"已付款"到"已发货"到"已完成",每个状态允许的操作不同

什么场景 不该 用:

  • 简单的表单应用 :一个联系人管理表、一个后台配置表,没有复杂的状态转换,CRUD 完全够用
  • 存储成本敏感 :事件日志比单行数据占用更多空间。不过 Chris 的观点是,存储是现代基础设施中最便宜的组件

Chris 还强调了一点:可以一点点引入。同一个项目里,有些模块用 Event Sourcing,其他模块继续 CRUD,两套模式可以共存。

性能:重放事件会不会太慢?

这是每个人第一次听说 Event Sourcing 时的第一反应。Chris 的导师 Martin Dilger 的回答是:"计算机很快。"

实际数据:事件流在约 2000 条以内,直接重放推导状态只需要毫秒级。大多数业务对象(一个购物车、一个订单、一个账户)的生命周期内不会产生那么多事件。

当事件流确实很长时,解决方案是 CQRS (Command Query Responsibility Segregation,命令查询职责分离):"命令"是会改变数据的操作(下订单、加商品),"查询"是只读数据的操作(查看购物车、生成报表)。两边走不同的路径后各自优化,不用互相将就。

写入路径: Command → Aggregate(业务对象) → Events → Event Store
读取路径: Event Store → Projector → Read Model → Query

Read Model(读模型)是一个独立的、预先计算好的视图。它订阅事件流,每次有新事件进来就增量更新自己。查询时直接读 Read Model,不需要重放全部事件。

这跟数据库的物化视图思路类似:用空间换时间。Read Model 可以是关系型数据库的一张表、Redis 里的缓存、或者 MongoDB 里的一个文档。

Chris 还提到一种借鉴会计学的技巧叫 Closing the Books (结账):在一个自然的业务边界(比如月末、年末)生成一个"摘要事件",然后开始一个新的事件流。重放时只需要从摘要事件开始,不用从第一天算起。这解决了"事件日志会不会无限增长"的担忧。

事件版本:Schema 怎么变?

传统数据库改字段,跑一个 ALTER TABLE 就行。Event Sourcing 不能这么做,因为历史事件已经写死了。

Chris 描述了三种策略:

  1. 只加字段,给默认值 :新版本的事件多了一个字段,旧事件读取时用默认值填充。大多数消费者忽略不认识的新字段,少数需要用到的按需处理
  2. Upcaster(升级器) :写一个小函数,把旧版事件在读的时候转成新版格式。这个函数只放在需要升级的那个功能模块里,不是全局的
  3. 全量拷贝重写 :把整个事件存储拷贝一份,转换过程中逐条改写事件,写到一个新的存储里。需要停写、耗时可能很长、任何转换错误都会污染整个新存储,所以只在没有其他办法时才用。Greg Young(Event Sourcing 的主要推广者)专门为此写了一本书

策略一够用时别用策略二,策略二够用时别用策略三。

审计、调试、合规中的意外收获

Chris 在播客中反复提到一个感受:"能精确回答过去的问题,让人上瘾。"

几个具体场景:

  • 调试 :一个字段莫名其妙是 null,CRUD 模式下只能猜"可能是某次更新清掉了"。Event Sourcing 模式下,找到产生那个 null 值的具体事件,看到当时的完整上下文
  • 合规 :PCI、HIPAA、GDPR 都要求审计追踪。传统做法是建一张"历史表",但 Chris 的观察是:"如果你从不测试备份策略,你就等于没有备份。"历史表也一样,团队经常忘记写,或者写了但没人验证。Event Sourcing 的事件日志天然就是审计日志,不需要额外维护
  • 按需生成新报表 :老板要一个过去 30 天的某项统计,写一个新的事件消费者,跑一遍历史事件,30 天的数据瞬间生成

这些好处在金融行业尤其明显。银行核心系统的记账方式天然就是 Event Sourcing 的存储模式:每笔交易(转账、存款、取款)一旦入账就不能修改或删除,只能做一笔冲正交易;账户余额不是直接存一个数字,而是所有交易记录的汇总结果。这其实就是复式记账法几百年来一直在做的事。Event Sourcing 把核心系统一直在用的思路搬到了整个应用架构里。也因为写操作只往事件存储追加一条记录,天然避免了 "写数据库成功但写消息队列失败"的不一致问题(双写问题)。

架构 : Event Sourcing : CQRS : 分布式系统 : 不可变性