可信赖的坪山网站建设,wordpress斜杠,建设商务网站的目的,全屋定制销售技巧本文对实际开发场景中面对高频的场景#xff0c;总结出来的一些处理方案#xff0c;希望能对业务开发的同学提供帮助#xff01;
1. 结构体转换
实际开发中会面对一个相似的数据结构#xff0c;由于引用不同的包#xff0c;需要开发转换到对应的结构上#xff0c;本质上…本文对实际开发场景中面对高频的场景总结出来的一些处理方案希望能对业务开发的同学提供帮助
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:idExtra string json:extra gorm:column:extra
}意味着查询出来结构后还需要进行 unmarshal 操作且写入数据的时候也要进行 marshal开发者在修改数据的时候需要额外考虑其他接口所使用的数据结构用起来不方便。
最佳实践
gorm 是支持很多拓展特性的通过实现Scan、Value的方法就可以省去在业务代码中序列化的操作降低开发者的心智负担优化后大致如下
type BaseInfo struct {ID int64 json:id gorm:column:idExtra *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 errorif len(bytes) 0 {err json.Unmarshal(bytes, result)}*j resultreturn err
}3. Slice 过滤元素
业务开发经常需要操作 slice、map例如过滤切片中的一些元素或者是二者的相互转换常规一般通过 range 后进行 append、set 等操作这些看起来逻辑都不太优雅这种场景我们都可以用 stream 或是泛型特性来实现
func Test(t *testing.T) {data : make([]int64, 10)for _, v : range data {// biz codefmt.Println(v)}
}最佳实践
过滤元素
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 64var 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 int64B int64C int64D int64...
}如果我们在代码中仅需要AB字段我们可以用一个简化的结构体来减少反序列化需要处理的字段数
简化后的结构体 type SimpResp struct{A int64B 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,omitemptyWorld 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.TypedDynamicFieldsremainedBytes, _ 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.UntypedDynamicFieldsremainedBytes, _ 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的单次查找一个keymap真的一定比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中小对象应优先考虑传值。排除掉语义需求上必须传值或是必须传指针的场景在一个传值与传指针都可以的场合我们究竟该怎么选择呢真正的抉择依据是什么呢
传指针导致堆分配
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在这个场景中无论如何调整结构体大小基本永远都是返回值更快原因就是在返回指针时因为指针被赋值给了全局变量所以这个对象逃逸到了堆上。在这个场景下拷贝的开销远远跟不上堆分配内存的开销。
不会导致堆分配的传参场景
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汇编就能看到相关的函数调用这里就不再赘述。
现实中在传值和传指针皆可的场合存在这样一个天然矛盾传指针通常意味着将对象分配到堆上会有一次较大的初始开销但后续每次传递的开销较小将对象放在栈上不会有较大的初始分配开销但每次在函数栈帧间传递的开销都会更大。在现实场景中传值和传指针哪个是更好的做法并没有一个简单的答案这更多的取决于传递次数对象大小等等因素需要结合场景具体分析调优。