提到 Go 的字符串拼接,第一反应是用 +。但刷过几篇性能文章之后,很多人就开始纠结:到底用 strings.Builder 还是 bytes.Buffer?它们长得像、接口像,甚至常用方法几乎一致。真正把它们区分开的,是底层设计取向和 String() 方法的关键差异。这篇文章会结合源码、基准测试、常见误区和工程场景,聊一聊 Go 字符串拼接中这两个类型的选择问题。

先给结论:如果只是把字符串拼起来,最终转成 string,优先选 strings.Builder。如果需要把缓冲区复制、读取,或者混合写入二进制数据,bytes.Buffer 更合适。
strings.Builder 与 bytes.Buffer 的底层差异
两者内部都维护着一个 []byte 缓冲区,写入时都是向切片追加数据。真正的分歧点在 String() 方法。Go 1.20 之后,strings.Builder.String() 的实现大约长这样:
func (b *Builder) String() string {
return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}
它通过 unsafe 绕过复制,直接把底层字节数组的视图转换成了字符串。也就是说,返回的字符串和内部缓冲区共享同一块内存。这个行为存在的前提,是 Builder 的使用者在调用 String() 之后不能再对 Builder 写入。如果违反这个约定,之前拿到的字符串内容可能被后续写入覆盖。
而 bytes.Buffer.String() 的实现是:
func (b *Buffer) String() string {
if b == nil {
return "<nil>"
}
return string(b.buf[b.off:])
}
它执行了一次标准类型转换。Go 中 []byte 转 string 会产生一次内存拷贝,因为字符串是不可变的,而切片是可变的,需要隔离数据。正是这一次拷贝,构成了两个类型性能差异的核心。
但要注意,这不是说 bytes.Buffer 永远慢。如果你不调用 String(),而是直接使用 Bytes() 方法获取 []byte,bytes.Buffer 反而没有额外分配。因为 Bytes() 返回的就是底层切片的一部分。所以选型时一定要想清楚“结果的最终形态是什么”。
性能对比:真实差异比想象中稳定
在常见场景中,strings.Builder 比 bytes.Buffer 要快一些。下面是一段可运行的基准测试代码,模拟了循环拼接多个短字符串:
func BenchmarkConcat(b *testing.B) {
parts := []string{"life", "is", "short", "golang", "is", "fun"}
b.Run("Plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("BytesBuffer", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
for _, p := range parts {
buf.WriteString(p)
}
_ = buf.String()
}
})
b.Run("StringsBuilder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
}
在大多数机器上,strings.Builder 比 bytes.Buffer 快 10% 到 20%,分配次数也更少。这个优势来自 String() 的零拷贝,而不是写入阶段的差异。实际上两者的写入路径几乎相同,都是对内部 []byte 的 append 操作。
如果配合 Grow 预分配容量,两者的分配次数都会明显减少。再强调一次:不要只看网上的基准结论,在自己的项目里跑一遍,数据会比任何文章更适合你的场景。
strings.Builder 的三个典型陷阱
性能好的东西往往需要更严格的使用约束。strings.Builder 最常见的坑有三个。
陷阱一:无意识的复制
strings.Builder 不建议按值拷贝,因为复制后的两个 Builder 会共享同一个底层缓冲区。一旦其中一个写入,另一个看到的也是新数据。更严重的是,这可能导致数据竞争甚至 panic。
func fill(sb strings.Builder) { // 按值传递,复制了 Builder
sb.WriteString("hello")
}
func main() {
var sb strings.Builder
fill(sb)
fmt.Println(sb.String()) // 依旧是空字符串
}
如果想在函数中修改 Builder,请传递指针 *strings.Builder,这也是标准库中的常规用法。
陷阱二:String() 之后继续写入
由于返回的字符串和内部缓冲区共享内存,你保存了 String() 的结果后,如果继续向 Builder 里写内容,原来的字符串数据也会被破坏。看这段代码:
var sb strings.Builder
sb.WriteString("hello")
s1 := sb.String() // 拿到 "hello"
sb.WriteString(", world")
// 此时 s1 的内容可能仍然是 "hello",也可能已经被新数据污染
许多从 bytes.Buffer 迁移到 strings.Builder 的团队,都会在这里踩坑。bytes.Buffer.String() 返回的是拷贝,不存在这个问题。所以在使用 strings.Builder 时,要有一个清晰的生命周期:要么让它活到最终输出,要么先用 Reset 放弃旧依赖。
陷阱三:盲目使用 Reset 复用对象
Grow 和 Reset 确实可以提升对象复用率,降低 GC 压力。但 Reset 只是把长度字段置零,底层数组并不清空。如果你之前通过 String() 留下的字符串与这个数组共享内存,Reset 之后继续写入,会让旧字符串的内容不可预测。所以在 sync.Pool 里复用 Builder 时,请在取出后立即 Reset,并且不要保存之前 String() 的结果。
bytes.Buffer 与 strings.Builder 选型对照表
下面这张表覆盖了日常最常见的几种需求,直接看推荐即可。
| 需求 | 推荐类型 | 原因 |
|---|---|---|
| 大量字符串拼接,最终转为 string | strings.Builder | String() 零拷贝,性能更好 |
| 需要按值传递缓冲区 | bytes.Buffer | 复制后行为安全,不共享底层数组 |
| 同时写入二进制与文本 | bytes.Buffer | 语义更通用,支持字节操作 |
| 实现 io.Writer 并输出字符串 | strings.Builder | 实现 WriteString,轻量高效 |
| 需要读取已写入的数据 | bytes.Buffer | 实现 io.Reader,可以迭代读取 |
| 反复 Reset 复用的池化对象 | 两者均可 | 如果还需保留 String 结果,选 bytes.Buffer |
这张表的判断依据不只是性能。很多团队在选择时只看速度,却忽略了数据流的完整生命周期。比如某个函数需要把缓冲区内容同时传给多个下游,如果选 strings.Builder,就得考虑复制问题;用 bytes.Buffer,就不用有这个担忧。
真实工程中的三种常见场景
场景一:报表导出和消息模板拼接
做 CSV 导出、告警消息生成时,往往要在循环里拼几十个字段。使用 + 会产生大量中间字符串,GC 压力大。切换到 strings.Builder 并调用 Grow(预估长度) 后,内存分配次数可以降低一个数量级,效果非常直接。
场景二:封包解析或流式处理
在实现网络协议解析时,经常需要一边向缓冲区追加数据,一边读取已处理的部分。这种情况下 bytes.Buffer 的 Bytes() 和 Read 方法非常顺手,因为它可以同时充当 Reader 和 Writer。而 strings.Builder 只实现了 Writer,没有读取能力,硬用会很别扭。
场景三:与 fmt.Fprintf 搭配输出
fmt.Fprintf(sb, ...) 可以很方便地格式化字符串并写入 Builder。这也是 strings.Builder 的常见用法,比 fmt.Sprintf 拼接后再赋值效率更高。但要注意,如果格式化内容中包含需要转换的复杂结构,性能提升会被反射开销抵消。此时更合理的选择是走业务逻辑一次生成,而不是反复格式化。
落地建议:让选型成为习惯
下面这段建议来自长期使用 Go 的人的经验,可以帮你少走弯路。
- 默认使用 strings.Builder 处理字符串拼接,尤其是循环、高频调用和格式构造场景。
- 明确知道最终结果是 string 时,不要让 bytes.Buffer 的 String() 成为你的最后一步。
- 在函数间传递 Builder 时,使用指针,避免复制。
- 需要读写都有的缓冲区,使用 bytes.Buffer。
- 调用 String() 之后,不要再对 strings.Builder 做任何写入,除非先 Reset。
- 在关键路径上通过 Grow 预分配容量,减少扩容带来的分配和拷贝。
这些习惯不需要刻意记忆。当你遇到的一个新任务,先问三个问题:结果是不是 string?要不要复制/读取缓冲区?会不会在拿完字符串后继续写?答案自然就出来了。
总结
strings.Builder 与 bytes.Buffer 的选择不是一道多选一,而是对 Go 内存模型理解的考察。Builder 用共享内存和更严格的约束,换取了字符串转换时的零拷贝;Buffer 用一次额外拷贝,换来了更通用的缓冲区语义和更安全的复制行为。
所以,没有一个绝对更好的类型,只有更适合当前路径的类型。理解它们的底层差异,比记住“哪个性能好”更重要。哪怕你最终选错了,也多了一个在基准测试中观察瓶颈的机会。这本身就是一种进步。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/740/