大家好,我是十三!欢迎来到十三Tech。
之前做过一个高 QPS 的网关,每次请求要构造一个 4KB 的 buffer 做 protobuf 序列化。压测的时候 GC 占了 30% CPU——几百万个小 buffer 在堆里生灭,GC 忙不过来。改成 sync.Pool 之后,GC 压力直接降到 5% 以内。
但同一个东西,后来被同事用在一个长生命周期的连接对象上——Pool.Put 进去之后,下次 Get 出来的连接已经断开了,业务报错满天飞。sync.Pool 不是"对象缓存",这个误解让我 debug 到半夜。
这一篇就聊聊对象池模式在 Go 工程里的真实价值,以及 sync.Pool 那些容易被忽略的语义陷阱。
一、对象池在解决什么——不是"复用对象"
教科书定义:对象池模式通过回收对象避免频繁创建销毁,减少 GC 压力。这话没错,但隐藏了一个关键约束——池里的对象必须是无状态的、可复用的。
很多人误把对象池当成"对象缓存",把有状态对象(数据库连接、HTTP client、用户 session)扔进去。这违反了对象池的核心假设——池里的对象状态在使用前后应该等价。无状态对象(buffer、临时 slice、序列化器)符合这个假设,有状态对象不符合。
回头看那个网关场景,4KB buffer 是无状态的——每次 Reset() 之后跟全新的没区别,可以放心池化。但数据库连接是有状态的——连接可能被服务端断开、事务可能没提交、session 变量可能被改过。这种对象池化需要额外的"健康检查 + 重置"逻辑,复杂度陡增。
判断口诀:对象无状态 + 高频创建销毁 → sync.Pool;对象有状态(连接/会话)+ 复用有价值 → 自定义池(带健康检查)。
二、sync.Pool 的正确打开方式
sync.Pool 是 Go 标准库提供的对象池实现,但它的语义比想象的微妙。最关键的一条:池里的对象可能被随时回收。
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func HandleRequest(w http.ResponseWriter, r *Request) {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
// 使用 buf ...
}
看这段代码——Pool.Get() 拿到一个 buffer,defer Put 还回去。关键约束有两个:Get 之后必须 Reset(不知道上一个调用方在 buffer 里留下了什么),Put 之前不要持有 buffer 的引用(GC 可能在任意时刻把它回收)。
最常见的误用是把 sync.Pool 当长期缓存用——比如缓存数据库连接、HTTP client。这违反了 Pool 的核心语义:Pool 里的对象不保证存活。每次 GC 时,Pool 里的对象会被清空,下次 Get 拿到的是 New 函数创建的新对象。这个设计是有意为之——Pool 的目标是"减轻 GC 压力",如果池里对象常驻,反而加重 GC 负担。
sync.Pool 内部用了 runtime_procPin 和 per-P 缓存,让 Get/Put 在大多数情况下无锁、无原子操作,性能极高。但代价是对象生命周期不可控——你不能依赖 Pool 里的对象一直存在。
三、什么时候用 sync.Pool,什么时候用自定义池
对象池不是所有"高频创建"场景都适用,至少要分清楚两类需求。
sync.Pool 适合:无状态临时对象(buffer、序列化器、临时 slice)、创建成本远高于使用成本、对对象存活没要求。典型例子是 net/http 标准库——每个请求的 bufio.Reader/Writer 都用 sync.Pool 复用。
自定义池适合:有状态对象(数据库连接、RPC client)、对象初始化贵且希望长期复用、需要健康检查和重置逻辑。这种场景通常用 channel 实现——chan *Conn,Get 是 <-ch,Put 是 ch <- conn,加上心跳检测和超时回收。
sync.Pool 的反模式主要有三个。
反模式一:池化有状态对象。前面提到的连接对象——Put 进去时连接还活着,Get 出来时可能已经断开。sync.Pool 没有健康检查机制,错误的池化会导致间歇性故障。
反模式二:池化大对象。一个 10MB 的 buffer 池化——单次 Get 节省了分配,但池里若存了 100 个就是 1GB 内存,GC 时被清空又要重新分配。sync.Pool 的最佳对象大小在 KB 级别,超过这个量级要重新评估。
反模式三:在热路径之外用 Pool。sync.Pool 的价值是减少 GC 压力,如果对象本身创建频率低(每秒几十次),用不用 Pool 区别不大,反而增加代码复杂度。只有 QPS 真的很高(每秒成千上万次创建)时,Pool 才显著。
Go 标准库里 sync.Pool 用得最经典的地方是 net/http 和 encoding/json——HTTP server 的 request/response buffer、JSON 的 encoder/decoder 全都用 Pool 复用。看 net/http/server.go 里 bufioPool 的用法,能学到 Pool 的所有最佳实践:对象大小适中、无状态、Reset 后复用、不依赖对象存活。
回头看那个网关场景——值得用 sync.Pool 吗?值得,4KB buffer 无状态、每秒数万次创建销毁、对对象存活无依赖,正是 Pool 的甜区。但如果只是数据库连接的复用,老老实实用 database/sql 的连接池(自定义池)反而对。
对象池真正教会我的,不是"复用对象减少 GC",而是区分"临时对象"和"长期对象"——临时对象用 sync.Pool 池化,长期对象用自定义池管理。这个区分比模式本身重要得多。
下一篇进入结构型模式,先讲适配器——它解决的是"接口不匹配"的问题,跟对象池完全是另一个维度。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
