Go 的 sync.Pool 实战:复用对象把 GC 压力打下来高并发的 Go 服务里,有一类性能问题很隐蔽:代码逻辑没毛病,QPS 也不算高,但 GC 频繁触发、P99 延迟毛刺不断。用 pprof 一看,大量时间花在内存分配和垃圾回收上。罪魁祸首常常是——你在热路径上反复 new 又扔掉同一种临时对象。这篇讲sync.Pool怎么复用这些临时对象、怎么用对(它有几个非常容易踩的坑),以及什么时候压根不该用它。先看问题:每个请求都分配一个大 buffer一个很典型的场景:HTTP handler 里需要一个 buffer 拼接响应,或者做 JSON 序列化的中转:funchandler(w http.ResponseWriter,r*http.Request){// 每个请求都新建一个 bufferbuf:make([]byte,0,4096)bufappend(buf,prefix:...)bufappend(buf,r.URL.Path...)// ... 一堆拼接w.Write(buf)}单看没问题。但如果这个 handler 每秒被调用几万次,就意味着每秒新建几万个 4KB 的切片,用完立刻变成垃圾。这些短命对象会快速填满堆,逼着 GC 频繁运行,而 GC 的 STW(stop-the-world)阶段会直接体现为请求延迟毛刺。核心矛盾:这些 buffer 生命周期极短、形状完全一样、用完就扔。与其反复 new/GC,不如建一个池子循环利用它们。这正是sync.Pool的用武之地。sync.Pool 基本用法sync.Pool是一个可以存放临时对象的池子。Get拿一个出来(池空时调用New造一个),用完Put放回去下次复用:import(bytessync)// New 定义池空时怎么造新对象varbufPoolsync.Pool{New:func()any{returnnew(bytes.Buffer)},}funchandler(w http.ResponseWriter,r*http.Request){// 从池里拿一个 bufferbuf:bufPool.Get().(*bytes.Buffer)// 关键:放回前必须重置,否则拿到的是上次的脏数据buf.Reset()deferbufPool.Put(buf)// 用完放回池子buf.WriteString(prefix:)buf.WriteString(r.URL.Path)w.Write(buf.Bytes())}三个要点:Get返回的是any,要类型断言;New只在池空时才调用;Put之前(或Get之后)一定要Reset,把上一个使用者留下的数据清掉。在压测里,这种改法通常能让分配次数下降一个数量级,GC 频率和延迟毛刺明显改善。你可以用 benchmark 对比:funcBenchmarkWithPool(b*testing.B){b.ReportAllocs()// 报告每次操作的分配次数fori:0;ib.N;i{buf:bufPool.Get().(*bytes.Buffer)buf.Reset()buf.WriteString(hello world)_buf.String()bufPool.Put(buf)}}跑go test -bench. -benchmem,对比不用池的版本,allocs/op会从 1~2 降到接近 0。坑一:忘了 Reset,拿到脏数据这是最常见的 bug。sync.Pool存的是「上一个人用完原样放回」的对象,里面还留着旧内容。如果你Get出来直接用,就会读到别的请求的残留数据——在并发下这是极难复现的串数据 bug。// 错误:没 Reset,buf 里可能有上个请求的内容buf:bufPool.Get().(*bytes.Buffer)buf.WriteString(new data)// 拼在旧数据后面!// 正确:先 Resetbuf:bufPool.Get().(*bytes.Buffer)buf.Reset()buf.WriteString(new data)对切片同理,拿出来要buf buf[:0]把长度归零(保留底层容量)。坑二:Put 进去的对象还被别处引用Put意味着「我不再用它了,交给池子」。如果你Put之后还持有这个对象的引用继续读写,就会和下一个Get到它的人产生数据竞争:buf:bufPool.Get().(*bytes.Buffer)buf.Reset()buf.WriteString(data)result:buf.Bytes()// 危险:Bytes() 返回的是底层数组的引用bufPool.Put(buf)// 放回后,别人可能拿到并覆写process(result)// result 可能已经被改!bytes.Buffer.Bytes()返回的切片指向 buffer 内部数组,Put之后这块内存归池子了。要么在Put前把数据 copy 出来,要么等真正用完再Put。规则:一旦 Put,就当这个对象已经不属于你了。坑三:池里的对象随时会被 GC 清空sync.Pool不保证对象一直在。它的设计就是「临时缓存」——每轮 GC 都可能清空池子里的对象(Go 1.13 后改成两级缓存,能扛过一轮 GC,但你不能依赖)。这带来两个约束:不能用它做需要「一定存在」的资源池,比如数据库连接、长连接。那些要用专门的连接池(database/sql自带,或者 channel 实现的固定容量池),它们保证连接不会被 GC 悄悄回收。New必须能随时造出可用对象。因为你无法预期Get会命中缓存还是走New。一句话:sync.Pool适合「有更好,没有就现造,造起来不贵」的临时对象,不适合「必须复用、创建昂贵、数量要受控」的资源。坑四:小对象用池反而更慢sync.Pool本身有开销——内部要处理 per-P 的本地池、跨 P 偷取、GC 清理。如果你池化的对象很小(比如一个只有几个字段的 struct),Get/Put的开销可能比直接new还大,得不偿失。判断标准:只有当对象「大」或者「分配频率极高」时,池化才划算。拿不准就写 benchmark 用-benchmem对比allocs/op和ns/op,别凭感觉。典型值得池化的:大 buffer(几 KB 以上)、序列化中转对象、gzip.Writer 这类构造成本高的对象。一个更完整的例子:池化带容量的 byte slicebuffer 之外,直接池化[]byte也很常见,比如做加密、压缩、IO 的中转缓冲:varslicePoolsync.Pool{New:func()any{// 返回指针,避免 Put 时切片 header 被复制装箱又产生分配b:make([]byte,0,8192)returnb},}funccompress(data[]byte)[]byte{ptr:slicePool.Get().(*[]byte)buf:(*ptr)[:0]// 长度归零,保留 8192 容量deferfunc(){*ptrbuf// 写回可能被 append 扩容后的切片slicePool.Put(ptr)}()bufappend(buf,doCompress(data)...)// 注意:要返回给调用方的数据得 copy 一份,不能直接返回 bufout:make([]byte,len(buf))copy(out,buf)returnout}这里两个细节:池化切片存指针(*[]byte)而不是切片本身,避免Put(slice)时切片头被装箱进 interface 又产生一次分配;返回给外部的数据必须 copy,因为buf马上要还回池子。小结sync.Pool复用生命周期短、形状一致的临时对象,把「反复 new/GC」变成「循环利用」,显著降低 GC 压力和延迟毛刺。用法三件套:New定义兜底构造、Get后类型断言、Put前Reset/[:0]清脏数据。四个坑:①忘 Reset 读到脏数据;②Put 后仍持有引用导致数据竞争;③池对象随时被 GC 清空,不能当连接池用;④小对象池化反而更慢。池化切片存指针、返回数据要 copy。判断口诀:「对象够大 or 分配够频繁,且没有它也能现造」才池化;拿不准就go test -benchmem用数据说话,别凭感觉优化。