Help us shape the future of Go! Take the 2026 Go Developer Survey

cached

package
v0.19.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT Imports: 14 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultFingerprint

func DefaultFingerprint[T any](spec crud.QuerySpec) (string, error)

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]) Count

func (r *CachedRepository[T, ID]) Count(ctx context.Context, q *crud.Query) (int64, error)

Count 直通(廉价查询,缓存无收益)。

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]) Exists

func (r *CachedRepository[T, ID]) Exists(ctx context.Context, q *crud.Query) (bool, error)

Exists 直通(廉价查询,缓存无收益)。

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

func (r *CachedRepository[T, ID]) GetOne(ctx context.Context, q *crud.Query) (*T, error)

GetOne 条件单读:开启查询缓存时走「版本号 + 指纹」键,负结果不缓存, ForUpdate 读与指纹失败降级直查。

func (*CachedRepository[T, ID]) List

func (r *CachedRepository[T, ID]) List(ctx context.Context, q *crud.Query) ([]T, int64, error)

List 条件列表:开启查询缓存时走「版本号 + 指纹」键缓存整页结果, 任意写操作 bump 版本整体失效。

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

type FingerprintFunc func(spec crud.QuerySpec) (string, error)

FingerprintFunc 将查询物化形态折算为稳定的指纹字符串。 返回 error 或空串时该次查询降级直通(绝不以错误指纹命中错误结果)。

type OnErrorFunc

type OnErrorFunc func(ctx context.Context, op string, keys []string, err error)

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 覆盖默认指纹算法。

type QueryPage

type QueryPage[T any] struct {
	Items []T
	Total int64
}

QueryPage 是 List 查询缓存的结果形态(对应仓储 List 的 ([]T, int64, error) 三元组)。

type ScopeKeyFunc added in v0.19.4

type ScopeKeyFunc func(ctx context.Context) (string, error)

ScopeKeyFunc 按调用上下文解析「作用域键」(租户/用户/分区…)。

返回 error 时**整个操作失败**:作用域解析不出来却继续用共享键,正是 跨作用域串数据的根因。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL