高质量代码及性能优化 | 豆包MarsCode AI刷题

134 阅读8分钟

高质量代码

编码规范

gofmt自动格式化代码

注释:代码作用,实现过程,实现原因,什么情况下会出错(提供代码未表达出的上下文信息)

命名规范

变量名:

  • 缩略词全大写,位于变量开头且不需要导出时小写

    ServeHTTP ; XMLHTTPRequest or xmlHTTPRequest

  • 变量距离被使用的地方越远,带更多上下文

函数名:

  • 函数名不携带包含的上下文信息,因为包含名和函数名总是成对出现的。
  • 函数名尽量简短
  • 名为 foo 的某个函数返回类型为 Foo 时,可以省略类型信息而不导致歧义。
  • 名为 foo 的某个函数返回类型为 T 时(T 并不是 Foo),可以在函数名中加入类型信息。

package

  • 仅由小写字母组成,不包含大写字母和下划线等字符。

  • 简短并包含一定的上下文信息,例如 schematask 等。

  • 不要与标准库同名,例如不要使用 syncstrings

    以下规则尽量满足,以标准库包名为例:

    • 不使用单词变量名作为包名,例如使用 bufio 而不是 buf
    • 使用单数而不是复数,例如使用 encoding 而不是 encodings
    • 谨慎地使用缩写,例如在不破坏上下文的情况下,使用 fmtformat 更加简短。

控制流程

避免嵌套,保持正常信息

eg: 两个分支都包含return,可以去除else

尽量保持正常代码路径为最小缩进

  • 优先处理错误情况或特殊情况,尽早返回或继续循环来减少嵌套层级。

错误与异常处理

  • error:尽可能提供简明的上下文信息链,方便定位问题。
  • panic:用于真正异常的情况。
  • recover:生效范围在当前 goroutine 的被 defer 的函数中。

简单的错误

  • 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。
  • 优先使用 errors.New 来创建局部变量,直接表示简单错误。
  • 如果有格式化的需求,使用 fmt.Errorf(".....")

错误的wrap和unwrap

  • 错误的 Wrap 提供了一个将一个错误嵌套在另一个错误中的能力,从而生成一个错误的跟踪链。
  • fmt.Errorf 中使用 %w 关键字,可以将一个错误关联到错误链中。

错误判定

  • 判断一个错误是否为特定错误时,使用 errors.Is
  • == 不同,errors.Is 方法可以判断错误链上的所有错误是否包含特定的错误
  • 在错误链上获取特定种类的错误时,使用 errors.As

panic 使用规范

  • 不建议在业务代码中使用 panic,避免程序崩溃。
  • 调用的函数不包含 recover 时会导致程序崩溃。
  • 如果问题可以被屏蔽或解决,建议使用 error 代替 panic
  • 当程序启动阶段发生不可逆转的错误时(如在 initmain 函数中),可以使用 panic。

recover 使用规范

  • recover 只能在被 defer 的函数中使用
  • 嵌套调用时无效,仅在当前 goroutine 中生效
  • defer 的执行顺序是后进先出,即最近的 defer 会最早执行。
  • 如果需要更多上下文信息,可以在 recover 后通过 log 记录当前的调用堆栈debug.stack()

性能优化

go test -bench=. -benchmem image.png

slice 预分配内存

  • 尽可能在使用 make() 初始化切片时提供容量信息
  • 切片的本质是一个数组片段的描述,包含以下信息:
    • 数组指针(指向底层数组的第一个元素)
    • 片段的长度 len(实际元素数)
    • 片段的容量 cap(最大长度,不改变内存分配的情况下最大长度)
  • 切片操作并不复制切片指向的元素,而是引用底层数组的部分内容。
  • 创建新切片会复用原切片的底层数组,直到容量达到上限才会分配新内存。
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

// 不进行预分配内存的例子
func NoPreAlloc(size int) {
    data := make([]int, 0) // 容量初始为 0
    for k := 0; k < size; k++ {
        data = append(data, k) // 每次 append 都可能触发重新分配
    }
}

// 预分配内存的例子
func PreAlloc(size int) {
    data := make([]int, 0, size) // 初始化时指定容量
    for k := 0; k < size; k++ {
        data = append(data, k) // 由于预分配,减少了内存分配次数
    }
}

大内存未释放的陷阱 re-slice 问题

  • 在已有切片基础上创建切片时,不会创建新的底层数组,而是继续引用原切片的底层数组。

可能的场景

  • 原切片较大,如果在此基础上创建小切片,仍然会引用原数组的内存,导致内存无法释放。
  • 原底层数组在内存中有引用,造成内存得不到释放,影响内存管理。

解决方法

  • 可以使用 copy 创建一个新的切片,以避免这种情况。

示例代码

//GetLastBySlice 函数:直接返回切片的最后两个元素的引用,导致内存无法释放。
func GetLastBySlice(origin []int) []int {
    return origin[len(origin)-2:]
}

