正确做法是写入时先取消旧定时器再新建,或用 time.After+select 非阻塞判断;缓存击穿需用 singleflight.Group 实现懒加载与请求归并;TTL 与 LRU 驱逐独立运行,过期检查惰性触发;测试应抽离 Clock 接口以控制时间。

用 time.AfterFunc 模拟缓存过期容易出错
Go 里没有内置的「带 TTL 的 map」,很多人直接用 time.AfterFunc 给每个 key 启一个 goroutine 清理,结果压测时发现内存暴涨、goroutine 数飙升到几万——这是因为没控制清理任务的生命周期,过期前就已失效的 key,其清理函数仍会执行,还可能访问已释放的结构体字段。
正确做法是配合 sync.Map 或 map[interface{}]interface{} + sync.RWMutex,在写入时先取消旧的定时器(需保存 *time.Timer),再新建。更稳妥的是改用 time.After + select 非阻塞判断,或直接用现成的 github.com/patrickmn/go-cache(它内部用 runtime.SetFinalizer 做兜底,但注意 finalizer 不保证及时触发)。
- 别在测试中依赖
time.Sleep等待过期:精度低、不稳定,CI 环境常因调度延迟导致误判 - 如果自己实现,务必在删除 key 前调用
timer.Stop(),否则 timer 仍会触发,可能 panic 或写入已回收内存 -
go-cache默认不开启自动清理,要显式调用RunExpirationCheckInterval,否则过期项只在 Get 时惰性清理
测试并发读写下缓存击穿的真实表现
单测里用顺序操作验证「过期后重新加载」没问题,一上压测就出现大量重复加载——这是典型的缓存击穿:多个 goroutine 同时发现 key 过期,都去查 DB,没做加载互斥。
解决核心是「懒加载 + 双检锁」,但 Go 里不能简单套 Java 的 synchronized,得用 sync.Once 或 singleflight.Group。后者更合适,因为它天然支持多 key 并发归并,且能透传 error。
立即学习“go语言免费学习笔记(深入)”;
- 别用
sync.Mutex锁整个 cache:性能差,且无法区分不同 key 的加载请求 -
singleflight.Group.Do返回的是首次调用的结果,后续并发请求会等它完成,但要注意:如果首次加载失败(如 DB 超时),后续请求也会拿到 error,需结合 fallback 或重试逻辑 - 测试时要用
sync.WaitGroup启多个 goroutine 同时 Get 同一个即将过期的 key,观察 DB 查询次数是否为 1
验证 LRU 驱逐与 TTL 过期的优先级冲突
用 github.com/hashicorp/golang-lru/v2 这类带 TTL 的 LRU 库时,发现某些 key 明明没过期,却因容量满被提前淘汰——这不是 bug,是设计使然:TTL 检查只在 Get 时惰性触发,而驱逐是主动行为,两者独立运行。
这意味着:即使设置了 5 分钟 TTL,只要 cache 满了,key 可能在 10 秒后就被踢掉;反过来,过期 key 若一直没人 Get,也不会被自动清理,直到下次访问或触发清理周期。
- 测试时不能只看「过期时间到了是否拿不到」,还要压满 cache,再检查过期 key 是否仍能 Get 到(应能,但下次 Get 会返回 nil 并清理)
- 该库的
OnEvicted回调不会触发于 TTL 过期,只触发于容量驱逐,别指望它做过期清理钩子 - 若需严格按时清理,得自己起 goroutine 定期调用
RemoveOldest或遍历Keys()手动清理,但注意加锁和性能开销
用 gomock 打桩外部依赖时绕不开的时间控制
测试缓存策略必须隔离 DB、HTTP 等外部调用,但一打桩就暴露时间问题:比如想验证「缓存过期后第 1 次 Get 触发加载」,可打桩后的加载函数执行飞快,根本来不及让 TTL 生效。
根本解法不是 mock 时间,而是把时间逻辑抽成接口,例如定义 type Clock interface { Now() time.Time },测试时注入可进快进的 MockClock,生产用 time.Now 包装。这样所有 TTL 判断、过期计算都走同一入口,可控、可预测。
- 别在业务代码里硬写
time.Now().Add(...):无法 mock,测试只能靠 Sleep,不可靠 - 第三方库如
go-cache不支持自定义 clock,此时只能接受其默认行为,或换库 - gomock 生成的 mock 对象本身不处理时间,你 mock 的是加载函数,不是时间源——这点常被忽略
真正难的不是写对缓存代码,而是让「时间」在测试里变得确定。一旦依赖真实时钟,所有关于「过期」「延迟」「并发窗口」的断言都会飘。










