把业务写仓储的心智规则从 7 条降到 2 条

一个 Go 泛型类型化仓储的设计

Posted by walikrence on September 8, 2026

背景脱敏:游戏服务端的玩家数据以 Protobuf 存 KV,多副本并发写。本文讲怎么把「乐观锁读改写」从业务手写收归框架,业务代码只剩一个闭包。

之前:业务要背 7 条规则

框架原本提供的是一个 cas.Spec,业务把 Load / Apply / Save 三步自己接线。接线自由度过大,反而成了坑源。业务写一次玩家数据要记住:

  1. 用不带缓存的 Load 读数据并取版本(接错了会「旧数据 + 新版本」,CAS 形同虚设);
  2. 判 NotFound 分支,未建档时给空实体 + 版本 0;
  3. 改字段;
  4. 没变化时必须返回 ErrNoChange,否则空写占版本号,放大别人的冲突;
  5. Save 里包装冲突错误要保留 %w 链,否则重试静默失效;
  6. 耗尽重试的错误各自翻译;
  7. 保存成功后做缓存失效等副作用,且重放时不能重复执行。

这 7 条里只有两条是本质的:Apply 会被重放,所以必须是纯函数业务判断要写在 Apply 里(因为读到的数据可能在下一轮就变了)。其余 5 条都是偶发复杂度,是可以由结构承接的。

之后:repo.New[T]

type Repo[T any, P interface{ *T; proto.Message }] struct {
    OnMiss     func(ctx context.Context, key uint64) (P, int64, error) // 可选
    BeforeSave func(P)                                                   // 可选
    // ...
}

func New[T any, P ...](module string, build func(*ModuleBuilder) *ModuleBuilder) *Repo[T, P]

func (r *Repo[T, P]) Update(ctx context.Context, key uint64, apply func(P, *Tx) error) error
func (r *Repo[T, P]) Load(ctx context.Context, key uint64) (P, error)

业务代码:

var wallet = repo.New[pb.Wallet]("wallet", nil)

func AddCoin(ctx context.Context, uid uint64, n int64) error {
    return wallet.Update(ctx, uid, func(w *pb.Wallet, tx *repo.Tx) error {
        if w.Frozen { return ErrFrozen }        // 判断写在 Apply 里
        w.Coin += n
        tx.AfterCommit(func() { invalidateCache(uid) })
        return nil
    })
}

框架负责:Load、CAS 重试、no-change 检测、落库、副作用。

几个设计点

自动 no-change:确定性序列化

装载时记下实体的序列化基线,Apply 之后再序列化一次比对,相同就不写库、不执行 AfterCommit。业务不用再返回 ErrNoChange

审查时抓出一条 Critical:proto.Marshal 对 map 字段的输出顺序每次随机,含 map 的实体 50 次无变更 Update 里 46 次被判成有变更,空写照发。必须用 proto.MarshalOptions{Deterministic: true}。这个 bug 单测很难自然发现,是靠变异测试(故意去掉 Deterministic 看用例会不会红)钉死的。

BeforeSave:时间戳不能参与比对

试点时发现一个反模式:业务把 LastUpdated = now() 写在 Apply 里,于是每轮必改,自动 no-change 对这类仓储永不生效。解法是加 BeforeSave 钩子:只在真正要落库那轮、no-change 检测之后、写库之前执行。数据没变就不保存,时间戳也不刷;冲突重放时每次真实保存前都会执行,保证盖在最新数据上。

OnMiss:懒迁移旧存量

很多仓储带「未建档时从旧 SQL 表回填」的逻辑。裸 Repo 的固定 Load 罩不住,于是加可选 OnMiss 钩子:返回(种子实体,版本,错误)。版本 0 视同首建,版本 > 0 视同已回填的真实版本并参与 no-change 基线。

一个语义抉择:设置了 OnMiss 之后 Load 也经它,读写对称,但这意味着 Load 不再返回「不存在」。调用方如果需要区分,由 OnMiss 自己的语义判断(比如种子为空实体)。这条写进了文档,因为它会让人意外。

首轮走版本化缓存,重试轮直读

CAS 首轮装载走带版本的缓存(缓存里是一致的「数据 + 版本」配对,陈旧配对会在保存时正常冲突),冲突后的重试轮强制直读后端。否则删缓存失败的场景下,陈旧缓存反复喂旧版本,会把重试上限烧完形成事实死循环。最坏代价被钉死为「多付一轮冲突」。

这个扩展用类型断言探测可选接口,不并入 Store 接口,因为加进去是 breaking change,会破坏业务侧已按现有接口写好的测试 fake。

观测

Spec.Module 打标签,暴露 cas_retry_rounds(按模块的重试轮数分布)和 cas_exhausted_total。两条告警:某模块 p99 重试轮数持续 > 3 是热点预警,耗尽即事件。这样 CAS 打架时能直接定位到模块,而不是全局看到「冲突率高」。

试点反馈:行数不降反升

第一版试点后,两个仓储的行数从 142 → 163、138 → 162。原因是 5 个 API 摩擦逼出了转发适配器:构造形态和项目惯用的 provider 注入不兼容、Load 不经 OnMiss、没有 BeforeSave 等。处置是回框架补 API 再发一版,而不是让带适配器的坏示范进主干。试点的价值就是暴露这些摩擦;如果试点没有反馈回框架,那只是换了一种写法。

测试范式

no-change 用例要注入递增时钟。秒级时间戳在同一秒内假绿,用例先要在旧实现上真红,再在新实现上绿,否则不知道它是否真有拦截力。

教训

  • 规则分两种:本质的(由问题域决定)和偶发的(由接线方式决定)。框架的工作是把偶发的收走。
  • 每消掉一条规则,配一条测试把对应的坑钉死。
  • 试点后行数上升不是失败,是 API 摩擦的信号,回框架修而不是让业务绕。
  • 可选钩子要写清语义变化(如 OnMiss 改变 Load 的 NotFound 语义),否则下一个人会踩。