跳到正文

参考

唯一键与 OCC

OCC(乐观并发控制)与聚合 Unique keys:ExpectedVersion、ErrOptimisticConcurrency、带租户前缀的 scope,以及 ErrUniqueKeyViolation。

唯一键与 OCC

每次追加时,EventStore 内部会做两项检查:对每个事件流的 OCC(用 ExpectedVersion 挡住并发写冲突),以及跨聚合的业务键唯一性(Unique keys)。

它们与 UnitOfWork、Idempotency 如何配合,见 写路径一致性。

乐观并发控制(OCC)

每个聚合事件流都有一个版本:最后已提交事件的序号。Repository 提交时,每个 StreamAppend 带着 ExpectedVersion,即聚合加载时的版本。

只有当流的末尾等于 ExpectedVersion 时,store 才接受追加,此时第一个新事件的序号必须是 ExpectedVersion + 1。否则返回 ErrOptimisticConcurrency。

err := app.Service.Execute(ctx, cmd)
if errors.Is(err, eventstore.ErrOptimisticConcurrency) {
    // 别的写入者先追加到了这个流。
    // 重新 Execute,聚合会以新版本加载。
}

Postgres 如何检查

store_events 的逻辑沿用 Sequent 同名过程:

  1. 锁定聚合行(SELECT ... FOR NO KEY UPDATE)。
  2. 读取最后序号。
  3. 要求第一个新事件是最后序号加一;空流则为 1。
  4. 插入事件,主键冲突即并发冲突。
  5. 更新唯一键。

完整性失败会映射为 ErrOptimisticConcurrency 或 ErrUniqueKeyViolation。

冲突时怎么办

不要在旧状态上打补丁——应当重新执行整条命令,让 Handler 加载最新聚合并重新检查规则。版本 5 时合法的取款,到版本 6 可能已经不合法。

  • 带退避重试,并限制重试次数。
  • 事务要短,一次 Execute 批次就是一个事务。
  • 故障转移之后以及热点聚合上,预期会出现冲突高峰。
  • 关注 sequent.optimistic_conflict.total(见 OpenTelemetry)。

如果单个聚合持续冲突,可能是模型粒度太粗。拆分热点聚合(例如按商户 + 币种,而不是只按商户)可以降低竞争。

Unique keys(唯一键)

OCC 保护单个事件流;Unique keys 保护跨事件流的业务规则,例如「一个 owner 只能有一个账户」。

聚合返回 scope → value 的映射:

func (a *Account) UniqueKeys() map[string]any {
    if !a.Open {
        return nil
    }
    scope := "bank.Account.owner"
    if pk := a.PartitionKey(); pk != "" {
        scope = "tenant:" + pk + ":bank.Account.owner"
    }
    return map[string]any{scope: a.Owner}
}

追加时,store 把 (scope, key) 记到带唯一约束的 aggregate_unique_keys 表。如果另一个聚合已经持有相同组合,追加会以 ErrUniqueKeyViolation 失败:

if errors.Is(err, eventstore.ErrUniqueKeyViolation) {
    // 例如:该租户下这个 owner 已经有账户
}

Base 提供 SetUniqueKeys(map[string]any),因此 applier 可以在相关状态变化时刷新键。从快照加载之后要再调用一次,bank 示例里的 refreshUniqueKeys 就是这样做的。

Scope 设计

该表在共享 schema 内是全局的。请把租户放进 scope,避免一个租户挡住另一个租户:

scope := tenancy.UniqueScope(tenantID, "email")
// "tenant:{uuid}:email"

每条规则使用有描述性的后缀;同一个词在不同聚合里含义不同时,把聚合类型也带上。Unique keys 在追加时检查。

OCC、Unique keys 与 Idempotency

问题 机制
读取之后这个事件流变了吗? OCC(ExpectedVersion)
这个业务值是否已被其他聚合占用? Unique keys
这个请求是否已经处理过?(同一业务键重复提交只生效一次) Idempotency

相关

文档随 go-sequent 源码更新;若与 API 不一致,以源码为准。