事件存储和乐观并发
Event store and optimistic concurrency
Greg Young 在 "Building an event storage" 部分关于 CQRS 的文档中,在将事件写入事件存储时,他检查了乐观并发性。我真的不明白他为什么要检查,谁能用具体的例子给我解释一下。
I do not really get why he made that check, can anyone explain to me with a concrete example.
事件存储应该是持久的,从某种意义上说,一旦你写了一个事件,它就会对每次后续读取可见。所以数据库中的每个动作都应该是一个追加。一个有用的心智模型是考虑单向链表。
如果数据库要支持多个具有写访问权限的执行线程,那么您将面临 "lost update" 问题。绘制为链接列表,这可能看起来像:
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) set(/x, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) set(/x, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
线程(2) 写入的历史记录不包括线程(1) 记录的event:709726c3。因此 "lost update".
在通用数据库中,您通常使用事务来管理它:幕后的一些魔法会跟踪您所有的数据依赖性,如果在您尝试提交事务时前提条件不成立,您的所有工作被拒绝。
但是事件存储不需要支持一般情况的所有自由度 -- 禁止编辑存储在数据库中的事件,以及更改事件之间的依赖关系。
更改的唯一可变部分 - 这是我们用新值替换覆盖旧值的唯一地方 - 是我们更改 /x.tail
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) set(/x, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) set(/x, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
这里的问题很简单,Thread(2) 认为 6 <- /x.tail
为真,并将其替换为丢失事件 7 的值。如果我们将写入从 set
更改为 compare-and-set
...
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 83b97195 <- /x.tail]) // FAILS
然后数据存储可以检测到冲突并拒绝无效写入。
当然,如果数据存储以不同的顺序看到线程的动作,那么
失败的命令可能会改变
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
Thread(1) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 709726c3 <- /x.tail]) // FAILS
更简单地说,set
给我们 "last writer wins" 语义,compare-and-set
给我们 "first writer wins",这消除了丢失更新的担忧。
TLDR;需要进行并发检查,因为发出的事件取决于之前的事件。因此,如果另一个进程同时发出其他事件,则必须重新做出决定。
事件存储的使用方式是这样的:
- 旧事件从 Eventstream 加载(=Eventstore 中的一个分区,其中包含聚合实例生成的所有事件)
- 旧事件 processed/applied 由拥有它们的聚合按照它们生成的顺序
- 聚合基于从这些事件构建的内部状态,决定发出一些新事件
- 这些新事件已附加到事件流
因此,第 3 步取决于在执行此命令之前生成的先前事件。
如果另一个进程并行生成的一些事件被附加到同一个事件流,那么这意味着所做的决定是基于错误的前提,因此必须从步骤 1 开始重复进行。
Greg Young 在 "Building an event storage" 部分关于 CQRS 的文档中,在将事件写入事件存储时,他检查了乐观并发性。我真的不明白他为什么要检查,谁能用具体的例子给我解释一下。
I do not really get why he made that check, can anyone explain to me with a concrete example.
事件存储应该是持久的,从某种意义上说,一旦你写了一个事件,它就会对每次后续读取可见。所以数据库中的每个动作都应该是一个追加。一个有用的心智模型是考虑单向链表。
如果数据库要支持多个具有写访问权限的执行线程,那么您将面临 "lost update" 问题。绘制为链接列表,这可能看起来像:
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) set(/x, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) set(/x, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
线程(2) 写入的历史记录不包括线程(1) 记录的event:709726c3。因此 "lost update".
在通用数据库中,您通常使用事务来管理它:幕后的一些魔法会跟踪您所有的数据依赖性,如果在您尝试提交事务时前提条件不成立,您的所有工作被拒绝。
但是事件存储不需要支持一般情况的所有自由度 -- 禁止编辑存储在数据库中的事件,以及更改事件之间的依赖关系。
更改的唯一可变部分 - 这是我们用新值替换覆盖旧值的唯一地方 - 是我们更改 /x.tail
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) set(/x, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) set(/x, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
这里的问题很简单,Thread(2) 认为 6 <- /x.tail
为真,并将其替换为丢失事件 7 的值。如果我们将写入从 set
更改为 compare-and-set
...
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(1) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 709726c3 <- /x.tail])
Thread(2) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 83b97195 <- /x.tail]) // FAILS
然后数据存储可以检测到冲突并拒绝无效写入。
当然,如果数据存储以不同的顺序看到线程的动作,那么 失败的命令可能会改变
Thread(1) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) [... <- 69726c3e <- /x.tail] = get(/x)
Thread(2) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 83b97195 <- /x.tail])
Thread(1) compare-and-set(/x, 69726c3e <- /x.tail, [ ... <- 69726c3e <- 709726c3 <- /x.tail]) // FAILS
更简单地说,set
给我们 "last writer wins" 语义,compare-and-set
给我们 "first writer wins",这消除了丢失更新的担忧。
TLDR;需要进行并发检查,因为发出的事件取决于之前的事件。因此,如果另一个进程同时发出其他事件,则必须重新做出决定。
事件存储的使用方式是这样的:
- 旧事件从 Eventstream 加载(=Eventstore 中的一个分区,其中包含聚合实例生成的所有事件)
- 旧事件 processed/applied 由拥有它们的聚合按照它们生成的顺序
- 聚合基于从这些事件构建的内部状态,决定发出一些新事件
- 这些新事件已附加到事件流
因此,第 3 步取决于在执行此命令之前生成的先前事件。
如果另一个进程并行生成的一些事件被附加到同一个事件流,那么这意味着所做的决定是基于错误的前提,因此必须从步骤 1 开始重复进行。