Go字符串拼接性能优化

一、现象
线上应用突然收到报警,提示单机内存使用率超过80%,紧急去抓profile数据,分析发现是一个数据下载的逻辑占用内存过大

二、分析
1、问题代码

func buildOldActContent(ctx context.Context, req DownloadReq, isOuter bool) (string, error) {
	datas, err := GetOldCouponActData(ctx, req.ActID, req.StartDate, req.EndDate)
	if err != nil {
		return "", err
	}

	contents := ""
	for _, data := range datas {
		// 日期,活动ID,活动名称,券领取量,券领取人数,券核销量
		contents += fmt.Sprintf("%s, %s, %s, %d, %d, %d", data.Ds, req.ActID, req.ActName, data.CouponReceiveNum, data.CouponReceiveUsers, data.CouponHxNum)
                // 其他拼接逻辑...
       }
       return contents, nil
}

2、分析
看上面的代码,逻辑很简单,就是从DB获取数据,然后按行拼接成逗号分隔的字符串。查了下异常请求的traceId, 发现待下载数据有2万+,初步分析是字符串拼接这里的问题

3、字符串拼接性能分析
我们知道字符串拼接除了直接加,还有常用的strings.Builder的WriteString方法,做一个benchmark, 比对下内存使用情况

package main

import (
	"fmt"
	"strings"
	"testing"
)

const (
	Iterations = 10000
)

func BenchmarkStringBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var contents strings.Builder

		for j := 0; j < Iterations; j++ {
			contents.WriteString("test")
		}

		_ = contents.String()
	}
}

func BenchmarkStringConcatenation(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var contents string

		for j := 0; j < Iterations; j++ {
			contents += "test"
		}

		_ = contents
	}
}

func TestDownload(t *testing.T) {
	fmt.Println("Testing strings.Builder...")
	sbResult := testing.Benchmark(BenchmarkStringBuilder)
	fmt.Println(sbResult)

	fmt.Println("Testing string concatenation...")
	concatResult := testing.Benchmark(BenchmarkStringConcatenation)
	fmt.Println(concatResult)

	fmt.Println("Memory usage comparison:")
	fmt.Printf("strings.Builder: %d bytes/op\n", sbResult.AllocedBytesPerOp())
	fmt.Printf("String concatenation: %d bytes/op\n", concatResult.AllocedBytesPerOp())
}

执行结果

=== RUN   TestDownload
Testing strings.Builder...
   30072	     37898 ns/op
Testing string concatenation...
      44	  27036305 ns/op
Memory usage comparison:
strings.Builder: 154360 bytes/op
String concatenation: 215441420 bytes/op
--- PASS: TestDownload (2.77s)
PASS

发现差异很大

三、优化
1、直接使用strings.Builder就好了

func buildOldActContent(ctx context.Context, req DownloadReq, isOuter bool) (string, error) {
	datas, err := GetOldCouponActData(ctx, req.ActID, req.StartDate, req.EndDate)
	if err != nil {
		return "", err
	}

	var contents strings.Builder
	for _, data := range datas {
		// 日期,活动ID,活动名称,券领取量,券领取人数,券核销量
		contents.WriteString(fmt.Sprintf("%s, %s, %s, %d, %d, %d", data.Ds, req.ActID, req.ActName, data.CouponReceiveNum, data.CouponReceiveUsers, data.CouponHxNum))
                // 其他拼接逻辑...
       }
       return contents.String(), nil
}

四、总结
1、以后注意字符串拼接,尽量使用strings.Builder
2、使用 strings.Builder 拼接字符串相比直接使用加号的优势主要有以下几点:

性能更高:因为字符串是不可变的,每次拼接字符串都会创建一个新的字符串对象,而使用 strings.Builder 则避免了创建多个中间字符串对象的开销。strings.Builder 内部使用了缓冲区,可以高效地进行字符串拼接操作。

减少内存分配:使用加号拼接字符串时,每次拼接都会创建一个新的字符串对象,并且原有的字符串对象会被丢弃,最终导致大量的内存分配和垃圾回收。而使用 strings.Builder 则可以复用内部缓冲区,减少了内存分配的次数,提高了程序的性能。

更好的代码可读性和可维护性:使用 strings.Builder 可以将多个字符串拼接的逻辑更清晰地表达出来,提高代码的可读性。同时,如果需要在多个地方进行字符串拼接操作,可以将 strings.Builder 对象传递给其他函数使用,使代码更易于维护。

总结来说,使用 strings.Builder 拼接字符串可以提高性能、减少内存分配,并且使代码更具可读性和可维护性。在需要频繁进行字符串拼接的场景下,推荐使用 strings.Builder 来替代直接使用加号拼接字符串的方式

发表评论

电子邮件地址不会被公开。 必填项已用*标注