高质量代码
编码规范
gofmt自动格式化代码
注释:代码作用,实现过程,实现原因,什么情况下会出错(提供代码未表达出的上下文信息)
命名规范
变量名:
-
缩略词全大写,位于变量开头且不需要导出时小写
ServeHTTP ; XMLHTTPRequest or xmlHTTPRequest -
变量距离被使用的地方越远,带更多上下文
函数名:
- 函数名不携带包含的上下文信息,因为包含名和函数名总是成对出现的。
- 函数名尽量简短。
- 当名为 foo 的某个函数返回类型为 Foo 时,可以省略类型信息而不导致歧义。
- 当名为 foo 的某个函数返回类型为 T 时(T 并不是 Foo),可以在函数名中加入类型信息。
package
-
仅由小写字母组成,不包含大写字母和下划线等字符。
-
简短并包含一定的上下文信息,例如
schema、task等。 -
不要与标准库同名,例如不要使用
sync或strings。以下规则尽量满足,以标准库包名为例:
- 不使用单词变量名作为包名,例如使用
bufio而不是buf。 - 使用单数而不是复数,例如使用
encoding而不是encodings。 - 谨慎地使用缩写,例如在不破坏上下文的情况下,使用
fmt比format更加简短。
- 不使用单词变量名作为包名,例如使用
控制流程
避免嵌套,保持正常信息
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。
- 当程序启动阶段发生不可逆转的错误时(如在
init或main函数中),可以使用 panic。
recover 使用规范
- recover 只能在被 defer 的函数中使用。
- 嵌套调用时无效,仅在当前 goroutine 中生效。
- defer 的执行顺序是后进先出,即最近的 defer 会最早执行。
- 如果需要更多上下文信息,可以在
recover后通过log记录当前的调用堆栈debug.stack()。
性能优化
go test -bench=. -benchmem
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.Builder和bytes.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
采样和过程原理
CPU
- 操作系统:每 10ms 向进程发送
SIGPROF信号。 - 进程:每次接收到
SIGPROF信号时记录调用堆栈信息。 - 写缓冲:每 100ms 读取已记录的调用堆栈并写入输出流。
Heap - 堆内存采样
- 采样过程:内存分配器在堆上分配和释放内存时,记录分配和释放的大小及次数。
- 采样率:每分配 512KB 记录一次,可在运行时调整,1 分钟内平均分配记录。
- 采样指标:包括
alloc_space、alloc_objects、inuse_space、inuse_objects。 - 计算方式:
inuse = alloc - free。
Goroutine 和 ThreadCreate 采样
- Goroutine:记录所有用户触发且正在运行中的
goroutine,如runtime.main的调用堆栈。 - ThreadCreate:记录程序创建的所有系统线程的信息。
Block - 阻塞 和 Mutex - 锁
- 阻塞操作:采样阻塞操作的次数和耗时,仅在阻塞耗时超出阈值时记录,默认每次阻塞记录。
- 锁竞争:采样锁争夺的次数和耗时,仅记录固定比例的锁操作,默认每次加锁均记录。
实战优化
业务服务优化
建立服务性能评估手段
服务性能评估方式
- 独立 benchmark:无法满足复杂逻辑分析的需求。
- 负载情境:在不同负载情况下,性能表现可能会有显著差异。
请求流量构造
- 参数覆盖:不同请求参数应覆盖各种逻辑路径。
- 模拟真实流量:尽量模拟线上真实的流量情况,以便更准确地评估服务的表现。
压测范围
- 单机器压测:测试单个服务器的性能表现。
- 集群压测:在集群环境下测试整体性能表现。
性能数据采集
- 单机性能数据:收集单个服务器的详细性能数据。
- 集群性能数据:汇总和分析整个集群的性能数据,以便评估整体系统的表现。
分析性能数据,定位性能瓶颈
使用库不规范
高并发场景优化不足
重点优化项改造
优化效果验证(重复压测)
进一步优化,服务整体链路分析
基础库优化
AB 实验 SDK 的优化
- 分析基础库核心逻辑和性能瓶颈
- 设计完善改造方案
- 按需获取数据
- 数据序列化协议优化
- 内部压测验证
- 推广业务服务落地验证
Go语言优化
编译器和运行时优化
- 优化内存分配策略
- 优化代码编译流程:生成更高效的程序
- 内部压测验证
- 推广业务服务落地验证