指南
写路径一致性
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,网络丢了响应,客户端用同一个键重试。
- 第一次提交成功,余额从 100.00 变为 70.00,Idempotency 记录
(tenant, key)已保存。 - 重试带着相同的键到达。中间件找到记录,不执行 Handler 直接返回
nil。余额保持 70.00。
再看另一种情况:两个键不同的请求同时从同一个账户取款。
- 两者都加载了版本 5 的账户。
- 第一个提交,事件序号为 6。
- 第二个的追加期望末尾是 5,实际是 6,返回
ErrOptimisticConcurrency。 - 调用方重试。重试加载版本 6,看到新的余额,重新决策。如果余额已不足,聚合会拒绝这条命令。
第二种情况 Idempotency 帮不上忙,第一种情况 OCC 也帮不上忙,所以两者都需要。
另见
- Repository 与 UnitOfWork
- 唯一键与 OCC
- Idempotency
- 名词释义:UoW、OCC、Idempotency
文档随 go-sequent 源码更新;若与 API 不一致,以源码为准。