Otter的RefreshWriting是异步刷新策略,以最后写入时间为起点计时,仅在Get触发时启动后台goroutine调用LoaderFunc返回旧值、不阻塞读请求、同一key刷新中后续Get仍返回旧值且不重复加载。

为什么 refreshAfterWrite 不等于异步刷新
Guava Cache 的 refreshAfterWrite 常被误认为“自动后台刷新”,但它实际只是“懒触发”:只有在调用 get() 且缓存项已过刷新时间时,才同步或异步加载新值。Go 生态中很多库(如 go-cache)压根不提供刷新能力;而 sync.Map 或手写 map + sync.RWMutex 更是连过期逻辑都要自己补全。真正的异步刷新必须满足两个硬条件:刷新动作不阻塞读请求、刷新过程可并发安全地复用(比如多个 goroutine 同时访问同一 key,只应触发一次加载)。否则就会出现“伪刷新”——看着配置了刷新,实际还是串行阻塞或重复加载。
otter 的 RefreshWriting 是怎么工作的
otter 把异步刷新拆成三件事:何时该刷(策略)、谁来刷(loader)、刷完怎么交差(后台 goroutine 管理)。RefreshWriting 策略以最后一次写入时间为起点计时,比如 otter.RefreshWriting(500 * time.Millisecond) 表示写入后 500ms 就“可刷新”,但不会立刻执行——只有下一次 Get 触发时,才启动后台 goroutine 调用你传入的 LoaderFunc。
-
cache.Get(ctx, key, loader)返回的是旧值,不等刷新完成 - 刷新失败不会影响本次返回,错误需在
loader内显式处理(比如打日志) - 同一 key 在刷新进行中,后续
Get仍返回旧值,不会重复触发 loader - 刷新 goroutine 没有上下文超时控制,
loader必须自己加ctx.Done()检查,否则可能泄漏
不用 otter 时,如何手动模拟异步刷新
如果项目已用 ristretto 或自研缓存,又不想换库,可以用 singleflight.Group + time.AfterFunc 组合实现轻量级异步刷新语义:
- 每次
Get前检查是否“该刷新”(比如记录 lastAccess 时间 + TTL/2),若是则调用group.Do(key, func() { loader() }) - loader 内部做真实数据拉取,并成功后调用
cache.Set()更新本地值 - 注意:不能把
time.AfterFunc直接挂到每个 key 上,key 太多会创建海量 timer;应改用“惰性标记 + 全局 ticker 扫描”或“访问时按概率触发” - 刷新结果写回缓存前,建议先比较新旧值(如用
reflect.DeepEqual),避免无意义写入和版本抖动
最容易被忽略的崩溃恢复问题
所有基于内存的异步刷新(包括 otter)都不保存刷新状态到磁盘或 Redis。服务重启后,所有“已过刷新时间但尚未触发”的条目会丢失刷新意图,变成“永久不过期”直到下次访问。这不是 bug,是设计取舍。如果你的业务要求“哪怕进程挂了,5 分钟内也必须刷新一次”,那必须引入外部协调机制:
- 单机部署:用
os.Signal捕获SIGHUP,在退出前持久化待刷新 key 列表到临时文件,启动时扫描恢复 - 多实例部署:用 Redis 的
SET key value NX EX 300抢占刷新权,抢到的实例负责全量扫描并刷新,其他实例只做本地 fallback - 别依赖
time.Ticker做全局刷新调度——它无法感知 key 级别的生命周期,容易把热 key 和冷 key 一锅炖

















