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 兜底。缓存后端读失败时读路径降级直查仓储。
- 多租户/多作用域:缓存键原本**没有作用域维度** ⇒ 底层仓储按租户 隔离时(RLS / 每租户连接)同 ID 在不同租户下是不同的行却共用一个 键 ⇒ 跨租户读泄漏,且某租户「不存在」的 null marker 会污染其他 租户。用 WithScopeKey 注入作用域解析器(按 ctx 解析)⇒ 键变为 「前缀:类型:scope:<作用域>:id:N」,一个仓储实例即可服务多个租户。 作用域解析失败 ⇒ 读路径降级直查仓储、写路径跳过失效(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, ID comparable](bf BloomChecker) CachedOption[T, ID]
- func WithID[T any, ID comparable](fn func(*T) ID) CachedOption[T, ID]
- func WithIDCodec[T any, ID comparable](encode func(ID) string, decode func(string) (ID, error)) CachedOption[T, ID]
- func WithOnError[T any, ID comparable](fn OnErrorFunc) CachedOption[T, ID]
- func WithPrefix[T any, ID comparable](prefix string) CachedOption[T, ID]
- func WithQueryCache[T any, ID comparable](qc *QueryCache[T]) CachedOption[T, ID]
- func WithScopeKey[T any, ID comparable](fn ScopeKeyFunc) CachedOption[T, ID]
- func WithType[T any, ID comparable](name string) CachedOption[T, ID]
- type CachedRepository
- func (r *CachedRepository[T, ID]) BatchCreate(ctx context.Context, entities []*T, batchSize int) error
- func (r *CachedRepository[T, ID]) BatchDelete(ctx context.Context, ids []ID) error
- func (r *CachedRepository[T, ID]) Count(ctx context.Context, q *crud.Query) (int64, error)
- func (r *CachedRepository[T, ID]) Create(ctx context.Context, entity *T) error
- func (r *CachedRepository[T, ID]) Delete(ctx context.Context, id ID) error
- func (r *CachedRepository[T, ID]) Exists(ctx context.Context, q *crud.Query) (bool, error)
- func (r *CachedRepository[T, ID]) GetByCondition(ctx context.Context, query any, args ...any) (*T, error)
- func (r *CachedRepository[T, ID]) GetByID(ctx context.Context, id ID) (*T, error)
- func (r *CachedRepository[T, ID]) GetByIDForUpdate(ctx context.Context, id ID) (*T, error)
- func (r *CachedRepository[T, ID]) GetByIDs(ctx context.Context, ids []ID) (map[ID]*T, error)
- func (r *CachedRepository[T, ID]) GetOne(ctx context.Context, q *crud.Query) (*T, error)
- func (r *CachedRepository[T, ID]) List(ctx context.Context, q *crud.Query) ([]T, int64, error)
- func (r *CachedRepository[T, ID]) RunInTx(ctx context.Context, fn func(repo crud.Repository[T, ID]) error) error
- func (r *CachedRepository[T, ID]) Update(ctx context.Context, entity *T) error
- func (r *CachedRepository[T, ID]) UpdateFields(ctx context.Context, id ID, fields map[string]any) error
- type FingerprintFunc
- type OnErrorFunc
- type QueryCache
- type QueryPage
- type ScopeKeyFunc
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, ID comparable] func(*config[T, ID])
CachedOption 配置 CachedRepository(命名避开裸 Option——多包 co-import 冲突,仓内硬规则;本仓 data/cache 壳已存在 cache.Option)。
func WithBloomFilter ¶ added in v0.7.0
func WithBloomFilter[T any, ID comparable](bf BloomChecker) CachedOption[T, ID]
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, ID comparable](fn func(*T) ID) CachedOption[T, ID]
WithID 注入实体 ID 访问器,写路径据此删实体键。 缺省时按顺序探测:GetID() 方法(含指针接收者,gormx.BaseModel 系列 天然满足;返回类型须与 ID 精确一致)→ 名为 "ID" 的可导出字段(类型 须与 ID 一致);两者都失败时写路径只 bump 查询版本、无法删实体键 (点读一致性退化为 TTL 兜底)。
func WithIDCodec ¶ added in v0.14.0
func WithIDCodec[T any, ID comparable](encode func(ID) string, decode func(string) (ID, error)) CachedOption[T, ID]
WithIDCodec 注入缓存键中 ID 段的编解码(encode:ID → 键段字符串; decode:键段字符串 → ID)。
缺省编解码:string 直通;全部整型走十进制(与 v0.13 及之前 int64 时代 的键格式完全兼容)。其余 comparable 类型(复合键、自定义结构等)的 键往返不可逆,必须显式注入——否则 source 解析键时报错(fail-fast, 不会静默错回源)。
func WithOnError ¶
func WithOnError[T any, ID comparable](fn OnErrorFunc) CachedOption[T, ID]
WithOnError 注入缓存侧异常上报钩子(读降级/回填失败/失效失败)。
func WithPrefix ¶
func WithPrefix[T any, ID comparable](prefix string) CachedOption[T, ID]
WithPrefix 覆盖键前缀(默认 "cached")。多应用共享一个 Redis 时用 应用名做前缀隔离。
func WithQueryCache ¶
func WithQueryCache[T any, ID comparable](qc *QueryCache[T]) CachedOption[T, ID]
WithQueryCache 开启查询缓存(GetOne/List)。传 nil 等价不开启。
func WithScopeKey ¶ added in v0.19.4
func WithScopeKey[T any, ID comparable](fn ScopeKeyFunc) CachedOption[T, ID]
WithScopeKey 注入作用域键解析器:作用域段进入**每一个**缓存键与布隆元素。
为什么需要 ¶
缓存键原本只有「前缀:类型:id:N」三段,**没有租户/作用域维度**。底层仓储 若是按租户隔离的(PostgreSQL RLS / 每租户连接 / 每租户库),同一个 ID 在 不同租户下是**不同的行**,却共用同一个缓存键 ⇒
- **跨租户读泄漏**:A 租户命中 B 租户写入的缓存条目;
- **「不存在」污染**:A 租户下不存在的 ID 写下的 null marker,让 B 租户 明明有这行却命中 marker 报 NotFound。
启用后键形态为「前缀:类型:scope:<作用域>:id:N」,作用域由 ctx 解析 ⇒ **一个仓储实例可服务多个租户**(此前只能每租户一个实例 + 每租户一个前缀)。
约束 ¶
- 作用域解析失败 ⇒ 操作失败(读路径降级直查仓储;写路径跳过失效并经 onError 上报,TTL 兜底),**绝不**回落到无作用域的共享键;
- 作用域键不得为空串——空串会被拒绝(否则等于没开隔离);确实没有 作用域时请返回一个占位值(如 "-")。
未启用时(默认)键形态与历史完全一致(无 scope 段),行为不变。
func WithType ¶
func WithType[T any, ID comparable](name string) CachedOption[T, ID]
WithType 覆盖键中的类型名段(默认反射类型名字符串,如 "main.User")。 同一实体跨服务共用缓存前缀时建议显式指定短名。
type CachedRepository ¶
type CachedRepository[T any, ID comparable] struct { // contains filtered or unexported fields }
CachedRepository 是带缓存编排的仓储装饰器,实现 crud.Repository[T, ID] 与 crud.TxRepository[T, ID]。可直接作为 crud.NewBaseService 的仓储入参 ——事务路径经类型断言自动启用,失效在提交后执行。 ID 为实体主键类型(int64 自增 / string 文本主键等,见 crud.Repository)。
func NewRepository ¶
func NewRepository[T any, ID comparable](repo crud.Repository[T, ID], newCache CacheFactory[T], opts ...CachedOption[T, ID]) *CachedRepository[T, ID]
NewRepository 创建缓存编排仓储装饰器。 newCache 必填:装饰器不代持后端构造,TTL/容量/序列化等后端参数 全部留在调用方手里,装饰器只注入回源函数。
func (*CachedRepository[T, ID]) BatchCreate ¶
func (r *CachedRepository[T, ID]) BatchCreate(ctx context.Context, entities []*T, batchSize int) error
BatchCreate 写库后逐实体删键 + 一次版本 bump。
func (*CachedRepository[T, ID]) BatchDelete ¶
func (r *CachedRepository[T, ID]) BatchDelete(ctx context.Context, ids []ID) error
BatchDelete 写库后批量删键 + 一次版本 bump。
func (*CachedRepository[T, ID]) Create ¶
func (r *CachedRepository[T, ID]) Create(ctx context.Context, entity *T) error
Create 写库后删实体键(预置主键的 Create 之前可能已有 null marker, 必须清)并 bump 查询版本(新行进列表)。
func (*CachedRepository[T, ID]) Delete ¶
func (r *CachedRepository[T, ID]) Delete(ctx context.Context, id ID) error
Delete 写库后删实体键 + bump 查询版本。
func (*CachedRepository[T, ID]) GetByCondition ¶
func (r *CachedRepository[T, ID]) GetByCondition(ctx context.Context, query any, args ...any) (*T, error)
GetByCondition 原始条件读永远直通:raw where 无法做稳定指纹。
func (*CachedRepository[T, ID]) GetByID ¶
func (r *CachedRepository[T, ID]) GetByID(ctx context.Context, id ID) (*T, error)
GetByID 点读:缓存命中直达,未命中经后端 GetOrLoad 回源回填 (null marker 防穿透、singleflight 防击穿由后端承担)。缓存读失败 降级直查仓储。
已知代价:GetOrLoad 的 error 无法区分"缓存读失败"与"回源仓储失败" (CacheAside 接口无错误分类),两者都按读降级处理并上报 op="read"; 仓储故障时每个并发等待者各降级查一次库,等于击穿到库——但仓储本来 就在报错,多这几击不改变故障性质。
func (*CachedRepository[T, ID]) GetByIDForUpdate ¶
func (r *CachedRepository[T, ID]) GetByIDForUpdate(ctx context.Context, id ID) (*T, error)
GetByIDForUpdate 行锁读直通:锁读的语义就是"拿最权威的数据", 过缓存既拿不到锁还会读到旧值。
func (*CachedRepository[T, ID]) GetByIDs ¶ added in v0.7.0
func (r *CachedRepository[T, ID]) GetByIDs(ctx context.Context, ids []ID) (map[ID]*T, error)
GetByIDs 批量点读:后端具备 MGet 批量能力(pkg/cache.BatchGetter, LocalCache/RedisCache/SingleFlightCache/twolevel 组合体均已实现)时 一次批量往返拿真值, 缺席键再逐个走点读缓存路径(真 miss 回源回填、null marker 在后端短路) ——N 次网络往返收敛为 1 次批量 + 有限次回源;批量读失败自动降级逐 ID。
返回 map 中只包含找到的实体;未找到的 ID **不在 map 里且不算错误** (与点读缓存的 (nil, nil) 双关语义一致,第二次批量查询同类 ID 时 全部走缓存,仓储零调用)。单个 ID 的仓储硬错误不阻断其余 ID, 以 errors.Join 聚合返回——调用方拿到「部分成功 + 聚合错误」。
func (*CachedRepository[T, ID]) GetOne ¶
GetOne 条件单读:开启查询缓存时走「版本号 + 指纹」键,负结果不缓存, ForUpdate 读与指纹失败降级直查。
func (*CachedRepository[T, ID]) RunInTx ¶
func (r *CachedRepository[T, ID]) RunInTx(ctx context.Context, fn func(repo crud.Repository[T, ID]) error) error
RunInTx 事务包装:fn 收到的事务仓储把写操作记录进失效清单, 提交成功后统一失效(先删实体键后 bump 版本);回滚不失效。 事务内的读操作直通事务仓储(保证读到事务内状态,缓存只会是旧的)。
func (*CachedRepository[T, ID]) Update ¶
func (r *CachedRepository[T, ID]) Update(ctx context.Context, entity *T) error
Update 写库后删实体键 + bump 查询版本。
func (*CachedRepository[T, ID]) UpdateFields ¶
func (r *CachedRepository[T, ID]) UpdateFields(ctx context.Context, id ID, 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 覆盖默认指纹算法。