bytes.Buffer 是 Go 拼接长文本最常用也易出错的工具:用对可降 GC 压力、提吞吐;用错(如不预分配、不复用、滥用 String())反比 += 更慢。

直接说结论:bytes.Buffer 是 Go 里拼接长文本最常用也最容易出错的工具——用对了能压低 GC 压力、提升吞吐;用错了,比如不预分配、不复用、乱调 String(),反而比 += 更慢。
为什么循环拼字符串不能用 +=
Go 字符串不可变,每次 += 都要 malloc 新内存、拷贝旧内容、丢弃旧对象。1000 次拼接,可能触发上百次堆分配,pprof 里 runtime.makeslice 占满屏。这不是“稍慢”,是生产环境 P99 延迟跳升的常见根因。
真正该盯住的是三件事:
- 估算总长度(比如 500 个 ID,平均 16 字节,加逗号和引号,预估 10KB)
- 调
buf.Grow(estimatedSize)一次性配够底层数组容量 - 别依赖自动扩容——它按 2× 增长,偏差大时 realloc 次数翻倍
Grow() 后还频繁扩容?检查初始容量
bytes.Buffer 默认初始化底层数组容量为 0。哪怕你写了 buf.Grow(1024),第一次 WriteString 仍会触发一次 make([]byte, 64) 分配——因为 Grow 只保证“后续至少 n 字节不扩容”,不干预首次写入逻辑。
立即学习“go语言免费学习笔记(深入)”;
更稳的做法:
- 用
bytes.NewBuffer(make([]byte, 0, estimatedSize))直接指定底层数组 cap - 或在
var buf bytes.Buffer后立刻buf.Grow(estimatedSize),再开始写 - 避免
bytes.NewBufferString("...")开头——它会把输入字符串拷进底层数组,浪费一次 copy
String() 和 Bytes() 到底怎么选
这两个方法语义完全不同,选错就踩坑:
-
buf.String():返回新分配的string,内部调用copy,产生一次额外内存拷贝 -
buf.Bytes():返回底层数组切片,零分配,但共享内存——如果之后还往buf写,这个切片内容可能被覆盖 - 如果目标是写入
io.Writer(如http.ResponseWriter),直接用buf.WriteTo(w),零拷贝直达 - 必须转
string且怕脏数据?用append([]byte{}, buf.Bytes()...)深拷贝,别用string(buf.Bytes())
高频场景下 Buffer 必须复用
HTTP handler、日志中间件、SQL 构建器这类每秒数百请求的路径,每请求 new 一个 bytes.Buffer,等于每请求都 new 一个 []byte,GC 压力直线飙升。
正确姿势是用 sync.Pool:
- 定义全局池:
var bufPool = sync.Pool{New: func() interface{} { return &bytes.Buffer{} }} -
buf := bufPool.Get().(*bytes.Buffer)后必须先buf.Reset() -
defer bufPool.Put(buf)前也要buf.Reset(),否则下次Get()可能读到残留数据 - 别长期持有池中对象——
sync.Pool对象生命周期由 GC 控制,强引用会导致泄漏
真正难的不是调 Grow 或 WriteString,而是容量估算是否靠谱、复用时机是否得当、以及意识到 String() 和 Bytes() 的行为差异——漏掉其中任意一个,性能优化就白忙活。


















