sync.map不会导致内存泄漏但会持续增长至oom,因其不清理、不驱逐、无ttl;delete仅标记不释放,大value强引用阻塞gc;range中不可删键,需先收集后批量删除;写多读少等场景应换用sync.rwmutex+map。
sync.map 本身不会导致内存无限暴增,但放任不管一定会涨到 oom —— 它不清理、不驱逐、不设 ttl,只保证并发安全。
sync.Map 内存“越用越多”的真实原因
不是 leak,是设计使然:read map 是只读快照,dirty map 升级时旧 read 不立即释放;Delete 只打标记(entry.p = nil),entry 结构体仍驻留底层哈希桶中;value 是 interface{},若存了大 struct、未关闭的 *http.Response.Body 或闭包捕获对象,强引用锁死整块内存。
常见现象包括:
-
pprof显示runtime.mapassign持续分配,HeapInuse单向上涨 -
Range越跑越慢,因为要跳过大量已删 entry,但底层 map 大小没变 - 监控发现每分钟
heap_allocs增长超 50MB,且sync.(*Map).Store和sync.(*Map).Range占 CPU Top 3
必须在 Range 外批量 Delete,不能在回调里删
Range 是快照语义,遍历时看不到新写入 key,更关键的是:回调函数内调用 Delete 或 Store 会 panic 或死锁 —— 因为内部锁和遍历状态冲突。
正确做法是用切片暂存待删 key:
var toDelete []interface{}
m.Range(func(k, v interface{}) bool {
if ts, ok := v.(int64); ok && time.Since(time.Unix(0, ts)) > 10*time.Minute {
toDelete = append(toDelete, k)
}
return true
})
for _, k := range toDelete {
m.Delete(k)
}
注意:
- 不要用
time.Ticker驱动全局定时清理 —— 额外 goroutine + 锁竞争,得不偿失 - 每 1000 次写操作触发一次扫描,比固定间隔更贴合实际负载
- 只对带时间戳的 key 清理,避免误删长期有效的配置项
LoadOrStore 不能替代清理,Store(nil) 也不等于删除
LoadOrStore("k", nil) 后,Load("k") 返回 (nil, true) —— ok 为 true,但 value 是零值;entry 本身还在,read 或 dirty 中都占位。
更危险的是误以为覆盖就清除了旧数据:LoadOrStore 只更新 value 指针,entry 结构体生命周期完全不受影响,GC 无法回收。
所以:
- 业务上需显式判断 value 是否有效:
if ok && v != nil,不能只看ok - 过期 key 必须调用
Delete,不能靠反复Store“覆盖” - 短期 token、traceID 类 key,优先哈希截断(如
key[:8])再存,控制键空间上限
什么时候该换方案,而不是硬扛 sync.Map
它不是万能并发 map。以下信号出现时,应果断换:
- 写远多于读(比如每秒写 5k+,读不到 100 次):misses 爆涨,dirty 频繁升级,性能反不如
sync.RWMutex + map[string]*T - 需要
Len()、排序遍历、批量删除、原子 CAS 更新等 ——sync.Map接口太薄,封装成本高 - 键类型复杂或需自定义哈希逻辑:运行时
interface{}断言开销大,且无编译期检查 - 内存敏感场景:双 map 结构天然吃内存,GC 压力比原生 map 明显
真正难处理的从来不是怎么删,而是怎么让 key 从一开始就不该进来 —— 控制键空间增长,比事后清理重要十倍。











