一、现象
线上应用突然收到报警,提示单机内存使用率超过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 来替代直接使用加号拼接字符串的方式