跳到正文

指南

写路径一致性

go-sequent 里 UnitOfWork、OCC(用 ExpectedVersion 防并发写冲突)与 Idempotency(同一业务键重复提交只生效一次)如何配合:各自防什么、不防什么,以及接线与冲突处理。

写路径一致性

一次成功的 CommandService.Execute 里,三种机制回答三个不同的问题:

  • 我在修改聚合的哪一个进程内实例?它产生了哪些事件?(UnitOfWork)
  • 这个事件流在我读取之后有没有被别人推进?(OCC:用 ExpectedVersion 防并发写冲突)
  • 这个请求是不是已经处理过?(Idempotency:同一业务键重复提交只生效一次)

它们互相补充,谁也替代不了谁。

各自做什么

机制 位置 作用
UnitOfWork(UoW) aggregate,挂在 Execute 的 context 上 每次 Execute 一个 identity map。Add 与 Load 复用同一实例;Commit 按加载顺序收集 UncommittedEvents。
OCC eventstore.StreamAppend.ExpectedVersion 追加要求流的末尾版本等于期望版本,否则返回 ErrOptimisticConcurrency。Repository 设置 ExpectedVersion: agg.Version()。
Idempotency idempotency 中间件、sequent.WithIdempotency 键空间为 (TenantID, IdempotencyKey)。成功之后,相同键默认是空操作;Strict 模式返回 ErrReplay。

UoW 不跨 Execute,也不跨 goroutine。OCC 管的是对同一聚合事件流的并发追加,并不知道 HTTP 客户端有没有复用业务键。Idempotency 管的是带键命令的重放,拦不住两个不同操作者在同一个账户版本上竞争。

如何分层

层 防什么 不覆盖什么
UoW 同一次 Execute 内重复 Load 得到两份可变副本;多聚合提交顺序不确定。 并行的 Execute;进程崩溃后的重试。
OCC 基于过期 Version() 的追加(丢失更新)。 「用户点了两次支付」且两次都看到空闲的新版本。
Idempotency 对相同 (tenant, key) 的成功重放。 缺失 IdempotencyKey;同一业务意图使用不同的键;OCC 冲突本身。

支付场景请使用稳定的 IdempotencyKey(例如商户订单号)防重放,并保留 OCC 处理账本并发更新。键按租户隔离,见 Tenancy(多租户)。

接线

把 tenancy 放在 idempotency 之前,这样 Idempotency 运行时 Meta.TenantID 已经设置好。

import (
    "github.com/go-sequent/go-sequent/command"
    "github.com/go-sequent/go-sequent/idempotency"
    "github.com/go-sequent/go-sequent/sequent"
    "github.com/go-sequent/go-sequent/tenancy"
)

app := sequent.New(sequent.Config{},
    sequent.WithIdempotency(idempotency.NewMemory() /* 或 NewPostgres(pool) */, false),
)
app.Service.Middleware = append(
    []command.Middleware{tenancy.Middleware},
    app.Service.Middleware...,
)

Chain 外层先行,第一个元素最先执行。

Handler 只通过 Repository 接触聚合

func (h *Handler) Handle(ctx context.Context, cmd command.Command) error {
    c := cmd.(*Deposit)
    acc, err := h.Repo.Load(ctx, c.AccountID, AggregateAccount)
    if err != nil {
        return err
    }
    return acc.(*Account).Deposit(c.Amount) // Apply → 事件进入 UoW 的未提交列表
}

不要在 Handler 里调用 EventStore.Append,由 Repository 在提交时完成。

处理 OCC 冲突

Repo.Commit 把 ExpectedVersion 设为 agg.Version(),即加载时已提交的末尾版本。如果别的写入者先追加了,store 会拒绝:

err := app.Service.Execute(ctx, cmd)
if errors.Is(err, eventstore.ErrOptimisticConcurrency) {
    // 重新 Execute,重新 Load。不要沿用之前读到的余额。
}

带退避重试。冲突多出现在热点聚合,以及数据库故障切换前后,见 故障转移。OpenTelemetry 计数器 sequent.optimistic_conflict.total 可以看到冲突频率(见 OpenTelemetry)。

使用 Idempotency 键

Idempotency 键要求有 TenantID。

ctx = tenancy.WithTenant(ctx, tenantID)
cmd.Meta().IdempotencyKey = "merchant-order:42"
err := app.Service.Execute(ctx, cmd)
// 成功后再用同一个键:默认返回 nil(空操作)。
// Strict 模式:返回 idempotency.ErrReplay。

在 Postgres 上,Idempotency 记录与命令在同一个事务里写入。idempotency.Postgres 复用 WithinTx 的连接,因此指向 command_id 的外键能看到尚未提交的命令行。事务回滚时键不会被记录,重试会正常执行。

一个具体场景

商户调用 Withdraw 取 30.00,网络丢了响应,客户端用同一个键重试。

  1. 第一次提交成功,余额从 100.00 变为 70.00,Idempotency 记录 (tenant, key) 已保存。
  2. 重试带着相同的键到达。中间件找到记录,不执行 Handler 直接返回 nil。余额保持 70.00。

再看另一种情况:两个键不同的请求同时从同一个账户取款。

  1. 两者都加载了版本 5 的账户。
  2. 第一个提交,事件序号为 6。
  3. 第二个的追加期望末尾是 5,实际是 6,返回 ErrOptimisticConcurrency。
  4. 调用方重试。重试加载版本 6,看到新的余额,重新决策。如果余额已不足,聚合会拒绝这条命令。

第二种情况 Idempotency 帮不上忙,第一种情况 OCC 也帮不上忙,所以两者都需要。

另见

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