Go业务开发常用关注点

本文对实际开发场景中面对高频的场景,总结出来的一些处理方案,希望能对业务开发的同学提供帮助!

1. 结构体转换

实际开发中会面对一个相似的数据结构,由于引用不同的包,需要开发转换到对应的结构上,本质上这些数据结构是一致的,但是所在包不同所以不能直接赋值。

常规的方案大致分为下面几种:

1. 直接转换 struct

这种适合结构完全一致的情况,参数名和类型都必须保持一致;适用场景相对较少,面对不同包的协议转换如果包含一个枚举就无效了

type aType int64
type A struct {
   a aType
}

type bType int64
type B struct {
   a bType
}

func Test(t *testing.T) {
   a := A{a: 1}
   b := B(a)
   fmt.Println(a, b)// 如果把aType和bType直接当做in64就可以正常转换
}

2. 手撸代码

开发手动转换结构,适合字段比较少的结构,同时命名不会很相似,如果相似度较高存在写错的可能,面对复杂有嵌套数据结构效率低下。

3. 正反序列化转换

这种方案相对于第一种具备更强的兼容性,可以通过 tag 来实现不同类型的转换,但是面对不同协议生成的代码还是具有局限性,同时效率比较低下,序列化是比较消耗 cpu 的操作;
需要注意的是,官方的原生 json 库处理大数存在精度丢失的问题,我们这里采用 jsonx 默认支持大数
jsonx: code.byted.org/gopkg/jsonx

type aType int64
type A struct {
   A aType `json:"a"`
}

type bType int64
type B struct {
   A bType `json:"a"`
}

func Test(t *testing.T) {
   aStr := jsonx.ToString(A{1})
   b := &B{}
   _ = jsonx.UnmarshalFromString(aStr, &b)
   fmt.Println(aStr, b)
}

最佳实现

这里的最佳实现其实要区分场景来考虑:

  • 面对高并发或是简单结构的场景,需要减少资源消耗,可以采用【手撸代码】的方式实现
  • 面对并发比较低的场景,通过【正反序列化】是比较好的方案,使用起来更简单

2. 数据库中存储json结构体

表中有extra字段,存储的是扩展信息,比如执行时间,通常的结构声明是这样的:

type BaseInfo struct {
   ID            int64        `json:"id" gorm:"column:id"`
   Extra         string       `json:"extra" gorm:"column:extra"`
}

意味着查询出来结构后还需要进行 unmarshal 操作,且写入数据的时候也要进行 marshal,开发者在修改数据的时候需要额外考虑其他接口所使用的数据结构,用起来不方便。

最佳实践

gorm 是支持很多拓展特性的,通过实现Scan、Value的方法就可以省去在业务代码中序列化的操作,降低开发者的心智负担,优化后大致如下:

type BaseInfo struct {
   ID            int64        `json:"id" gorm:"column:id"`
   Extra *ExtraInfo `json:"check_in_detail" gorm:"column:check_in_detail"`
}

type ExtraInfo struct {
    Info1 `json:"info1"`
}

func (BaseInfo) TableName() string {
   return "base_info"
}

// Value return json value, implement driver.Valuer interface
// 如果接受者是指针,那么就只能是指针来调用
// 如果接受者是值类型,则支持指针、值类型来调用
func (j ExtraInfo) Value() (driver.Value, error) {
   return json.Marshal(j)
}

// Scan scan value into Jsonb, implements sql.Scanner interface
// 接受者要使用指针类型,这才才能实际赋值
func (j *ExtraInfo) Scan(value interface{}) error {
   bytes, ok := value.([]byte)
   if !ok {
      return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
   }
   result := ExtraInfo{}
   var err error
   if len(bytes) > 0 {
      err = json.Unmarshal(bytes, &result)
   }
   *j = result
   return err
}

3. Slice 过滤元素

业务开发经常需要操作 slice、map,例如过滤切片中的一些元素,或者是二者的相互转换,常规一般通过 range 后进行 append、set 等操作,这些看起来逻辑都不太优雅;这种场景我们都可以用 stream 或是泛型特性来实现~

func Test(t *testing.T) {
   data := make([]int64, 10)
   for _, v := range data {
      // biz code
      fmt.Println(v)
   }
}

最佳实践

  1. 过滤元素
import "code.byted.org/lang/gg/stream"   // 注意一定要go1.18版本

func main(){
	d := []int64{0, 1, 2, 3}
	arr := stream.FromSlice(d).Filter(
		func(i int64) bool {
			return i != 0
		},
	).ToSlice()
	fmt.Println(arr)  // [1 2 3]
}

