直接用 sync.rwmutex 包裹 map 是最可控、最易调试的并发安全缓存起点;过期清理必须惰性 + 定期扫描结合,不能为每个 key 启 goroutine 或 timer。

直接用 sync.RWMutex 包裹 map 是最可控、最易调试的并发安全缓存起点;过期清理必须惰性 + 定期扫描结合,不能为每个 key 启 goroutine 或 timer。
为什么不用 sync.Map 做带过期的缓存
sync.Map 确实免锁、适合高频读写,但它不支持原子性地“读 + 判断过期 + 删除”这一连串操作。你无法在不遍历全量数据的前提下获取所有过期项——而遍历 sync.Map 本身没有稳定快照语义,容易漏删或 panic。更关键的是,sync.Map 不提供写锁粒度控制,你没法在清理时安全地批量删除多个 key 而不干扰正在进行的 Get。
- 适用场景:键集稳定、几乎不删、只增不改的元数据缓存(如配置映射表)
- 不适用场景:需要 TTL、LRU 淘汰、后台清理、或 Get 时需校验并隐式驱逐的业务缓存
- 性能陷阱:频繁
Delete会导致内部桶分裂和 GC 压力上升,实测比加锁map更慢
Get 时必须做惰性过期检查
每次调用 Get 都要先拿 RLock,读出 item 后立刻判断是否过期,过期就删掉再返回未命中——而不是“先返回再异步删”。否则会出现脏读:用户拿到一个已过期但尚未被清理的值。
在 Go 中使用 google/wire 实现编译时依赖注入——wire.NewSet、wire.Build、wire.Bind(接口→实现)、wire.Struct、wire.Value、wire.Interface
- 检查逻辑必须在读锁持有期间完成,避免检查后被其他 goroutine 修改
- 删除动作需升级为写锁:
RLock→ 检查 →Unlock→Lock→delete→Unlock,中间有竞态窗口,但这是可接受的权衡 - 不要在
Get里触发清理 goroutine,会快速堆积成千上万个 goroutine
后台清理 goroutine 的正确启动方式
用一个单独 goroutine 每隔 cleanupInterval 扫描一次全量 items,但必须全程持 Lock,且扫描是 O(n) —— 这就是为什么不能靠它扛高并发写入:写操作会被阻塞。
- 推荐间隔设为 30s–2m,太短加重锁争用,太长导致内存滞留
- 清理前先
Len()估算规模,若 > 10k 项,考虑分批扫描(每次锁一小段 map 分片) - 必须监听
stopChan,在程序退出前 close 清理 goroutine,否则测试时容易 leak - 不要用
time.Tick,它不会跳过积压的 tick,可能引发雪崩式重扫
Set 时不处理过期,只更新时间戳
Set 的唯一职责是写入或覆盖,不做任何清理、不检查旧值是否过期、不触发淘汰。过期和淘汰是读(Get)和后台(cleanup)的事。
- 这样能保证
Set是纯写操作,延迟稳定,不因旧数据状态波动 - LRU 类淘汰策略也应独立于
Set:由访问序维护,而非写入序 - 如果业务强依赖“写入即失效旧值”,那说明不是缓存问题,是数据一致性模型没理清
真正难的不是加锁或写定时器,而是厘清“谁在什么时候对哪个状态做哪类修改”——读写锁只是工具,状态流转逻辑才是并发安全的命门。一个 item 从写入、被读、被判定过期、到物理删除,每一步的锁持有范围和可见性边界都得对得上。错一点,就是偶发 panic 或静默数据污染。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










