Documentation
¶
Overview ¶
Package cached 是 oekit 的缓存读写编排层:把 cache-aside 语义 (写后失效、读未命中回源回填、null marker 防穿透)套在 gormx/crud 仓储之上,让 CRUD 自动获得缓存一致性编排。
语义边界(务必理解后再接入) ¶
- 模式是 Cache-Aside(写库成功后删缓存),不是 write-through 双写:删缓存 + TTL 兜底是自愈的,双写存在部分失败与并发覆盖 两类无法根治的不一致。
- 点读(GetByID)默认缓存:miss → 仓储回源 → 回填;ErrNotFound 翻译为 (nil, nil) 交给后端缓存 null marker(防穿透);缓存 命中 null marker 时返回 crud.ErrNotFound。TTL 由后端缓存的 默认 TTL 决定——务必配置有限 TTL,它是删缓存失败时的唯一安全网。
- 查询缓存(GetOne/List)经 WithQueryCache 显式开启:键 = 「表级版本号 + QuerySpec 指纹」,任意写操作 bump 版本让旧键 自然过期(改一行即全表列表失效,代价是列表命中率换一致性)。 GetOne/List 的负结果不缓存(普通 Cache 接口无 null marker 通道)。ForUpdate 读永不缓存。Exists/Count/GetByCondition 永远直通。
- 事务安全:CachedRepository 实现 TxRepository——RunInTx 事务内 的写操作只收集失效清单,提交成功后统一执行失效(嵌套事务同样 收集,由最外层提交后执行);回滚不失效。失效必须发生在提交后: 事务内删缓存会放跑"回源读到未提交旧值 → 回填旧值"的竞态。 事务内读直通仓储(保证读到事务内状态)。提交后的失效以 WithoutCancel 上下文执行——调用方取消不吞失效。
- 失效是 best-effort:写库成功后删缓存/bump 版本失败不回滚写 (返回错误会让调用方误判写失败而重试),错误经 WithOnError 上报,TTL 兜底。缓存后端读失败时读路径降级直查仓储。
别名警示(调用方必读) ¶
缓存命中时返回的是后端缓存持有的引用:零拷贝型后端(如 LocalCache) 直接共享内部对象——调用方**禁止改写**返回的 *T / []T 内容,改写会 污染缓存并与并发读构成 data race;序列化型后端(如 RedisCache)每 次反序列化返回新副本,天然无别名。两类后端共用本装饰器,因此调用方 一律按"只读"对待返回值(契约同 github.com/oekit/pkg/cache 包级文档)。 注意这与裸 gormrepo 的习惯不同——仓储每次查询返回新对象,而缓存路径 不是。需要可变副本时自行拷贝。
用法 ¶
repo := cached.NewRepository[User](rawRepo,
func(src cache.SourceFunc[User]) cache.CacheAside[User] {
return cache.NewSingleFlightCache(cache.NewLocalCache(cache.LocalConfig[User]{
TTL: 10 * time.Minute, Source: src, // ← 装饰器注入回源函数
}))
},
cached.WithID[User](func(u *User) int64 { return u.ID }),
)
svc := crud.NewBaseService[User, CreateUserReq, UpdateUserReq](repo)
Source 由装饰器注入是刻意的:null marker 防穿透与 singleflight 防击穿都长在后端缓存的 GetOrLoad 里,装饰器必须借道它回源。
Index ¶
- func DefaultFingerprint[T any](spec crud.QuerySpec) (string, error)
- type BloomChecker
- type CacheFactory
- type CachedOption
- func WithBloomFilter[T any](bf BloomChecker) CachedOption[T]
- func WithID[T any](fn func(*T) int64) CachedOption[T]
- func WithOnError[T any](fn OnErrorFunc) CachedOption[T]
- func WithPrefix[T any](prefix string) CachedOption[T]
- func WithQueryCache[T any](qc *QueryCache[T]) CachedOption[T]
- func WithType[T any](name string) CachedOption[T]
- type CachedRepository
- func (r *CachedRepository[T]) BatchCreate(ctx context.Context, entities []*T, batchSize int) error
- func (r *CachedRepository[T]) BatchDelete(ctx context.Context, ids []int64) error
- func (r *CachedRepository[T]) Count(ctx context.Context, q *crud.Query) (int64, error)
- func (r *CachedRepository[T]) Create(ctx context.Context, entity *T) error
- func (r *CachedRepository[T]) Delete(ctx context.Context, id int64) error
- func (r *CachedRepository[T]) Exists(ctx context.Context, q *crud.Query) (bool, error)
- func (r *CachedRepository[T]) GetByCondition(ctx context.Context, query any, args ...any) (*T, error)
- func (r *CachedRepository[T]) GetByID(ctx context.Context, id int64) (*T, error)
- func (r *CachedRepository[T]) GetByIDForUpdate(ctx context.Context, id int64) (*T, error)
- func (r *CachedRepository[T]) GetByIDs(ctx context.Context, ids []int64) (map[int64]*T, error)
- func (r *CachedRepository[T]) GetOne(ctx context.Context, q *crud.Query) (*T, error)
- func (r *CachedRepository[T]) List(ctx context.Context, q *crud.Query) ([]T, int64, error)
- func (r *CachedRepository[T]) RunInTx(ctx context.Context, fn func(repo crud.Repository[T]) error) error
- func (r *CachedRepository[T]) Update(ctx context.Context, entity *T) error
- func (r *CachedRepository[T]) UpdateFields(ctx context.Context, id int64, fields map[string]any) error
- type FingerprintFunc
- type OnErrorFunc
- type QueryCache
- type QueryPage
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func DefaultFingerprint ¶
DefaultFingerprint 默认指纹:JSON(QuerySpec) 的 SHA-256 前 16 hex。 QuerySpec 字段有序、JSON 对 map key 排序,输出稳定。
两类参数 fail-closed 拒绝指纹(降级直通,绝不以错误指纹命中错误结果):
- 序列化失败(chan/func 等);
- 序列化成功但**无区分度**——driver.Valuer 风格的非导出字段结构体 恒编为 {}、nil 恒编为 null,不同取值会同指纹串数据(这是"查到 错误数据"而非"查到旧数据",绝不接受)。此类工作负载请用 WithFingerprint 提供类型感知的指纹。
Types ¶
type BloomChecker ¶ added in v0.7.0
type BloomChecker interface {
// Exists 判断元素"可能存在"(true 含误判)或"绝对不存在"(false 可信)。
Exists(ctx context.Context, item string) (bool, error)
// Add 写入元素(只增不减)。
Add(ctx context.Context, item string) error
}
BloomChecker 是布隆过滤器的最小接口(redisx.BloomFilter 天然满足, 测试可注入 fake)。
type CacheFactory ¶
type CacheFactory[T any] func(source cache.SourceFunc[T]) cache.CacheAside[T]
CacheFactory 用装饰器注入的回源函数构造缓存后端。
装饰器把「键 → ID 解析、ErrNotFound → (nil, nil)」的数据源交给工厂, 工厂决定后端形态(LocalCache/RedisCache、是否包 SingleFlightCache) 与 TTL——TTL 是删缓存失败时的唯一安全网,务必配置有限值。
type CachedOption ¶
type CachedOption[T any] func(*config[T])
CachedOption 配置 CachedRepository(命名避开裸 Option——多包 co-import 冲突,仓内硬规则;本仓 data/cache 壳已存在 cache.Option)。
func WithBloomFilter ¶ added in v0.7.0
func WithBloomFilter[T any](bf BloomChecker) CachedOption[T]
WithBloomFilter 开启布隆防穿透前置门(可选件):GetByID 回源前先问 bloom,判定"绝对不存在"的 ID 直接走 null marker,仓储零调用—— 在 null marker 之前再挡掉一层恶意 ID 扫描。
⚠️ 启用前提(违反会造成"数据消失"级事故):
- **所有写入必须走本装饰器**(Create 时自动 Add 新 ID)——旁路写入 的 ID 不在 bloom 里,点读会被误判为不存在(null marker 兜底, 错误窗口受 null TTL 约束但数据在窗口内不可见);
- **存量数据必须预热**(部署前把既有 ID 全量 MAdd);
- ID 空间应当单调递增(自增主键天然满足)——bloom 只增不减,删除 不摘除,误判只会放行仓储(正确,只是少省一次查询),绝不会反向。
bloom 读失败 fail-open 放行仓储(fail-closed 会错杀真数据,绝不接受); 读写异常均经 WithOnError 上报(op="bloom")。
func WithID ¶
func WithID[T any](fn func(*T) int64) CachedOption[T]
WithID 注入实体 ID 访问器,写路径据此删实体键。 缺省时按顺序探测:GetID() int64 方法(含指针接收者,gormx.BaseModel 系列天然满足)→ 名为 "ID" 的可导出 int64 字段;两者都失败时写路径 只 bump 查询版本、无法删实体键(点读一致性退化为 TTL 兜底)。
func WithOnError ¶
func WithOnError[T any](fn OnErrorFunc) CachedOption[T]
WithOnError 注入缓存侧异常上报钩子(读降级/回填失败/失效失败)。
func WithPrefix ¶
func WithPrefix[T any](prefix string) CachedOption[T]
WithPrefix 覆盖键前缀(默认 "cached")。多应用共享一个 Redis 时用 应用名做前缀隔离。
func WithQueryCache ¶
func WithQueryCache[T any](qc *QueryCache[T]) CachedOption[T]
WithQueryCache 开启查询缓存(GetOne/List)。传 nil 等价不开启。
func WithType ¶
func WithType[T any](name string) CachedOption[T]
WithType 覆盖键中的类型名段(默认反射类型名字符串,如 "main.User")。 同一实体跨服务共用缓存前缀时建议显式指定短名。
type CachedRepository ¶
type CachedRepository[T any] struct { // contains filtered or unexported fields }
CachedRepository 是带缓存编排的仓储装饰器,实现 crud.Repository[T] 与 crud.TxRepository[T]。可直接作为 crud.NewBaseService 的仓储入参 ——事务路径经类型断言自动启用,失效在提交后执行。
func NewRepository ¶
func NewRepository[T any](repo crud.Repository[T], newCache CacheFactory[T], opts ...CachedOption[T]) *CachedRepository[T]
NewRepository 创建缓存编排仓储装饰器。 newCache 必填:装饰器不代持后端构造,TTL/容量/序列化等后端参数 全部留在调用方手里,装饰器只注入回源函数。
func (*CachedRepository[T]) BatchCreate ¶
func (r *CachedRepository[T]) BatchCreate(ctx context.Context, entities []*T, batchSize int) error
BatchCreate 写库后逐实体删键 + 一次版本 bump。
func (*CachedRepository[T]) BatchDelete ¶
func (r *CachedRepository[T]) BatchDelete(ctx context.Context, ids []int64) error
BatchDelete 写库后批量删键 + 一次版本 bump。
func (*CachedRepository[T]) Create ¶
func (r *CachedRepository[T]) Create(ctx context.Context, entity *T) error
Create 写库后删实体键(预置主键的 Create 之前可能已有 null marker, 必须清)并 bump 查询版本(新行进列表)。
func (*CachedRepository[T]) Delete ¶
func (r *CachedRepository[T]) Delete(ctx context.Context, id int64) error
Delete 写库后删实体键 + bump 查询版本。
func (*CachedRepository[T]) GetByCondition ¶
func (r *CachedRepository[T]) GetByCondition(ctx context.Context, query any, args ...any) (*T, error)
GetByCondition 原始条件读永远直通:raw where 无法做稳定指纹。
func (*CachedRepository[T]) GetByID ¶
func (r *CachedRepository[T]) GetByID(ctx context.Context, id int64) (*T, error)
GetByID 点读:缓存命中直达,未命中经后端 GetOrLoad 回源回填 (null marker 防穿透、singleflight 防击穿由后端承担)。缓存读失败 降级直查仓储。
已知代价:GetOrLoad 的 error 无法区分"缓存读失败"与"回源仓储失败" (CacheAside 接口无错误分类),两者都按读降级处理并上报 op="read"; 仓储故障时每个并发等待者各降级查一次库,等于击穿到库——但仓储本来 就在报错,多这几击不改变故障性质。
func (*CachedRepository[T]) GetByIDForUpdate ¶
func (r *CachedRepository[T]) GetByIDForUpdate(ctx context.Context, id int64) (*T, error)
GetByIDForUpdate 行锁读直通:锁读的语义就是"拿最权威的数据", 过缓存既拿不到锁还会读到旧值。
func (*CachedRepository[T]) GetByIDs ¶ added in v0.7.0
GetByIDs 批量点读:逐 ID 走点读缓存路径(命中直达 / 未命中 GetOrLoad 回源回填 / 不存在的 ID 经 null marker 防穿透),并发场景由后端的 SingleFlightCache 去重回源。
返回 map 中只包含找到的实体;未找到的 ID **不在 map 里且不算错误** (与点读缓存的 (nil, nil) 双关语义一致,第二次批量查询同类 ID 时 全部走缓存,仓储零调用)。单个 ID 的仓储硬错误不阻断其余 ID, 以 errors.Join 聚合返回——调用方拿到「部分成功 + 聚合错误」。
注意:这是"逐 ID 查"而非 MGet 批量网络往返——本地缓存后端每键开销 可忽略;Redis 后端是 N 次往返,大批量场景请自行控制批次大小 (MGet 快路径等 pkg/cache 出现批量能力接口后再接)。
func (*CachedRepository[T]) RunInTx ¶
func (r *CachedRepository[T]) RunInTx(ctx context.Context, fn func(repo crud.Repository[T]) error) error
RunInTx 事务包装:fn 收到的事务仓储把写操作记录进失效清单, 提交成功后统一失效(先删实体键后 bump 版本);回滚不失效。 事务内的读操作直通事务仓储(保证读到事务内状态,缓存只会是旧的)。
func (*CachedRepository[T]) Update ¶
func (r *CachedRepository[T]) Update(ctx context.Context, entity *T) error
Update 写库后删实体键 + bump 查询版本。
func (*CachedRepository[T]) UpdateFields ¶
func (r *CachedRepository[T]) UpdateFields(ctx context.Context, id int64, fields map[string]any) error
UpdateFields 写库后删实体键 + bump 查询版本。
type FingerprintFunc ¶
FingerprintFunc 将查询物化形态折算为稳定的指纹字符串。 返回 error 或空串时该次查询降级直通(绝不以错误指纹命中错误结果)。
type OnErrorFunc ¶
OnErrorFunc 缓存侧异常上报钩子:读降级(op="read")、回填失败 (op="backfill")、指纹失败(op="fingerprint")、写后失效失败 (op="invalidate")。所有场景都是"降级继续 + TTL 兜底",钩子只管 观测(记日志/打点),不能也不需要参与决策。
type QueryCache ¶
type QueryCache[T any] struct { // contains filtered or unexported fields }
QueryCache 是查询缓存(GetOne/List)的存储面:单条结果、整页结果、 表级版本号三个键空间 + 条目 TTL + 指纹算法。
键由 CachedRepository 组装("前缀:类型:q:one|list:版本:指纹"), entity 键空间与点读键空间("前缀:类型:id:N")互不重叠,因此 entity 可以复用点读后端的同一个缓存实例(本地缓存直接传同一实例;Redis 后端传同前缀实例即可)。
func NewQueryCache ¶
func NewQueryCache[T any]( entity cache.Cache[T], page cache.Cache[QueryPage[T]], versions cache.Cache[string], ttl time.Duration, ) (*QueryCache[T], error)
NewQueryCache 创建查询缓存。
- entity:GetOne 结果缓存(cache.Cache[T]),可与点读后端共享实例;
- page:List 结果缓存;
- versions:表级版本号缓存,Set 走其默认 TTL——务必配置长 TTL (远大于条目 TTL;版本键过期只导致多一次重建,不致脏读,但 频繁重建徒增回源压力);
- ttl:条目 TTL,必须为正——写后失效(bump 版本/删键)失败时 TTL 是唯一安全网,无 TTL 意味着可能永久脏读。
entity/page/versions 任意为 nil 在构造期即拒绝(否则到使用时才 nil panic)。
func (*QueryCache[T]) WithFingerprint ¶
func (qc *QueryCache[T]) WithFingerprint(fn FingerprintFunc) *QueryCache[T]
WithFingerprint 覆盖默认指纹算法。