用 sync.pool 缓存 bytes.buffer 或 json.encoder 能显著减少 gc 压力,但必须避免逃逸、不复用含状态对象,且仅适用于高频小对象场景。

直接说结论:用 sync.Pool 缓存 *bytes.Buffer 或 *json.Encoder 能显著减少 GC 压力,但必须避免逃逸到 goroutine 外、不能复用含状态的对象、且仅在高频小对象场景下有效。
为什么 JSON 序列化容易成为性能瓶颈
Go 的 json.Marshal 每次调用都会分配新 []byte,而 json.Encoder 写入 *bytes.Buffer 也会频繁扩容。高并发 API 中,这些临时对象会快速堆积,触发高频 GC,表现为 CPU 在 runtime.mallocgc 上耗时陡增,P99 延迟毛刺明显。
常见错误现象:pprof 显示 runtime.gcBgMarkWorker 占比超 15%,或 go tool trace 中看到大量 “GC pause” 小竖线密集出现。
实操建议:
- 先用
go tool pprof -http=:8080 ./binary http://localhost:6060/debug/pprof/heap确认堆分配热点是否集中在encoding/json相关路径 - 若单次序列化结果
len波动小(如固定结构的 API 响应),缓存*bytes.Buffer最简单有效;若需复用编码器配置(如SetEscapeHTML(false)),则缓存*json.Encoder - 切勿缓存
json.RawMessage或用户传入的 struct 指针——它们生命周期不可控,Pool 会意外延长其存活时间
如何安全地缓存 *bytes.Buffer
*bytes.Buffer 是最常被 Pool 复用的对象,但它自带内部切片,Reset() 只清长度不缩容,反复使用可能造成内存浪费甚至 OOM。
实操建议:
- 定义 Pool 时提供
New函数,每次新建不超过 1KB 的初始 buffer:var bufferPool = sync.Pool{ New: func() interface{} { return bytes.NewBuffer(make([]byte, 0, 1024)) }, } - 每次取用后必须调用
buf.Reset(),不能只靠buf.Truncate(0)——前者重置buf.len并保留底层数组,后者只是截断,不保证后续写入不会越界 - 写完立即放回 Pool:
defer bufferPool.Put(buf),且确保 buf 未被传给其他 goroutine 或注册到 channel - 若响应体平均 >4KB,考虑按 size 分级 Pool(如
smallBufPool/largeBufPool),避免小请求长期占用大 buffer
缓存 *json.Encoder 时的隐藏陷阱
*json.Encoder 本身无状态,但它的底层 io.Writer(比如 *bytes.Buffer)有状态。直接缓存 *json.Encoder 并复用,会导致前一次编码残留的 buffer 内容被拼接到下一次结果开头。
实操建议:
- 不要单独缓存
*json.Encoder,而是缓存一个结构体封装 buffer 和 encoder:type jsonWriter struct { buf *bytes.Buffer enc *json.Encoder } var writerPool = sync.Pool{ New: func() interface{} { buf := bytes.NewBuffer(make([]byte, 0, 1024)) return &jsonWriter{buf: buf, enc: json.NewEncoder(buf)} }, } // 使用时: w := writerPool.Get().(*jsonWriter) w.buf.Reset() // 必须重置 buffer! w.enc.SetEscapeHTML(false) w.enc.Encode(data) result := w.buf.Bytes() writerPool.Put(w) - 切勿调用
w.enc.Encode()后再对w.buf手动写入 ——encoder 内部可能已修改 buffer 的len或cap,手动操作易 panic - 如果需要输出流式 JSON(如数组元素逐个 encode),必须确保每次从 Pool 获取的是全新实例,不能跨请求复用同一
jsonWriter
什么时候不该用 sync.Pool
Pool 不是银弹。它在低频、大对象、或对象生命周期难以界定的场景下反而有害。
典型误用场景:
- HTTP handler 中为每个请求分配一个
sync.Pool实例(如按 path 分 Pool)——Pool 本意是跨 goroutine 复用,局部实例失去意义,还增加 map 查找开销 - 缓存包含 mutex、channel 或闭包的结构体——Pool 不保证 Get/Put 成对发生在同一 goroutine,可能导致锁竞争或 channel 关闭 panic
- 单次序列化耗时主要花在字段反射(如结构体嵌套深、tag 解析多)而非内存分配上——此时优化点应在预生成
json.Marshaler实现或改用easyjson/ffjson - 程序运行时间短(如 CLI 工具),GC 还没触发几次就退出——Pool 中的对象根本没机会复用,白占内存
真正关键的一点:sync.Pool 中的对象可能被 runtime 在任意 GC 周期清理,你无法控制何时回收。所以任何依赖“缓存对象绝对存在”的逻辑,本质都是错的。











