跳到正文

参考

Idempotency

租户级 Idempotency(同一业务键重复提交只生效一次):(TenantID, IdempotencyKey) 键空间、memory 与 Postgres 存储、默认空操作重放、Strict 模式的 ErrReplay,以及接线顺序。

Idempotency

包:idempotency

Idempotency 让客户端可以安全地重试命令:同一业务键重复提交只生效一次。带某个键的命令已经成功过,再用相同键重试时不会再次执行 Handler。

键空间

键是 (TenantID, IdempotencyKey) 这一对。两个租户可以用相同的键字符串而不冲突。设置了 IdempotencyKey 却没有 TenantID 会报错:idempotency: TenantID required when IdempotencyKey is set。

ctx = tenancy.WithTenant(ctx, tenantID)
cmd.Meta().IdempotencyKey = "merchant-order:42"
err := app.Service.Execute(ctx, cmd)

没有 IdempotencyKey 的命令完全跳过检查。

行为

对每条带键的命令,中间件的处理如下:

  1. 在存储里查找 (TenantID, IdempotencyKey)。
  2. 如果记录已存在:
    • 默认:返回 nil,重试静默成功(空操作)。
    • Strict:返回包装了 idempotency.ErrReplay 的错误,其中包含原命令的 id 与类型。
  3. 如果不存在,执行链的其余部分,包括 Handler 与 Repo.Commit。
  4. 成功之后保存一条记录:命令 id、类型、状态 succeeded 与时间。

命令失败时不会写入记录,重试会正常执行。

err := app.Service.Execute(ctx, cmd)
if idempotency.IsReplay(err) {
    // 仅 Strict 模式:这个键已处理过
}

存储

type Store interface {
    Get(ctx context.Context, tenantID uuid.UUID, key string) (*Record, error)
    Put(ctx context.Context, rec Record) error
}
存储 用途
idempotency.NewMemory() 测试与演示
idempotency.NewPostgres(pool) 生产。使用表 idempotency_keys,主键为 (tenant_id, key)。

在 Postgres 上,记录与命令在同一个事务里写入。idempotency.Postgres 复用 WithinTx 的连接,所以指向 command_id 的外键能看到尚未提交的命令行。事务回滚时,键也一起回滚。

接线

app := sequent.New(sequent.Config{},
    sequent.WithIdempotency(idempotency.NewPostgres(pool), false), // 第二个参数:strict
)
app.Service.Middleware = append(
    []command.Middleware{tenancy.Middleware},
    app.Service.Middleware...,
)

tenancy 必须先运行,这样 Idempotency 查询时 Meta.TenantID 已经设好。中间件外层先行。也可以直接构造:idempotency.Middleware(idempotency.Config{Store: s, Strict: false})。

与 AuthZ 的关系

sequent.WithAuthorizer 安装的是过滤器,而过滤器在中间件链之后运行。因此重放的键会在过滤器运行之前返回。第一次尝试执行时已经通过了授权。如果希望每次尝试都做授权,把 security.Middleware(auth) 放在 Idempotency 中间件之前。见 AuthZ。

如何选键

  • 键应当来自业务意图,而不是每次尝试生成的随机值。商户订单号、支付流水号,或在重试中保持不变的客户端请求 id 都可以。
  • 键在重试之间必须稳定,在不同意图之间必须不同。如果客户端每次重试都生成新键,Idempotency 就帮不上忙。
  • 键按租户隔离,不要把租户编码进键里。
  • 把同一个键用于不同类型的命令是客户端的 bug。store 保留的是第一条命令的 id 与类型。

它不做什么

  • 不能替代 OCC。两个键不同的请求仍可能在同一聚合上竞争,见 唯一键与 OCC。
  • 不保护没有键的请求。
  • 不返回原始结果。默认的重放返回 nil;客户端需要结果时,请读取当前状态或读模型(Projector 维护的查询表)。

Handler 仍应保持防御性,使用自然键和保护条件——Idempotency 是传输层面的安全网,不是领域规则的替代品。

相关

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