//GetLastByCopy 函数:创建新的切片并将数据复制进去,避免内存泄漏。
func GetLastByCopy(origin []int) []int {
    result := make([]int, 2)
    copy(result, origin[len(origin)-2:])
    return result
}
//使用 go test -run=. -v 进行内存和性能测试。
func testGetLast(t *testing.T, f func([]int) []int) {
    result := make([][]int, 0)
    for k := 0; k < 100; k++ {
        origin := generateWithCap(128 * 1024) // 生成一个大切片
        result = append(result, f(origin))
    }
    printMem(t)
    _ = result
}
//测试结果示例
    //TestLastBySlice:内存使用 100.14 MB,运行时间 0.23s。
    //TestLastByCopy:内存使用 3.14 MB,运行时间 0.19s。

map预分配内存

  • 不断向 map 中添加元素会触发 map 的扩容。
  • 提前分配足够的空间可以减少内存拷贝和 Rehash 的消耗。
  • 建议根据实际需求预估所需空间,并进行预分配,以优化性能。

StringBuilder

使用+性能最差,strings.Builder, bytes.Buffer相近,前者更快,直接把底层[]byte转换返回,后者要重新申请空间转换返回

  • 在 Go 语言中,字符串是不可变类型,占用的内存大小是固定的。
  • 使用 + 进行字符串拼接时,每次操作都会重新分配内存,效率较低。
  • strings.Builderbytes.Buffer 底层都是 []byte 数组,strings.Builder 拼接性能更优。
  • strings.Builder 会自动扩展容量,避免每次拼接都重新分配内存。

空结构体

  • 空结构体 struct{} 实例不占用任何内存空间。
  • 可以作为各种场景下的占位符使用,既能节省资源,又表达了明确的语义。
func EmptyStructMap(n int) {
    m := make(map[int]struct{})
    for i := 0; i < n; i++ {
        m[i] = struct{}{}
    }
}

atomic包

使用 atomic 包进行原子操作

  • 锁的实现依赖操作系统调用,效率较低。
  • atomic 操作通过硬件实现,效率高于锁。
  • sync.Mutex 适用于需要保护多个变量或一段逻辑的情况,而 atomic 操作更适合单一数值变量。
type atomicCounter struct {
    i int32
}

func AtomicAddOne(c *atomicCounter) {
    atomic.AddInt32(&c.i, 1)
}

性能优化分析工具

go tool pprof 9cd6498e-3193-4125-a7d7-ffb132187ab4.png

采样和过程原理

CPU

  • 操作系统:每 10ms 向进程发送 SIGPROF 信号。
  • 进程:每次接收到 SIGPROF 信号时记录调用堆栈信息。
  • 写缓冲:每 100ms 读取已记录的调用堆栈并写入输出流。

Heap - 堆内存采样

  • 采样过程:内存分配器在堆上分配和释放内存时,记录分配和释放的大小及次数。
  • 采样率:每分配 512KB 记录一次,可在运行时调整,1 分钟内平均分配记录。
  • 采样指标:包括 alloc_spacealloc_objectsinuse_spaceinuse_objects
  • 计算方式inuse = alloc - free

Goroutine 和 ThreadCreate 采样

  • Goroutine:记录所有用户触发且正在运行中的 goroutine,如 runtime.main 的调用堆栈。
  • ThreadCreate:记录程序创建的所有系统线程的信息。

Block - 阻塞 和 Mutex - 锁

  • 阻塞操作:采样阻塞操作的次数和耗时,仅在阻塞耗时超出阈值时记录,默认每次阻塞记录。
  • 锁竞争:采样锁争夺的次数和耗时,仅记录固定比例的锁操作,默认每次加锁均记录。

实战优化

业务服务优化

建立服务性能评估手段

服务性能评估方式

  • 独立 benchmark:无法满足复杂逻辑分析的需求。
  • 负载情境:在不同负载情况下,性能表现可能会有显著差异。

请求流量构造

  • 参数覆盖:不同请求参数应覆盖各种逻辑路径。
  • 模拟真实流量:尽量模拟线上真实的流量情况,以便更准确地评估服务的表现。

压测范围

  • 单机器压测:测试单个服务器的性能表现。
  • 集群压测:在集群环境下测试整体性能表现。

性能数据采集

  • 单机性能数据:收集单个服务器的详细性能数据。
  • 集群性能数据:汇总和分析整个集群的性能数据,以便评估整体系统的表现。

分析性能数据,定位性能瓶颈

使用库不规范

高并发场景优化不足

重点优化项改造

优化效果验证(重复压测)

进一步优化,服务整体链路分析

基础库优化

AB 实验 SDK 的优化

  • 分析基础库核心逻辑和性能瓶颈
    • 设计完善改造方案
    • 按需获取数据
    • 数据序列化协议优化
  • 内部压测验证
  • 推广业务服务落地验证

Go语言优化

编译器和运行时优化

  • 优化内存分配策略
  • 优化代码编译流程:生成更高效的程序
  • 内部压测验证
  • 推广业务服务落地验证