缓存清理必须用 sync.RWMutex 控制并发读写,避免 panic;优先按条件清理过期或指定 key,禁用全量遍历删除;清理前需显式释放资源并防 panic,测试须覆盖真实并发场景。

缓存清理必须考虑并发读写冲突
Go 的 map 本身不是线程安全的,直接在多 goroutine 场景下对缓存 map 执行 delete 或遍历 + 清理,会触发 panic:fatal error: concurrent map iteration and map write。这不是“偶尔出错”,而是只要存在读(比如 cache[key])和写(比如 delete(cache, key))同时发生,就必然崩溃。
正确做法是用 sync.RWMutex 控制访问:读操作用 RLock()/RUnlock(),清理(写)操作用 Lock()/Unlock()。注意不要在持有读锁时调用清理函数——否则会死锁。
- 清理函数内部必须获取写锁,且全程独占
- 如果清理逻辑包含回调(如释放资源),回调里不能再读写该缓存
map - 避免在清理循环中调用可能阻塞或重入缓存读写的函数
按条件清理比全量清空更常见也更安全
生产中极少需要「清空全部」,多数场景是清理过期项、指定前缀的 key、或引用计数归零的条目。全量遍历 map 并 delete 不仅慢,还会长时间持写锁,阻塞所有读请求。
推荐用「标记 + 延迟清理」或「分批清理」。例如为每个 entry 增加 expiresAt time.Time 字段,在 Get 时惰性淘汰;或用 sync.Map 配合原子计数,但要注意 sync.Map 不支持遍历删除——你仍需自己维护 key 列表或用定时器扫描。
立即学习“go语言免费学习笔记(深入)”;
- 定期触发的清理(如每分钟)应限制单次最多删 100–500 条,防止 STW
- 用
time.Now().After(entry.ExpiresAt)判断过期,别依赖系统时钟跳变 - 若用
sync.Map,清理前先LoadAll()(需自行实现)或改用带锁的普通map
清理函数必须能处理 panic 和资源泄漏
缓存 value 往往是结构体指针或含文件句柄、网络连接等资源的对象。单纯 delete(cache, key) 不会自动释放这些资源,容易导致内存或 fd 泄漏。
安全做法是在清理前显式调用析构逻辑。例如定义接口:
type Cleaner interface {
Cleanup()
}
然后在清理函数中检查 value 是否实现了该接口并调用:
- 强制所有可缓存类型实现
Cleanup(),并在其中关闭 conn、释放 buffer、unref refcount 等 - 避免在
Cleanup()中再操作缓存本身(如再次调用Get或Delete) - 用
defer func() { recover() }()包裹Cleanup()调用——个别实现可能 panic,不能让整个清理流程中断
测试清理函数要覆盖真实并发路径
单元测试只跑单 goroutine 的 DeleteAll() 没用。真正危险的是读写竞争,必须用 go test -race 并构造并发压力。
一个最小可行测试片段:
func TestCacheCleanupConcurrent(t *testing.T) {
cache := NewSafeCache()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
cache.Get(fmt.Sprintf("key-%d", j%10))
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
cache.CleanExpired() // 或 CleanAll()
}()
wg.Wait()
}
- 必须加上
-race参数运行,否则测不出数据竞争 - 清理函数被调用时,其他 goroutine 正在执行
Get或Set,这才是真实场景 - 如果清理逻辑涉及外部依赖(如调用 HTTP client),测试中要用 httptest 或 mock,避免超时拖慢或失败
最常被忽略的一点:清理函数的「安全」不只在于不 panic,而在于不丢数据、不漏释放、不阻塞业务——它往往比缓存读写逻辑更难压测和验证。


