4. 通过减少堆内存分配,优化CPU占用率

堆内存分配是影响cpu占用率的重要因素。
大家可能平时可能会有一种想法:一次rpc请求都已经是ms级的了,而一次内存分配再慢也是ns级的,纠结内存分配次数真的有意义吗?答案是肯定的,因为在发起rpc请求后,cpu就去处理别的任务了,其ms级的处理延时主要影响的是请求延时(off-cpu);而内存分配这一动作虽是ns级的,却是实打实的cpu运算时间(on-cpu)。当我们的优化目标是cpu占用率时,内存分配就是一个绕不开的话题。

const size = 64

var avoidEliminationSlice []int // 防止编译器优化的全局变量
// 堆分配测试
func BenchmarkMallocSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
       avoidEliminationSlice = make([]int, 0, size)
    }
}

var avoidEliminationArray [size]int
// 栈分配测试
func BenchmarkMallocArray(b *testing.B) {
    for i := 0; i < b.N; i++ {
       avoidEliminationArray = [size]int{}
    }
}

上面给出两个单测,需要申请的内存都是size个int的大小,我们可以通过命令go test -bench=. -benchmem来测试这两种申请方式的性能,结果如下。

BenchmarkMallocSlice-8   14629460  94.45 ns/op  512 B/op  1 allocs/op
BenchmarkMallocArray-8  240144676  4.902 ns/op  0 B/op    0 allocs/op

可以看到,二者的alloc次数是1和0,说明了前者发生了堆分配,后者则是栈上分配。二者的cpu运算时间基本差了一个数量级。在实践中,这种差异会因为单核上千的qps而被放大,从而产生显著的cpu占用率的差别。

既然堆分配那么慢,那我们有办法将实践中的大部分堆分配都替换成栈分配吗?答案是否定的。

栈分配对象最大的特点实际上是需要编译期就能确定大小,因为这个特点,很多时候分配堆对象是不可避免的:因为业务开发上很多时候需要的对象大小就是需要到运行时才能确定,例如我们常用的各种容器。另外,在go中是否发生堆分配也和逃逸分析机制有关:即变量的生命周期是否超出了其所在的函数栈帧。最后,内联优化和接口值的赋值行为有时候也会决定一个对象是否在堆上分配。

因此,在go语言中,内存是否会被堆分配其实并没有那么明晰,go实际上也希望使用者可以尽可能不关注这一细节。尽管如此,尝试去推测和理解Go的堆分配行为依然对提升程序性能,降低runtime开销有所助益。

5. 序列化 只选取有需要的字段

大pack结构的结构体

type OriginalResp struct{
	A int64
	B int64
	C int64
	D int64
	...
}

如果我们在代码中仅需要A,B字段,我们可以用一个简化的结构体来减少反序列化需要处理的字段数

简化后的结构体


type SimpResp struct{
	A int64
	B int64
}

简化结构体定义,显著加速了反序列化过程,但这并没有减少任何堆内存分配次数

6. 反序列化,不要用通用的string string, 要用明确含义的类型

大部分需要访问远程数据库的服务,会将大量的cpu时间用在反序列化上。优化反序列化过程的cpu占用,在很多时候是决定性的。我们经常会选择在远程数据库中存入map[string]string类型的数据。对于需要动态更改的数据,这样的选择无可厚非:它减少了代码改动和上线的次数。但相比于使用每个字段都有具体类型的结构体,这个选择在客观上会显著增加cpu的开销。

假设我们现在有一个int64和一个float64类型的数据需要用MsgPack存储进redis。我们定义以下两种结构作为数据的schema:一种拥有正确的数据类型,一种全部转成string后塞入map[string]string中。

//go:generate msgp
type TypedDynamicFields struct {
    Hello int64   `gorm:"hello" json:"hello,omitempty" msg:"hello,omitempty"`
    World float64 `gorm:"world" json:"world,omitempty" msg:"world,omitempty"`
}

//go:generate msgp
type UntypedDynamicFields map[string]string
// 产生带类型结构体序列化后的内容
func generateTypedBytes() []byte {
    vals := model.TypedDynamicFields{
       Hello: 1,
       World: 1.0,
    }
    bytes, _ := vals.MarshalMsg(nil)
    return bytes
}
// 产生map[string]string序列化后内容
func generateUntypedBytes() []byte {
    vals := model.UntypedDynamicFields{
       "hello": "1",
       "world": "1.0",
    }
    bytes, _ := vals.MarshalMsg(nil)
    return bytes
}
// 测试带类型结构体的反序列化
func BenchmarkUnmarshalTypedBytes(b *testing.B) {
    bytes := generateTypedBytes()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       var res model.TypedDynamicFields
       remainedBytes, _ = res.UnmarshalMsg(bytes)
    }
}
// 测试map[string]string的反序列化
func BenchmarkUnmarshalUntypedBytes(b *testing.B) {
    bytes := generateUntypedBytes()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       var res model.UntypedDynamicFields
       remainedBytes, _ = res.UnmarshalMsg(bytes)
    }
}

