如果你写过本地缓存、对象去重池、元数据索引,大概率遇到过这种尴尬:缓存是为了省资源,结果跑久了自己变成最大内存户。Go 1.24 加进来的 weak.Pointer 和 runtime.AddCleanup,就是专门给这类高级场景准备的工具。但它不是银弹,用错了会比普通 map 更难排查。
这篇我按生产代码的视角来讲:弱引用到底弱在哪里、为什么 Value() 一定要判空、缓存清理怎么和 GC 配合,以及什么时候我宁愿不用它。
先说人话:弱引用不是“更轻的指针”
普通指针会把对象“留住”。只要还有强引用指向这个对象,GC 就不能回收它。弱引用不一样,weak.Pointer 指向对象,但不会阻止 GC 回收对象。
这意味着一件很重要的事:你从弱指针里取值时,可能拿到对象,也可能拿到 nil。所以它的使用姿势不是“我存了就一定有”,而是“如果对象还活着,我就复用;如果对象已经没了,我就重新创建”。
它适合什么场景
我会优先想到三类场景:对象规范化、去重池、辅助缓存。比如同一个配置快照、同一个解析后的 schema、同一个大对象的共享表示,很多请求都可能用到,但业务上又不值得强行永久留在内存里。
如果你只是想做一个有 TTL、有容量、有淘汰策略的业务缓存,那弱引用不是首选。该用 ristretto、groupcache、bigcache 或者你自己的 LRU,就老老实实用缓存。弱引用解决的是“不要因为缓存 map 的引用让对象永远活着”这个问题。
一个最小的弱引用缓存骨架
下面这段代码只是骨架,重点是看流程:先从 map 里拿弱指针,再 Value() 判空;对象不存在时重新创建,再用 weak.Make 放回去。
package cache
import (
"sync"
"weak"
)
type Schema struct {
Name string
}
type Cache struct {
mu sync.Mutex
m map[string]weak.Pointer[Schema]
}
func (c *Cache) Get(key string) *Schema {
c.mu.Lock()
defer c.mu.Unlock()
if wp, ok := c.m[key]; ok {
if v := wp.Value(); v != nil {
return v
}
}
v := &Schema{Name: key}
c.m[key] = weak.Make(v)
return v
}
这里最关键的就是这句:if v := wp.Value(); v != nil。弱引用里的对象可能已经被 GC 回收,取出来以后不能直接用。很多 bug 就来自“我以为缓存命中了,所以对象一定在”。
map 里的 key 谁来清理
弱引用还有一个现实问题:对象被 GC 回收了,但你的 map key 还在。值虽然取出来是 nil,但索引本身会越积越多。这个时候就轮到 runtime.AddCleanup 出场。
可以把它理解成对象被回收后的清理钩子。对象生命周期结束时,我们顺手把缓存里的 key 删除掉,避免 map 变成“空壳索引仓库”。
func (c *Cache) Get(key string) *Schema {
c.mu.Lock()
defer c.mu.Unlock()
if wp, ok := c.m[key]; ok {
if v := wp.Value(); v != nil {
return v
}
}
v := &Schema{Name: key}
c.m[key] = weak.Make(v)
runtime.AddCleanup(v, func(k string) {
c.mu.Lock()
delete(c.m, k)
c.mu.Unlock()
}, key)
return v
}
这段代码表达的是思路,不建议你不加测试直接搬到生产。清理函数里能做什么、不能做什么,要结合官方文档认真看。我的习惯是让 cleanup 尽量短,只做删除索引、释放辅助资源这类动作,不在里面塞复杂业务逻辑。
它解决不了什么
弱引用不能替你设计缓存策略。它不知道热点 key 是什么,不知道哪些对象应该保留,也不知道什么时候应该主动淘汰。它只是告诉 GC:如果除了弱引用没人需要这个对象了,你可以回收。
所以它不适合拿来做强一致缓存,也不适合保存必须命中的业务数据。比如用户会话、权限规则、订单状态,这些东西不能因为 GC 心情好就突然没有。弱引用适合“能复用就复用,没了就重建”的派生对象。
生产里我会加哪些保护
- 所有
Value()后面都必须判空,review 时看到直接解引用我会拦下来。 - 缓存构建逻辑必须可重入,因为弱引用失效后会重新创建对象。
- map 访问仍然要加锁,weak.Pointer 不会让普通 map 变成并发安全。
- cleanup 函数尽量短,不做网络调用、复杂日志和可能阻塞的操作。
- 上线前看 heap、对象数量、GC 次数和缓存重建次数,不只看 QPS。
一个容易踩的坑:临时强引用会影响观察结果
你在测试 weak.Pointer 时,经常会发现“为什么 GC 后对象还在”。很多时候不是 weak 失效,而是你自己还拿着强引用,比如局部变量、闭包、slice、日志参数,甚至调试打印都可能延长对象生命。
写测试时最好把创建对象和触发 GC 的阶段隔开,避免无意间留住对象。否则你会误判 weak.Pointer 的行为,进而在生产代码里做出错误假设。
我的建议
weak.Pointer 和 runtime.AddCleanup 是高级工具,不是日常业务 CRUD 的标配。它们适合内存敏感、对象可重建、缓存只是辅助优化的场景。用之前先确认:对象丢了能不能重建?缓存 miss 会不会影响正确性?cleanup 失败会不会影响主流程?
如果这些问题都能答清楚,它们会很有价值。尤其是那些“缓存引用把大对象一直挂住”的老问题,weak.Pointer 给了我们一个更像运行时原生能力的解法。我的态度是:可以用,但要带着指标、测试和 review 清单一起用。
参考资料:Go Blog《Cleanups and weak pointers》、Go 1.24 Release Notes。