日志就是总线
Wow 服务知道的每一条事实,都是它事件日志里的一个条目。快照、投影、发布的事件流和数仓,都从日志读出来。本页说明日志是什么、怎样排序,以及为什么除了它没有别的地方可写。
为什么只写一处
一个系统先追加事件、再把它发布到总线,就写了两次。如果在两次写之间崩溃,事件已经提交却永远不会发布,所有观察者都永久错过了它。outbox 和重试能缩小这个缺口,却合不上它。
Wow 的做法是只写一次。提交一条命令,就是对日志的一次原子追加。所有对这次变更作出反应的东西,都从自己保存的检查点开始读日志。观察者崩溃了,重启后从检查点接着读。提交与观察者之间不会丢东西,因为两者之间什么都没有。
每个限界上下文一份日志
每个限界上下文有自己的日志,只属于这个上下文的服务。其他上下文看到的是它发布的东西,永远看不到日志本身(上下文之间)。
日志的一个条目就是一条事件流:一条命令产出的事件,加上它们周围的事实。
| 部分 | 内容 |
|---|---|
| 身份 | 聚合(上下文、聚合类型、租户和 ID);这次提交产生的版本 |
| 事件 | 每个事件的名字、修订号和内容,按顺序 |
| 请求 | 产生这些事件的命令 ID、请求 ID 和命令类型 |
| 谁 | 记录这次提交的操作人、提交后聚合的所有者和空间,以及准入放行的请求事实 |
| 因果 | 对 Saga 发出的命令,记录链条的根命令和直接原因 |
| 时间 | 提交时间 |
在事实之外,条目还可以带传输段:完成一个未结束的等待所需的信息、要确认的预留、追踪上下文。传输段只为投递服务。领域代码和查询永远看不到它,迁移和重放会丢弃它。
版本
每个聚合都有版本。它的第一次提交是版本 1,之后每次提交恰好加一。日志由两个唯一键守护:
- (聚合,版本):基于同一版本做出决定的两个写者,不可能都提交。后到的那个得到冲突,在新状态上重新决定。
- (租户,请求 ID):同一个请求不能提交两次。用同一请求 ID 重试,得到的是第一次提交的结果。
让写入正确的是这两个唯一键,而不是单写者。见写:从准入到追加。
分区
日志分成固定数量的分区,默认 64 个。一个聚合总是落在同一个分区里:聚合的身份(包括租户)被哈希到 4,096 个槽位之一,再由槽位映射到分区。哈希是存储格式的一部分,所以在每个 JVM、每个版本上都一样。
分区数必须是能整除 4,096 的 2 的幂,日志里有条目之后就不能再改。它是吞吐的旋钮:一个分区内的提交逐个编号,所以一个分区的提交速度大约就是一次存储往返允许的速度,而各分区之间并行提交。
分区也是归属的单位。服务的每个节点通过存储里的租约拥有其中一部分分区。分区的属主执行该分区内聚合的命令,并在它上面运行订阅。
位置
在一个分区内,每个条目都有一个位置:条目提交时分配的序号。位置的顺序,就是提交变得可见的顺序。
正是这条规则让读取是安全的。提交时间或自增 ID 都做不到这一点:先拿到 ID 的事务可能最后才提交,按 ID 翻页的读者就会永久跳过它。因为位置跟随可见性,从某个位置开始读一个分区、再从看到的最后一个位置接着读的读者,不会漏掉任何东西,每次看到的序列也都相同。
唯一的顺序保证是按聚合的:同一个聚合的事件按版本顺序到达。不同聚合的条目,哪怕在同一分区、哪怕一个是另一个引起的,也没有读者可以依赖的顺序。
后端
日志合同没有可选部分:后端要么全部实现,要么就不是日志后端。一套测试套件让每个后端都遵守同一份合同。
| 后端 | 用途 |
|---|---|
| PostgreSQL | 首选。一条语句完成版本检查、条目编号和插入;冲突时整体回滚,不留空洞。 |
| MongoDB | 一等后端。用事务给条目编号;分区的属主批量提交,数据库看不到争用。需要副本集。 |
| 内存 | 测试和示例。 |
每个聚合类型有自己的日志表或集合,所以访问权限、变更数据捕获和重建都能按聚合进行。分区、位置和租约仍然按上下文计。同一上下文的不同聚合可以把日志放在不同的存储里。
格式
日志永久保存,所以它的格式是一份合同。格式版本按表记录,每个线上结构也带着自己的格式版本。增加一个带默认值的可选字段不算格式变化:旧读者忽略它,新读者填上默认值。在同一主版本内,新字段先由一个次版本开始读取,下一个次版本才开始写入,所以滚动升级期间相邻的两个次版本可以并存。旧读者处理不了的变化,要等下一个主版本和它的迁移工具。
你自己的事件以另一种方式变化:通过它们的名字、修订号和升级函数。见决定与演化。