二者的运行结果如下。大家可以试着分析一下,在这个测试中,map[string]string反序列化过程中产生的5次堆分配分别是用于存储什么。

BenchmarkUnmarshalTypedBytes-8    59285511    19.94 ns/op    0 B/op  0 allocs/op
BenchmarkUnmarshalUntypedBytes-8  5916292     203.4 ns/op  352 B/op  5 allocs/op

通过这个单测本身,我们就已经能观察到这两种存储方式在反序列化时的巨大性能差异。然而在现实中,在map中根据key检索也显著慢于在struct中根据字段名取字段值。这也就意味着,后续对反序列化产物的使用过程会产生更大的性能差异。

但实际编码中我们并不总是能将两者互相转换的,结构体终究是没有map灵活。结构体没有map灵活的根本原因在于:结构体中所能包含的键值对在编译完成后就已经固定住了,而我们时常希望新增字段时不需要上线变更,这只有动态容器能做到。因此,一个比较务实的做法是:尽可能将初期设计的动态容器在不会发生变更后用结构体的方式固定下来。

7. 查找元素,数据量小时,slice比map更快

会有一些场景,我们需要判断一个值的集合中是否包含某个特定的值。一般来说,我们会选择用map来做这种存在性检验,这很符合我们学到的知识: 哈希表判断一个key是否存在是常数复杂度的。

当我们有这种需求时,现有的内容可能只有一个slice。我们会想,如果选择直接遍历slice查看其是否包含某个特定的key,算法复杂度为O(n),因此速度会比创建一个map然后在map中查找更慢。但事实真的是这样的吗?当我们在比较这两种做法的时候,有几个因素是不可忽略的:

  • slice是现有的,map是需要新malloc的
  • 单次查找一个key,map真的一定比slice快吗
  • 在map比slice单次查找更快的时候,查找次数能均摊掉malloc带来的成本吗

不难发现,以上这些因素中,最关键的点在于:当元素数量达到什么程度,map的单次查找速度能才能快于slice。因此这里也提供了一个简单的单测尝试探讨这一问题。

const capacity = 16
// 生成一个slice
func generateSlice() []int {
    res := make([]int, capacity)
    for i := 0; i < capacity; i++ {
       res[i] = i
    }
    return res
}
// 生成一个map
func generateMap() map[int]struct{} {
    res := make(map[int]struct{}, capacity)
    for i := 0; i < capacity; i++ {
       res[i] = struct{}{}
    }
    return res
}
// 判断slice中是否有某个key
func sliceContains(s []int, target int) bool {
    for _, val := range s {
       if val == target {
          return true
       }
    }
    return false
}
// 判断map中是否有某个key
func mapContains(m map[int]struct{}, target int) bool {
    _, ok := m[target]
    return ok
}
var exist bool // 防止编译器优化
// 测试slice中单次查找性能
func BenchmarkContainsSlice(b *testing.B) {
    s := generateSlice()
    target := fastrand.Intn(capacity)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       exist = sliceContains(s, target)
    }
}
// 测试map单次查找性能
func BenchmarkContainsMap(b *testing.B) {
    m := generateMap()
    target := fastrand.Intn(capacity)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       exist = mapContains(m, target)
    }
}
// 容量=8时
BenchmarkContainsSlice-8    505969701    2.257 ns/op    0 B/op    0 allocs/op
BenchmarkContainsMap-8      298618323    3.960 ns/op    0 B/op    0 allocs/op
// 容量=16时
BenchmarkContainsSlice-8    966832947    3.161 ns/op    0 B/op    0 allocs/op
BenchmarkContainsMap-8      231526172    5.792 ns/op    0 B/op    0 allocs/op
// 容量=32时
BenchmarkContainsSlice-8    348595730    16.51 ns/op    0 B/op    0 allocs/op
BenchmarkContainsMap-8      230518400    5.334 ns/op    0 B/op    0 allocs/op
// 容量=64时
BenchmarkContainsSlice-8    53850733     19.06 ns/op    0 B/op    0 allocs/op
BenchmarkContainsMap-8      168312292    6.387 ns/op    0 B/op    0 allocs/op

可以看到,在容量较小时,slice查找单key的速度实际上要快于map。因此,在实践中,我们还是应该结合具体的业务场景特点来做抉择。

