基于事件溯源(event sourcing)的业务架构范式
问题描述
📔 Data change VS business event
在笔者就职的部门的现有系统中,一个常见的模式是让下游直接消费“解析后的MySQL 表的 binlog”——也就是数据库行级变更的 before / after 镜像——并把它当成“业务消息”来使用。
这种做法在原型阶段确实省事:binlog 已经包含了字段差异,无需额外开发,任何一次 UPDATE、DELETE 都能自动生成“事件”。
然而,把数据变更(Data change)当成业务事件(Business event)来驱动整个链路,本质上是用“存储层实现细节”替代“业务流程核心语义”,不仅丢失了上下文信息(从而影响业务决策),也使得上下游之间存在了更强的耦合。从架构设计的角度看,是一种反模式,它存在语义缺失的问题,并在长期看增大了系统模块间的耦合程度,容易造成隐患。通过下表,我们可以对比数据变更和业务事件的区别:
| 维度 | 数据变更(Binlog) | 业务事件(Domain Event) |
|---|---|---|
| 核心意图 | 描述“哪一行、哪一列被改成了什么” | 描述“业务上发生了什么事” |
| 携带的语义 | 仅有字段前后值,无业务上下文,不清楚业务意图 | 明确“是谁出于什么目的触发了什么动作” |
| 稳定性 | 随表结构、字段命名、分库分表策略而变 | 以业务语言命名,模型演进时保持向后兼容 |
| 下游耦合 | 任何字段改名/拆表/数据结构变更需要导致下游 N 个服务升级,导致基本改不动了 | 事件 schema 演进独立,下游按需订阅 |
为了降低模块间的耦合程度、在交互时保留业务意图,应该使用业务消息,而避免使用数据变更替代业务事件(业务消息)。
🩺 失血模型(Anemic Model)大量运用,状态变更缺乏业务规则保护
在大部分我见过的中国互联网公司的系统中,使用的都是失血模型。其典型特征是,领域对象(repo层的struct)没有任何业务逻辑和行为约束,只是数据的容器,所有业务逻辑都在Service层或其他业务处理层中实现。在这种模式下,状态流转逻辑很分散,通过repo层的UpdateXXX函数修改数据库或通过刷数脚本刷数据库,很容易破坏业务完整性。同时,状态转换逻辑分散在各处,没有一个统一的地方维护状态机。这会导致如下问题:
- 业务逻辑分散,缺乏封装
- 逻辑一致性难以保证:由于逻辑分散,不同地方对同一业务规则的理解和实现可能存在差异,导致系统行为不一致
- 业务领域知识流失:业务规则没有在领域对象中得到体现,领域知识无法在代码中得到清晰有效的表达
- 状态变更缺乏业务约束
- 状态转换条件检查缺失:系统无法防止不合理的状态转换,例如,跳过了某个业务流程,直接将一个状态强行改成另一个状态,导致业务流程的完整性被破坏
- 业务不变量被破坏:缺乏对业务不变量的保护,例如授权结束时间不能早于开始时间,但这种约束没有在对象层面得到保证
- 刷数操作绕过业务逻辑
- 在失血模型架构下,运维人员经常通过直接的SQL脚本或者刷数工具来修改数据库状态,这种操作完全绕过了应用层的业务逻辑。
- 业务完整性被破坏:刷数操作可能导致数据状态不一致,例如直接修改歌曲状态但没有触发相应的下游通知
- 审计追踪困难:直接的数据库操作缺乏完整的审计日志,难以追溯变更的原因和责任人
- 业务规则被绕过:刷数操作无法执行复杂的业务规则检查,可能产生在正常业务流程中不可能出现的数据状态
正确的解决方案是使用领域驱动设计,通过聚合根封装业务行为和规则。所有状态变更必须通过聚合根的业务方法进行。
type SongAggregate struct {
// 聚合根封装状态和行为
id SongID
status SongStatus
// ... 其他属性
}
// 状态转换方法封装业务规则
func (s *SongAggregate) Approve(reviewer UserID) error {
if !s.status.CanTransitionTo(SongStatusApproved) {
return NewInvalidStateTransitionError(s.status, SongStatusApproved)
}
s.status = SongStatusApproved
s.recordEvent(SongApprovedEvent{...})
return nil
}
DDD的创始人Eric Evans在《领域驱动设计》一书中明确批评了失血模型(Anemic Domain Model),他认为:
"失血领域模型是反模式的,它违背了面向对象设计的基本思想"
Martin Fowler也专门写过文章《Anemic Domain Model》来批评这种做法,认为这样的对象"看起来像对象,但实际上只是数据结构"。
🔁 数据变更频繁,缺乏历史溯源能力
大量的数据和状态变更行为,通常仅在DB中维护了最新的状态,而没有记录历史的变更事件和状态。这意味着:
- 数据变更黑盒化:当发现数据库中某个字段的值不符合预期时,无法追溯这个值是何时、由哪个服务、因为什么原因被修改的
- 故障排查困难:运营人员和开发人员经常需要依赖历史发生的事件来排查问题,但缺乏完整的变更历史,导致需要编写复杂的Hive查询来尝试反推数据变更过程
- 审计合规风险:对于版权授权、结算等敏感业务场景,缺乏完整的操作审计链路,在遇到关键外部投诉/诉讼时,难以拿出有效的证据
- 无法基于事件流做细粒度数据分析:由于丢失了历史发生的事件,我们很难去细粒度分析用户行为路径
典型场景举例:
- 版权授权变更追溯:当版权方投诉某首歌曲的授权状态不正确时,我们无法查询这首歌的授权在历史上发生过哪些变更、是谁在什么时间点修改了授权范围
- 结算数据异常排查:当发现某首歌曲的结算金额异常时,无法追溯歌曲的版权状态、汇报移除标记、分成比例等关键字段的历史变更轨迹
- 盗版状态变更溯源:当歌曲的盗版状态出现异常时,无法确定是人工审核、机器审核还是确权流程导致的状态变更
- 细粒度用户行为分析:历史的不同时间段内,版权方完成确权的速度是变快了还是变慢了,投诉案件处理效率是变快了还是变慢了,整体时效是否符合业务目标,哪些环节还有提升空间
传统CRUD模式下,我们只关注了数据的最终状态,而忽略了状态变更的过程和原因。这种设计在系统规模较小、业务逻辑简单时问题不明显,但随着业务复杂度的增加和对数据一致性要求的提高,这些问题逐渐暴露并影响系统的可维护性和业务的可靠性。
针对以上问题,结合业界的先进实践,本文将介绍一种架构范式,以极大提升系统的审计、追溯和重建能力。
事件溯源模式简介
事件溯源是一个架构范式。它指的是一种保存系统状态的方法,不是像传统数据库那样存储当前状态(Current State),而是记录所有导致该状态的事件(Events)。而事件的集合可以根据业务逻辑,确定性的构建出来一个投影,即包含了状态的、可被用于查询的表。https://docs.aws.amazon.com/zh_cn/prescriptive-guidance/latest/cloud-design-patterns/event-sourcing.html
简单来说,我们不再通过对数据库表进行CRUD来维护系统的状态,而是通过在EventStore中维护这个表对应的业务实体在历史上发生的所有事件,来推导这个实体当前应该是什么状态。
在单数据中心的模式下,典型的事件溯源架构如下:
其中,
- EventStore是一个append-only的存储,只增不删。
- 所有的业务事件会存储在EventStore中,作为变更的历史用于随时进行回放、重建等操作。
- 增量事件产生时,会通过事件总线发出消息,供下游消费者监听,以驱动业务流程的流转。
- 聚合根中维护了业务约束,来保证聚合根内的一致性
- 业务的MySQL表不再是系统中的source of truth,而仅仅看作是一个数据视图。在事件溯源架构中,这被称为投影。这意味着,我们可以随时依据历史事件来重建/修复读模型,或是依据不同的查询意图构建出完全不一样的读模型
事件溯源模式如何解决上述痛点
以业务事件为核心对需求进行建模
事件溯源模式通过将业务事件作为系统的第一等公民,从根本上解决了数据变更(Data change)与业务事件(Business event)混淆的问题。核心解决方案:
- 业务语义保留:事件溯源强制使用业务领域语言来命名事件,如
SongApprovedEvent、LicenseUpdatedEvent、CopyrightClaimSubmittedEvent,而不是使用技术层面的数据变更记录。这确保了每个事件都承载明确的业务含义。 - 上下文信息完整性:每个业务事件都包含完整的上下文信息,包括:
- 事件发生的原因(why)
- 执行操作的用户(who)
- 操作发生的时间(when)
- 相关的业务数据(what)
- 例如:
{
"eventType": "SongLicenseTerminatedEvent",
"aggregateId": "song-12345",
"timestamp": "2024-01-15T10:30:00Z",
"causedBy": "copyright_holder_request",
"operatorId": "user-789",
"data": {
"licenseId": "license-456",
"terminationReason": "权利人主动下架",
"effectiveDate": "2024-01-16T00:00:00Z"
}
}
- 解耦上下游系统:下游系统订阅业务事件而非数据库变更,实现了真正的业务解耦。当歌曲授权状态发生变化时,版权治理系统接收到的是
LicenseStatusChangedEvent而不是license表的UPDATE操作,这样下游系统能够理解变更的业务含义并做出相应的业务决策。 - 业务流程可见性:通过事件流可以清晰地看到业务流程的执行路径,例如:
SongUploadedEvent → CopyrightCheckInitiatedEvent → ManualReviewRequiredEvent → SongApprovedEvent → LicenseActivatedEvent使用充血模型,实现状态变更与业务规则的紧密贴合
事件溯源模式天然适配充血模型(Rich Domain Model),通过聚合根封装业务行为和规则,彻底解决失血模型带来的业务逻辑分散和状态变更缺乏约束的问题。核心解决方案:
- 聚合根封装业务规则:所有状态变更必须通过聚合根的业务方法进行,每个方法都内置了完整的业务规则检查:
- 业务不变量保护:聚合根确保业务不变量始终得到维护,例如:
- 授权结束时间不能早于开始时间
- 已下架的歌曲不能被直接设为上架状态
- 版权争议期间的歌曲不能修改分成比例
- 防止数据库直接操作:在事件溯源架构中,业务数据表(投影)变成只读的查询视图,所有写操作都必须通过发送命令到聚合根来完成。这从架构层面杜绝了绕过业务逻辑的数据库直接操作:
type SongAggregate struct {
aggregate.Root
ID SongID
Status SongStatus
LicenseInfo LicenseInfo
// ... 其他业务属性
}
// 审核通过方法,封装了完整的业务规则
func (s *SongAggregate) Approve(reviewer UserID, comments string) error {
// 前置条件检查
if !s.Status.CanTransitionTo(SongStatusApproved) {
return NewInvalidStateTransitionError(s.Status, SongStatusApproved)
}
if !s.LicenseInfo.IsValid() {
return NewLicenseValidationError("歌曲授权信息不完整")
}
// 执行状态变更
s.Status = SongStatusApproved
// 产生业务事件
s.RecordEvent(SongApprovedEvent{
SongID: s.ID,
Reviewer: reviewer,
Comments: comments,
Timestamp: time.Now(),
})
return nil
}
// 错误的做法(已被架构禁止)
// db.Exec("UPDATE songs SET status = 'approved' WHERE id = ?", songID)
// 正确的做法
cmd := ApproveSongCommand{
SongID: songID,
Reviewer: currentUser.ID,
Comments: "符合平台规范",
}
err := commandBus.Send(cmd)
- 状态机统一管理:聚合根内部维护清晰的状态转换逻辑,所有可能的状态变更路径都在一个地方定义和管理:
type SongStatus int
const (
SongStatusPending SongStatus = iota
SongStatusUnderReview
SongStatusApproved
SongStatusRejected
SongStatusTakenDown
)
func (s SongStatus) CanTransitionTo(target SongStatus) bool {
transitions := map[SongStatus][]SongStatus{
SongStatusPending: {SongStatusUnderReview},
SongStatusUnderReview: {SongStatusApproved, SongStatusRejected},
SongStatusApproved: {SongStatusTakenDown},
SongStatusRejected: {SongStatusUnderReview},
SongStatusTakenDown: {SongStatusApproved},
}
allowedTargets := transitions[s]
for _, allowed := range allowedTargets {
if allowed == target {
return true
}
}
return false
}
- 业务知识可见性:通过充血模型,业务规则直接体现在代码中,新团队成员可以通过阅读聚合根的方法来理解业务逻辑,而不需要在各个Service类中寻找分散的业务规则。
这种设计确保了所有状态变更都受到业务规则保护,同时提供了完整的审计轨迹,从根本上解决了失血模型带来的业务完整性问题。
赋予历史状态溯源的能力
事件溯源架构中,天然存储了历史上发生的所有历史事件。因此我们可以在线上生产环境中,轻松查询一个实体的历史状态——通过回放EventStore中的事件流,你可以轻松查看这个实体的任意一个历史版本,并了解状态的变更是被什么事件触发。我们可以通过这个表格来对比传统CRUD和事件溯源:
| 传统CRUD | 事件溯源 | |
|---|---|---|
| 完整的历史记录(审计追踪) | 只保留最新状态,除非手动加审计字段或日志系统。不知道每次变更的原因。 | 每一个状态变化都有事件记录,等于有了“系统的时间机器”。可以回答:“这个数据在去年是什么状态?”、“是谁在什么时候修改了什么?” |
| 数据一致性与可追溯性 | 数据表中的当前状态即为所有信息,刷数、意外编辑,都可能导致出现脏数据、不一致。数据可能越修越错。 | 状态完全由事件推导而来,没有人为写入不一致的脏数据。可以轻松重建当前状态或方便地回滚到某个历史版本。 |
| 排查问题方便程度 | 运营和研发经常需要依赖历史发生的事件来排查问题,没有完整的历史事件流会导致需要临时写 Hive 查询来查询一个实体的历史状态,排查成本大幅增加,占用人力。 | 直接查询一个聚合根 ID 底下的所有历史事件,省时省力。 |
| 基于历史状态的下游应用 | 部分下游应用场景需要使用实体在“上一个版本”的状态,以及变更的原因,用来执行业务动作。传统 CRUD 无法提供这些数据。例如,盗版治理系统中希望获取版权方执行主动下架的时间,但授权侧只提供了当前状态,并不知道版权方何时执行了主动下架。如果在在线系统中很难依赖 Hive 去计算,并且依赖 Hive 去计算所得到的结果并不是“事实”,而是一种脆弱的“反推结果”。 | 通过获取原始事件流,方便地获取历史变更时间和原因等过程信息。更进一步,可以分析用户行为路径、操作频率等,因为原始事件数据都还在,比静态数据更能提供过程信息。 |
事件溯源在同行中的应用
- Netflix:Netflix 是事件溯源和事件驱动架构的早期且重要的实践者。他们的博客中有多篇文章详细介绍了如何在海量用户和数据下应用事件溯源。 https://netflixtechblog.com/scaling-event-sourcing-for-netflix-downloads-episode-2-ce1b54d46eec
- Amazon:AWS 作为云计算平台,经常发布关于如何在 AWS 服务上实现事件溯源的最佳实践和案例。
- PayPal 在其支付处理系统中使用事件溯源来维护所有交易、余额和账户变更的完整历史记录。
- Amazon 在其零售和电子商务系统中使用事件溯源来维护所有订单和客户互动的完整历史记录。
- Uber 在其交通平台上使用事件溯源来管理和跟踪乘车请求、司机分配和行程历史。
在继续之前,我们先了解几个事件溯源中的常见概念:
事件溯源中的常见概念介绍
- 事件(Event)
- 定义: 事件是系统中发生过的、已经完成的事实的记录。它们是过去发生的、不可改变的(immutable)领域事实。例如,“订单已创建”、“商品库存已更新”、“用户已注册”等。
- 特性:
- 不可变性: 事件一旦发生并记录,就不能被修改或删除。这是事件溯源的基石。
- 历史性: 事件记录了某个时间点上发生的事情。
- 领域驱动: 事件通常以领域语言命名,反映业务行为。
- 原子性: 每个事件都是一个独立的、完整的业务事实。
- 示例:
OrderCreatedEvent(订单创建事件)ProductStockUpdatedEvent(商品库存更新事件)UserRegisteredEvent(用户注册事件)
- 事件存储(Event Store)
- 定义: 事件存储是持久化事件的数据库或存储机制。它不仅仅是一个简单的数据库,更像是一个事件日志(event log),负责按顺序存储事件,并提供查询事件的能力。
- 特性:
- 追加写入(Append-only): 事件只能被追加到事件流的末尾,不能被修改或删除。
- 顺序保证: 事件通常按照它们发生的顺序存储,以便正确地重放。
- 事件流(Event Stream): 每个聚合(或实体)都有一个或多个与之关联的事件流,记录了该聚合的所有变更。
- 聚合(Aggregate)
- 定义: 聚合是领域驱动设计(DDD)中的概念,它是一组相关对象的集合,被视为一个独立的、原子性的单元。在事件溯源中,聚合是事件流的主体,所有的事件都围绕着特定的聚合发生。
- 作用: 聚合负责处理命令(Command)并生成事件。它是命令的执行者,事件的发布者。聚合维护自身的内部状态,并通过应用事件来改变状态。(重要!)
- 示例:
Order聚合、Product聚合、User聚合。
- 命令(Command)
- 定义: 命令是对系统意图的描述,表示用户或系统想要执行的某个操作。它通常以动词形式命名,例如“创建订单”、“更新库存”、“注册用户”。
- 特性:
- 意图性: 命令表达的是“我希望发生什么”,而不是“发生了什么”。
- 一次性: 命令通常只处理一次。
- 可拒绝性: 命令可能会因为业务规则或前置条件不满足而被拒绝。
- 流程: 命令被发送到聚合,聚合处理命令并根据业务逻辑决定是否生成一个或多个事件。
- 投影(Projection)/ 读模型(Read Model)
- 定义: 投影是从事件流中派生出来的数据视图,通常用于满足特定的查询需求。由于事件存储是面向写入优化的(追加写入),直接查询事件流来获取当前状态可能效率不高,因此需要创建针对读取优化的数据模型。
- 作用: 解决读写分离(CQRS)中的读取问题。通过订阅事件,并根据事件更新读模型,从而提供快速的查询能力。
- 特性:
- 可变性: 读模型是可变的,会随着新事件的到来而更新。
- 冗余性: 为了查询效率,读模型可能会包含冗余数据。
- 多样性: 可以根据不同的查询需求创建多个读模型,例如,一个用于展示订单列表,一个用于展示订单详情。
- 示例:
- 关系型数据库中的表格(例如
Orders表,存储订单的当前状态)。 - NoSQL 数据库中的文档(例如 Elasticsearch 中的索引,用于全文搜索)。
- 关系型数据库中的表格(例如
- 快照(Snapshot)
- 定义: 快照是聚合在某个特定时间点的状态记录。由于重放所有历史事件来重建聚合状态可能非常耗时,尤其是在事件数量庞大时,因此会定期创建快照。
- 作用: 优化聚合状态的重建过程。当需要重建聚合状态时,可以从最新的快照开始,然后只重放快照之后发生的事件,从而大大减少重建时间。
- 特性:
- 周期性: 快照通常是周期性地或在事件数量达到某个阈值时生成。
- 可变性: 快照是可以被新的快照取代的。
- 事件处理器(Event Handler)/ 订阅者(Subscriber)
- 定义: 事件处理器或订阅者是监听并响应特定事件的组件。它们可以是领域服务、投影更新器、外部系统集成器等。
- 作用: 当事件发生时,事件处理器会执行相应的逻辑。例如,更新读模型、发送通知、触发其他业务流程等。
理解这些核心概念是掌握事件溯源模式的前提。事件溯源通过将变更记录为不可变的事件,提供了强大的审计能力、时间旅行能力,并且天然支持读写分离和微服务架构。
落地方案
Go package 选型
由于笔者所在的部门使用的是Go语言。为了避免重复造轮子,我们先看GitHub上有哪些Go的事件溯源package:
- EventHorizon (looplab/eventhorizon)
EventHorizon 是一个功能全面且广受欢迎的CQRS(命令查询职责分离)与事件溯源工具包。它不仅仅是一个事件溯源库,更提供了一整套构建事件驱动应用的解决方案。特点:
- 完整的CQRS/ES支持: 原生支持CQRS架构,清晰地分离了命令处理(写模型)和查询处理(读模型)。
- 丰富的集成: 提供了多种开箱即用的组件和集成方案:
- 事件存储 (Event Store): 支持内存、MongoDB(v1和v2两种模式,v2支持全局事件位置跟踪)以及可扩展的接口以支持其他数据库。
- 事件总线 (Event Bus): 支持内存、NATS (Jetstream)、Kafka 和 Redis Streams,用于事件的发布和订阅。
- 读模型仓库 (Read Repository): 提供了缓存和追踪等中间件,方便构建查询模型。
- 中间件支持: 提供了丰富的中间件,如日志、追踪(OpenTracing)、命令验证等,便于扩展和定制。
- 清晰的抽象: 定义了清晰的
Aggregate,Event,Command等核心接口,有助于遵循领域驱动设计(DDD)原则。 - 社区活跃: 拥有较高的GitHub Star数和持续的更新,社区相对活跃,文档和示例也比较完善。
- 不足: 对于初学者来说,其全面的功能和相对复杂的概念可能需要一定的学习曲线。
- Watermill (ThreeDotsLabs/watermill)
Watermill 是一个通用的、用于构建事件驱动应用的Go语言库。虽然它并非一个纯粹的事件溯源框架,但其强大的消息处理能力和灵活的架构使其非常适合用于实现事件溯源模式。特点:
- 通用与灵活: Watermill 的核心是处理消息流,不局限于特定的架构模式。您可以利用它来构建事件溯源、CQRS、消息队列、Sagas等多种应用。
- 广泛的Pub/Sub支持: 提供了对多种消息队列和流处理平台的适配器,包括 Kafka, RabbitMQ, Google Cloud Pub/Sub, Amazon SQS/SNS, NATS, SQL (通过outbox模式) 等。这使得它在异构系统集成中表现出色。
- 易于上手: 提供了简洁明了的API,其核心
HandlerFunc的设计与Go标准库的http.HandlerFunc类似,降低了学习成本。 - 强大的中间件: 内置了重试、限流、熔断、去重、日志等多种实用中间件,可以轻松地增强消息处理的健壮性。
- 专注于消息传递: Watermill 更侧重于事件的传递和处理,对于事件溯源中的聚合(Aggregate)管理和快照(Snapshotting)等,需要开发者自行实现或与其他库结合使用。
- goes (modernice/goes)
goes 是一个相对较新的、现代化的Go语言事件溯源框架。它充分利用了Go泛型的特性,提供了类型安全的开发体验。特点:
- 泛型应用: 大量使用Go 1.18+的泛型,使得在处理不同类型的聚合和事件时更加类型安全和简洁。
- 模块化设计: 框架被设计为一系列可独立使用的模块,包括事件总线(eventbus)、命令总线(commandbus)、事件存储(eventstore)等。
- 多样的后端支持: 提供了对PostgreSQL和MongoDB的事件存储实现,同时也支持NATS (Jetstream) 进行事件发布。
- 内置Saga支持: 提供了对Saga模式的支持,便于处理跨多个聚合的分布式事务。
- 命令行工具: 附带一个命令行工具,可以帮助进行一些日常的开发任务,如触发投影(Projection)的重建。
- 持续发展: 作为一个较新的项目,它仍在积极开发中,部分文档可能尚在完善。
- eventsourcing (thefabric-io/eventsourcing)
这是一个设计精良、专注于PostgreSQL的事件溯源库,同样也利用了Go泛型来提升开发体验。特点:
- 专为PostgreSQL优化: 提供了针对PostgreSQL优化的事件存储实现,可以充分利用PostgreSQL的强大功能和事务保证。
- 简洁的API: 提供了清晰且易于使用的API来定义聚合、事件和事件处理器。
- 泛型加持: 通过泛型,实现了聚合状态和事件的类型安全。
- 内置消费者管理: 提供了消费者(Consumer)管理机制,用于处理事件流并更新读模型(Projections),并支持消费者偏移量的持久化。
- 易于集成: 其核心概念清晰,可以方便地与其他库或框架集成。
- eventsourcing (hallgren/eventsourcing)
这是一个更为轻量级和基础的事件溯源库,专注于提供事件溯源的核心构建块。特点:
- 轻量级: 库的规模较小,核心概念简单,易于理解和上手。
- 关注核心: 主要提供了聚合(Aggregate)和事件存储(EventStore)的基本实现,让开发者可以专注于业务逻辑。
- 快照支持: 内置了对快照的支持,当聚合的事件流过长时,可以通过快照来优化加载性能。
- 可扩展性: 提供了事件存储的接口,方便开发者根据自己的需求实现不同的存储后端。
- 学习资源: 对于希望深入理解事件溯源基本原理的开发者来说,这是一个很好的学习和入门项目。
| 框架 | 主要特点 | 优势 | 不足 |
|---|---|---|---|
| EventHorizon | 功能全面的 CQRS/ES 工具包,支持 MongoDB 后端 | 功能丰富,社区活跃,文档示例完善 | 框架偏重,学习曲线相对陡峭 |
| Watermill | 通用的事件驱动应用库,没有事件存储支持 | 极其灵活,Pub/Sub 支持广泛,中间件强大 | 非纯粹 ES 框架,需自行实现部分核心逻辑,体现不出用框架的作用 |
| goes | 现代化、基于泛型的 ES 框架,支持 MongoDB 和 PostgreSQL 后端 | 类型安全,模块化设计,支持 Saga | 文档基本等于没有,不敢用 |
| thefabric-io/eventsourcing | 专为 PostgreSQL 优化的 ES 库 | 针对性优化,API 简洁,利用泛型 | 专为 PostgreSQL 设计,不支持 MySQL |
| hallgren/eventsourcing | 轻量级、基础的 ES 库,支持 EventStoreDB、PostgreSQL、SQL Server | 简单易学,核心概念清晰,支持快照 | 功能相对基础 |
根据以上的对比,我们可以看到,hallgren/eventsourcing是一个相对比较简单轻量、可拓展的包,比较适合轻量接入+快速概念验证+老系统迁移的场景。并且万一之后有什么需要魔改适配的,fork出来也方便改。我们可以通过实现包提供的存储接口来对公司内的存储生态进行适配。
事件存储选型
事件存储方面,上述几个包支持后端呈现明显的集中性——要么是MongoDB,要么是PostgreSQL,“最流行的数据库”MySQL在这个场景中完全失踪。
推测其原因,一方面是PostgreSQL的GIST、GIN索引、JSONB类型等功能客观上确实比较完善,并内置了LISTEN/NOTIFY功能,非常适合用作事件存储。另一方面是事件溯源本身是一个相对小众、学术的架构,作者情感上来说确实不喜欢用MySQL这种野路子数据库,更偏好学院派的PostgreSQL。对于中小规模的事件存储,使用现有的MySQL库就可以作为eventStore。
生态适配
我们基于hallgren/eventsourcing包提供的PostgreSQL的存储实现来改造,以适配公司内占绝对主流的数据库MySQL。原始的PostgreSQL表结构如下:
CREATE TABLE IF NOT EXISTS events (
seq SERIAL PRIMARY KEY, -- 事件id
id VARCHAR NOT NULL, -- 聚合根id
version INTEGER, -- 聚合根版本
reason VARCHAR, -- 事件类型
type VARCHAR, -- 聚合根类型
timestamp VARCHAR, -- 时间戳
data BYTEA, -- 事件内容
metadata BYTEA, -- 事件元数据
UNIQUE (id, type, version)
);这里主键seq使用了自增的ID来保证每个事件都有不同的ID,同时也起到了全局版本号的作用,需要保证单调递增。
此外,在这个实现中,对于一个聚合根的事件序列,会创建连续的version。例如Song A的第一个事件,version是1,第二个事件version是2。受制于公司自研的多机房同步组件本身是一个外挂组件,若要多机房写,则不支持使用自增ID,否则会出现冲突。另外,连续的版本号本质上也依赖master的强一致,因此在多机房同步架构下也不可以使用,容易导致冲突。基于这个现状,我们需要设计一个机制,让seq和version单调递增,同时尽可能降低冲突概率。
我这里的方案是,使用微秒级别timestamp+机房ID编码生成uint64类型的主键seq和version,其中timestamp占前61位,而机房id占最后三位。这样的优势是:
- 同机房内由于数据库的主键约束,必然不可能出现id冲突,并且微秒冲突概率比较小
- 而不同机房间由于已经将机房id编码在了主键key中,因此也不会互相冲突
- 主键seq是单调递增的(考虑到单个机房的数据库由于授时可能导致数据库时间回退,存储事件到数据库的时候会做校验,如果发现出现回退,会报错拒绝提交。不同机房之间的MySQL实例时间无法保证强一致,但预期差距在1秒内,属于业务上可以接受的范围)
- version字段也是单调递增的,只是不连续,但这对使用是没有影响的
最终的MySQL DDL如下:
CREATE TABLE IF NOT EXISTS %s (
seq BIGINT UNSIGNED NOT NULL,
datacenter_id TINYINT UNSIGNED NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
version BIGINT UNSIGNED NOT NULL,
reason VARCHAR(255),
type VARCHAR(255) NOT NULL,
timestamp VARCHAR(255),
data JSON,
metadata JSON,
PRIMARY KEY (seq),
UNIQUE KEY unique_dc_aggregate_version (datacenter_id, aggregate_id, type, version),
KEY idx_aggregate_type (aggregate_id, type),
KEY idx_global_order (seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;事件总线选型
事件溯源中的事件总线,负责可靠地将已经写入到event store中的事件流传递给事件的订阅者(event handler),event handler可以用来更新读模型,或者在接到指定消息后,调用command以产生另一个消息,通过这种方式驱动业务流程的流转。鉴于我们已经使用MySQL表作为event store,且在我所在的公司内具有完善的binlog监听生态,使用公司内的binlog监听生态来作为事件总线是非常自然的选择,它避免了轮询,且可以保证和event store的强一致。
事件溯源的潜在缺点
事件溯源确实提升了系统的可审计性、一致性并通过回放历史的能力支持了更广泛的应用场景。作者在生产环境的业务中亲身使用了事件溯源之后,认为有如下的考虑项是值得在冲向事件溯源架构之前留意的:
- 由于事件溯源架构要求使用者从传统CRUD的思维方式中转变为事件驱动的架构,需要在整个团队中对这些概念有深入理解,并且拥有更高的架构设计能力。当使用事件溯源的系统和不使用事件溯源的系统之间交互的时候,由于理念差异,可能存在开发人员之间的摩擦
- 在小数据量级下,即便不使用快照机制也可以非常顺畅的使用事件溯源模式。但在较大的数据量级下,或系统中存在热门的聚合根,则必须使用快照机制,否则系统运行将会非常缓慢,甚至不可用。
- 需要更大的存储空间:由于需要记录系统中的完整事件历史,其相比仅维护最新状态的CRUD式数据库需要更多的存储空间
- 整套架构比较庞大,从event store选型、消息中间件选型、数据库和中间件的运维、读模型投影的构建和维护、业务流程流转,要维护的东西很多。除非你就是团队的技术负责人,且确保拥有足够的人力投入在这件事情上,否则作为团队的individual contributor,要说服你的老板采用事件溯源比较困难。或者即便你成功说服,后续的维护也会让你心力交瘁
- 由于架构特性,一切引发不可逆业务行为的读取必须强一致,不允许读从库。因此eventstore在数据规模比较大的情况下需要是强一致的分片/集群式的数据库,不能使用有延迟的从库。若使用主从模式的数据库,主库的压力会比较大。
以及还有一些你在整套系统上线之前可能难以想到的坑:
- 使用消息中间件消费业务事件,构建投影和驱动业务流程流转时,消息中间件无法可靠地保证消费的exactly once。这时需要引入额外的处理流程
- 当由于bug,event store中出现了不合预期的事件时,删掉是最让人心安、并降低存储和查询压力的选择,但却是违反最佳实践(append-only)的选择。此外,修复数据还需要同时考虑event store和读模型,当出现bug污染了数据的时候,要考虑的东西更多
选型建议
对于需要可审计性,以及需要支持回放历史的能力的系统,尤其是增量模块,建议使用如下步骤,渐进式地采用事件溯源架构:
- step1:为需要可审计性和需要支持回放历史的能力的、增量且数据量级较小的模块使用事件溯源架构进行开发,并随着时间推移,观察系统是否如预期般运行
- step2:验证完成后,将既有的、规模较小的,需要可审计性和需要支持回放历史的能力的模块,迁移到事件溯源架构,并随着时间推移,观察系统是否如预期般运行
- step3:验证完成后,将既有的、规模较大的、需要可审计性和需要支持回放历史的能力的模块,迁移到事件溯源架构
对于无审计和回放历史能力需求的简单CRUD系统,可以不使用本架构。