8. 传值与传指针

我们知道,cpu在工作时,实际上就是在不停的拷贝bits,传值还是传指针,对cpu而言其实是没有区别的,都意味着复制,差别只在于拷贝内容的多少。但我们应该也听过,在Go中小对象应优先考虑传值。排除掉语义需求上必须传值或是必须传指针的场景,在一个传值与传指针都可以的场合,我们究竟该怎么选择呢?真正的抉择依据是什么呢?

  1. 传指针导致堆分配
const StructSize = 1024 // 用于控制结构体大小

type Value struct {
    content [StructSize]byte
}

func returnValue() Value { // 返回值
    return Value{
       content: [StructSize]byte{},
    }
}

func returnPtr() *Value { // 返回指针
    return new(Value)
}

var returnedValue Value // 防止编译器优化
// 测试返回值
func BenchmarkReturnValue(b *testing.B) {
    for i := 0; i < b.N; i++ {
       returnedValue = returnValue()
    }
}

var returnedPtr *Value // 防止编译器优化
// 测试返回指针
func BenchmarkReturnPtr(b *testing.B) {
    for i := 0; i < b.N; i++ {
       returnedPtr = returnPtr()
    }
}
BenchmarkReturnValue-8  128926057  9.106 ns/op     0 B/op  0 allocs/op
BenchmarkReturnPtr-8    7841412    151.5 ns/op  1024 B/op  1 allocs/op

在这个场景中,无论如何调整结构体大小,基本永远都是返回值更快,原因就是在返回指针时,因为指针被赋值给了全局变量,所以这个对象逃逸到了堆上。在这个场景下,拷贝的开销远远跟不上堆分配内存的开销。

  1. 不会导致堆分配的传参场景
const copyTimes = 1024 // 拷贝次数,放大传参影响
const StructSize = 16 // 控制结构体大小

var existingValue Value // 防止编译器优化
// 测试返回一个现有值
func BenchmarkReturnExistingValue(b *testing.B) {
    value := Value{}
    returnExistingValue := func() Value {
       return value
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       for j := 0; j < copyTimes; j++ {
          existingValue = returnExistingValue()
       }
    }
}

var existingPtr *Value // 防止编译器优化
// 测试返回一个现有指针
func BenchmarkReturnExistingPtr(b *testing.B) {
    ptr := new(Value)
    returnExistingPtr := func() *Value {
       return ptr
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       for j := 0; j < copyTimes; j++ {
          existingPtr = returnExistingPtr()
       }
    }
}
// 当StructSize = 16 时
BenchmarkReturnExistingValue-8    3795523    320.7 ns/op    0 B/op    0 allocs/op
BenchmarkReturnExistingPtr-8      2694915    429.1 ns/op    0 B/op    0 allocs/op
// 当StructSize = 32 时
BenchmarkReturnExistingValue-8    3024436    391.9 ns/op    0 B/op    0 allocs/op
BenchmarkReturnExistingPtr-8      2834446    420.5 ns/op    0 B/op    0 allocs/op
// 当StructSize = 64 时
BenchmarkReturnExistingValue-8    1366735    872.1 ns/op    0 B/op    0 allocs/op
BenchmarkReturnExistingPtr-8      2745406    450.1 ns/op    0 B/op    0 allocs/op

可以看到,两个单测在不同的参数下都没有发生任何堆对象分配。通过调整StructSize参数,我们观察到拷贝指针的开销是相对比较稳定的,而拷贝值的开销则随着StructSize的增大而增大,最终显著超过了拷贝指针。

当需要拷贝的值较大时,传值会比传指针慢很容易理解,毕竟指针实际上只是一个整数的大小。但小对象为什么会传值会更快呢?Go的gc的优化目标是减小stw时间,其采用的三色标记算法需要在堆对象指针发生写行为时,由编译器在生成代码时插入相应写屏障,这会导致一次指针赋值行为不仅仅是一个指针值的拷贝。这实际上是一种为了减少暂停而牺牲吞吐量的做法,感兴趣的同学可以写一段代码后编译成Go汇编,就能看到相关的函数调用,这里就不再赘述。

现实中,在传值和传指针皆可的场合,存在这样一个天然矛盾:传指针通常意味着将对象分配到堆上,会有一次较大的初始开销,但后续每次传递的开销较小;将对象放在栈上,不会有较大的初始分配开销,但每次在函数栈帧间传递的开销都会更大。在现实场景中,传值和传指针哪个是更好的做法并没有一个简单的答案,这更多的取决于传递次数,对象大小等等因素,需要结合场景具体分析调优。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值